9480e48312
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>
234 lines
12 KiB
Markdown
234 lines
12 KiB
Markdown
# 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.
|