feat: add the kettle CLI, replacing the plugin's Python scripts
The plugin resolved its issue store from `__file__`, which put it inside a versioned plugin cache: issues written from one project were invisible from the next, and `origin: local` files — the only copy of that work by definition — were stranded a version bump at a time. The walk that answers "which directory is the project" was written three times over, and in a linked worktree the three disagreed. Both are runtime failures rather than logic ones, so the fix is a compiled binary: one walk, imported rather than re-derived, and a layering rule the build graph enforces instead of a grep. Seven packages, knowledge flowing one way. `project` answers which directory is the project and depends on nothing. `issue` is the domain — format, taxonomy, validation, checkboxes, dependency graph, the store, eviction — offline, with no tracker in it. `wire` holds the protocol shapes. `gitea` is the transport, `mapping` the bridge, `config` the credentials, `cmd` the command tree. Four tests hold the boundaries, each failing on a real mistake rather than a naming convention. The marker moves to `.kettle/` and the login pin moves out of the harness's settings file into `.kettle/config.yaml`, which pins a login by NAME; the tokens live in one file per machine, mode 0600, outside every working tree. That retires the PreToolUse guard hook entirely — the binary holds its own credentials, so a command running under a login nobody chose is not expressible rather than caught. `kettle init` migrates an older `tmp/issues` or `.tea/issues` store in, as a move: a store left behind at an old path is one somebody edits by accident months later. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+233
@@ -0,0 +1,233 @@
|
||||
# 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.
|
||||
|
||||
`<project>/.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
|
||||
|
||||
`<project root>/.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 `<id>.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 `<!-- kettle:id … -->`, 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 `<!-- tea:id … -->`, 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.
|
||||
Reference in New Issue
Block a user