This file gives AI assistants the context needed to work effectively in this repository.
abitool is a Go CLI tool that bridges Ethereum smart contracts and humans. It lets users pull contract ABIs from Etherscan, inspect their interface (functions, events, selectors), and — in future — encode/decode calldata and send transactions directly.
| Concern | Choice |
|---|---|
| Language | Go 1.25 |
| CLI framework | cobra |
| Config | viper (YAML, bound to cobra flags) |
| Keccak-256 | golang.org/x/crypto/sha3 (NewLegacyKeccak256) |
| External API | Etherscan v2 REST API |
| TUI framework | bubbletea + bubbles |
| TUI styling | lipgloss |
cmd/ Cobra command definitions (entry points only — no business logic)
abitool/ Main package — `go install github.com/MqllR/abitool/cmd/abitool@latest`
main.go Program entry point — calls Execute()
root.go Root command; Version var (injected at build time); loads config + launches TUI when called with no subcommand
decode.go abitool decode — calldata / return data decoding (top-level)
encode.go abitool encode — ABI calldata encoding (top-level)
abi.go Parent "abi" command; registers persistent flags (chainid, abi-store)
chain.go Parent "chain" command; registers UseCmd
rpc.go Parent "rpc" command
abi/
download.go abitool abi download <address>
view.go abitool abi view <address>
list.go abitool abi list
delete.go abitool abi delete <address>
registry.go abitool abi registry — list curated contracts for the active chain
chain/
use.go abitool chain use <chainID> — persists default chain to config
rpc/
call.go abitool rpc call
internal/
abitool/
app.go Config loading (sync.Once + viper); SaveChainID / saveConfig helpers
config.go Config struct (with yaml + mapstructure tags); ConfigInstance() accessor
contract/
abi.go ABIManager — orchestrates download, view, list, delete
storage.go ABIManager storage helpers (getContract, saveContractWithABI, …)
types.go Contract, Metadata structs
config.go KnownChains alias for pkg/chains.Known; ChainName() helper
display.go Print / PrintContractList — format-aware output (table/json, type filter)
decode.go DecodeManager — calldata and return data decoding
encode.go EncodeManager — ABI calldata encoding
ui/
app.go Root TUI app model; screen stack; homeModel, contractListModel, downloadModel (with registry picker step)
browse.go browseModel — split-pane ABI element browser (list + detail + action modal)
call.go callFormScreen — argument input form for eth_call
encode.go encodeFormScreen + encodeResultScreen — argument input + calldata display
form.go RunForm — generic multi-field text-input form (used by rpc call)
onboarding.go onboardingModel — first-launch checklist to import registry contracts
result.go callResultScreen — displays eth_call results
pkg/
abiparser/
types.go ABI element types (FunctionType, EventType, …)
parser.go ParseABI(), Element.Signature(), Element.Selector()
print.go PrettyPrinter (JSON) and TablePrinter with lipgloss colours
chains/
chains.go Known map (chain ID → name + default RPC URL); Name() helper
etherscan/
types.go ContractSourceCodeResponse
client.go HTTP client; call() helper
contract.go GetABI(), GetSourceCode()
registry/
registry.go Curated per-chain contract address registry; Contract type, Resolve(), ForChain()
storage/
abi/local.go File-per-contract ABI storage (Write/Read/Delete/GetPath)
contract/local.go contracts.json index (Add/Get/Delete/List); ErrNotFound/ErrAlreadyExists
internal/vspkg/—internal/contains app-specific orchestration (ABIManager, config, UI).pkg/contains reusable, app-agnostic packages (abiparser, etherscan client, storage backends, registry, chains).- Registry —
pkg/registry/holds a curated, per-chain map of named contracts (same package family aspkg/chains/).Resolve(chainID, nameOrAddress)is the single entry-point: hex inputs pass through unchanged, names are looked up case-insensitively. All address-accepting CLI commands callResolvebefore hittingABIManager. Adding a new entry is one line in theContractsmap. - Chains —
pkg/chains/provides theKnownmap (chain ID → display name + public RPC URL).internal/contract/config.gore-exports it asKnownChainsfor backward compatibility within that package. - Storage — ABIs are stored as raw JSON files named after their contract address. A
contracts.jsonindex file holds metadata (contract name, ABI file path). Both live under<abi-store>/<chainid>/. - Config — Loaded once via
sync.Onceininternal/abitool/app.go, then accessed globally throughConfigInstance(). Cobra binds CLI flags into Viper; config file values are the fallback.SaveChainID(int)writes the updatedConfigstruct back to the YAML file usinggo.yaml.in/yaml/v3(notviper.WriteConfig(), to avoid flushing transient flags). - No external ABI codec — ABI encoding/decoding is intentionally not implemented yet. Function selectors are computed in-house with Keccak-256 over the canonical signature string.
- TUI as the default entry point — Running
abitoolwith no subcommand launches the full-screen Bubble Tea dashboard. All CLI subcommands remain fully functional for scripting and piping.
// ABI element from the parsed JSON
type Element struct {
Type Type // function | event | error | constructor | receive | fallback
Name string
Inputs []Input
StateMutability StateMutability // pure | view | nonpayable | payable
}
type Input struct {
Name string
Type string // e.g. "uint256", "address", "tuple", "tuple[]"
InternalType string // Solidity-level type hint (struct Foo.Bar)
Components []Parameter // Populated for tuple types (struct members)
}Element.Selector() → Element.Signature() → Keccak-256 → first 4 bytes → 0x... hex string.
The canonical signature format is functionName(type1,type2). Tuple types must be expanded recursively into ((memberType1,memberType2,...)) — see known issue #1 in the plan.
# $HOME/.config/abitool/config.yaml
etherscan:
api_key: "YOUR_KEY"
chainid: 137 # optional — persisted by `abitool chain use` or TUI chain switcher
rpc:
url: "" # optional RPC fallbackThe api_key field is the only required config value. All other settings have defaults and can be overridden via CLI flags. chainid is written back automatically when the user changes the chain (via TUI or abitool chain use).
All issues from the initial code review have been resolved. See docs/ROADMAP.md for planned features.
For abi subcommands:
- Create
cmd/abitool/abi/<name>.gowith acobra.Commandexported as<Name>Cmd. - Register it in
cmd/abitool/abi.gowithabiCmd.AddCommand(abi.<Name>Cmd). - Business logic goes in
internal/contract/(new method onABIManagerif it involves stored contracts). - New Etherscan endpoints go in
pkg/etherscan/.
For root-level command groups (like chain):
- Create
cmd/abitool/<group>/directory with subcommand files (e.g.use.go). - Create
cmd/abitool/<group>.godeclaring the parentcobra.Commandand callingrootCmd.AddCommandininit(). - Register it in
cmd/abitool/root.goby importing the package (theinit()wires it up automatically).
AI assistants must update documentation whenever making code changes. Specifically:
README.md— update the Commands table, Flags Reference, Configuration section, and Features list when adding commands, flags, or config fields.AGENTS.md— update the repository layout, design decisions, and configuration sections to reflect structural or architectural changes.docs/ROADMAP.md— mark features as done when implemented; add new planned features if introduced.
Documentation updates should be part of the same commit as the code change. Never leave docs out of sync with the implementation.
Add the chain ID and its display metadata to Known in pkg/chains/chains.go. The Etherscan v2 API already supports multiple chains via the chainid query parameter. To add registry entries for the new chain, add a slice to Contracts in pkg/registry/registry.go.
This section is the authoritative guide for AI assistants making changes to the terminal UI.
The TUI uses the Bubble Tea elm-architecture framework with Lip Gloss for layout and colour. The entry point is ui.RunApp(), called from cmd/root.go when no subcommand is given.
All screens live in internal/ui/. Navigation is managed by a single root appModel in app.go that owns a []screen stack:
appModel.stack = [homeModel, contractListModel, browseModel]
↑ bottom ↑ top (active)
- Push a new screen by returning
func() tea.Msg { return pushMsg{next} }fromUpdate. - Pop (go back) by returning
func() tea.Msg { return popMsg{} }. - Pop + re-init the screen below (e.g. after a download) by returning
popAndRefreshMsg{}. This callsInit()on the now-top screen, so it reloads fresh data. appModel.Updatepropagatestea.WindowSizeMsgto all screens in the stack (not just the top), so every screen is always correctly sized when it becomes active.- Popping the last screen quits the program (
tea.Quit).
Every screen implements the screen interface:
type screen interface {
tea.Model // Init() / Update() / View()
setSize(w, h int) screen
}setSize must return a copy with width and height updated — screens are value types (structs), not pointers.
- Create a struct in
internal/ui/that implementsscreen. - Load data asynchronously: return a
tea.CmdfromInit()that fetches data and returns a typed message (e.g.type myDataMsg struct{...}). Handle it inUpdate. - Push it from another screen's
Updateby emittingpushMsg{newMyScreen(...)}. - Pop back with
popMsg{}onEsc/Backspace/q. - Never call
tea.Quitdirectly inside a screen except for an explicit global quit key (q).
All colours are defined as package-level lipgloss.Style vars at the top of app.go and reused across screens (same package). Do not define new colours inline — add them to the shared var block.
| Token | Hex | Semantic use |
|---|---|---|
colorPrimary |
#7D56F4 |
Border highlights, titles, selected-item background |
colorDim |
#6272A4 |
Secondary text, separators, status bars, empty states |
colorWhite |
#F8F8F2 |
Primary content text |
colorGreen |
#50FA7B |
Success, view/pure mutability, constructor badge, ABI present |
colorRed |
#FF5555 |
Error states, error-type badge, ABI absent |
colorYellow |
#F9D449 |
Warnings, payable mutability, event badge |
colorBlue |
#4BAFED |
Function badge, selector values |
The TablePrinter in pkg/abiparser/print.go has its own mirrored set of table*Style vars (same palette, slightly extended with purple #BD93F9 for selectors in the list view).
- Borders: always
lipgloss.RoundedBorder()withBorderForeground(colorPrimary). - Full-screen screens (contract list, browse): use
lipgloss.NewStyle().Width(innerW).Height(h-2).Render(content)whereinnerW = w - 2(border chars). This constrains the box exactly to the terminal. - Centred overlay screens (home, download): use
lipgloss.Place(w, h, lipgloss.Center, lipgloss.Center, box). - Split panes (browse screen): left pane = 38% of inner width, right pane = remainder minus 3 (for
" │ "separator). Both panes padded to exactlyvisibleRowslines so the separator stays straight. - Narrow fallback (< 80 cols): switch to single-column layout. The browse screen detects
w < 80inView()and callsrenderNarrowinstead ofrenderSplit. - Status bar: always the last line inside the box, using
dimStyle.
Plain fmt.Fprintf("%-*s", w, s) breaks when s contains ANSI escape codes because len(s) counts bytes, not visible characters.
Always use lipgloss width-constrained rendering for table cells:
cell := func(styled string, colW int) string {
return lipgloss.NewStyle().Width(colW + gap).Render(styled)
}Measure column widths from raw (unstyled) strings first, then apply styles only when rendering.
Every screen must support these bindings consistently:
| Key | Action |
|---|---|
↑ / k |
Move selection up |
↓ / j |
Move selection down |
Enter |
Select / drill into next screen |
Esc / Backspace |
Go back (pop screen) |
/ |
Focus filter input |
Esc (while filtering) |
Clear filter and blur input |
q |
Quit entire program |
Ctrl+C |
Quit (handled at appModel level) |
The status bar at the bottom of each screen must display the applicable subset of these bindings as a reminder.
func (m myScreen) Init() tea.Cmd {
return loadDataCmd(m.someParam)
}
func loadDataCmd(param string) tea.Cmd {
return func() tea.Msg {
data, err := expensiveLoad(param)
if err != nil {
return loadErrMsg{err}
}
return loadedMsg{data}
}
}
func (m myScreen) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case loadedMsg:
m.data = msg.data
m.loaded = true
return m, nil
case loadErrMsg:
m.err = msg.err
m.loaded = true
return m, nil
}
// ...
}Show a dimStyle.Render(" Loading...") placeholder in View() while !m.loaded.
Use the shared badge styles defined in app.go for consistent type labelling across all screens:
| Type | Badge | Style |
|---|---|---|
function |
[fn] |
badgeFunction — bold blue |
event |
[ev] |
badgeEvent — bold yellow |
error |
[er] |
badgeError — bold red |
constructor |
[co] |
badgeConstructor — bold green |
fallback / receive |
[fb] |
badgeFallback — dim |
These are exported via the elementBadge(el abiparser.Element) string helper in browse.go.
The TablePrinter in pkg/abiparser/print.go uses Lip Gloss for the static abi view command (default output is table; use --output json for JSON). Columns: Type, Name, Inputs, Outputs, Selector/Topic, StateMutability. Options --with-input-name and --with-output-name expand parameter names. Follow the same column-sizing pattern and the same colour palette. The table*Style vars there mirror the TUI palette — keep them in sync if colours change.
go build -o abitool ./cmd/abitool/
go test ./...Before submitting changes, run the pre-checks:
make lint # requires golangci-lint
make testWhen injecting the version at build time, use the main package path (not the import path):
go build -ldflags="-X main.Version=<version>" -o abitool ./cmd/abitool/
# or simply:
make build # uses git describe automatically