# AGENTS.md — internal/cmd **The command tree: flags, receipts, exit codes.** The only package that may import every layer below it, and the only one that prints. `cmd/kettle` is four lines around `cmd.Main(os.Args[1:])` — everything a `main` usually accumulates lives here instead, because a `main` package cannot be imported and therefore cannot be tested. ## Commands are values Each command is one `register(&Command{…})` in an `init()`, carrying the metadata a human needs — `Short`, `Long`, `Examples`, `Args`, `Group` — **in the same struct that carries the code**. That is what lets a project's skills be generated from this list: a command whose flags changed cannot ship with documentation that says otherwise. ```go func init() { register(&Command{ Name: "tree", Group: GroupIssue, Args: "[…]", Short: "draw the dependency graph of the local store", Long: `…`, Examples: []Example{{"kettle tree", "every root (nothing depends on it)"}}, Setup: func(fs *flag.FlagSet) func([]string) error { out := storeFlag(fs) depth := fs.Int("depth", 6, "maximum depth") return func(args []string) error { … } }, }) } ``` **`Setup` registers flags and returns the runner**, closing over them. Splitting it that way is what lets `Command.Flags()` walk a command's flags without running anything — which is how the doc generator reads them. **The tree is flat.** `kettle new`, not `kettle issue new`: an agent pays for every token of every invocation, and the grouping that matters for reading is carried in `Group` and only shows up in the docs. Four groups, in presentation order: `project`, `issue`, `sync`, `api`. | file | what is in it | |---|---| | `command.go` | `Command`, the registry, `Main`, help rendering, `SilentError`, `Fail`, and `permute` | | `flags.go` | `storeFlag`/`storeRoot`, `wasSet`, the repeatable `stringList` | | `sync.go` | `syncStart`/`syncStartExisting`, `commentsSidecarPath` — the shared opening of every tracker command | | `gen.go` | `kettle gen scaffold`: rendering [`scaffold`](../scaffold/AGENTS.md)'s documents with the registry's flag tables spliced in | | `init.go` `auth.go` `config.go` `version.go` `mirror.go` | group `project`. `version.go` also holds `Version`, the string a release build stamps in with `-ldflags -X` | | `interactive.go` | `kettle init --interactive`: the only thing here that reads a terminal | | `settings.go` | registering `kettle mirror --hook` in `.claude/settings.json`, and refusing to reformat one it did not write | | `new.go` `check.go` `ac.go` `tree.go` `index.go` `evict.go` | group `issue` — no network in any of them | | `pull.go` `push.go` `remote.go` `comment.go` `close.go` `labels.go` `evict_sync.go` | group `sync` | | `api.go` | group `api`, alone in it: one request to an endpoint nothing here wraps | | `cli_test.go` | builds the binary in `TestMain`, runs it as a subprocess | | `sync_pull_test.go` `sync_write_test.go` `api_test.go` | the tracker halves, against fake servers | | `gen_test.go` | the generator: the whole tree, determinism, and that a local edit does not survive | | `mirror_test.go` | the repair command, and the hook form's promise never to fail a Bash call | | `init_scaffold_test.go` | the `.claude/` tree, the settings refusal, the worktree refusal, the not-a-terminal refusal | | `scaffold_coverage_test.go` | the registry ⇄ documents seam, in the one package that can see both | The fourth group is one command and was still worth naming, because a group is a skill written into a project: `api` is a subject somebody loads on its own — which endpoint, and does it paginate — and folding it into `sync` would have put "how do I cut a release" behind a skill about the issue round trip. **`mirror` is the one command in the tree that has nothing to do with issues**, and it is here rather than in a second binary for the reason this repository keeps repeating to itself: a binary holds what can be enforced. Repairing an `AGENTS.md`/`CLAUDE.md` pair is a seven-branch filesystem decision with one refusal in it, which is a thing a table test can hold down and a shell script cannot. It was 141 lines of Python behind a filename that said `.sh`, shipped in the plugin's hooks; when the plugin went, the shell had nowhere to live and Go did. The domain is untouched: [`mirror`](../mirror/AGENTS.md) imports nothing. ## Three conventions every command follows **Flags may come after positionals.** The standard `flag` package stops parsing at the first non-flag argument, so `kettle ac --check 3` would hand `--check` to the command as a positional and tick nothing. `permute` moves flags forward, using the `FlagSet` to know whether a flag swallows the next argument; `--` ends the permutation. Every other CLI an operator uses interleaves the two, and a tool that silently ignores a flag because of where it was typed is worse than one that rejects it. **Exit codes are three.** `0` fine, `2` for a usage problem (unknown command, unparseable flags), `1` for an ordinary failure — printed as `kettle : ` by `Main`, which is why no command prefixes its own errors. `SilentError{Code: 1}` is for a command that has already said everything it has to say: `check` and `gen --check` use it, because findings went to stdout and a second copy on stderr would be noise. **The store is resolved before a socket is opened.** `syncStart` does that in one place: a command that dialled first would report a network problem for a project that was never initialized, and an operator would go looking at the wrong thing. `syncStartExisting` adds `RequireStore` for the commands that read the store rather than create it — `push`, `comment`, `close`, `sync-evict` — because a missing store is a mistake to report, not a directory to conjure. **There is no `--login` and no `--repo`** on any sync command bar `labels`. Which login a project runs under is a fact about the project, stated once by `kettle init`. That the two could disagree is what the Python version needed a `PreToolUse` hook to police. `api` keeps that rule and needs no flag to: a cross-repository address is an address, so `repos/other-owner/other-repo/releases` is simply a path with nothing to substitute — `{owner}` and `{repo}` are filled in only where they are spelled. Another **instance** is `KETTLE_URL`/`KETTLE_TOKEN`, and a full URL pointing at a host that is not this project's is refused by the transport rather than sent with the token attached. It also resolves the store it never reads, exactly as `labels` does, so "there is no project here" fails the same way for every command that talks to a tracker. **`-X DELETE` needs `--yes`.** The only gate of its kind in the tree, and it is here because this is the only command that can delete something that is not an issue — a release, a tag, a branch — from an argument. A flag typed on purpose is an operator's decision; everything else about the request goes out as spelled. `--out` is the one flag almost every command has, and an explicit one is used **exactly as typed**: a relative `--out` stays relative to the working directory, because that is what the operator asked for. ## Usage ```bash kettle help # the tree, grouped kettle help push # one command in full: flags, defaults, examples kettle init --interactive # a person at a terminal, walked through it kettle init --login noodles --repo owner/name kettle mirror --check # AGENTS.md ⇄ CLAUDE.md, everywhere below here kettle new --type task --title "Wire sqlc into the appclick repo layer" kettle ac wire-sqlc-appclick --check 3 kettle check --strict # exit 1 on any error; --strict counts warnings too kettle pull 42 # the issue and everything blocking it, any state kettle push --update wire-sqlc-appclick kettle sync-evict --dry-run ``` Every command's own `Long` text is the reference — it is what `kettle help ` prints and what the generator splices into [`scaffold`](../scaffold/AGENTS.md)'s documents. **Do not restate a flag table here**; it would be a third copy of something already in two places, one of them mechanically checked. ## `--interactive`, and the two things it may never become `interactive.go` is the only code in this binary that reads a terminal. Two rules hold it in place, and both are about what it must not turn into. **It answers no question the flags cannot answer.** Every prompt has a field in `initOptions` and therefore a flag — `--login`, `--repo`, `--scaffold-out`, `--no-scaffold`, `--mirror-hook`. A capability reachable only by answering a prompt would be a capability no script, no CI run and no agent could ever use. **It performs nothing.** It fills the struct in and hands it back, so `runInit` is one code path: the run that follows an interactive session is byte for byte the run somebody else gets from flags. It **refuses a standard input that is not a terminal**, and that refusal is load bearing rather than fussy. A model that reached for `--interactive` would otherwise block forever on a prompt nobody is going to answer; the error names the flags instead. It is also what makes the one genuinely better thing here possible — `term.ReadPassword` puts a token in no history, no file and no scrollback, which every other route into this binary can only approximate. `--repo` is offered with a guess read out of `.git/config`, parsed rather than shelled out to. `git` is one more thing that has to be installed and one more process to misread; a guess is worth what it costs, and anything unparseable is no guess at all. ## Writing into `.claude/settings.json` `settings.go` registers `kettle mirror --hook` on `PreToolUse(Bash)`, and is the only place this binary touches a file the operator owns and commits. It has three outcomes and the third is the whole reason it is not ten lines long: - **no file** — written, hook and all; - **a file already holding a `kettle mirror` hook** — nothing happens; - **a file holding something else** — **refused**, with the snippet printed to paste, unless `--force-settings`. Go's `encoding/json` cannot preserve key order, so any merge reformats the whole document. An operator who asked for a documentation hook and got an unrelated diff across a file they share with their team has been badly served; a snippet they paste costs them ten seconds. The refusal is reported on stderr and the run still **succeeds** — everything before it already happened, and reporting a failure would send somebody looking for damage there is none of. The registered command carries a `command -v kettle` guard. That line outlives this binary: an operator who uninstalls `kettle` would otherwise get `command not found` on every Bash call, from a hook they set up months ago and have long stopped thinking about. ## push and pull, the two halves of one rule The rule is that **the store holds what has not left this machine.** Both halves are worth reading in full before either file is touched. `push` (`push.go`) deletes `.md` and every sidecar under that slug — on create and on `--update` alike, one rule with no exception, because a `PATCH` is a push and two rules would put back exactly the question this removes ("is my copy the fresh one?"). The deletion is the **last** thing that happens, and only after all three of: 1. the call came back without an error and with a 2xx, 2. the answer carries a plausible number — on `--update`, the very number that was `PATCH`ed, 3. the number → slug ledger has been written. Network down, non-2xx, an answer that does not confirm the write: the file stays and the run stops. Get the ordering wrong and a slug is lost at exactly the moment the local copy stops being the record, which is why the ledger is written *before* anything is deleted. A never-pushed `origin: local` issue is never touched by any of it. Dependencies go first, in topological order, so a blocker has its number before the issue that names it. An `--update` can take one extra request with it, because Gitea's edit endpoint carries no labels — when the answer's label set and the issue's disagree the whole set goes up in a `PUT`, and a warning on stderr says which names moved. `pull` (`pull.go`) is how a pushed issue comes back. Three sources answer "what is this issue called here", in this order: the ledger (the only one that knows what is on disk *right now*, so it wins), the `` marker in the tracker-side body, then the slugified title. A marker is taken at its word only when the slug is free; a name already in use is a collision, not an identity, and is uniquified. Two ways to name what to pull, and they are **not the same operation**: a key is an *address* and fetches an issue in any state, while a filter is a *query* and leaves closed issues out. `--limit` is on the **write**, not the selection — it counts what lands in the store, which is why a filtered pull can enumerate far more than it keeps and says so. Blockers come down too, recursively to `--depth`, and are outside the limit: a blocker is followed because a stored issue named it. A pull **overwrites the body** — it is a fetch, not a merge — with checkbox state the one exception. ## The generator `gen.go` writes a project's `.claude/` tree: [`scaffold`](../scaffold/AGENTS.md)'s embedded documents, with this registry's flag tables spliced into the four that declare a region. **It writes every file whole, and that is a deliberate reversal.** The old generator owned a region and left every byte outside it alone, because the prose around the block was somebody's hand-written file in this repository. It is not any more — it is embedded — so there is no hand-written half left to protect, and preserving local edits would freeze a project's documentation at whatever version first initialized it. The markers stay in the output so a reader can still see which half came from the registry. The principle the old refusal protected did not go away; it moved. **Nobody's prose is clobbered because nobody's prose is there** — it lives in `assets/`, next to the code it describes, and `--check` warns before an upgrade replaces a local edit. The output is deterministic to the byte — no timestamps, no map iteration — so regenerating something unchanged produces no diff. `--check` is that property made useful: it writes nothing and exits 1 when anything on disk differs, which is what a pre-commit hook or a CI step calls, and it wins over `--dry-run`. One document per **group**, so adding a group here means adding one under `internal/scaffold/assets`; name a group only when it is a subject somebody would load on its own. `TestEveryGroupHasSomewhereToBeWritten` is what makes that a two-step change rather than a silent one-step mistake — the generator walks the documents, not the registry, so a group with no document would have its commands written nowhere and nothing would say so. A `Long` or `Example` that spells a region marker out in full is a hard error: the generated block would end inside itself. `--out` defaults to `/.claude`, resolved by the same walk everything else uses, and no marker is an answer rather than a fallback. ## Keeping this file true - **Scope:** the shape of the command tree — the registry, the shared helpers, the three conventions, the round trip, the generator. The file table names every source file in this directory. - **Update it when** a command file is added or removed, a group is added, a shared helper in `flags.go`/`sync.go` changes, an exit code gains a meaning, a command gains a confirmation gate, the push/pull ordering guarantees change, or the rules around writing into `.claude/` change. - **Do not** copy a flag list or a command's `Long` text here. `kettle help ` and the generated SKILL.md blocks are the two places that exist for it, and a third would be the one that drifts.