# AGENTS.md — the kettle CLI `kettle` is a globally installed binary. It owns everything that used to be a Python script under `plugins/tea/skills/*/scripts/`: what an issue is, where the store lives, who this machine is, and (once the transport lands) how issues move to and from Gitea. The plugin keeps what only a plugin can carry — the rules an operator states and a binary cannot enforce. Everything mechanical is here. ## Why a binary Three failures in the Python version were failures of *runtime*, not of logic: - the store resolved from `__file__`, so it landed inside a versioned plugin cache and issues written from one project were invisible from the next; - the walk that answers "which directory is the project" was written three times — store, login pin, guard hook — and in a linked worktree the three disagreed; - `sys.path.insert` was the import mechanism, so the layering rule was a convention checked by grep. A compiled binary answers all three by construction. There is one walk (`internal/project`), it is imported rather than re-derived, and the layering rule is a build graph a test walks. ## One dependency `gopkg.in/yaml.v3`, vendored, and that is the whole list. Everything else is the standard library: the transport is plain `net/http` against a documented REST API, and the CLI has no cobra — commands are values in a registry, which is what lets the plugin's SKILL.md files be generated from the same struct that holds the code. `vendor/` is committed, so a build needs no network. ## Layers Knowledge flows one way. The arrow means "imports". ``` cmd/kettle thin main; exit status only internal/cmd the command tree: flags, receipts, exit codes │ │ │ │ │ └────► internal/config who this machine is, what this │ │ project points at; yaml lives here │ │ and only here │ └───────────► internal/gitea TRANSPORT: one door for every │ │ request, pagination, payload dumps, │ │ the number -> slug ledger │ ▼ ├────────────────────► internal/wire PROTOCOL: the JSON shapes and the │ ▲ identifiers. Imports nothing. │ │ └──► internal/mapping ─────┘ BRIDGE: md <-> those shapes, pure, │ no I/O; label colours live here ▼ internal/issue DOMAIN what an issue is: format, taxonomy, validation, │ checkboxes, dependency graph, the store, eviction │ offline — no tracker, no network, no JSON ▼ internal/project ROOT which directory is the project, and every path resolved from it: store, payload, config depends on nothing ``` Read it bottom-up and each layer knows strictly less about trackers than the one above it. Four tests hold the line, and each fails on a real mistake rather than on a naming convention: - `internal/issue` may import `internal/project` and the standard library, and nothing else. One test walks `go list -deps` and fails on any path with a dot in its first element — which is also what keeps yaml out of the domain — and another names `net/http`, `net`, `os/exec` and `encoding/json`, standard library the first test would not catch. - `internal/wire` imports only the standard library, checked the same two ways. - `internal/gitea` must not import `internal/issue` **or** `internal/mapping`: the transport knows numbers, logins, HTTP and JSON, and none of what they mean. - `internal/mapping` performs no I/O and imports neither the transport nor the configuration. `wire` exists because Go needs the JSON shapes to be one type. The transport and the bridge were written in parallel and each invented its own `Issue`, `Label`, `Milestone` and `Comment`; every command on top would then have copied fields from one struct into the other by hand, which is two vocabularies for one thing — exactly what this layering exists to prevent. Python did not have the problem because it passed dicts. If a tracker concept — an issue number, a login, an HTTP call, a label colour — shows up in `internal/issue`, it is in the wrong place. ## The walk `internal/project` answers one question and everything else reads the answer. Anchors, first hit wins: `$CLAUDE_PROJECT_DIR`, then the working directory. Each is searched up its parent chain for a `.kettle/` marker, and then — only if that found nothing — up the parent chain of the **main working tree of any linked worktree** met on the way, reached by reading `gitdir:` out of a `.git` *file* and following `commondir`. Nothing here resolves from the executable's own location. Where an installation keeps its files is a fact about the installation; whose issues a tree has is a fact about the tree, and a binary installed in one place and pointed at another must answer from the one it was pointed at. **The marker is created by `kettle init`, never inferred.** `.git` was tried and is in every clone. No marker anywhere is an answer, not a fallback: the command reports which directories it searched and stops. ## Configuration, and where secrets are not Two files, and the split is the whole design. `/.kettle/config.yaml` — written by `kettle init`, says which tracker repository the issues belong to and which login to reach it under. It pins a login by **name**. The name is worth nothing on its own, which is what makes it safe to keep in a file inside a working tree. `~/.config/kettle/logins.yaml` (`$KETTLE_CONFIG_HOME` or `$XDG_CONFIG_HOME` override it) — one file per machine, mode 0600, holding the tokens. Managed with `kettle auth`, which reads the token from standard input by default because an argument is in the shell history the moment it is typed. Nothing prints a token back: `kettle config` shows `(set)`. A token in a file inside a working tree ends up in a commit. Not always, not immediately, and not by anyone careless — but a project config is exactly the file somebody eventually decides to share, and a secret that has ever been pushed has to be rotated. `KETTLE_LOGIN`, `KETTLE_REPO`, `KETTLE_URL` and `KETTLE_TOKEN` each override the file they shadow, for CI and for anyone who would rather have no token on disk. Unknown keys in either file are an **error**, not a silent drop: an older binary reading a newer config would otherwise delete the setting it did not recognize the next time it wrote the file. `init` gitignores `.kettle/` wholesale. An `origin: local` issue is the only copy of that work, and what goes into a shared history is the operator's call, not this binary's — drop the line if the team decides otherwise. ## The store `/.kettle/issues`, one flat markdown file per issue, named by its slug, one metadata field per line so plain grep works without a parser. It holds two kinds of file and only one of them is a store. An `origin: local` issue lives here and nowhere else — that file **is** the issue. Anything with a tracker origin is a working copy, deleted the moment a push confirms the tracker is up to date. Eviction makes the same trade one step earlier and asks the file instead of the tracker, which is why it lives in the domain. `.kettle/payload/` is a sibling, never a child: request bodies are debris of the transport, and a scratchpad inside a store makes `ls .kettle/issues` lie about what exists. `kettle init` migrates older layouts in, oldest first — `tmp/issues` and then `.tea/issues` — and each is a **move**. A store left behind at an old path is a store somebody will edit by accident months later. It refuses to pick a winner when both sides hold a file of the same name. ## Tests ```bash cd cli && go test ./... ``` `internal/cmd` builds the binary once in `TestMain` and runs it as a subprocess against a throwaway project in a temp directory — the same discipline the Python suite arrived at, for the same reason. **The binary is never run in the directory it was built in**, because that is exactly the arrangement that hid the `__file__` bug: a tool is installed in one place and used on projects in another, and a test that collapses the two proves nothing about resolution. Every fixture strips `CLAUDE_PROJECT_DIR`. It is the first anchor of the walk, so the harness's own value would point every fixture at this repository. Anything touching credentials sets `KETTLE_CONFIG_HOME` at a temp directory, so a test run can neither read nor overwrite the developer's own tokens. ## The round trip `push` and `pull` are the two halves of one rule, and the rule is that **the store holds what has not left this machine.** A successful push deletes `.md` and every sidecar under that slug, on create and on `--update` alike, and prints the number and URL the issue now lives at. The deletion happens **only after a confirmed tracker response and only after the number -> slug ledger has been written** — network down, non-2xx, or an answer that does not carry the right number, and the file stays where it is while the run stops. A never-pushed `origin: local` issue is never touched by any of it. The slug survives that round trip two ways over, and a test proves both: it goes up in the body as ``, and it is indexed by number in `.kettle/issues/.remote.json`. A rename in the web UI, a lost ledger, a fresh clone, another machine — the file comes back under the same name and every `depends:` pointing at it still resolves. The marker is written in that spelling and read in both it and the older ``, because issues pushed before the rename are still in the tracker. Pull by number fetches an issue in **any** state: a number is an address, not a query, and `42`, `#42`, `owner/repo#42` and a URL all name one. Only filter mode leaves closed issues out. A pull returns the unit of work rather than one row of it — blockers come down with it unless `--no-deps` says otherwise — and it overwrites the body, because it is a fetch and not a merge. The one exception is checkbox state, which is the local half of the work and is merged rather than clobbered. ## No guard hook The Python version needed a `PreToolUse` hook to block any `tea` command that would run under a login the model picked instead of the operator. That whole apparatus is gone. The binary holds its own credentials and reads the login out of the project's own configuration, so there is no argument to police and no way for the transport and the guard to disagree — the failure the hook existed to catch is not expressible any more. There is also no `--login` and no `--repo` on any sync command bar `labels`. Which login a project runs under, and which repository its issues belong to, are facts about the project, stated once by `kettle init`. A cross-repository address is still an address: `kettle pull owner/repo#42` re-points the client for that one call. ## Status Done and tested: all seven packages, and the commands `init`, `auth`, `config`, `new`, `check`, `ac`, `tree`, `index`, `evict`, `pull`, `push`, `remote`, `comment`, `close`, `labels`, `sync-evict`. 89 tests. Not done: the plugin still ships the Python scripts and the guard hook, and still resolves `.tea/`. Rewiring `plugins/tea` onto this binary — and generating its SKILL.md files from the command registry, so the docs cannot drift from the CLI — is the remaining work.