feat: drop the kettle plugin; the binary writes its own skills

The plugin and the binary shipped on two release cadences and nothing on an
operator's machine ever checked that the one they installed described the other.
The generated flag block existed precisely so a renamed flag could not ship with
documentation recommending the old one — and then shipped one version behind the
registry it came from, which is the same bug one hop downstream.

So the prose moved into the binary. `internal/scaffold` embeds every document;
`kettle init` and `kettle gen scaffold` write them into a project's own
`.claude/`. The two cannot disagree because there is one artefact.

The namespace survived the move. A project's skills are flat, so the prefix is
spelled into the directory name (`kettle-issue`); a project's *commands* take
their namespace from a subdirectory, so `commands/kettle/init.md` is still
`/kettle:init`. Four of the six command files are thin pointers at a skill, and
that is what kept ~1,600 lines of `/kettle:…` cross-references true without a
rewrite. `init` and `auth` lost `disable-model-invocation: true` — being a
command is that property — and `auth` now restricts `allowed-tools` so a model
cannot reach `kettle auth add` at all.

`gen scaffold` writes files whole rather than splicing a region. The old
refusal protected somebody's hand-written prose around the block; that prose is
embedded now, so there is none to protect, and preserving local edits would
freeze a project's documentation at whatever version first initialized it.
`--check` warns before an upgrade discards one.

The plugin's `agents-sync.sh` — 141 lines of Python behind a filename that said
`.sh` — became `internal/mirror` and `kettle mirror`. Same seven branches, same
refusal to merge two real files that differ, now with a table test per branch
and a check that a repair converges in one pass. `--hook` is the PreToolUse
form and exits 0 on every path including a panic. It is opt-in per project,
which is strictly narrower than the plugin hook that was on for everybody who
installed it.

`kettle init --interactive` walks a person through the login, the token (read
with the echo off, so it lands in no history and no file), the repository, the
`.claude/` tree and the mirror hook. It refuses a stdin that is not a terminal
and names the flags instead: every question it asks has one, and it performs
nothing itself, so an interactive run and a flag run are one code path.

Two rules that used to be prose are now the binary's: init refuses a linked
worktree and names the main checkout, and writing into an existing
`.claude/settings.json` is refused with the snippet printed rather than
reformatting a file the operator commits.

The scaffold version stamp went to its own `.kettle/scaffold.yaml` rather than
into `config.yaml`, because unknown keys there are a hard error and that file
may be committed and read by whatever build each machine has.

golang.org/x/term becomes a direct dependency; it was already in the tree
indirectly, so no module was added.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
naudachu
2026-08-12 16:17:24 +05:00
parent f18a633185
commit 8b1b11001a
445 changed files with 231172 additions and 1339 deletions
+18 -4
View File
@@ -1,6 +1,6 @@
# AGENTS.md — internal/, and the boundaries between the packages in it
Seven packages, one direction of knowledge. The diagram is in
Nine packages, one direction of knowledge. The diagram is in
[`cli/AGENTS.md`](../AGENTS.md); **this file owns the rules that hold it and the
tests that fail when one is broken.** Each package's own document owns what is
inside it.
@@ -12,7 +12,7 @@ number, a login, an HTTP call, a label colour — that shows up in
section, an acceptance criterion, a type taxonomy — that shows up in
[`gitea`](gitea/AGENTS.md) is in the wrong place too.
## Four rules, seven tests
## Six rules, eleven tests
Each test fails on a real mistake rather than on a naming convention.
@@ -22,10 +22,24 @@ Each test fails on a real mistake rather than on a naming convention.
| [`wire`](wire/AGENTS.md) imports **only** the standard library | `TestWireDependsOnNothing` and `TestWireReachesNeitherTheNetworkNorTheDisk`, the same two checks |
| [`gitea`](gitea/AGENTS.md) must not import [`issue`](issue/AGENTS.md) **or** [`mapping`](mapping/AGENTS.md) | `TestTransportDoesNotImportTheDomain` — the transport knows numbers, logins, HTTP and JSON, and none of what they mean |
| [`mapping`](mapping/AGENTS.md) reaches for nothing but the domain, `wire` and the SDK, and does no I/O | `TestTheBridgeTranslatesAndNothingElse` on its **direct** imports, with `os`, `net/http`, `internal/gitea`, `internal/config` and `internal/project` named; `TestTheBridgeHasNoClock` greps its sources for `time.Now` |
| [`mirror`](mirror/AGENTS.md) imports **only** the standard library, and shells out to nothing | `TestMirrorDependsOnNothing` and `TestMirrorNeitherDialsNorShellsOut` |
| [`scaffold`](scaffold/AGENTS.md) imports **only** the standard library, and reads nothing off the disk | `TestScaffoldDependsOnNothing` and `TestScaffoldReadsNothingOffTheDisk` |
The domain's two tests were **untouched by the migration to the Gitea SDK, and
that is the point: the domain did not notice it happened.**
The last two rows are the newest and are there for the same reason as `wire`'s,
turned outward rather than inward. `mirror` walks any directory on the machine
and `scaffold` hands out documents that must exist wherever the binary does — so
one import of `internal/config` would make `mirror` unusable outside a project,
and one `os.ReadFile` would make `scaffold`'s documents files that can be
missing. Neither failure would show up in this repository; both would show up on
somebody else's machine.
`mirror` bans `os/exec` by name, and that one is a small monument: this package
was 141 lines of Python behind a filename that said `.sh`, so the shell-out it
must not grow is the exact thing it used to be.
## The one rule that got weaker, and why the trade was taken
The payload shapes used to live in `wire`, a package that imported the standard
@@ -76,8 +90,8 @@ and add its rule to the table above.
## Keeping this file true
- **Scope:** the boundaries *between* the packages under `internal/` — the four
rules, the seven tests that hold them, and the history of the one that changed.
- **Scope:** the boundaries *between* the packages under `internal/` — the six
rules, the eleven tests that hold them, and the history of the one that changed.
Files: every `layering_test.go`, plus `TestTransportDoesNotImportTheDomain` in
`gitea/client_test.go`.
- **Update it when** a layering test is added, renamed, removed or weakened; when
+105 -28
View File
@@ -11,9 +11,9 @@ and therefore cannot be tested.
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 the plugin's SKILL.md files be generated
from this list: a command whose flags changed cannot ship with documentation that
says otherwise.
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() {
@@ -47,20 +47,34 @@ token of every invocation, and the grouping that matters for reading is carried
| `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 skills`: the generated region in the plugin's SKILL.md files |
| `init.go` `auth.go` `config.go` `version.go` | group `project`. `version.go` also holds `Version`, the string a release build stamps in with `-ldflags -X` |
| `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: determinism, the region splice, the missing-marker refusal |
| `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 directory over in the plugin: `api` is a subject somebody loads on its own —
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
@@ -114,7 +128,9 @@ because that is what the operator asked for.
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
@@ -125,9 +141,59 @@ kettle sync-evict --dry-run
```
Every command's own `Long` text is the reference — it is what
`kettle help <name>` prints and what the generator writes into the plugin. **Do not
restate a flag table here**; it would be a third copy of something already in two
places, one of them mechanically checked.
`kettle help <name>` 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
@@ -173,30 +239,40 @@ the body** — it is a fetch, not a merge — with checkbox state the one except
## The generator
`gen.go` writes the plugin's SKILL.md command reference from this registry.
`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 owns a region, not a file.** Everything between `<!-- kettle:gen -->` and
`<!-- /kettle:gen -->` is replaced on every run; every byte outside comes back
exactly as it was, which matters most for `description:`, the prose that decides
whether an agent loads the skill at all and the one thing here no generator can
write. A file with **no** markers is reported and left alone, never overwritten —
clobbering somebody's prose because they forgot a marker is the failure this design
exists to prevent.
**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`.
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 file per **group**, so adding a group here adds a skill directory over there;
name one only when it is a subject somebody would load on its own. `api` was
added exactly that way, and the first `gen skills` run after it wrote a stub whose
`description:` said TODO — a stub is not shippable, and filling that line in by
hand is the last step of adding a group, not an optional one. A command with no
`Group` is in no skill and the run says so. A `Long` or `Example` that spells a
region marker out in full is a hard error the generated block would end inside
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 `<project>/.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
@@ -204,7 +280,8 @@ itself.
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, or the push/pull ordering guarantees change.
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 <name>`
and the generated SKILL.md blocks are the two places that exist for it, and a
third would be the one that drifts.
+3 -3
View File
@@ -2,9 +2,9 @@
//
// Commands are values, not init() side effects on a framework: each one carries
// the metadata a human needs (what it does, what it takes, worked examples) in
// the same struct that carries the code. That is deliberate — the plugin's
// SKILL.md files are generated from this list, so a command whose flags changed
// cannot ship with documentation that says otherwise.
// the same struct that carries the code. That is deliberate — the skills kettle
// writes into a project are generated from this list, so a command whose flags
// changed cannot ship with documentation that says otherwise.
//
// 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
+28
View File
@@ -37,6 +37,7 @@ This is the command to reach for when the store looks empty, when a push says
fmt.Printf("payload %s\n", project.PayloadRoot(""))
fmt.Printf("config %s\n", config.ProjectPath(""))
fmt.Printf("logins %s\n", config.LoginsPath())
printScaffold()
r, err := config.Resolve("")
if err != nil {
@@ -66,3 +67,30 @@ This is the command to reach for when the store looks empty, when a push says
},
})
}
// printScaffold says where the agent-harness tree went and whether the build
// that wrote it is the build that is running now.
//
// The mismatch is worth a line because these documents are generated whole:
// there is nothing inside one that tells an operator how old it is, and a skill
// four releases behind describes flags that have since been renamed. It is a
// note, never a failure — `kettle gen scaffold` is the fix and the operator
// decides when to run it.
func printScaffold() {
rec, existed, err := config.ReadScaffoldFile(config.ScaffoldPath(""))
if err != nil || !existed {
fmt.Printf("scaffold (none written — `kettle gen scaffold`)\n")
return
}
out := rec.Out
if out == "" {
out = "(unrecorded)"
}
switch {
case rec.Version == Version:
fmt.Printf("scaffold %s (kettle %s)\n", out, rec.Version)
default:
fmt.Printf("scaffold %s (written by kettle %s; this is %s — run `kettle gen scaffold`)\n",
out, rec.Version, Version)
}
}
+144 -125
View File
@@ -9,21 +9,24 @@ import (
"path/filepath"
"strings"
"unicode/utf8"
"git.noodles.cam/claude-skills/marketplace/cli/internal/project"
"git.noodles.cam/claude-skills/marketplace/cli/internal/scaffold"
)
// The region markers. What sits between them belongs to the generator; the
// rest of the file belongs to whoever wrote it.
// The region markers. What sits between them comes from the registry; the rest
// of the document is the embedded prose around it.
const (
genOpen = "<!-- kettle:gen -->"
genClose = "<!-- /kettle:gen -->"
)
// genBanner opens every generated region. The first thing anybody who finds
// the block wants to do is edit it in place, so the block says who wrote it and
// genBanner opens every generated region. The first thing anybody who finds the
// block wants to do is edit it in place, so the block says who wrote it and
// which command writes it again.
const genBanner = "**Generated from the kettle command registry by `kettle gen skills`.** " +
const genBanner = "**Generated from the kettle command registry by `kettle gen scaffold`.** " +
"Everything between the two markers is replaced on the next run — " +
"hand-written prose belongs outside them."
"the prose around it is embedded in the binary and replaced with it."
// exampleAlign is the widest example command that still gets its `# what`
// padded into a column. One long pipeline would otherwise push every other
@@ -34,176 +37,199 @@ func init() {
register(&Command{
Name: "gen",
Group: GroupProject,
Args: "skills",
Short: "write the plugin's SKILL.md files from the command registry",
Long: `A SKILL.md tells an agent how to invoke this binary. Hand-written, it drifts: a
flag is renamed here and the documentation goes on recommending the old one,
and the agent that reads it fails in a way nobody traces back to a stale
sentence. Everything those files say about a command — its usage line, its
flags with their defaults, its worked examples — is already in the registry
this binary is built from, so it is written from there and cannot disagree.
Args: "scaffold",
Short: "write this project's .claude/ commands, skills and subagent",
Long: `A skill tells an agent how to invoke this binary, and a command is how an
operator invokes one by hand. Both are written from here, whole, because both
travel INSIDE the binary: the prose is embedded next to the code it describes
and the flag tables are rendered from the command registry the binary is built
from, so neither can be a version behind the other.
THE GENERATOR OWNS A REGION, NOT A FILE. Each SKILL.md carries a pair of HTML
comment markers — ` + "`kettle:gen`" + ` to open and ` + "`/kettle:gen`" + ` to close, both written in
the ` + "`<!-- … -->`" + ` form and visible at the top and bottom of the block below.
Everything between them is replaced on every run; every byte outside them comes
back exactly as it was, which matters most for ` + "`description:`" + `, the prose that
decides whether an agent loads the skill at all, and the one thing here that no
generator can write.
That is the whole reason these documents are not a plugin any more. A plugin
ships on its own cadence, and nothing on an operator's machine ever checked that
the one they installed described the binary they installed — so a renamed flag
could still arrive with documentation recommending the old one, which is exactly
the failure the generated block was invented to prevent, one hop further
downstream.
A file with no markers is REPORTED AND LEFT ALONE, never overwritten: clobbering
somebody's prose because they forgot a marker is the failure this design exists
to prevent. A file that does not exist yet is created with a frontmatter stub
around a generated block, for a human to fill in.
EVERY FILE IS WRITTEN 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. It is not any more: it is
embedded, so there is no hand-written half left to protect, and preserving local
edits would mean freezing a project's documentation at whatever version first
initialized it. The markers stay in the output so a reader can see which half
came from the registry.
WHAT THIS MEANS FOR A LOCAL EDIT: it does not survive. Run --check before an
upgrade if you have made one; the fix for a sentence that is wrong is a newer
kettle, not a patch that the next run silently discards.
The output is deterministic to the byte — no timestamps, no map iteration — so
regenerating something that has not changed produces no diff. --check is that
property made useful: it writes nothing and exits 1 when any file on disk
differs from what would be generated, which is what a pre-commit hook or a CI
step calls. It wins over --dry-run when both are given.`,
differs from what would be written, which is what a pre-commit hook or a CI step
calls. It wins over --dry-run when both are given.`,
Examples: []Example{
{"kettle gen skills --out ../plugins/kettle/skills", "write the region in every group's SKILL.md"},
{"kettle gen skills --out ../plugins/kettle/skills --dry-run", "print what would change; write nothing"},
{"kettle gen skills --out ../plugins/kettle/skills --check", "exit 1 if the docs are out of date"},
{"kettle gen scaffold", "write .claude/ under this project"},
{"kettle gen scaffold --out ~/code/x/.claude", "write it somewhere else"},
{"kettle gen scaffold --dry-run", "print what would change; write nothing"},
{"kettle gen scaffold --check", "exit 1 if the documents are out of date"},
},
Setup: func(fs *flag.FlagSet) func([]string) error {
out := fs.String("out", "", "directory the skills live in; one <group>/SKILL.md under it")
out := fs.String("out", "", "directory to write into (default: <project>/"+scaffold.Marker+")")
dryRun := fs.Bool("dry-run", false, "print what would change; write nothing")
check := fs.Bool("check", false, "write nothing, exit 1 if anything is out of date")
return func(args []string) error {
target := "skills"
target := "scaffold"
if len(args) > 0 {
target = args[0]
}
if len(args) > 1 || target != "skills" {
return Fail("the only target is `skills` — try `kettle gen skills --out <dir>`")
if len(args) > 1 || target != "scaffold" {
return Fail("the only target is `scaffold` — try `kettle gen scaffold`")
}
if *out == "" {
return Fail("--out is required — the directory the SKILL.md files live under")
dir, err := scaffoldDir(*out)
if err != nil {
return err
}
return genSkills(*out, *dryRun, *check)
return genScaffold(dir, *dryRun, *check)
}
},
})
}
// errNoRegion is what a file that the generator may not touch reports.
var errNoRegion = errors.New("no " + genOpen + " … " + genClose + " region")
// scaffoldDir resolves where the tree goes.
//
// An explicit --out is used exactly as typed, relative and all, because that is
// what the operator asked for. Without one the answer comes from the marker, the
// same walk every other command uses — and no marker is an answer rather than a
// fallback, because a `.claude/` written into a plausible-looking directory is
// the failure the marker exists to replace.
func scaffoldDir(out string) (string, error) {
if out != "" {
return out, nil
}
root := project.Root("")
if root == "" {
return "", project.NotFoundError("")
}
return filepath.Join(root, scaffold.Marker), nil
}
func genSkills(dir string, dryRun, check bool) error {
func genScaffold(dir string, dryRun, check bool) error {
// --check is a read-only question about the working tree, so it overrules
// --dry-run rather than combining with it.
if check {
dryRun = true
}
groups := docGroups()
var written, unchanged, outdated, kept int
for _, group := range groups {
path := filepath.Join(dir, group, "SKILL.md")
block, err := renderGroup(commandsIn(group))
if err != nil {
return err
}
files, err := renderAll()
if err != nil {
return err
}
var written, unchanged, outdated int
for _, f := range files {
path := filepath.Join(dir, filepath.FromSlash(f.Path))
existing, err := os.ReadFile(path)
switch {
case errors.Is(err, fs.ErrNotExist):
outdated++
if check {
fmt.Printf("%-13s %s\n", "missing", path)
continue
}
if dryRun {
fmt.Printf("%-13s %s\n", "would create", path)
continue
}
if err := writeFile(path, stubFile(group, block)); err != nil {
if err := report(path, "missing", "would create", "created", dryRun, check, func() error {
return writeFile(path, f.Body)
}); err != nil {
return err
}
written++
fmt.Printf("%-13s %s\n", "created", path)
if !dryRun {
written++
}
case err != nil:
return err
case string(existing) == f.Body:
unchanged++
fmt.Printf("%-13s %s\n", "unchanged", path)
default:
want, err := spliceRegion(string(existing), block)
if err != nil {
// Reported, never repaired: a missing marker is somebody's
// prose sitting where the block used to be.
kept++
fmt.Fprintf(os.Stderr, "kettle gen: %s left alone — %v\n", path, err)
continue
}
if want == string(existing) {
unchanged++
fmt.Printf("%-13s %s\n", "unchanged", path)
continue
}
outdated++
if check {
fmt.Printf("%-13s %s\n", "stale", path)
continue
}
if dryRun {
fmt.Printf("%-13s %s\n", "would update", path)
continue
}
if err := writeFile(path, want); err != nil {
if err := report(path, "stale", "would update", "updated", dryRun, check, func() error {
return writeFile(path, f.Body)
}); err != nil {
return err
}
written++
fmt.Printf("%-13s %s\n", "updated", path)
if !dryRun {
written++
}
}
}
switch {
case check:
fmt.Printf("%d file(s) checked, %d out of date, %d without a region\n",
len(groups), outdated, kept)
fmt.Printf("%d file(s) checked, %d out of date\n", len(files), outdated)
if outdated > 0 {
fmt.Printf("run `kettle gen skills --out %s`\n", dir)
fmt.Printf("run `kettle gen scaffold --out %s`\n", dir)
return SilentError{Code: 1}
}
case dryRun:
fmt.Printf("%d file(s) would change, %d unchanged, %d without a region — nothing was written\n",
outdated, unchanged, kept)
fmt.Printf("%d file(s) would change, %d unchanged — nothing was written\n", outdated, unchanged)
default:
fmt.Printf("%d file(s) written, %d unchanged, %d without a region\n", written, unchanged, kept)
fmt.Printf("%d file(s) written, %d unchanged\n", written, unchanged)
}
return nil
}
// docGroups lists the groups that have commands, in the order Commands()
// returns them — the same order twice, so two runs cannot differ.
func docGroups() []string {
var out []string
seen := map[string]bool{}
for _, c := range Commands() {
if c.Group == "" {
fmt.Fprintf(os.Stderr, "kettle gen: command %q has no group and is in no skill\n", c.Name)
continue
}
if !seen[c.Group] {
seen[c.Group] = true
out = append(out, c.Group)
// report prints one line for one file and performs the write unless this run is
// only answering a question.
func report(path, checkWord, dryWord, doneWord string, dryRun, check bool, write func() error) error {
switch {
case check:
fmt.Printf("%-13s %s\n", checkWord, path)
case dryRun:
fmt.Printf("%-13s %s\n", dryWord, path)
default:
if err := write(); err != nil {
return err
}
fmt.Printf("%-13s %s\n", doneWord, path)
}
return out
return nil
}
func commandsIn(group string) []*Command {
var out []*Command
for _, c := range Commands() {
if c.Group == group {
out = append(out, c)
// renderAll is every embedded document with its generated region filled in.
//
// Nothing here touches the disk: the result is what the tree SHOULD be, and
// comparing it against what is there is a separate question asked by the caller.
// That split is what lets --check be exact rather than a heuristic about
// timestamps.
func renderAll() ([]scaffold.File, error) {
files := scaffold.Files()
out := make([]scaffold.File, 0, len(files))
for _, f := range files {
if f.Group == "" {
out = append(out, f)
continue
}
block, err := renderGroup(commandsIn(f.Group))
if err != nil {
return nil, err
}
body, err := spliceRegion(f.Body, block)
if err != nil {
// The embedded document is shipped inside this binary, so a missing
// marker is a build-time mistake in this repository and not
// something an operator can have caused.
return nil, Fail("%s: %v — this is a bug in the embedded document, not in your project", f.Path, err)
}
f.Body = body
out = append(out, f)
}
return out
return out, nil
}
// errNoRegion is what a document that declares a region and has none reports.
var errNoRegion = errors.New("no " + genOpen + " … " + genClose + " region")
// renderGroup is the generated block for one group, without the markers and
// without a trailing newline.
func renderGroup(cmds []*Command) (string, error) {
@@ -295,23 +321,16 @@ func spliceRegion(existing, block string) (string, error) {
return existing[:start] + region(block) + existing[rest+end+len(genClose):], nil
}
// stubFile is a new SKILL.md: the least frontmatter that is still a skill,
// and the region.
//
// The description is left as a TODO on purpose. It is the sentence that decides
// whether an agent loads this skill at all — prose a human tunes against real
// failures to trigger, and the one thing here a generator has no way to write.
func stubFile(group, block string) string {
title := "# kettle " + group + "\n"
if blurb := groupBlurb[group]; blurb != "" {
title += "\n" + blurb + "\n"
// commandsIn lists a group's commands in the order Commands() returns them —
// the same order twice, so two runs cannot differ.
func commandsIn(group string) []*Command {
var out []*Command
for _, c := range Commands() {
if c.Group == group {
out = append(out, c)
}
}
return "---\n" +
"name: " + group + "\n" +
"description: TODO — write this by hand. It is the only thing that decides whether an agent loads this skill at all, so it is prose a human tunes; kettle gen never reads or writes it.\n" +
"---\n\n" +
title + "\n" +
region(block) + "\n"
return out
}
func writeFile(path, content string) error {
+118 -114
View File
@@ -1,9 +1,9 @@
package cmd_test
// `kettle gen` writes documentation an agent reads to invoke this binary, into
// files a human also writes prose in. Both halves of that are tested here: what
// it produces has to be the same twice over, and what it does NOT own has to
// come back byte for byte.
// `kettle gen scaffold` writes the documents an operator invokes and a model
// loads. They are embedded in the binary, so this file tests the two properties
// that follow from that: what it produces is the same twice over, and it is the
// binary's answer rather than whatever happens to be on disk.
import (
"os"
@@ -17,39 +17,66 @@ const (
genClose = "<!-- /kettle:gen -->"
)
func TestGenWritesOneFilePerGroupAndIsIdempotent(t *testing.T) {
dir := t.TempDir()
out := filepath.Join(dir, "skills")
// everything the tree is made of. Named here rather than derived, because a file
// that silently stopped being written is exactly the failure this catches.
var scaffoldFiles = []string{
"agents/kettle-runner.md",
"commands/kettle/api.md",
"commands/kettle/auth.md",
"commands/kettle/init.md",
"commands/kettle/issue.md",
"commands/kettle/project.md",
"commands/kettle/sync.md",
"skills/kettle-api/SKILL.md",
"skills/kettle-issue/SKILL.md",
"skills/kettle-issue/references/format.md",
"skills/kettle-project/SKILL.md",
"skills/kettle-sync/SKILL.md",
}
first := mustRun(t, dir, "gen", "skills", "--out", out)
for _, group := range []string{"project", "issue", "sync"} {
path := filepath.Join(out, group, "SKILL.md")
raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("%s was not created: %v\n%s", path, err, first.out())
func TestGenWritesTheWholeTreeAndIsIdempotent(t *testing.T) {
dir := t.TempDir()
out := filepath.Join(dir, "out")
first := mustRun(t, dir, "gen", "scaffold", "--out", out)
for _, rel := range scaffoldFiles {
path := filepath.Join(out, filepath.FromSlash(rel))
if _, err := os.Stat(path); err != nil {
t.Fatalf("%s was not created: %v\n%s", rel, err, first.out())
}
body := string(raw)
// The frontmatter is what makes it a skill at all, and the description
// is prose a human tunes — the stub says so and generates nothing.
if !strings.HasPrefix(body, "---\nname: "+group+"\n") {
t.Errorf("%s has no frontmatter naming the group:\n%s", path, firstLines(body, 5))
}
// The four skills that carry a flag table carry the markers around it, so a
// reader can see which half came from the registry.
for _, group := range []string{"project", "issue", "sync", "api"} {
path := filepath.Join(out, "skills", "kettle-"+group, "SKILL.md")
body := readFile(t, path)
if !strings.HasPrefix(body, "---\nname: kettle-"+group+"\n") {
t.Errorf("%s does not name itself after its directory:\n%s", path, firstLines(body, 4))
}
if !strings.Contains(body, genOpen) || !strings.Contains(body, genClose) {
t.Errorf("%s was created without the region markers:\n%s", path, body)
t.Errorf("%s has no region markers", path)
}
// The block has to say what wrote it: the first thing anybody who finds
// it will want to do is edit it in place.
if !strings.Contains(body, "kettle gen skills") {
t.Errorf("%s does not name the command that regenerates it:\n%s", path, body)
if !strings.Contains(body, "kettle gen scaffold") {
t.Errorf("%s does not name the command that regenerates it", path)
}
}
// A command is invoked by a person who typed it, and takes its name from its
// filename — a `name:` here would be a second spelling free to drift.
initBody := readFile(t, filepath.Join(out, "commands", "kettle", "init.md"))
if strings.Contains(firstLines(initBody, 6), "\nname:") {
t.Errorf("the init command carries a name: of its own:\n%s", firstLines(initBody, 6))
}
if !strings.Contains(initBody, "description:") {
t.Errorf("the init command has no description for the command list:\n%s", firstLines(initBody, 6))
}
// One command's documentation, end to end: usage line, short, a flag out of
// the flag set, and a worked example with its explanation beside it.
issues, err := os.ReadFile(filepath.Join(out, "issue", "SKILL.md"))
if err != nil {
t.Fatal(err)
}
issues := readFile(t, filepath.Join(out, "skills", "kettle-issue", "SKILL.md"))
for _, want := range []string{
"## `kettle evict [<id>…]`",
"remove closed issues from the local store",
@@ -57,8 +84,8 @@ func TestGenWritesOneFilePerGroupAndIsIdempotent(t *testing.T) {
"kettle evict --dry-run",
"# print what would go; touch nothing",
} {
if !strings.Contains(string(issues), want) {
t.Errorf("the issue group is missing %q:\n%s", want, issues)
if !strings.Contains(issues, want) {
t.Errorf("the issue skill is missing %q", want)
}
}
@@ -66,7 +93,7 @@ func TestGenWritesOneFilePerGroupAndIsIdempotent(t *testing.T) {
// changed must produce no diff at all, or every run of a CI step is a
// spurious one.
before := readAll(t, out)
second := mustRun(t, dir, "gen", "skills", "--out", out)
second := mustRun(t, dir, "gen", "scaffold", "--out", out)
if strings.Contains(second.stdout, "updated") {
t.Errorf("a second run rewrote a file:\n%s", second.out())
}
@@ -76,105 +103,56 @@ func TestGenWritesOneFilePerGroupAndIsIdempotent(t *testing.T) {
}
}
if r := run(t, dir, "gen", "skills", "--out", out, "--check"); r.code != 0 {
if r := run(t, dir, "gen", "scaffold", "--out", out, "--check"); r.code != 0 {
t.Errorf("--check exited %d on files that were just written:\n%s", r.code, r.out())
}
}
// The generator owns a region, not a file. Everything outside the markers is
// somebody's prose and comes back exactly as it was.
func TestGenLeavesHandWrittenProseAlone(t *testing.T) {
// The reversal, and the one behaviour worth stating out loud: these files are
// the binary's, whole. The old generator owned a region and left the prose
// around it alone, because that prose was somebody's hand-written file. It is
// embedded now — 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.
func TestGenReplacesLocalEditsRatherThanPreservingThem(t *testing.T) {
dir := t.TempDir()
out := filepath.Join(dir, "skills")
mustRun(t, dir, "gen", "skills", "--out", out)
out := filepath.Join(dir, "out")
mustRun(t, dir, "gen", "scaffold", "--out", out)
path := filepath.Join(out, "issue", "SKILL.md")
raw := readFile(t, path)
start := strings.Index(raw, genOpen)
end := strings.Index(raw, genClose) + len(genClose)
if start < 0 || end < len(genClose) {
t.Fatalf("no region in the generated file:\n%s", raw)
path := filepath.Join(out, "skills", "kettle-issue", "SKILL.md")
pristine := readFile(t, path)
edited := strings.Replace(pristine, "# /kettle:issue", "# my own heading", 1)
edited = strings.Replace(edited, genClose, "hand-added line\n"+genClose, 1)
if edited == pristine {
t.Fatal("the fixture did not actually edit anything")
}
const above = "\n## Identity: the slug\n\nThe file name is the id, and it never changes.\n\n"
const below = "\n\n## Layering rule\n\nThis skill must keep working with the sync skill deleted.\n"
// A description a human tuned, in the frontmatter the generator must not
// touch: it is the only thing that decides whether the skill loads at all.
edited := strings.Replace(raw[:start], "description: TODO", "description: Work with this project's issues as units of work", 1)
edited += above + raw[start:end] + below
if err := os.WriteFile(path, []byte(edited), 0o644); err != nil {
t.Fatal(err)
}
mustRun(t, dir, "gen", "skills", "--out", out)
after := readFile(t, path)
if after != edited {
t.Errorf("a no-op regeneration did not return the file byte for byte:\n--- want ---\n%s\n--- got ---\n%s", edited, after)
// --check is the warning, and it comes before the loss rather than after.
if r := run(t, dir, "gen", "scaffold", "--out", out, "--check"); r.code != 1 {
t.Errorf("--check exited %d on an edited file, want 1:\n%s", r.code, r.out())
}
// And the prose survives a regeneration that actually rewrites the block.
shortened := strings.Replace(after, genClose, "the block was gutted by hand\n"+genClose, 1)
if err := os.WriteFile(path, []byte(shortened), 0o644); err != nil {
t.Fatal(err)
}
mustRun(t, dir, "gen", "skills", "--out", out)
restored := readFile(t, path)
if restored != edited {
t.Error("regenerating the block did not restore it, or did not preserve the prose around it")
}
if !strings.Contains(restored, "description: Work with this project's issues") {
t.Errorf("the hand-tuned description was overwritten:\n%s", firstLines(restored, 5))
}
if !strings.Contains(restored, above) || !strings.Contains(restored, below) {
t.Errorf("hand-written prose outside the markers was lost:\n%s", restored)
}
}
// Clobbering somebody's prose because they forgot a marker is the failure this
// whole design exists to prevent.
func TestGenNeverOverwritesAFileWithoutMarkers(t *testing.T) {
dir := t.TempDir()
out := filepath.Join(dir, "skills")
path := filepath.Join(out, "issue", "SKILL.md")
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
const prose = "---\nname: issue\ndescription: hand written, every word of it\n---\n\n# Everything here is somebody's work\n"
if err := os.WriteFile(path, []byte(prose), 0o644); err != nil {
t.Fatal(err)
}
r := mustRun(t, dir, "gen", "skills", "--out", out)
if got := readFile(t, path); got != prose {
t.Fatalf("a file with no markers was rewritten:\n%s", got)
}
// Left alone silently is how it drifts unnoticed, so it is reported — and
// on stderr, where a warning belongs.
if !strings.Contains(r.stderr, path) {
t.Errorf("the skipped file was not named on stderr:\n%s", r.out())
}
if !strings.Contains(r.stdout, "without a region") {
t.Errorf("the receipt did not account for it:\n%s", r.stdout)
}
// The other groups still got written — one unmanaged file stops nothing.
if _, err := os.Stat(filepath.Join(out, "sync", "SKILL.md")); err != nil {
t.Error("one file without markers stopped the whole run")
mustRun(t, dir, "gen", "scaffold", "--out", out)
if got := readFile(t, path); got != pristine {
t.Errorf("regenerating did not restore the binary's own copy:\n%s", firstLines(got, 8))
}
}
func TestGenCheckFailsOnAStaleFileAndNamesIt(t *testing.T) {
dir := t.TempDir()
out := filepath.Join(dir, "skills")
mustRun(t, dir, "gen", "skills", "--out", out)
out := filepath.Join(dir, "out")
mustRun(t, dir, "gen", "scaffold", "--out", out)
stale := filepath.Join(out, "sync", "SKILL.md")
raw := readFile(t, stale)
edited := strings.Replace(raw, genClose, "kettle push --thoroughly-renamed-flag\n"+genClose, 1)
stale := filepath.Join(out, "skills", "kettle-sync", "SKILL.md")
edited := strings.Replace(readFile(t, stale), genClose, "kettle push --thoroughly-renamed-flag\n"+genClose, 1)
if err := os.WriteFile(stale, []byte(edited), 0o644); err != nil {
t.Fatal(err)
}
r := run(t, dir, "gen", "skills", "--out", out, "--check")
r := run(t, dir, "gen", "scaffold", "--out", out, "--check")
if r.code != 1 {
t.Fatalf("--check exited %d, want 1 — this is what a hook or a CI step calls:\n%s", r.code, r.out())
}
@@ -190,7 +168,7 @@ func TestGenCheckFailsOnAStaleFileAndNamesIt(t *testing.T) {
if err := os.Remove(stale); err != nil {
t.Fatal(err)
}
if r := run(t, dir, "gen", "skills", "--out", out, "--check"); r.code != 1 {
if r := run(t, dir, "gen", "scaffold", "--out", out, "--check"); r.code != 1 {
t.Errorf("--check exited %d for a missing file, want 1:\n%s", r.code, r.out())
}
if _, err := os.Stat(stale); err == nil {
@@ -200,9 +178,9 @@ func TestGenCheckFailsOnAStaleFileAndNamesIt(t *testing.T) {
func TestGenDryRunWritesNothingAtAll(t *testing.T) {
dir := t.TempDir()
out := filepath.Join(dir, "skills")
out := filepath.Join(dir, "out")
fresh := mustRun(t, dir, "gen", "skills", "--out", out, "--dry-run")
fresh := mustRun(t, dir, "gen", "scaffold", "--out", out, "--dry-run")
if !strings.Contains(fresh.stdout, "would create") {
t.Errorf("a dry run said nothing about what it would do:\n%s", fresh.out())
}
@@ -211,14 +189,14 @@ func TestGenDryRunWritesNothingAtAll(t *testing.T) {
}
// And on an existing tree: the file is described, never touched.
mustRun(t, dir, "gen", "skills", "--out", out)
path := filepath.Join(out, "issue", "SKILL.md")
mustRun(t, dir, "gen", "scaffold", "--out", out)
path := filepath.Join(out, "skills", "kettle-issue", "SKILL.md")
edited := strings.Replace(readFile(t, path), genClose, "gutted\n"+genClose, 1)
if err := os.WriteFile(path, []byte(edited), 0o644); err != nil {
t.Fatal(err)
}
r := mustRun(t, dir, "gen", "skills", "--out", out, "--dry-run")
r := mustRun(t, dir, "gen", "scaffold", "--out", out, "--dry-run")
if !strings.Contains(r.stdout, "would update") || !strings.Contains(r.stdout, "nothing was written") {
t.Errorf("the dry run did not report the pending change:\n%s", r.out())
}
@@ -227,12 +205,38 @@ func TestGenDryRunWritesNothingAtAll(t *testing.T) {
}
}
func TestGenRefusesAnUnknownTargetAndAMissingOut(t *testing.T) {
// Without --out the tree goes under the project marker, resolved by the same
// walk every other command uses. No marker is an answer, not a fallback: a
// `.claude/` written into a plausible-looking directory is the failure the
// marker exists to replace.
func TestGenWithoutOutResolvesTheProject(t *testing.T) {
dir := newProject(t)
sub := filepath.Join(dir, "cli", "internal")
if err := os.MkdirAll(sub, 0o755); err != nil {
t.Fatal(err)
}
mustRun(t, sub, "gen", "scaffold")
if _, err := os.Stat(filepath.Join(dir, ".claude", "skills", "kettle-issue", "SKILL.md")); err != nil {
t.Errorf("run from %s, the tree did not land at the project root: %v", sub, err)
}
orphan := t.TempDir()
r := run(t, orphan, "gen", "scaffold")
if r.code == 0 {
t.Fatalf("gen outside a project must stop:\n%s", r.out())
}
if !strings.Contains(r.stderr, ".kettle") {
t.Errorf("the refusal does not name what is missing:\n%s", r.out())
}
if _, err := os.Stat(filepath.Join(orphan, ".claude")); err == nil {
t.Error("the refused run created a tree anyway")
}
}
func TestGenRefusesAnUnknownTarget(t *testing.T) {
dir := t.TempDir()
if r := run(t, dir, "gen", "skills"); r.code == 0 || !strings.Contains(r.stderr, "--out") {
t.Errorf("gen without --out must stop and say so:\n%s", r.out())
}
if r := run(t, dir, "gen", "agents", "--out", filepath.Join(dir, "x")); r.code == 0 {
t.Errorf("an unknown target must be refused:\n%s", r.out())
}
+173 -42
View File
@@ -1,6 +1,7 @@
package cmd
import (
"errors"
"flag"
"fmt"
"os"
@@ -9,8 +10,27 @@ import (
"git.noodles.cam/claude-skills/marketplace/cli/internal/config"
"git.noodles.cam/claude-skills/marketplace/cli/internal/project"
"git.noodles.cam/claude-skills/marketplace/cli/internal/scaffold"
)
// initOptions is everything init does, as data.
//
// It exists so that --interactive and the flags are two ways of filling in one
// struct rather than two implementations of one command. Every question the
// wizard asks has a field here and therefore a flag: a step that could only be
// reached by answering a prompt would be a step no script, no CI run and no
// agent could take.
type initOptions struct {
Root string
Login string
Repo string
Scaffold bool
ScaffoldOut string
MirrorHook bool
ForceSettings bool
DryRun bool
}
// writeConfig creates or updates .kettle/config.yaml, touching only the
// settings it was given.
//
@@ -66,7 +86,8 @@ func init() {
Short: "make this directory a project that tracks issues",
Long: `Creates ` + "`.kettle/`" + ` — the marker every other command resolves the store from,
and ` + "`.kettle/config.yaml`" + `, which says which tracker repository these issues
belong to and which login to reach it under.
belong to and which login to reach it under — and writes ` + "`.claude/`" + `: the slash
commands an operator invokes, the skills a model loads, and the runner subagent.
The marker is deliberately something an operator makes, not something inferred
from the tree: ` + "`.git`" + ` is in every clone, so anything that inferred a root from
@@ -77,18 +98,26 @@ machine, outside every working tree, managed with ` + "`kettle auth`" + `.
All of it is idempotent: it creates .kettle/issues and .kettle/payload, migrates
an older store in if it finds one (either layout the tea plugin used, oldest
first), writes the config without disturbing settings it was not given, and adds
.kettle/ to .gitignore. Each migration is a move, not a copy — two stores is the
state the marker exists to prevent — and it refuses to pick a winner when both
sides hold a file of the same name.
first), writes the config without disturbing settings it was not given, writes
the .claude/ tree, and adds .kettle/ to .gitignore. Each migration is a move,
not a copy — two stores is the state the marker exists to prevent — and it
refuses to pick a winner when both sides hold a file of the same name.
Do NOT run this inside a linked worktree. A worktree is the same project on
another branch and reaches the store by a hop out to the main checkout; a marker
here would give one project two stores, and the directory holding the second one
disappears with the branch.`,
IT REFUSES TO RUN IN A LINKED WORKTREE, and names the main checkout instead. A
worktree is the same project on another branch and reaches the store by a hop
out to the main checkout; a marker here would give one project two stores, and
the directory holding the second one disappears with the branch.
--interactive walks a person through the whole thing — the login, the token with
the echo turned off, the repository, the .claude/ tree and the AGENTS.md mirror
hook. IT REQUIRES A TERMINAL and refuses a standard input that is not one, which
is deliberate: every question it asks has a flag beside it, so nothing that is
not a person ever needs to answer a prompt.`,
Examples: []Example{
{"kettle init", "initialize the current directory"},
{"kettle init --interactive", "be walked through it, at a terminal"},
{"kettle init --login noodles --repo claude-skills/marketplace", "and point it at a tracker"},
{"kettle init --mirror-hook", "register the AGENTS.md mirror on PreToolUse(Bash)"},
{"kettle init --at ~/code/x", "initialize somewhere else"},
{"kettle init --dry-run", "say what it would do, touch nothing"},
},
@@ -96,6 +125,11 @@ disappears with the branch.`,
at := fs.String("at", "", "directory to initialize (default: the working directory)")
login := fs.String("login", "", "name of a login in the machine-wide file (see `kettle auth`)")
repo := fs.String("repo", "", "tracker repository, as owner/name")
interactive := fs.Bool("interactive", false, "ask, one question at a time; requires a terminal")
noScaffold := fs.Bool("no-scaffold", false, "do not write the .claude/ commands, skills and subagent")
scaffoldOut := fs.String("scaffold-out", "", "where the .claude/ tree goes (default: <project>/"+scaffold.Marker+")")
mirrorHook := fs.Bool("mirror-hook", false, "register `kettle mirror --hook` on PreToolUse(Bash)")
forceSettings := fs.Bool("force-settings", false, "let the hook be merged into an existing settings.json, reformatting it")
dryRun := fs.Bool("dry-run", false, "report what would happen; change nothing")
return func(args []string) error {
@@ -115,43 +149,140 @@ disappears with the branch.`,
return Fail("%s is not a directory", root)
}
// A second marker inside an existing project gives it a second
// store, and the nearer one wins — which is a surprise worth
// naming before it happens, not after.
if existing := project.Root(root); existing != "" && existing != root {
fmt.Fprintf(os.Stderr,
"warning: %s already sits inside the project at %s — a second marker here gives it a second store, and the nearer one wins.\n",
root, existing)
opts := initOptions{
Root: root,
Login: *login,
Repo: *repo,
Scaffold: !*noScaffold,
ScaffoldOut: *scaffoldOut,
MirrorHook: *mirrorHook,
ForceSettings: *forceSettings,
DryRun: *dryRun,
}
if *repo != "" {
if owner, name, ok := strings.Cut(*repo, "/"); !ok || owner == "" || name == "" {
return Fail("--repo %q is not owner/name", *repo)
if *interactive {
if err := askInit(&opts); err != nil {
return err
}
}
done, err := project.Init(root, *dryRun)
if err != nil {
return err
}
line, err := writeConfig(root, *login, *repo, *dryRun)
if err != nil {
return err
}
if line != "" {
done = append(done, line)
}
prefix := ""
if *dryRun {
prefix = "would: "
}
for _, l := range done {
fmt.Println(prefix + l)
}
return nil
return runInit(opts)
}
},
})
}
func runInit(opts initOptions) error {
// A worktree is the same project on another branch. The rule used to live in
// a skill somebody had to read; it is here because the wizard is now the
// front door and a front door cannot rely on the reader having read anything.
if main := project.MainWorktree(opts.Root); main != "" {
return Fail("%s is a linked worktree of the project at %s.\n"+
"A worktree reaches that store on its own — the walk crosses to it through the `gitdir:` in the .git file — "+
"and a marker here would give one project two stores, the second of which is deleted with the branch.\n"+
"Initialize the main checkout instead: kettle init --at %s", opts.Root, main, main)
}
// A second marker inside an existing project gives it a second store, and
// the nearer one wins — which is a surprise worth naming before it happens,
// not after.
if existing := project.Root(opts.Root); existing != "" && existing != opts.Root {
fmt.Fprintf(os.Stderr,
"warning: %s already sits inside the project at %s — a second marker here gives it a second store, and the nearer one wins.\n",
opts.Root, existing)
}
if opts.Repo != "" {
if owner, name, ok := strings.Cut(opts.Repo, "/"); !ok || owner == "" || name == "" {
return Fail("--repo %q is not owner/name", opts.Repo)
}
}
done, err := project.Init(opts.Root, opts.DryRun)
if err != nil {
return err
}
line, err := writeConfig(opts.Root, opts.Login, opts.Repo, opts.DryRun)
if err != nil {
return err
}
if line != "" {
done = append(done, line)
}
prefix := ""
if opts.DryRun {
prefix = "would: "
}
for _, l := range done {
fmt.Println(prefix + l)
}
if opts.Scaffold {
if err := initScaffold(opts); err != nil {
return err
}
}
if opts.MirrorHook {
if err := initMirrorHook(opts); err != nil {
return err
}
}
return nil
}
// scaffoldOut is where the .claude/ tree goes for this run. An explicit
// --scaffold-out is used exactly as typed; without one it sits beside the
// marker, which is the only place another command can find it again.
func (o initOptions) scaffoldOut() string {
if o.ScaffoldOut != "" {
return o.ScaffoldOut
}
return filepath.Join(o.Root, scaffold.Marker)
}
func initScaffold(opts initOptions) error {
out := opts.scaffoldOut()
if err := genScaffold(out, opts.DryRun, false); err != nil {
return err
}
if opts.DryRun {
return nil
}
// The stamp is written last and is not load-bearing: nothing resolves from
// it, and deleting it costs the warning in `kettle config` and nothing else.
rec := &config.Scaffold{Version: Version, Out: relativeTo(opts.Root, out)}
return config.SaveScaffoldFile(filepath.Join(opts.Root, project.Marker, "scaffold.yaml"), rec)
}
func initMirrorHook(opts initOptions) error {
line, err := writeMirrorHook(opts.scaffoldOut(), opts.ForceSettings, opts.DryRun)
if err != nil {
// An existing settings.json is a decision for the operator, not a
// failure of the run: everything before this point already happened and
// saying otherwise would send them looking for damage there is none of.
if errors.Is(err, errSettingsExist) {
fmt.Fprintf(os.Stderr, "kettle init: the mirror hook was not registered — %v\n", err)
return nil
}
return err
}
if line != "" {
prefix := ""
if opts.DryRun {
prefix = "would: "
}
fmt.Println(prefix + line)
}
return nil
}
// relativeTo is path as written down in the scaffold record: relative when it
// sits under the project, absolute when the operator sent it somewhere else.
func relativeTo(root, path string) string {
rel, err := filepath.Rel(root, path)
if err != nil || strings.HasPrefix(rel, "..") {
return path
}
return rel
}
+233
View File
@@ -0,0 +1,233 @@
package cmd_test
// What `kettle init` writes beyond the marker: the .claude/ tree, the record of
// which build wrote it, and the optional mirror hook. Plus the two refusals that
// used to be prose in a skill and are now the binary's.
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
func TestInitWritesTheClaudeTreeByDefault(t *testing.T) {
dir := t.TempDir()
r := mustRun(t, dir, "init")
for _, rel := range scaffoldFiles {
if _, err := os.Stat(filepath.Join(dir, ".claude", filepath.FromSlash(rel))); err != nil {
t.Errorf("%s was not written: %v\n%s", rel, err, r.out())
}
}
// And the record of what wrote it, which is the only way an operator can
// tell a current tree from one four releases old.
stamp := readFile(t, filepath.Join(dir, ".kettle", "scaffold.yaml"))
for _, want := range []string{"version:", "out: .claude"} {
if !strings.Contains(stamp, want) {
t.Errorf("the scaffold record is missing %q:\n%s", want, stamp)
}
}
if cfg := mustRun(t, dir, "config"); !strings.Contains(cfg.stdout, "scaffold .claude") {
t.Errorf("`kettle config` does not report the tree:\n%s", cfg.stdout)
}
// Idempotent all the way through: a second init rewrites nothing.
second := mustRun(t, dir, "init")
if strings.Contains(second.stdout, "updated") {
t.Errorf("a second init rewrote a document:\n%s", second.out())
}
}
func TestInitCanBeToldToWriteNoTreeAtAll(t *testing.T) {
dir := t.TempDir()
mustRun(t, dir, "init", "--no-scaffold")
if _, err := os.Stat(filepath.Join(dir, ".claude")); err == nil {
t.Error("--no-scaffold still wrote the tree")
}
if _, err := os.Stat(filepath.Join(dir, ".kettle", "scaffold.yaml")); err == nil {
t.Error("--no-scaffold recorded a tree it did not write")
}
// Nothing else is affected: this is still a project.
if _, err := os.Stat(filepath.Join(dir, ".kettle", "issues")); err != nil {
t.Errorf("--no-scaffold skipped the store as well: %v", err)
}
// And `kettle config` says so rather than saying nothing.
if cfg := mustRun(t, dir, "config"); !strings.Contains(cfg.stdout, "scaffold (none written") {
t.Errorf("`kettle config` is silent about the missing tree:\n%s", cfg.stdout)
}
}
func TestInitScaffoldOutIsUsedAsTyped(t *testing.T) {
dir := t.TempDir()
elsewhere := filepath.Join(dir, "somewhere", "else")
mustRun(t, dir, "init", "--scaffold-out", elsewhere)
if _, err := os.Stat(filepath.Join(elsewhere, "skills", "kettle-issue", "SKILL.md")); err != nil {
t.Errorf("the tree did not go where it was told: %v", err)
}
if _, err := os.Stat(filepath.Join(dir, ".claude")); err == nil {
t.Error("it went to the default as well")
}
}
func TestInitDryRunWritesNoTreeAndNoRecord(t *testing.T) {
dir := t.TempDir()
r := mustRun(t, dir, "init", "--dry-run", "--mirror-hook")
if !strings.Contains(r.stdout, "would create") {
t.Errorf("the dry run said nothing about the tree:\n%s", r.out())
}
for _, p := range []string{".claude", ".kettle"} {
if _, err := os.Stat(filepath.Join(dir, p)); err == nil {
t.Errorf("a dry run created %s", p)
}
}
}
// The mirror hook: offered, never assumed. A project that keeps no AGENTS.md
// files wants nothing to do with it.
func TestInitRegistersTheMirrorHookOnlyWhenAsked(t *testing.T) {
plain := t.TempDir()
mustRun(t, plain, "init")
if _, err := os.Stat(filepath.Join(plain, ".claude", "settings.json")); err == nil {
t.Error("init registered a hook nobody asked for")
}
asked := t.TempDir()
mustRun(t, asked, "init", "--mirror-hook")
body := readFile(t, filepath.Join(asked, ".claude", "settings.json"))
var settings struct {
Hooks struct {
PreToolUse []struct {
Matcher string `json:"matcher"`
Hooks []struct {
Type string `json:"type"`
Command string `json:"command"`
} `json:"hooks"`
} `json:"PreToolUse"`
} `json:"hooks"`
}
if err := json.Unmarshal([]byte(body), &settings); err != nil {
t.Fatalf("the settings file is not valid JSON: %v\n%s", err, body)
}
if len(settings.Hooks.PreToolUse) != 1 || settings.Hooks.PreToolUse[0].Matcher != "Bash" {
t.Fatalf("the hook is not registered on PreToolUse(Bash):\n%s", body)
}
command := settings.Hooks.PreToolUse[0].Hooks[0].Command
if !strings.Contains(command, "kettle mirror --hook") {
t.Errorf("the registered command is %q", command)
}
// The guard outlives the binary: an operator who uninstalls kettle must not
// get "command not found" on every Bash call from a hook they set up months
// ago and have stopped thinking about.
if !strings.Contains(command, "command -v kettle") {
t.Errorf("the registered command has no guard against kettle being gone: %q", command)
}
}
// settings.json is a file the operator owns and commits, and Go cannot preserve
// its key order. So an existing one is never rewritten without being asked
// twice: the run reports what it did not do, prints the snippet, and succeeds.
func TestInitRefusesToRewriteAnExistingSettingsFile(t *testing.T) {
dir := t.TempDir()
settings := filepath.Join(dir, ".claude", "settings.json")
if err := os.MkdirAll(filepath.Dir(settings), 0o755); err != nil {
t.Fatal(err)
}
const theirs = `{ "permissions": { "allow": ["Bash(ls:*)"] } }`
if err := os.WriteFile(settings, []byte(theirs), 0o644); err != nil {
t.Fatal(err)
}
r := mustRun(t, dir, "init", "--mirror-hook")
if got := readFile(t, settings); got != theirs {
t.Errorf("the operator's settings file was rewritten:\n%s", got)
}
if !strings.Contains(r.stderr, "kettle mirror") {
t.Errorf("the refusal did not print the snippet to paste:\n%s", r.out())
}
if !strings.Contains(r.stderr, "--force-settings") {
t.Errorf("the refusal did not name the way past it:\n%s", r.out())
}
// Everything before the hook still happened; this is a decision, not a
// failure, and reporting it as one would send somebody looking for damage.
if _, err := os.Stat(filepath.Join(dir, ".kettle", "issues")); err != nil {
t.Error("the refusal rolled back the rest of the run")
}
// Asked twice, it merges — and keeps what was already in the file.
mustRun(t, dir, "init", "--mirror-hook", "--force-settings")
merged := readFile(t, settings)
if !strings.Contains(merged, "kettle mirror") {
t.Errorf("--force-settings did not add the hook:\n%s", merged)
}
if !strings.Contains(merged, "Bash(ls:*)") {
t.Errorf("--force-settings dropped what was already there:\n%s", merged)
}
// And a third run adds nothing: the hook is already there.
mustRun(t, dir, "init", "--mirror-hook")
if again := readFile(t, settings); again != merged {
t.Errorf("a re-run duplicated the hook:\n%s", again)
}
}
// The rule that used to be a paragraph in a skill an operator had to read. The
// wizard is the front door now, and a front door cannot assume anybody read
// anything.
func TestInitRefusesALinkedWorktree(t *testing.T) {
main := t.TempDir()
worktree := filepath.Join(main, "wt")
gitdir := filepath.Join(main, ".git", "worktrees", "wt")
if err := os.MkdirAll(gitdir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(worktree, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(gitdir, "commondir"), []byte("../..\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(worktree, ".git"), []byte("gitdir: "+gitdir+"\n"), 0o644); err != nil {
t.Fatal(err)
}
r := run(t, worktree, "init")
if r.code == 0 {
t.Fatalf("init in a linked worktree must be refused:\n%s", r.out())
}
if !strings.Contains(r.stderr, main) {
t.Errorf("the refusal does not name the main checkout to use instead:\n%s", r.out())
}
if _, err := os.Stat(filepath.Join(worktree, ".kettle")); err == nil {
t.Error("the refused run created a second marker anyway")
}
}
// --interactive is for a person. Under a test harness, a script or an agent,
// standard input is a pipe and the answer is an error that names the flags
// rather than a prompt nobody will ever answer.
func TestInteractiveRefusesWhatIsNotATerminal(t *testing.T) {
dir := t.TempDir()
r := runWith(t, dir, nil, "\n\n\n", "init", "--interactive")
if r.code == 0 {
t.Fatalf("--interactive succeeded without a terminal:\n%s", r.out())
}
if !strings.Contains(r.stderr, "terminal") {
t.Errorf("the refusal does not say what is missing:\n%s", r.out())
}
for _, flag := range []string{"--login", "--repo", "--mirror-hook"} {
if !strings.Contains(r.stderr, flag) {
t.Errorf("the refusal does not name %s, which is the way through:\n%s", flag, r.out())
}
}
if _, err := os.Stat(filepath.Join(dir, ".kettle")); err == nil {
t.Error("the refused run initialized the directory anyway")
}
}
+285
View File
@@ -0,0 +1,285 @@
package cmd
import (
"bufio"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"golang.org/x/term"
"git.noodles.cam/claude-skills/marketplace/cli/internal/config"
"git.noodles.cam/claude-skills/marketplace/cli/internal/project"
)
// askInit fills in an initOptions by asking, and is the ONLY thing in this
// binary that reads from a terminal.
//
// Two rules hold it in place, and both are about what it is not allowed to
// become. It answers no question the flags cannot answer, so nothing here is a
// capability that exists only behind a prompt; and it performs nothing itself —
// it fills in the struct and hands it back, so the run that follows an
// interactive session is byte for byte the run somebody else gets from flags.
func askInit(opts *initOptions) error {
if !term.IsTerminal(int(os.Stdin.Fd())) {
return Fail("--interactive needs a terminal, and standard input is not one.\n" +
"Every question it asks has a flag: --login, --repo, --scaffold-out, --no-scaffold, --mirror-hook.")
}
in := bufio.NewReader(os.Stdin)
fmt.Printf("kettle init — %s\n\n", opts.Root)
if main := project.MainWorktree(opts.Root); main != "" {
// Asked before anything else, because every answer after it would be
// about a project that must not be created here.
return Fail("%s is a linked worktree of the project at %s — initialize the main checkout instead", opts.Root, main)
}
if err := askLogin(in, opts); err != nil {
return err
}
if err := askRepo(in, opts); err != nil {
return err
}
opts.Scaffold = askYesNo(in, "Write the kettle commands and skills into "+
relativeTo(opts.Root, opts.scaffoldOut())+"?", true)
opts.MirrorHook = askYesNo(in, "Mirror AGENTS.md to CLAUDE.md in this project? "+
"(registers `kettle mirror --hook` on PreToolUse)", false)
fmt.Println()
return nil
}
// askLogin offers what the machine already holds, and never invents one.
//
// Adding a login is offered here rather than left to `kettle auth add` for one
// reason: this is a person at a terminal, so the token can be read with the echo
// turned off — no shell history, no temp file, no scrollback. That is strictly
// better than every other way of getting a secret into this program, and it is
// the single strongest argument for the wizard existing at all.
func askLogin(in *bufio.Reader, opts *initOptions) error {
logins, err := config.LoadLogins()
if err != nil {
return err
}
if opts.Login != "" {
fmt.Printf("login %s (given on the command line)\n", opts.Login)
return nil
}
names := logins.Names()
fmt.Println("Logins on this machine:")
if len(names) == 0 {
fmt.Println(" (none)")
}
for i, l := range logins.Logins {
fmt.Printf(" %d) %-16s %s\n", i+1, l.Name, l.URL)
}
fmt.Printf(" a) add one\n s) skip — pin a login later with `kettle init --login <name>`\n")
for {
answer := strings.TrimSpace(ask(in, "Which login should this project run under?", "s"))
switch strings.ToLower(answer) {
case "s", "skip":
return nil
case "a", "add":
name, err := addLogin(in, logins)
if err != nil {
return err
}
opts.Login = name
return nil
}
if n, err := strconv.Atoi(answer); err == nil && n >= 1 && n <= len(logins.Logins) {
opts.Login = logins.Logins[n-1].Name
return nil
}
// A name typed in full is the same answer as its number, and is what
// somebody who already knows the login will reach for.
if logins.Find(answer) != nil {
opts.Login = answer
return nil
}
fmt.Printf(" %q is not one of them.\n", answer)
}
}
// addLogin writes one entry into the machine-wide file, token and all.
func addLogin(in *bufio.Reader, logins *config.Logins) (string, error) {
name := required(in, " Name for this login (a label, not a username)")
url := strings.TrimRight(required(in, " Instance URL, e.g. https://git.example.com"), "/")
user := ask(in, " Account it belongs to (documentation only)", "")
scopes := ask(in, " Scopes it was minted with (documentation only)", "write:issue,write:repository")
token, err := askSecret(" Token (not echoed)")
if err != nil {
return "", err
}
if token == "" {
return "", Fail("no token given")
}
entry := config.Login{
Name: name,
URL: url,
User: user,
Scopes: splitScopes(scopes),
Token: token,
}
if existing := logins.Find(name); existing != nil {
*existing = entry
} else {
logins.Logins = append(logins.Logins, entry)
}
if err := config.SaveLogins(logins); err != nil {
return "", err
}
fmt.Printf(" %s -> %s %s\n\n", entry.Name, entry.URL, config.LoginsPath())
return name, nil
}
// askRepo offers what the git remote says, because that is right nearly every
// time and wrong in a way the operator can see immediately.
func askRepo(in *bufio.Reader, opts *initOptions) error {
if opts.Repo != "" {
fmt.Printf("repo %s (given on the command line)\n", opts.Repo)
return nil
}
guess := repoFromGitConfig(opts.Root)
answer := strings.TrimSpace(ask(in, "Tracker repository, as owner/name", guess))
if answer == "" {
return nil
}
if owner, name, ok := strings.Cut(answer, "/"); !ok || owner == "" || name == "" {
return Fail("%q is not owner/name", answer)
}
opts.Repo = answer
return nil
}
// repoFromGitConfig reads owner/name out of `origin` in .git/config, or "".
//
// Parsed rather than shelled out to, because `git` is one more thing that has to
// be installed and one more process to fail in a way this has to interpret. A
// guess is worth exactly what it costs, and this costs a file read: anything it
// cannot make sense of is no guess at all, and the operator types the answer.
func repoFromGitConfig(root string) string {
raw, err := os.ReadFile(filepath.Join(root, ".git", "config"))
if err != nil {
return ""
}
inOrigin := false
for _, line := range strings.Split(string(raw), "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "[") {
inOrigin = strings.HasPrefix(line, `[remote "origin"]`)
continue
}
if !inOrigin {
continue
}
value, ok := strings.CutPrefix(line, "url")
if !ok {
continue
}
if _, v, found := strings.Cut(value, "="); found {
return ownerName(strings.TrimSpace(v))
}
}
return ""
}
// ownerName is the last two path elements of a git URL, without any .git.
// `git@host:owner/name.git` and `https://host/owner/name` both answer
// `owner/name`; anything else answers "".
func ownerName(url string) string {
url = strings.TrimSuffix(strings.TrimSpace(url), ".git")
if _, after, found := strings.Cut(url, "://"); found {
url = after
if _, path, ok := strings.Cut(url, "/"); ok {
url = path
} else {
return ""
}
} else if _, after, found := strings.Cut(url, ":"); found {
url = after
}
parts := strings.Split(strings.Trim(url, "/"), "/")
if len(parts) < 2 {
return ""
}
owner, name := parts[len(parts)-2], parts[len(parts)-1]
if owner == "" || name == "" {
return ""
}
return owner + "/" + name
}
// ask prints a question and returns the answer, or def when the line is empty.
func ask(in *bufio.Reader, question, def string) string {
if def != "" {
fmt.Printf("%s [%s]: ", question, def)
} else {
fmt.Printf("%s: ", question)
}
line, err := in.ReadString('\n')
if err != nil && line == "" {
return def
}
if answer := strings.TrimSpace(line); answer != "" {
return answer
}
return def
}
func required(in *bufio.Reader, question string) string {
for {
if answer := ask(in, question, ""); answer != "" {
return answer
}
}
}
// askYesNo is deliberately biased: the default is what an operator gets by
// holding down return, so every prompt whose wrong answer costs something
// defaults to no.
func askYesNo(in *bufio.Reader, question string, def bool) bool {
hint := "y/N"
if def {
hint = "Y/n"
}
for {
fmt.Printf("%s [%s]: ", question, hint)
line, err := in.ReadString('\n')
if err != nil && line == "" {
return def
}
switch strings.ToLower(strings.TrimSpace(line)) {
case "":
return def
case "y", "yes":
return true
case "n", "no":
return false
}
}
}
// askSecret reads a line with the terminal's echo turned off.
//
// This is the whole reason --interactive needs a terminal rather than merely
// preferring one: a token typed at a prompt that echoes is a token in somebody's
// scrollback, and a token passed as an argument is a token in their shell
// history. Neither is recoverable after the fact.
func askSecret(question string) (string, error) {
fmt.Print(question + ": ")
raw, err := term.ReadPassword(int(os.Stdin.Fd()))
fmt.Println()
if err != nil {
return "", err
}
return strings.TrimSpace(string(raw)), nil
}
+206
View File
@@ -0,0 +1,206 @@
package cmd
import (
"encoding/json"
"flag"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"git.noodles.cam/claude-skills/marketplace/cli/internal/mirror"
)
// hookPayload is the part of a PreToolUse payload this command reads. Every
// other field is somebody else's business and is ignored rather than rejected —
// a payload that grows a key must not stop a Bash call.
type hookPayload struct {
CWD string `json:"cwd"`
}
// hookOutput is what a PreToolUse hook says back. additionalContext is
// advisory: it is shown, and it decides nothing.
type hookOutput struct {
HookSpecificOutput struct {
HookEventName string `json:"hookEventName"`
AdditionalContext string `json:"additionalContext"`
} `json:"hookSpecificOutput"`
}
func init() {
register(&Command{
Name: "mirror",
Group: GroupProject,
Args: "[<dir>]",
Short: "keep CLAUDE.md a symlink to AGENTS.md in every directory below here",
Long: `Two agent harnesses read two different filenames for the same document. A
repository that keeps both as real files keeps TWO DOCUMENTS, and they drift —
silently, until somebody reads the stale one and believes it. This walks a tree
and leaves one arrangement behind everywhere:
AGENTS.md is the real file; CLAUDE.md is a symlink pointing at it.
The link is relative, so a tree that is moved, copied or cloned keeps working.
AGENTS.md is the real one because the convention is not one vendor's: a
repository that names its documents after a single tool has picked a side it did
not need to pick.
NOTHING HERE DELETES CONTENT. Six of the seven states it can find are repaired
losslessly — a missing link is created, a reversed layout is swapped round, a
duplicate whose bytes match its original is replaced by the link. The seventh,
two real files whose contents DIFFER, is reported and left exactly as it was:
one of them is somebody's writing and no rule here knows which.
It walks the directory given, or the working directory. node_modules, vendor,
venv, __pycache__ and every dot-directory are skipped, because somebody else's
tree is somebody else's business.
--hook is the PreToolUse form: it reads the hook payload on standard input,
writes any report back as additionalContext, and ALWAYS EXITS 0 — including when
it fails. A tool that broke somebody's Bash call because its documentation
helper crashed would be worse than no tool. --check is the opposite end: it
writes nothing and exits 1 when the tree is not canonical, which is what a
pre-commit hook or a make target calls.
` + "`kettle init --interactive`" + ` offers to register the --hook form in
.claude/settings.json. It is offered rather than assumed: this is one
repository's documentation convention, and a project that does not keep AGENTS.md
files wants nothing to do with it.`,
Examples: []Example{
{"kettle mirror", "repair the working directory and everything below it"},
{"kettle mirror ~/code/x", "repair somewhere else"},
{"kettle mirror --check", "exit 1 if anything is out of place; write nothing"},
{"kettle mirror --hook", "the PreToolUse form; reads a payload, always exits 0"},
},
Setup: func(fs *flag.FlagSet) func([]string) error {
check := fs.Bool("check", false, "write nothing, exit 1 if the tree is not canonical")
hook := fs.Bool("hook", false, "PreToolUse form: payload on stdin, report as additionalContext, always exit 0")
quiet := fs.Bool("quiet", false, "repair without printing what was repaired")
return func(args []string) error {
if len(args) > 1 {
return Fail("give one directory, or none for the working directory")
}
explicit := ""
if len(args) == 1 {
explicit = args[0]
}
if *hook {
runHook(explicit)
return nil
}
root, err := mirrorRoot(explicit, "")
if err != nil {
return err
}
var res mirror.Result
if *check {
res = mirror.Check(root)
} else {
res = mirror.Sync(root)
}
if !*quiet {
printMirror(os.Stdout, res, *check)
}
// A conflict is a state a person has to resolve, so --check
// reports it as a failure. A repair run says so and carries on:
// the six branches it could fix, it fixed.
if *check && !res.Clean() {
return SilentError{Code: 1}
}
return nil
}
},
})
}
// mirrorRoot decides which tree to walk.
//
// An explicit argument wins, then the harness's own idea of the project, then
// the working directory. project.Root is deliberately NOT consulted: this
// command has nothing to do with issues and must be usable in a tree that has
// never seen `kettle init`.
func mirrorRoot(explicit, payloadCWD string) (string, error) {
for _, candidate := range []string{explicit, os.Getenv("CLAUDE_PROJECT_DIR"), payloadCWD} {
if candidate == "" {
continue
}
abs, err := filepath.Abs(candidate)
if err != nil {
continue
}
if fi, err := os.Stat(abs); err == nil && fi.IsDir() {
return abs, nil
}
if candidate == explicit {
return "", Fail("%s is not a directory", explicit)
}
}
wd, err := os.Getwd()
if err != nil {
return "", err
}
return wd, nil
}
func printMirror(w io.Writer, res mirror.Result, check bool) {
prefix := ""
if check {
prefix = "would: "
}
for _, line := range res.Fixes {
fmt.Fprintln(w, prefix+line)
}
for _, line := range res.Conflicts {
fmt.Fprintln(w, "conflict: "+line)
}
if res.Clean() {
fmt.Fprintln(w, "every AGENTS.md has its CLAUDE.md symlink — nothing to do")
}
}
// runHook is the PreToolUse form, and its whole contract is that it cannot fail.
//
// Every path here returns normally and the caller exits 0: an unreadable
// payload, an unwritable tree, a bug in this function. Documentation maintenance
// is not permitted to break somebody's build, so silence is the failure mode and
// a report is the only output.
func runHook(explicit string) {
// The one recover in the tree, and it earns its place: this function runs
// before every Bash call in every project the hook is registered in, and a
// panic here would surface as a failed tool call rather than as a bug in a
// documentation helper.
defer func() { _ = recover() }()
var payload hookPayload
if raw, err := io.ReadAll(os.Stdin); err == nil && len(raw) > 0 {
_ = json.Unmarshal(raw, &payload)
}
root, err := mirrorRoot(explicit, payload.CWD)
if err != nil {
return
}
res := mirror.Sync(root)
if res.Clean() {
return // silence means the tree was already canonical
}
var parts []string
if len(res.Fixes) > 0 {
parts = append(parts, "kettle mirror fixed:\n "+strings.Join(res.Fixes, "\n "))
}
if len(res.Conflicts) > 0 {
parts = append(parts, "kettle mirror needs manual resolution:\n "+strings.Join(res.Conflicts, "\n "))
}
var out hookOutput
out.HookSpecificOutput.HookEventName = "PreToolUse"
out.HookSpecificOutput.AdditionalContext = strings.Join(parts, "\n")
if encoded, err := json.Marshal(out); err == nil {
fmt.Println(string(encoded))
}
}
+205
View File
@@ -0,0 +1,205 @@
package cmd_test
// `kettle mirror` is the one command in the tree that has nothing to do with
// issues, and the one that must never fail a caller. Both are tested here.
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
func writeDoc(t *testing.T, dir, name, body string) {
t.Helper()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
func linkTarget(t *testing.T, path string) string {
t.Helper()
target, err := os.Readlink(path)
if err != nil {
t.Fatalf("%s is not a symlink: %v", path, err)
}
return target
}
// The tree it walks is the working directory, not a project: this must work in
// a directory that has never seen `kettle init`, because the convention it
// maintains has nothing to do with issues.
func TestMirrorNeedsNoProject(t *testing.T) {
dir := t.TempDir()
writeDoc(t, dir, "AGENTS.md", "root\n")
writeDoc(t, filepath.Join(dir, "cli"), "AGENTS.md", "cli\n")
r := mustRun(t, dir, "mirror")
if strings.Contains(r.out(), ".kettle") {
t.Errorf("mirror asked for a project:\n%s", r.out())
}
for _, sub := range []string{".", "cli"} {
if got := linkTarget(t, filepath.Join(dir, sub, "CLAUDE.md")); got != "AGENTS.md" {
t.Errorf("%s/CLAUDE.md points at %q, want a relative AGENTS.md", sub, got)
}
}
// Idempotent, and it says so rather than printing nothing at all.
again := mustRun(t, dir, "mirror")
if !strings.Contains(again.stdout, "nothing to do") {
t.Errorf("a canonical tree did not report itself clean:\n%s", again.out())
}
}
func TestMirrorTakesADirectoryArgument(t *testing.T) {
dir := t.TempDir()
elsewhere := filepath.Join(dir, "elsewhere")
writeDoc(t, elsewhere, "AGENTS.md", "there\n")
mustRun(t, dir, "mirror", elsewhere)
if _, err := os.Lstat(filepath.Join(elsewhere, "CLAUDE.md")); err != nil {
t.Errorf("the named directory was not repaired: %v", err)
}
if r := run(t, dir, "mirror", filepath.Join(dir, "nowhere")); r.code == 0 {
t.Errorf("a directory that is not there must be refused:\n%s", r.out())
}
}
func TestMirrorCheckWritesNothingAndFailsLoudly(t *testing.T) {
dir := t.TempDir()
writeDoc(t, dir, "AGENTS.md", "root\n")
r := run(t, dir, "mirror", "--check")
if r.code != 1 {
t.Fatalf("--check exited %d on a tree with work to do, want 1:\n%s", r.code, r.out())
}
if !strings.Contains(r.stdout, "would: ") {
t.Errorf("--check did not say what it would do:\n%s", r.out())
}
if _, err := os.Lstat(filepath.Join(dir, "CLAUDE.md")); err == nil {
t.Error("--check wrote to the tree it was asked about")
}
mustRun(t, dir, "mirror")
if r := run(t, dir, "mirror", "--check"); r.code != 0 {
t.Errorf("--check exited %d on a canonical tree:\n%s", r.code, r.out())
}
}
// The refusal, end to end. One of these two files is somebody's writing and no
// rule in this binary knows which, so both survive and the operator is told.
func TestMirrorRefusesTwoDifferentRealFiles(t *testing.T) {
dir := t.TempDir()
writeDoc(t, dir, "AGENTS.md", "mine\n")
writeDoc(t, dir, "CLAUDE.md", "theirs\n")
r := mustRun(t, dir, "mirror")
if !strings.Contains(r.stdout, "conflict:") || !strings.Contains(r.stdout, "merge manually") {
t.Errorf("the conflict was not reported:\n%s", r.out())
}
if got := readFile(t, filepath.Join(dir, "AGENTS.md")); got != "mine\n" {
t.Errorf("AGENTS.md was changed: %q", got)
}
if got := readFile(t, filepath.Join(dir, "CLAUDE.md")); got != "theirs\n" {
t.Errorf("CLAUDE.md was changed: %q", got)
}
// A repair run reports the conflict and still exits 0 — the six branches it
// could fix, it fixed. --check is the one that turns it into a failure.
if r := run(t, dir, "mirror", "--check"); r.code != 1 {
t.Errorf("--check exited %d on a conflict, want 1", r.code)
}
}
// The hook form's whole contract: it cannot fail a Bash call. Every one of these
// is a payload or a state that could plausibly arrive, and every one exits 0.
func TestMirrorHookAlwaysExitsZero(t *testing.T) {
cases := []struct {
name string
stdin string
setup func(t *testing.T, dir string)
}{
{name: "no payload at all", stdin: ""},
{name: "a payload that is not JSON", stdin: "not json at all"},
{name: "an empty object", stdin: "{}"},
{name: "a payload with keys it does not know", stdin: `{"cwd":".","tool_name":"Bash","future":{"x":1}}`},
{
name: "a tree it cannot resolve",
stdin: `{"cwd":"/nowhere/at/all"}`,
setup: func(t *testing.T, dir string) { writeDoc(t, dir, "AGENTS.md", "root\n") },
},
{
name: "a conflict it must not resolve",
stdin: `{}`,
setup: func(t *testing.T, dir string) {
writeDoc(t, dir, "AGENTS.md", "mine\n")
writeDoc(t, dir, "CLAUDE.md", "theirs\n")
},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
dir := t.TempDir()
if tc.setup != nil {
tc.setup(t, dir)
}
r := runWith(t, dir, nil, tc.stdin, "mirror", "--hook")
if r.code != 0 {
t.Errorf("exited %d — a documentation helper may not break a Bash call:\n%s", r.code, r.out())
}
})
}
}
// What it says when it did something, and what it says when it did not.
func TestMirrorHookReportsOnlyWhenThereIsSomethingToSay(t *testing.T) {
dir := t.TempDir()
writeDoc(t, dir, "AGENTS.md", "root\n")
r := runWith(t, dir, nil, "{}", "mirror", "--hook")
var out struct {
HookSpecificOutput struct {
HookEventName string `json:"hookEventName"`
AdditionalContext string `json:"additionalContext"`
} `json:"hookSpecificOutput"`
}
if err := json.Unmarshal([]byte(r.stdout), &out); err != nil {
t.Fatalf("the hook did not emit JSON: %v\n%s", err, r.out())
}
if out.HookSpecificOutput.HookEventName != "PreToolUse" {
t.Errorf("hookEventName is %q", out.HookSpecificOutput.HookEventName)
}
if !strings.Contains(out.HookSpecificOutput.AdditionalContext, "CLAUDE.md") {
t.Errorf("the report says nothing about what it did:\n%s", out.HookSpecificOutput.AdditionalContext)
}
// Silence means the tree was already canonical. A hook that spoke on every
// Bash call would be noise in every transcript.
quiet := runWith(t, dir, nil, "{}", "mirror", "--hook")
if strings.TrimSpace(quiet.stdout) != "" {
t.Errorf("a canonical tree still produced output:\n%s", quiet.stdout)
}
}
// CLAUDE_PROJECT_DIR is the harness's own answer to "which tree", and it wins
// over the payload's cwd — a Bash call made from a subdirectory must still
// repair the whole project rather than the corner it was made in.
func TestMirrorHookPrefersTheProjectDirectory(t *testing.T) {
dir := t.TempDir()
sub := filepath.Join(dir, "deep", "inside")
writeDoc(t, dir, "AGENTS.md", "root\n")
writeDoc(t, sub, "AGENTS.md", "inside\n")
r := runWith(t, sub, []string{"CLAUDE_PROJECT_DIR=" + dir}, `{"cwd":"`+sub+`"}`, "mirror", "--hook")
if r.code != 0 {
t.Fatalf("exited %d:\n%s", r.code, r.out())
}
if _, err := os.Lstat(filepath.Join(dir, "CLAUDE.md")); err != nil {
t.Errorf("the project root was not repaired: %v", err)
}
}
+136
View File
@@ -0,0 +1,136 @@
package cmd
// The seam between the registry and the documents written from it. Both halves
// are in this package's reach, so it is asserted here rather than inferred from
// a generated file downstream.
import (
"sort"
"strings"
"testing"
"git.noodles.cam/claude-skills/marketplace/cli/internal/scaffold"
)
// A group with no document is a group whose flag table is written nowhere, and
// nothing would say so: gen walks the documents, not the registry, so the
// commands would simply be absent. This is the test that makes adding a group a
// two-step change rather than a silent one-step mistake.
func TestEveryGroupHasSomewhereToBeWritten(t *testing.T) {
inRegistry := map[string]bool{}
for _, c := range Commands() {
if c.Group == "" {
t.Errorf("command %q has no group, so it is in no document at all", c.Name)
continue
}
inRegistry[c.Group] = true
}
for _, g := range scaffold.Groups() {
if !inRegistry[g] {
t.Errorf("scaffold ships a document for group %q, which no command is in", g)
}
}
for g := range inRegistry {
if scaffold.PathFor(g) == "" {
t.Errorf("group %q has commands and no document — add one to internal/scaffold/assets", g)
}
}
}
// Every document renders, and the flag table inside it is this binary's. A
// command whose Long text spelled a region marker out in full would end the
// generated block inside itself, and renderAll is where that is caught.
func TestEveryDocumentRendersWithItsOwnFlags(t *testing.T) {
files, err := renderAll()
if err != nil {
t.Fatalf("rendering the tree failed: %v", err)
}
if len(files) != len(scaffold.Files()) {
t.Fatalf("rendered %d of %d documents", len(files), len(scaffold.Files()))
}
for _, f := range files {
if f.Group == "" {
continue
}
for _, c := range commandsIn(f.Group) {
if !strings.Contains(f.Body, "## `"+c.Usage()+"`") {
t.Errorf("%s does not document `%s`", f.Path, c.Usage())
}
for _, flag := range c.Flags() {
if !strings.Contains(f.Body, "| `--"+flag.Name+"` |") {
t.Errorf("%s documents `%s` without --%s", f.Path, c.Name, flag.Name)
}
}
}
if strings.Contains(f.Body, genOpen+"\n"+genOpen) {
t.Errorf("%s has a doubled marker", f.Path)
}
}
}
// Rendering twice produces the same bytes. Everything downstream — --check, the
// idempotence of init, a diff in somebody's repository — is built on it.
func TestRenderingIsDeterministic(t *testing.T) {
a, err := renderAll()
if err != nil {
t.Fatal(err)
}
b, err := renderAll()
if err != nil {
t.Fatal(err)
}
if len(a) != len(b) {
t.Fatalf("two renders produced %d and %d documents", len(a), len(b))
}
for i := range a {
if a[i].Path != b[i].Path {
t.Fatalf("document %d is %s then %s", i, a[i].Path, b[i].Path)
}
if a[i].Body != b[i].Body {
t.Errorf("%s differs between two renders", a[i].Path)
}
}
}
// The guess the wizard offers for --repo. It is worth exactly what it costs —
// a file read — so anything it cannot make sense of must answer "" and let the
// operator type it, rather than proposing half an address.
func TestOwnerNameReadsTheSpellingsGitActuallyWrites(t *testing.T) {
cases := []struct{ url, want string }{
{"git@git.example.com:claude-skills/marketplace.git", "claude-skills/marketplace"},
{"git@git.example.com:claude-skills/marketplace", "claude-skills/marketplace"},
{"https://git.example.com/claude-skills/marketplace.git", "claude-skills/marketplace"},
{"https://git.example.com/claude-skills/marketplace", "claude-skills/marketplace"},
{"ssh://git@git.example.com:2222/claude-skills/marketplace.git", "claude-skills/marketplace"},
{" https://git.example.com/owner/name.git ", "owner/name"},
// Deeper paths: a Gitea instance served under a prefix still ends in
// owner/name, and the last two elements are the address.
{"https://example.com/git/owner/name.git", "owner/name"},
// Nothing that can be read as an address.
{"", ""},
{"https://git.example.com/", ""},
{"https://git.example.com/lonely", ""},
{"/a/local/path", "local/path"},
}
for _, tc := range cases {
if got := ownerName(tc.url); got != tc.want {
t.Errorf("ownerName(%q) = %q, want %q", tc.url, got, tc.want)
}
}
}
// The document list is stable and sorted, which is what makes a receipt from one
// machine comparable with a receipt from another.
func TestScaffoldFilesAreSorted(t *testing.T) {
files := scaffold.Files()
paths := make([]string, len(files))
for i, f := range files {
paths[i] = f.Path
}
if !sort.StringsAreSorted(paths) {
t.Errorf("the document list is not sorted: %v", paths)
}
}
+133
View File
@@ -0,0 +1,133 @@
package cmd
import (
"encoding/json"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"
)
// mirrorHookCommand is what gets registered on PreToolUse(Bash).
//
// The `command -v` guard is not decoration. This line outlives the binary that
// wrote it: an operator who uninstalls kettle, or moves it off PATH, would
// otherwise get a "command not found" on every Bash call in this project, from a
// hook they set up months ago and have long stopped thinking about. The guard
// makes the failure mode silence.
const mirrorHookCommand = `command -v kettle >/dev/null && kettle mirror --hook || true`
// mirrorHookSnippet is what an operator is shown when the merge is not this
// command's to make.
const mirrorHookSnippet = `{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "` + mirrorHookCommand + `" }
]
}
]
}
}`
// errSettingsExist means the file is there, does not hold the hook, and merging
// it is a decision rather than a step.
var errSettingsExist = errors.New("settings file already exists")
// settingsPath is `<out>/settings.json` — the shared file, not
// settings.local.json. The convention this registers is a property of a
// repository rather than of one developer's checkout, so it belongs in the file
// that is committed.
func settingsPath(out string) string { return filepath.Join(out, "settings.json") }
// writeMirrorHook registers the PreToolUse hook, and refuses to rewrite a file
// it did not create.
//
// Three outcomes, and the third is the interesting one:
//
// - no file: it is written, hook and all.
// - a file already holding a `kettle mirror` hook: nothing happens.
// - a file holding something else: REFUSED unless force, and the snippet is
// printed for the operator to paste.
//
// That refusal is deliberate and is the only reason this file is not a dozen
// lines shorter. settings.json is a file the operator owns and commits, and Go's
// encoding/json cannot preserve key order — so any merge reformats the whole
// document, and an operator who asked for a documentation hook would find an
// unrelated diff across a file they share with their team. A snippet they paste
// costs them ten seconds; a reformat costs them a review.
func writeMirrorHook(out string, force, dryRun bool) (string, error) {
path := settingsPath(out)
rel := filepath.Join(filepath.Base(out), "settings.json")
raw, err := os.ReadFile(path)
switch {
case errors.Is(err, fs.ErrNotExist):
if dryRun {
return fmt.Sprintf("created %s (PreToolUse: kettle mirror --hook)", rel), nil
}
if err := writeFile(path, mirrorHookSnippet+"\n"); err != nil {
return "", err
}
return fmt.Sprintf("created %s (PreToolUse: kettle mirror --hook)", rel), nil
case err != nil:
return "", err
}
var settings map[string]any
if err := json.Unmarshal(raw, &settings); err != nil {
return "", Fail("%s is not readable as JSON (%v) — fix it, or add the hook by hand:\n\n%s", path, err, mirrorHookSnippet)
}
if strings.Contains(string(raw), "kettle mirror") {
return "", nil // already registered; nothing to do and nothing to say
}
if !force {
return "", fmt.Errorf("%w: %s. Add this to it, or re-run with --force-settings to have kettle merge it (which reformats the file):\n\n%s",
errSettingsExist, path, mirrorHookSnippet)
}
merged, err := mergeMirrorHook(settings)
if err != nil {
return "", err
}
if dryRun {
return fmt.Sprintf("merged the hook into %s (reformatting it)", rel), nil
}
body, err := json.MarshalIndent(merged, "", " ")
if err != nil {
return "", err
}
if err := writeFile(path, string(body)+"\n"); err != nil {
return "", err
}
return fmt.Sprintf("merged the hook into %s (reformatting it)", rel), nil
}
// mergeMirrorHook appends the hook to whatever PreToolUse already holds,
// creating the path if it is not there. Nothing existing is removed or
// reordered — what is lost is key order, which JSON does not carry, and that
// is the whole reason this is behind a flag.
func mergeMirrorHook(settings map[string]any) (map[string]any, error) {
if settings == nil {
settings = map[string]any{}
}
hooks, _ := settings["hooks"].(map[string]any)
if hooks == nil {
hooks = map[string]any{}
}
pre, _ := hooks["PreToolUse"].([]any)
pre = append(pre, map[string]any{
"matcher": "Bash",
"hooks": []any{
map[string]any{"type": "command", "command": mirrorHookCommand},
},
})
hooks["PreToolUse"] = pre
settings["hooks"] = hooks
return settings, nil
}
+23 -6
View File
@@ -1,11 +1,12 @@
# AGENTS.md — internal/config
**Two files: what this project is, and who this machine is.** The only package in
the tree that imports yaml.
**Three files: what this project is, who this machine is, and what was last
written into the project's agent-harness tree.** The only package in the tree
that imports yaml.
| file | what is in it |
|---|---|
| `config.go` | `Project` and `Logins` (the two files), `Resolve`/`ResolveOutsideAProject`/`Require`, `Resolved` with `Complete` and `Redacted`, the `KETTLE_*` overrides, and the 0600 write |
| `config.go` | `Project`, `Logins` and `Scaffold` (the three files), `Resolve`/`ResolveOutsideAProject`/`Require`, `Resolved` with `Complete` and `Redacted`, the `KETTLE_*` overrides, and the 0600 write |
## The split is the whole design
@@ -15,8 +16,18 @@ the tree that imports yaml.
~/.config/kettle/logins.yaml logins: [{name, url, user, scopes, token}]
mode 0600
<project>/.kettle/scaffold.yaml version: v1.4.0 a note, load-bearing
out: .claude for nothing
```
The third file is the newest and the least important, which is exactly why it is
a file of its own — see *Unknown keys are an error* below. It records what
`kettle gen scaffold` last wrote into the project and which build wrote it, so
`kettle config` can say when a project's skills are four releases behind the
binary reading them. **Nothing resolves from it.** Delete it and you lose the
warning and nothing else.
`user` and `scopes` are **documentation and nothing else** — nothing is checked
against either, and no request is refused because of one. `scopes` is what the
token was minted with, as Gitea spells it (`write:issue`, `write:repository`),
@@ -103,17 +114,23 @@ operator upgrades; it would **not** be acceptable for `config.yaml`, which is
committed and read by whatever version each machine happens to have. Adding a
field to the project file means answering that first, out loud, here.
`scaffold.yaml` exists because that answer came back "no". Recording which build
wrote a project's `.claude/` tree wanted two keys, and putting them in
`config.yaml` would have made every older `kettle` in the world stop reading a
committed file. A separate file written and read by one binary about one
directory carries the same cost for nothing: an older build never opens it.
## What does not belong here
A request, a store path, an issue. This package reads and writes two files and
A request, a store path, an issue. This package reads and writes three files and
answers "who am I and where am I pointed"; [`gitea`](../gitea/AGENTS.md) takes the
answer and dials, and the paths themselves come from
[`project`](../project/AGENTS.md).
## Keeping this file true
- **Scope:** `config.go` — the two files, their fields, the overrides, the file modes.
- **Update it when** a field is added to either file (both tables above are the
- **Scope:** `config.go` — the three files, their fields, the overrides, the file modes.
- **Update it when** a field is added to any of them (the block above is the
contract), an override is added or renamed, the location or mode of the login file
changes, or the unknown-key policy changes.
- **Do not** move a credential into the project file, and if that ever changes, the
+66
View File
@@ -47,6 +47,14 @@ const projectHeader = `# kettle — project configuration
# Overrides, when you need one: ` + EnvLogin + `, ` + EnvRepo + `, ` + EnvURL + `, ` + EnvToken + `.
`
const scaffoldHeader = `# kettle — what was last written into this project's agent-harness tree.
#
# Written by ` + "`kettle init`" + ` and ` + "`kettle gen scaffold`" + `; read by
# ` + "`kettle config`" + `, which says so when the build that wrote the tree is not the
# build that is installed now. Nothing resolves from this file — deleting it
# costs the warning and nothing else.
`
// Project is `<project>/.kettle/config.yaml`.
type Project struct {
// Login names an entry in the machine-wide login file. Never a token.
@@ -55,6 +63,27 @@ type Project struct {
Repo string `yaml:"repo"`
}
// Scaffold is `<project>/.kettle/scaffold.yaml`: what `kettle` last wrote into
// this project's agent-harness tree, and which build wrote it.
//
// A file of its own rather than two more keys in Project, and that is the rule
// in "Unknown keys are an error" being obeyed rather than worked around: a field
// added to config.yaml is a one-way door for a file that may be committed and
// read by whatever version each machine happens to have. This one is written and
// read by a single binary about a single directory, so it can carry that cost.
//
// It exists to answer one question — "were these documents written by the kettle
// that is installed now?" — because the documents are generated whole and an
// operator has no other way to tell a current tree from one four releases old.
type Scaffold struct {
// Version is what `kettle version` reported when the tree was written. A
// hand build says `dev` and means it.
Version string `yaml:"version"`
// Out is where the tree went, relative to the project root when it is
// underneath it.
Out string `yaml:"out"`
}
// Login is one set of credentials for one Gitea instance.
type Login struct {
Name string `yaml:"name"`
@@ -139,6 +168,43 @@ func SaveProject(path string, p *Project) error {
return os.WriteFile(path, append([]byte(projectHeader+"\n"), body...), 0o644)
}
// ScaffoldPath is where this project records what it last had written into it,
// or "" with no project.
func ScaffoldPath(start string) string { return project.ScaffoldPath(start) }
// ReadScaffoldFile reads a scaffold record at a path already known, reporting
// whether the file was there.
//
// A missing file is the zero value and not an error: a project initialized
// before this record existed, or one written with --no-scaffold, has nothing to
// say here and that is an ordinary state rather than a fault.
func ReadScaffoldFile(path string) (*Scaffold, bool, error) {
raw, err := os.ReadFile(path)
if os.IsNotExist(err) {
return &Scaffold{}, false, nil
}
if err != nil {
return nil, false, err
}
var s Scaffold
if err := strictUnmarshal(raw, &s); err != nil {
return nil, true, fmt.Errorf("%s: %w", path, err)
}
return &s, true, nil
}
// SaveScaffoldFile writes the scaffold record.
func SaveScaffoldFile(path string, s *Scaffold) error {
body, err := yaml.Marshal(s)
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
return os.WriteFile(path, append([]byte(scaffoldHeader+"\n"), body...), 0o644)
}
// LoginsPath is the machine-wide login file.
//
// One file per machine, deliberately outside every working tree: which tokens
+3 -3
View File
@@ -167,7 +167,7 @@ chip and not what an issue is.
- **Update it when** a metadata field is added to `DomainKeys`, a type or severity
is added to the taxonomy, a required or expected section changes, a file appears
or goes in the table above, or any rule about what may be deleted changes. The
format's operator-facing statement of intent lives in the plugin
(`plugins/kettle/skills/issue/references/format.md`) — when the taxonomy moves,
both change.
format's operator-facing statement of intent is embedded in the binary
(`cli/internal/scaffold/assets/skills/kettle-issue/references/format.md`) — when
the taxonomy moves, both change.
- **Do not** document how any of this reaches a tracker.
+97
View File
@@ -0,0 +1,97 @@
# AGENTS.md — internal/mirror
**One filesystem invariant, in every directory of a tree:**
```
AGENTS.md is the real file; CLAUDE.md is a symlink pointing at it.
```
**Imports the standard library and nothing else** — no issue, no tracker, no
configuration, no login. That is not a stylistic preference: this walks whatever
directory it is pointed at, on any machine, and a package that reached for a
project's configuration could not be run outside a project. Two tests hold it,
see [`internal/AGENTS.md`](../AGENTS.md).
| file | what is in it |
|---|---|
| `mirror.go` | `Sync`, `Check`, `Result`, and `fixDir` — the seven branches |
| `mirror_test.go` | one case per branch, plus the walk, the skip list and idempotence |
| `layering_test.go` | the two tests that keep this package at the bottom |
## Why the invariant
Two agent harnesses read two different filenames for the same document. A
repository that keeps both as real files keeps **two documents**, and they drift —
silently, until somebody reads the stale one and believes it. One real file with a
link beside it is the only arrangement where that cannot happen.
`AGENTS.md` is the real file rather than `CLAUDE.md` because the convention is not
one vendor's: a repository that names its documents after a single tool has picked
a side it did not need to pick.
## The seven branches
Every one of them is either lossless or a refusal. **Nothing here deletes content.**
| starting state | what happens |
|---|---|
| `AGENTS.md` real, no `CLAUDE.md` | create the symlink |
| `CLAUDE.md` real, no `AGENTS.md` | rename to `AGENTS.md`, link back |
| `CLAUDE.md` symlink → `AGENTS.md` | canonical; nothing to do |
| `CLAUDE.md` symlink elsewhere | re-point it |
| `AGENTS.md` symlink → real `CLAUDE.md` | reversed layout; swap it round |
| both real, identical content | replace `CLAUDE.md` with the symlink |
| both real, **different** content | **refuse**, and name the directory |
The last row is the reason the other six can be automatic. One of those two files
is somebody's writing and no rule here knows which, so a merge is not attempted and
not offered — the conflict is reported and the directory is left exactly as it was.
Two smaller refusals sit beside it, both about a symlink with no target: a broken
`AGENTS.md` with no `CLAUDE.md` beside it, and a `CLAUDE.md` pointing at something
that is gone. Neither is repairable without inventing content.
**The link target is relative.** `CLAUDE.md -> AGENTS.md`, never an absolute path:
a tree that is moved, copied, cloned or mounted somewhere else keeps working, and
an absolute link would point at wherever the repair happened to run.
## Two properties the tests hold
**A repair that fails halfway reports nothing.** An unwritable directory, a race
with an editor — the fix is abandoned and no line is added. Claiming a repair that
did not happen is worse than silence: the next run finds the same state, and the
operator has now been told twice that it was handled.
**Sync converges in one pass.** Every case in the table runs `Sync` twice and fails
if the second run still has work. This matters more here than it looks: the command
that wraps this package runs on `PreToolUse(Bash)`, so a state that reported itself
fixed without converging would re-report on every Bash call, forever.
`Check` is the same walk with the writes turned off — one code path, not a second
implementation that might disagree — so a check that says nothing is a promise
about the run that follows it.
## What is skipped
`node_modules`, `__pycache__`, `venv`, `vendor`, and every dot-directory.
Somebody else's tree is somebody else's business: a vendored dependency's
`AGENTS.md` rewritten here is a diff nobody asked for. `.git` gets the same
treatment for a second reason — it is not a place to be creating symlinks.
## What does not belong here
The JSON a `PreToolUse` hook reads and writes, the decision to run at all, and the
exit status. Those are [`cmd`](../cmd/AGENTS.md)'s, in `mirror.go` there; this
package takes a directory and returns a `Result`. That split is what lets the
repair be tested against a temp directory without a hook payload anywhere near it.
## Keeping this file true
- **Scope:** `mirror.go` and its tests — the invariant, the seven branches, the
skip list, and the two properties above.
- **Update it when** a branch is added or its outcome changes (the table is the
contract), a directory is added to or dropped from the skip list, or the link
stops being relative.
- **Do not** document the command, the hook payload or the exit codes here.
[`internal/cmd`](../cmd/AGENTS.md) owns those.
+57
View File
@@ -0,0 +1,57 @@
package mirror
import (
"os/exec"
"strings"
"testing"
)
// This package repairs a filesystem layout and knows nothing else. It has no
// business with an issue, a tracker, a login or a configuration file, and the
// moment it imports one of them it stops being a thing that can be run over any
// directory on the machine.
//
// The dependency walk, so a helper pulled in three packages deep is caught as
// the same violation as one written at the top of a file.
func TestMirrorDependsOnNothing(t *testing.T) {
out, err := exec.Command("go", "list", "-deps", ".").Output()
if err != nil {
t.Fatalf("go list: %v", err)
}
for _, dep := range strings.Fields(string(out)) {
if dep == "git.noodles.cam/claude-skills/marketplace/cli/internal/mirror" {
continue
}
// A standard-library import path has no dot in its first element,
// because it has no domain name in front of it.
if first, _, _ := strings.Cut(dep, "/"); strings.Contains(first, ".") {
t.Errorf("mirror imports %s — this walks a directory, and nothing else belongs here", dep)
}
}
}
// The other half: os and net/http are standard library, so "no third-party
// imports" would not catch a request or a shell-out written by hand here. os
// itself is the point of this package, so it is the one that is allowed.
//
// DIRECT imports, not the dependency walk — fmt reaches os on its own, and the
// question this asks is what THIS package reaches for.
func TestMirrorNeitherDialsNorShellsOut(t *testing.T) {
forbidden := map[string]string{
"net/http": "a documentation convention is not fetched from anywhere",
"net": "a documentation convention is not fetched from anywhere",
"os/exec": "the repair is syscalls, not a shell — that is the whole reason it left bash",
"encoding/json": "the hook's JSON is the command layer's business, not this one's",
"time": "nothing here has a clock in it",
}
out, err := exec.Command("go", "list", "-f", `{{join .Imports "\n"}}`, ".").Output()
if err != nil {
t.Fatalf("go list: %v", err)
}
for _, dep := range strings.Fields(string(out)) {
if why, bad := forbidden[dep]; bad {
t.Errorf("mirror imports %s — %s", dep, why)
}
}
}
+232
View File
@@ -0,0 +1,232 @@
// Package mirror enforces one filesystem invariant, in every directory of a
// tree:
//
// AGENTS.md is the real file; CLAUDE.md is a symlink pointing at it.
//
// Two agent harnesses read two different filenames for the same document, and a
// repository that keeps both as real files keeps two documents — which drift,
// silently, until somebody reads the stale one and believes it. One real file
// with a link beside it is the only arrangement where that cannot happen.
//
// This package depends on nothing but the standard library. It performs no
// merge and DELETES NO CONTENT: every branch is either a lossless repair or a
// report, and the one case it refuses to resolve — two real files whose contents
// differ — is the one where a wrong guess would destroy somebody's writing.
package mirror
import (
"bytes"
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"
)
// The two names, and the link's target. The target is written relative on
// purpose: a tree that is moved, copied or mounted somewhere else keeps working,
// and an absolute link would point at wherever the repair happened to run.
const (
Agents = "AGENTS.md"
Claude = "CLAUDE.md"
)
// skipDirs are never descended into. Each holds somebody else's tree — a
// vendored dependency's AGENTS.md is that dependency's business, and rewriting
// it would show up as a diff nobody asked for. Dot-directories are skipped by
// the same argument and by a second one: `.git` is not a place to be creating
// symlinks.
var skipDirs = map[string]bool{
"node_modules": true,
"__pycache__": true,
"venv": true,
"vendor": true,
}
// Result is what one walk found. Both halves are ordered by directory, because
// the walk is, so two runs over the same tree report in the same order.
type Result struct {
// Fixes are the repairs made — or, from Check, the repairs that would be.
Fixes []string
// Conflicts are the directories this package refuses to resolve. A conflict
// is reported identically by both entry points: nothing about it is a write.
Conflicts []string
}
// Clean reports whether the tree was already canonical.
func (r Result) Clean() bool { return len(r.Fixes) == 0 && len(r.Conflicts) == 0 }
// Sync walks root and repairs every directory under it.
func Sync(root string) Result { return walk(root, true) }
// Check walks root and reports what Sync would do, writing nothing.
//
// The two share one code path with the writes turned off, so a check that says
// nothing is a promise about the run that follows it rather than a second
// implementation that might disagree.
func Check(root string) Result { return walk(root, false) }
func walk(root string, apply bool) Result {
var res Result
abs, err := filepath.Abs(root)
if err != nil {
return res
}
_ = filepath.WalkDir(abs, func(path string, d fs.DirEntry, err error) error {
if err != nil {
// An unreadable directory is skipped, never fatal: this runs over
// somebody's whole working tree and one bad mode must not stop it.
if d != nil && d.IsDir() {
return fs.SkipDir
}
return nil
}
if !d.IsDir() {
return nil
}
if path != abs {
if name := d.Name(); skipDirs[name] || strings.HasPrefix(name, ".") {
return fs.SkipDir
}
}
fixDir(path, abs, apply, &res)
return nil
})
return res
}
// fixDir applies the invariant to one directory.
//
// The seven cases, and every one of them is either lossless or a refusal:
//
// AGENTS.md real, no CLAUDE.md ........ create the symlink
// CLAUDE.md real, no AGENTS.md ........ rename to AGENTS.md, link back
// CLAUDE.md symlink -> AGENTS.md ...... canonical, nothing to do
// CLAUDE.md symlink elsewhere ......... re-point it
// AGENTS.md symlink -> real CLAUDE.md . reversed layout, swap it round
// both real, identical content ........ replace CLAUDE.md with the symlink
// both real, different content ........ REFUSE, and say which directory
//
// A repair that fails halfway — an unwritable directory, a race with an editor —
// reports nothing rather than a fix it did not make. Claiming a repair that did
// not happen is worse than silence, because the next run would find the same
// state and the operator would have been told twice that it was handled.
func fixDir(dir, root string, apply bool, res *Result) {
agents := filepath.Join(dir, Agents)
claude := filepath.Join(dir, Claude)
aInfo, aErr := os.Lstat(agents)
cInfo, cErr := os.Lstat(claude)
a, c := aErr == nil, cErr == nil
if !a && !c {
return
}
aLink := a && aInfo.Mode()&os.ModeSymlink != 0
cLink := c && cInfo.Mode()&os.ModeSymlink != 0
rel := func(p string) string {
r, err := filepath.Rel(root, p)
if err != nil {
return p
}
if r == "." {
return "<root>"
}
return r
}
conflict := func(format string, v ...any) {
res.Conflicts = append(res.Conflicts, fmt.Sprintf(format, v...))
}
// fix runs the repair unless this is a check, and records it only if every
// step of it succeeded.
fix := func(msg string, steps ...func() error) {
if apply {
for _, step := range steps {
if err := step(); err != nil {
return
}
}
}
res.Fixes = append(res.Fixes, msg)
}
link := func() error { return os.Symlink(Agents, claude) }
switch {
case a && !c:
if aLink && !exists(agents) {
conflict("%s: broken symlink and no %s", rel(agents), Claude)
return
}
fix(fmt.Sprintf("%s: created symlink -> %s", rel(claude), Agents), link)
case c && !a:
if cLink {
target, _ := os.Readlink(claude)
conflict("%s: symlink to missing target (%s)", rel(claude), target)
return
}
fix(fmt.Sprintf("%s: renamed to %s, symlink left in place", rel(claude), Agents),
func() error { return os.Rename(claude, agents) }, link)
case cLink:
if sameFile(claude, agents) {
return // canonical
}
old, _ := os.Readlink(claude)
fix(fmt.Sprintf("%s: re-pointed symlink (%s -> %s)", rel(claude), old, Agents),
func() error { return os.Remove(claude) }, link)
case aLink:
// Reversed layout: AGENTS.md is the link and CLAUDE.md the real file.
if !sameFile(agents, claude) {
conflict("%s: symlink elsewhere while %s is a real file", rel(agents), Claude)
return
}
fix(fmt.Sprintf("%s: swapped — %s is now the real file", rel(agents), Agents),
func() error { return os.Remove(agents) },
func() error { return os.Rename(claude, agents) }, link)
default:
// Both are real files, and only their contents decide what happens.
if !identical(agents, claude) {
conflict("%s: %s and %s are different real files — merge manually",
rel(dir), Agents, Claude)
return
}
fix(fmt.Sprintf("%s: identical to %s, replaced with symlink", rel(claude), Agents),
func() error { return os.Remove(claude) }, link)
}
}
func exists(p string) bool {
_, err := os.Stat(p)
return err == nil
}
// sameFile reports whether two paths resolve to one file.
func sameFile(a, b string) bool {
ra, err := filepath.EvalSymlinks(a)
if err != nil {
return false
}
rb, err := filepath.EvalSymlinks(b)
if err != nil {
return false
}
return ra == rb
}
// identical compares two files by content, not by size or mtime. The whole
// point of the comparison is to decide whether one of them may be deleted.
func identical(a, b string) bool {
ba, err := os.ReadFile(a)
if err != nil {
return false
}
bb, err := os.ReadFile(b)
if err != nil {
return false
}
return bytes.Equal(ba, bb)
}
+268
View File
@@ -0,0 +1,268 @@
package mirror
import (
"os"
"path/filepath"
"strings"
"testing"
)
// state is what one directory looks like after a walk: what kind of thing each
// name is, and what the real file holds. It is the assertion for every case
// below, because every case is a statement about exactly this.
type state struct {
agentsIsFile bool
claudeIsLink bool
linkTarget string
body string
}
func read(t *testing.T, dir string) state {
t.Helper()
var s state
if fi, err := os.Lstat(filepath.Join(dir, Agents)); err == nil {
s.agentsIsFile = fi.Mode().IsRegular()
}
if fi, err := os.Lstat(filepath.Join(dir, Claude)); err == nil {
if fi.Mode()&os.ModeSymlink != 0 {
s.claudeIsLink = true
s.linkTarget, _ = os.Readlink(filepath.Join(dir, Claude))
}
}
if b, err := os.ReadFile(filepath.Join(dir, Agents)); err == nil {
s.body = string(b)
}
return s
}
// canonical is the one arrangement this package exists to produce.
func canonical(body string) state {
return state{agentsIsFile: true, claudeIsLink: true, linkTarget: Agents, body: body}
}
func writeFile(t *testing.T, path, body string) {
t.Helper()
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
func symlink(t *testing.T, target, path string) {
t.Helper()
if err := os.Symlink(target, path); err != nil {
t.Fatal(err)
}
}
// The seven branches, each named by the state it starts in.
func TestEveryBranchIsLosslessOrARefusal(t *testing.T) {
cases := []struct {
name string
// setup builds one directory in the state under test.
setup func(t *testing.T, dir string)
// want is the state afterwards; a conflict case wants no change at all.
want state
// fixes and conflicts are how many of each the walk reports.
fixes, conflicts int
// says is a fragment the one report has to contain, so a message that
// stops naming what happened fails here rather than in somebody's
// terminal a year from now.
says string
}{
{
name: "AGENTS.md alone gets a link beside it",
setup: func(t *testing.T, dir string) {
writeFile(t, filepath.Join(dir, Agents), "doc\n")
},
want: canonical("doc\n"),
fixes: 1,
says: "created symlink",
},
{
name: "CLAUDE.md alone is renamed and linked back",
setup: func(t *testing.T, dir string) {
writeFile(t, filepath.Join(dir, Claude), "doc\n")
},
want: canonical("doc\n"),
fixes: 1,
says: "renamed to " + Agents,
},
{
name: "already canonical is left completely alone",
setup: func(t *testing.T, dir string) {
writeFile(t, filepath.Join(dir, Agents), "doc\n")
symlink(t, Agents, filepath.Join(dir, Claude))
},
want: canonical("doc\n"),
},
{
name: "a link pointing elsewhere is re-pointed",
setup: func(t *testing.T, dir string) {
writeFile(t, filepath.Join(dir, Agents), "doc\n")
writeFile(t, filepath.Join(dir, "OTHER.md"), "other\n")
symlink(t, "OTHER.md", filepath.Join(dir, Claude))
},
want: canonical("doc\n"),
fixes: 1,
says: "re-pointed symlink",
},
{
name: "the reversed layout is swapped round",
setup: func(t *testing.T, dir string) {
writeFile(t, filepath.Join(dir, Claude), "doc\n")
symlink(t, Claude, filepath.Join(dir, Agents))
},
want: canonical("doc\n"),
fixes: 1,
says: "swapped",
},
{
name: "two real files with one content lose the copy",
setup: func(t *testing.T, dir string) {
writeFile(t, filepath.Join(dir, Agents), "doc\n")
writeFile(t, filepath.Join(dir, Claude), "doc\n")
},
want: canonical("doc\n"),
fixes: 1,
says: "replaced with symlink",
},
{
// The one case that must never be resolved: one of the two is
// somebody's writing, and no rule here knows which.
name: "two real files with different content are refused",
setup: func(t *testing.T, dir string) {
writeFile(t, filepath.Join(dir, Agents), "mine\n")
writeFile(t, filepath.Join(dir, Claude), "theirs\n")
},
want: state{agentsIsFile: true, body: "mine\n"},
conflicts: 1,
says: "merge manually",
},
{
name: "a broken link with nothing beside it is reported, not replaced",
setup: func(t *testing.T, dir string) {
symlink(t, "GONE.md", filepath.Join(dir, Agents))
},
want: state{},
conflicts: 1,
says: "broken symlink",
},
{
name: "a CLAUDE.md link to nothing is reported",
setup: func(t *testing.T, dir string) {
symlink(t, "GONE.md", filepath.Join(dir, Claude))
},
want: state{claudeIsLink: true, linkTarget: "GONE.md"},
conflicts: 1,
says: "missing target",
},
{
name: "a directory holding neither is not touched",
setup: func(t *testing.T, dir string) {},
want: state{},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
dir := t.TempDir()
tc.setup(t, dir)
res := Sync(dir)
if len(res.Fixes) != tc.fixes {
t.Errorf("fixes = %d (%v), want %d", len(res.Fixes), res.Fixes, tc.fixes)
}
if len(res.Conflicts) != tc.conflicts {
t.Errorf("conflicts = %d (%v), want %d", len(res.Conflicts), res.Conflicts, tc.conflicts)
}
if tc.says != "" {
all := strings.Join(append(res.Fixes, res.Conflicts...), "\n")
if !strings.Contains(all, tc.says) {
t.Errorf("no report mentions %q; got:\n%s", tc.says, all)
}
}
if got := read(t, dir); got != tc.want {
t.Errorf("after Sync:\n got %+v\nwant %+v", got, tc.want)
}
// A second run must find nothing left to do. A repair that reported
// itself fixed and did not converge would loop forever inside a
// PreToolUse hook, once per Bash call.
if again := Sync(dir); len(again.Fixes) != 0 {
t.Errorf("not idempotent — a second run still fixes %v", again.Fixes)
}
})
}
}
// Check is the same walk with the writes turned off, and the promise it makes is
// that the run after it does exactly what it said.
func TestCheckReportsWithoutWriting(t *testing.T) {
dir := t.TempDir()
writeFile(t, filepath.Join(dir, Agents), "doc\n")
before := read(t, dir)
res := Check(dir)
if len(res.Fixes) != 1 {
t.Fatalf("Check found %d fixes, want 1", len(res.Fixes))
}
if after := read(t, dir); after != before {
t.Errorf("Check wrote to the tree: %+v -> %+v", before, after)
}
sync := Sync(dir)
if len(sync.Fixes) != len(res.Fixes) || sync.Fixes[0] != res.Fixes[0] {
t.Errorf("Sync did not do what Check said:\n check %v\n sync %v", res.Fixes, sync.Fixes)
}
if !Check(dir).Clean() {
t.Error("the tree is still not canonical after Sync")
}
}
// Somebody else's tree is somebody else's business. A vendored dependency's
// AGENTS.md rewritten here is a diff nobody asked for, and `.git` is not a place
// to be creating symlinks.
func TestSkippedDirectoriesAreNotTouched(t *testing.T) {
dir := t.TempDir()
for _, skip := range []string{"vendor", "node_modules", ".git", ".claude"} {
sub := filepath.Join(dir, skip, "pkg")
if err := os.MkdirAll(sub, 0o755); err != nil {
t.Fatal(err)
}
writeFile(t, filepath.Join(sub, Agents), "theirs\n")
}
if res := Sync(dir); !res.Clean() {
t.Errorf("walked into a skipped directory: %+v", res)
}
for _, skip := range []string{"vendor", "node_modules", ".git", ".claude"} {
p := filepath.Join(dir, skip, "pkg", Claude)
if _, err := os.Lstat(p); err == nil {
t.Errorf("%s was created inside %s", Claude, skip)
}
}
}
// The walk is a walk: a document six directories down is as canonical as one at
// the top, and the report says where it was.
func TestTheWholeTreeIsWalked(t *testing.T) {
dir := t.TempDir()
deep := filepath.Join(dir, "cli", "internal", "issue")
if err := os.MkdirAll(deep, 0o755); err != nil {
t.Fatal(err)
}
writeFile(t, filepath.Join(dir, Agents), "root\n")
writeFile(t, filepath.Join(deep, Agents), "issue\n")
res := Sync(dir)
if len(res.Fixes) != 2 {
t.Fatalf("fixes = %v, want one per directory", res.Fixes)
}
joined := strings.Join(res.Fixes, "\n")
if !strings.Contains(joined, filepath.Join("cli", "internal", "issue", Claude)) {
t.Errorf("the report does not name the nested directory:\n%s", joined)
}
if got := read(t, deep); got != canonical("issue\n") {
t.Errorf("nested directory not canonical: %+v", got)
}
}
+7 -1
View File
@@ -9,7 +9,7 @@ one in the tree with no other package below it.
| file | what is in it |
|---|---|
| `project.go` | `Marker`, `Anchors`, `Parents`, `GitDirOf`, `MainWorktree`, `Root`, and the paths resolved from it — `StoreRoot`, `PayloadRoot`, `ConfigPath` — plus `NotFoundError` |
| `project.go` | `Marker`, `Anchors`, `Parents`, `GitDirOf`, `MainWorktree`, `Root`, and the paths resolved from it — `StoreRoot`, `PayloadRoot`, `ConfigPath`, `ScaffoldPath` — plus `NotFoundError` |
| `init.go` | `Init`: creates the marker, migrates an older layout in, gitignores `.kettle/`. `ClashError` is its refusal |
| `project_test.go` | the walk, including the worktree hop and the "no marker anywhere" answer |
@@ -87,6 +87,12 @@ A non-empty `start` overrides both anchors and exists so resolution can be
exercised against a scratch tree — which is what the test suite does, and why
every fixture also strips `CLAUDE_PROJECT_DIR`.
`MainWorktree` has a second caller now, and it is the one that made the function
worth having in public: `kettle init` refuses to run where it answers, and names
the main checkout instead. That rule used to be a paragraph in a skill somebody
had to read, which stopped being good enough the moment an interactive wizard
became the front door — a front door cannot assume anybody read anything.
## What does not belong here
Anything that reads or writes an issue, a config file or a socket. This package
+15 -3
View File
@@ -29,9 +29,10 @@ const Marker = ".kettle"
// Everything under the marker, each resolved by the same walk so that which
// command wrote a file cannot change where it landed.
var (
storeParts = []string{Marker, "issues"}
payloadParts = []string{Marker, "payload"}
configParts = []string{Marker, "config.yaml"}
storeParts = []string{Marker, "issues"}
payloadParts = []string{Marker, "payload"}
configParts = []string{Marker, "config.yaml"}
scaffoldParts = []string{Marker, "scaffold.yaml"}
)
// Anchors are the directories a root search starts from, in order, first hit
@@ -200,6 +201,17 @@ func PayloadRoot(start string) string { return under(start, payloadParts) }
// ConfigPath is the absolute path of the project's tracker config, or "".
func ConfigPath(start string) string { return under(start, configParts) }
// ScaffoldPath is the absolute path of the record of what was written into the
// project's `.claude/` tree, or "".
//
// A file of its own rather than a field in config.yaml, and the reason is a rule
// stated in [config]: an unknown key in config.yaml is an error rather than a
// silent drop, so a field added there is a one-way door for a file that may be
// committed and read by whatever version each machine happens to have. This
// record is written and read by one binary about one directory, so it can carry
// that cost where the shared file cannot.
func ScaffoldPath(start string) string { return under(start, scaffoldParts) }
func under(start string, parts []string) string {
root := Root(start)
if root == "" {
+121
View File
@@ -0,0 +1,121 @@
# AGENTS.md — internal/scaffold
**The documents kettle writes into a project**: the slash commands an operator
invokes, the skills a model loads, and the runner subagent. Embedded with
`//go:embed`, handed out as bytes.
**Imports the standard library and nothing else** — and, unusually, is forbidden
`os` as well. These documents travel *inside* the binary; one `os.ReadFile` and
they are back to being files on a machine that may not have them, which is the
entire failure this package exists to make impossible. Two tests hold it, see
[`internal/AGENTS.md`](../AGENTS.md).
| file | what is in it |
|---|---|
| `scaffold.go` | `Marker`, `File`, `Files`, `Groups`, `PathFor`, `Dirs`, and the `generated` map |
| `assets/` | every document, exactly as it is written out |
| `scaffold_test.go` | frontmatter, names, the region declaration, determinism |
| `layering_test.go` | the two tests that keep this package at the bottom |
## Why these are not a plugin
They were, and the plugin shipped on its own release cadence. **Nothing on an
operator's machine ever checked that the plugin they had installed described the
binary they had installed** — so a renamed flag could ship with documentation
recommending the old one, which is the exact failure the generated block was
invented to prevent, one hop further downstream.
Prose that travels inside the binary cannot be a version behind it. That is the
whole argument, and everything else here follows from it: the generator writes
files whole rather than splicing a region, `kettle init` writes the tree, and
`kettle config` reports which build wrote the one on disk.
What was lost is worth naming. The marketplace was discovery — `/plugin` is a
shop window and a README URL is not — and a plugin's `/kettle:…` prefix came free
where a project's does not. The second was recoverable and is recovered below;
the first was not, and the trade was made anyway, because a document that
describes the wrong binary is worse than one nobody found.
## The layout, and where the namespace went
```
commands/kettle/*.md → /kettle:init, /kettle:auth, /kettle:issue, …
skills/kettle-*/SKILL.md → loaded by description, never by name
skills/kettle-issue/references/format.md
agents/kettle-runner.md
```
A project's skills have **no namespace**: `.claude/skills/` is flat, and a skill
called `issue` would collide with every other tool's. So the prefix is spelled
into the directory name — `kettle-issue`, not `issue` — and the `name:` in the
frontmatter matches it, because the harness resolves one from the other.
**Commands do have a namespace, and it is free.** A subdirectory under
`.claude/commands/` becomes the prefix, so `commands/kettle/init.md` is
`/kettle:init` — the same spelling the plugin had, with no plugin. Every
`/kettle:…` in the embedded prose is still true because of this, which is why
four of the six command files exist at all: `issue`, `sync`, `api` and `project`
are three lines each, pointing at the skill that holds the substance. They are
not duplication; they are the reason 1,600 lines of cross-references did not have
to be rewritten.
**`init` and `auth` are commands with no skill.** That is not an omission. They
were skills carrying `disable-model-invocation: true` — a frontmatter flag saying
"an operator invokes this, not a model". A command *is* operator-invoked, so the
flag disappeared into the shape of the thing. `init` in particular must not be
routable: which directory is the project is the one question this binary exists
to have a person answer.
## The generated region
Four documents carry `<!-- kettle:gen -->` markers around a flag table rendered
from the command registry. Which four is declared in the `generated` map, and
declared rather than derived from the directory name — the group ⇄ skill
correspondence is a decision, and one that has never been one-to-one: `project`
is a single skill covering five commands, and `init` and `auth` are commands
inside it with no skill of their own.
`scaffold_test.go` asserts the map and the markers agree in both directions, and
[`internal/cmd`](../cmd/AGENTS.md) asserts every group in the registry is named
here. A group added without a document would have its commands silently written
nowhere — the generator walks the documents, not the registry.
**This package does not render.** It hands out embedded bytes and says which of
them carry a region; the splice is `internal/cmd`'s, because the registry is. The
split is what keeps the import arrow pointing one way.
## Bootstrapping, which is circular and is meant to be
`assets/` holds the block already spliced in — the files here are what a project
gets, byte for byte. So:
```bash
make check # gen scaffold --check, against assets/
dist/kettle gen scaffold --out internal/scaffold/assets # the fix
```
A flag change makes the render differ from the embedded copy, `--check` exits 1,
regenerating updates `assets/`, and the binary has to be **rebuilt** to pick the
new bytes up. That loop is the same one the plugin's SKILL.md files were in; what
changed is that its output now ships with the binary rather than beside it.
## Editing a document
Edit it here, under `assets/`, then `make check`. Never edit the copy in a
project: it is replaced whole on the next `kettle gen scaffold`, and the run says
so before it does it.
The one line no generator may write is `description:` — it is what decides
whether a model loads a skill at all, and it is prose tuned against real
failures. `scaffold_test.go` fails on an empty one; nothing checks that it is
*good*, and nothing can.
## Keeping this file true
- **Scope:** `scaffold.go`, everything under `assets/`, and the two seams — the
`generated` map, and the commands ⇄ skills split.
- **Update it when** a document is added or removed, a group gains or loses its
file, the output layout changes, or the argument for embedding rather than
shipping a plugin stops being the argument.
- **Do not** restate what any individual document says. Each one is its own
procedure and says it in full.
@@ -0,0 +1,125 @@
---
name: kettle-runner
description: Runs `kettle` commands and hands back a compact receipt. Use for the mechanical half of issue work — a bulk pull, pushing a set the caller already named, posting a comment from a file, bootstrapping labels, rebuilding the index or the tree. It runs commands; it never decides what an issue should say. Delegate a batch, not a single call.
tools: Bash, Read, Grep, Glob, Skill
model: haiku
---
# kettle-runner — the execution layer
You run `kettle` commands and hand back a short receipt. The binary holds the
mechanics; the skills hold the meaning; you hold neither.
**You have no opinion about content.** Titles, bodies, types, labels,
dependencies, what is worth filing and what is worth closing — all of that was
decided before you were called, and if it was not, the answer is to say so, not
to fill the gap yourself.
## Where the commands come from
Load the skill, do not remember the flags:
- `/kettle:sync``pull`, `push`, `remote`, `comment`, `close`, `labels`,
`sync-evict`
- `/kettle:issue``new`, `check`, `ac`, `tree`, `index`, `evict`
- `/kettle:project``config`, `auth list`
- `/kettle:api``api`, for the Gitea entities that have no command of their own
Invoke `Skill` with the one that owns the task at the start and use the generated
command reference it carries verbatim. That block is written from the binary's
own command registry, so it cannot disagree with the binary; a flag you recall
from another session can. If the reference does not document a flag, it does not
exist — report that instead of trying it. `kettle help <command>` is the same
truth if you need it in a hurry.
## Hard rules
1. **`kettle` only.** No curl at a tracker, no other CLI, no request you composed
yourself. The binary carries the project's credentials; there is no login for
you to name and none for you to choose. `kettle api` is a kettle command and
is yours to run **as the caller spelled it** — endpoint, method and body come
from the task, and a `-X DELETE` is never something you add. An entity nobody
named an endpoint for is a finding for the caller, not a request for you to
improvise.
2. **No writing to issue files.** You have no `Edit` and no `Write`. Commands
write files; you do not. If a task needs a body edited or a metadata field
changed by hand, stop and say which file and which field. `kettle ac` is the
one command that touches a body and it changes a single character: tick only
the items the caller named, by the number or the substring the caller gave.
Whether a criterion is actually met is a judgement about content, and content
is never yours.
3. **Push only what you were told to push.** `kettle push` publishes to a tracker
other people read, **and it deletes the local file on success** — so a widened
set is not an over-share, it is somebody else's working copy gone. Run it with
the ids or filter the caller named. Never widen the set, never run a bare
`kettle push` because it looked like the obvious next step, and **never pass
`--force`** — a validation failure is a result to report, not an obstacle to
route around. Report the number and URL it printed; that is now the only
address the issue has.
4. **Close only the ids the caller named.** Same discipline as push. Never infer
that an issue is finished because its checkboxes are ticked or its branch is
merged. `--reopen` is the same rule backwards. Retitling is not yours, and
deleting anything on a tracker is never yours.
Two local deletions are allowed, both only when the caller asked for them:
push's own, on the issues you were told to push, and eviction (`kettle evict`
/ `kettle sync-evict`) of closed issues. Run eviction with `--dry-run` first
and report what it named. It refuses to touch an `origin: local` issue by
itself — that is the binary's guarantee, not your judgement, and it is not a
reason to point it at a store nobody asked you to clean.
5. **One retry, maximum.** A command that fails twice is a finding. Do not
permute flags looking for one that works.
6. **No payload dumps.** Never `cat` a pulled issue body back into your report.
`kettle` prints compact output by design; the caller reads the files it needs
from disk.
## Procedure
1. Load the skill you need.
2. Run the commands. Prefer one filtered call over a loop — `kettle pull
--milestone 6` pages the list endpoint, `kettle pull 41 42 43 …` is a request
per issue and per blocker.
3. If a command exits non-zero, capture the last lines of stderr and stop that
branch. Keep going on independent branches.
4. Report.
## Report format
Your final message is the return value. Keep it under ~20 lines. No preamble, no
restatement of the request, no advice about what to do next.
```
ran:
kettle pull --milestone 6 --state all ok 7 issues, 3 threads
kettle index ok INDEX.md rebuilt
kettle push wire-sqlc-appclick FAIL exit 1
touched: .kettle/issues/{a,b,c}.md, .kettle/issues/INDEX.md
failed: kettle push wire-sqlc-appclick
ERROR wire-sqlc-appclick: missing section '## Acceptance criteria'
blocked: none
```
- `ran` — one line per command: what, ok/FAIL, and the one number that matters.
- `touched` — paths only. Never contents.
- `failed` — the command, then stderr verbatim, trimmed to the lines that name
the cause. Quote it exactly; do not paraphrase an error.
- `blocked` — what you refused to decide, phrased as the question the caller has
to answer. `none` when there is nothing.
## Known stops
Report these and halt; none of them is yours to resolve.
| Condition | Report |
|---|---|
| `command not found: kettle` | `blocked: kettle is not installed — operator builds it from cli/ or go install`s it |
| `no .kettle/ found — searched up from …` | `blocked: not a project — operator must run /kettle:init here` |
| no login pinned, an unknown login name, 401/403 | `blocked: credential — operator runs /kettle:auth`, with the binary's own line |
| `kettle check` errors before a push | the validator's own lines, verbatim |
| a dependency is still `origin: local` | name the id; the caller decides whether to push it |
| a milestone or label does not exist in the repo | the command prints the real ones — pass that list through |
| a command asks for a decision (type, label, `--force`) | `blocked:` with the question |
| the tracker refuses a close because the issue is still blocked | the tracker's own line and the blocker's number; the caller decides |
@@ -0,0 +1,14 @@
---
description: Reach everything Gitea has that is not an issue — pull requests, releases, tags, milestones, branches, commits, actions, webhooks — with `kettle api`.
argument-hint: "[the endpoint, or what you want from the tracker]"
---
Load the **`kettle-api`** skill and follow it. Which endpoint answers which
question, which of them paginate, and what `-X DELETE` requires are all mapped
there.
$ARGUMENTS
One request, under the login this project already pins — no second tool and no
second credential. Issues are not handled here: `/kettle:issue` works on them
offline and `/kettle:sync` moves them.
@@ -0,0 +1,130 @@
---
description: Give `kettle` a Gitea credential and choose which login this project runs under — `kettle auth list/add/remove` manages the machine-wide token file, `kettle init --login <name>` pins one of those names into `.kettle/config.yaml`. Invoke when a sync command reports no login, a 401, or an unknown login name, or to switch the account this project's issues are pushed under. The OPERATOR picks the login; you never type a token.
allowed-tools: Bash(kettle auth list:*), Bash(kettle config:*)
---
# /kettle:auth — the credential a project runs under
Two files, and the split is the whole design.
| where | what is in it | who writes it |
|---|---|---|
| `~/.config/kettle/logins.yaml` | the tokens, one file per machine, mode 0600, outside every working tree | `kettle auth add` |
| `<project>/.kettle/config.yaml` | the **name** of one of those logins, and the tracker repo | `kettle init --login … --repo …` |
A name is worth nothing on its own, which is what makes it safe to keep in a
file inside a repository. A token in a working tree ends up in a commit
eventually, and a secret that has ever been pushed has to be rotated.
`$KETTLE_CONFIG_HOME` or `$XDG_CONFIG_HOME` move the machine file;
`KETTLE_LOGIN`, `KETTLE_URL` and `KETTLE_TOKEN` override it outright, which is
how CI runs with no token on disk.
**No credential is pinned in `.claude/` anywhere, and no hook rewrites a
`--login` argument.** That mechanism is gone with the Python scripts. If you find
a `GITEA_LOGIN` in a settings file, it is dead weight from the old plugin — the
only thing `kettle` ever writes into `.claude/settings.json` is the optional
`AGENTS.md` mirror hook, which holds no identity of any kind.
## The one hard rule: the operator chooses, and holds the token
- **Never pick a login.** Not from memory, not from the repo URL, not from a
previous session. Present the choice with `AskUserQuestion` — name, url and
user out of `kettle auth list` — and let the operator answer. Exactly one login
on the machine is the only case where you may propose, and you still confirm.
- **Never type, echo, paste or read a token.** `kettle auth add` takes it on
stdin precisely so it does not land in shell history; a token that goes through
a model's context is a token in a transcript. Adding a login is the operator's
own terminal, not a Bash call you make for them.
## Steps
1. See what this machine holds. It never prints a token, and there is no flag to
make it:
```bash
kettle auth list
```
2. **Nothing there** — stop and hand the operator the command to run themselves:
```bash
kettle init --interactive # prompts for it, echo off
kettle auth add --name noodles --url https://git.example.com --user naudachu < token.txt
pass show gitea/token | kettle auth add --name noodles --url https://git.example.com
```
The first is the one to suggest to a person at a terminal: it reads the token
with the echo turned off, so it is in no history, no file and no scrollback.
**It is not a command for you to run** — it refuses a stdin that is not a
terminal, and that refusal is correct. `--user` is documentation only, and
`kettle auth remove <name>` forgets one.
3. **Pin the choice into the project.** Ask `kettle config` first and only
proceed if it answers with a project — `kettle init` in a directory that is
not one would *create* a project there, which is the one statement that is
never yours to make (`/kettle:init`):
```bash
kettle config
kettle init --login noodles
```
`init` on an initialized project prints `already initialized — nothing to do`
and rewrites only the settings it was given, so the repo pinned earlier stays.
4. Confirm with `kettle config`. Tell the operator which login is pinned and
which file it went in. It is live immediately — nothing caches it, no restart.
```
login noodles
url https://git.example.com
token (set)
repo claude-skills/marketplace
```
## When it goes wrong
| what you see | what it means |
|---|---|
| `no login "X" in …/logins.yaml — known: …` | the project pins a name this machine does not hold. Either add it (step 2) or pin one that is there |
| `no .kettle/ found — searched up from …` | not a project. `/kettle:init`, and it is the operator's to run |
| `401` / `403` from a sync command | report it verbatim. Do **not** try another login, and do not edit or remove one to route around it — that is somebody's identity, not a setting |
| `token none` in `kettle config` | a name is pinned but no credential answers to it |
## Scopes: what the token is allowed to do
Gitea mints a token with scopes, spelled `<read|write>:<category>`. A token made
for issues carries `write:issue` — and that is enough for everything
`/kettle:issue` and `/kettle:sync` do, and **not** enough for anything
`/kettle:api` reaches: releases, pull requests, branches, tags and actions all
sit under `repository`.
| doing | needs |
|---|---|
| pull, push, comment, close, evict | `write:issue` |
| `kettle labels` | `write:issue` |
| `kettle api` on releases, PRs, tags, branches, actions | `write:repository` too |
| reading any of those without writing | the `read:` half is enough |
`kettle auth add --scopes write:issue,write:repository` writes that down beside
the login. **It is a note and nothing else** — nothing is checked against it and
nothing is refused because of it. It is worth writing down because the instance
will not answer the question: Gitea's own token listing needs a password rather
than a token, so a token cannot be asked what it may do. `kettle auth list` and
`kettle config` show what was recorded; `(not recorded)` means nobody wrote it
down, never "none".
A **403** from a sync command or from `kettle api` is usually this and says so.
Minting a new token is the operator's job in the web UI — the same flow as step
2 above, with both scopes ticked this time. Never remove or re-point a login to
route around a 403.
**No `kettle` on PATH?** `command not found: kettle` is the whole story — and it
means the binary that wrote this file has since moved or been removed, because
nothing else could have put it here. Stop and tell the operator to reinstall it:
`go install git.noodles.cam/claude-skills/marketplace/cli/cmd/kettle@latest`, or
`cd cli && make install` in a checkout of that repository (go.mod requires
**go 1.26**).
The full flag table for `auth`, `config` and `init` is the generated block in
`/kettle:project`.
@@ -0,0 +1,90 @@
---
description: Make THIS directory a project that tracks issues — run `kettle init`, which creates the `.kettle/` marker every other command resolves the store from, migrates an older store in, gitignores it, and writes this command file and its skills. Carries the rules the binary cannot enforce — never an `--at` nobody named, never a migration clash resolved for them.
argument-hint: "[--at DIR] [--interactive] [--login NAME] [--repo owner/name] [--dry-run]"
allowed-tools: Bash(kettle init:*), Bash(kettle config:*), Bash(git rev-parse:*)
---
# /kettle:init — make this directory a project
Initializing is a statement, and the operator makes it: *this* directory is the
project whose issues live in it. Nothing infers it — `.git` is in every clone,
and a tool that inferred its root from one wrote other projects' issues into its
own versioned cache. It is answered once, by a person, and every command
downstream reads the answer instead of guessing.
**This is a command, not a skill, and that is the point.** It is invoked by an
operator who typed it. There is no `description:` here for a model to match on,
because "which directory is the project" is not a question a model gets to answer
on its own.
The binary does the work and is idempotent. What this file carries is what it
cannot decide.
## Steps
1. Run it, passing the operator's arguments through unchanged:
```bash
kettle init $ARGUMENTS
```
With no `--at` it initializes the current directory. **Never supply an `--at`
the operator did not name.** Which directory is the project is the one
question this command exists to have a person answer; picking a plausible one
is the failure the marker replaces.
2. Report what it printed, verbatim. `already initialized — nothing to do` is a
success, not something to work around.
`--login` pins a login by name and `--repo` names the tracker repository; both
are optional and both can be added later by running `init` again — it writes the
config without disturbing settings it was not given. Neither is a credential:
the tokens live in one file per machine, `/kettle:auth`.
## `--interactive` is for a person at a terminal
It walks the operator through the login, the repository, this `.claude/` tree and
the `AGENTS.md` mirror hook, one question at a time.
**Do not run it.** It refuses a stdin that is not a terminal and says so, which is
the right failure — but the right move is not to reach for it in the first place.
Every question it asks has a flag: `--login`, `--repo`, `--scaffold`,
`--mirror-hook`. Pass the flags the operator named and let the binary answer the
rest.
## When it stops
- **A linked worktree.** The binary refuses, and it is right to: `.kettle/` is
gitignored, so a worktree has no marker by design and reaches the main
checkout's store on its own — the walk crosses to it through the `gitdir:` in
the `.git` *file*. A marker here would give one project two stores, and the
second is deleted with the branch. What needs initializing, if anything, is the
main checkout, and the refusal names it.
- **A name clash on the migration** — the same file name on both sides. It exits
having changed nothing and names the files. Report that. Do **not** move,
delete, or merge either side: one of them may be an `origin: local` issue,
which *is* the issue and the only copy of that work. The operator decides
which survives.
- **A marker already exists above this directory.** A second one gives that
project a second store and the nearer one wins. Confirm with the operator
before going ahead; usually they are standing in a subdirectory and there is
nothing to do.
**No `kettle` on PATH?** `command not found: kettle` is the whole story — and it
means the binary that wrote this file has since moved or been removed, because
nothing else could have put it here. Stop and tell the operator to reinstall it:
`go install git.noodles.cam/claude-skills/marketplace/cli/cmd/kettle@latest`, or
`cd cli && make install` in a checkout of that repository (go.mod requires
**go 1.26**).
## After
- `/kettle:issue` works now — offline, no login, no network.
- `/kettle:auth` puts a token on this machine and pins the login this project
runs under; needed only for the tracker side, `/kettle:sync`.
- `kettle config` prints every path and setting this directory resolved to, and
is the first thing to run when something looks like it landed in the wrong
place. It also reports when this `.claude/` tree was written by an older
`kettle` than the one now installed.
The full flag table for `init` is the generated block in `/kettle:project`.
@@ -0,0 +1,14 @@
---
description: Work on this project's issues offline — create, read, grep, validate, tick checkboxes, evict, and walk the dependency graph.
argument-hint: "[what you want to do with an issue]"
---
Load the **`kettle-issue`** skill and follow it. Everything about what an issue is
— the format, the taxonomy, validation, checkboxes, the dependency graph — lives
there, along with the canonical format reference it points at.
$ARGUMENTS
This half is entirely offline: issues are markdown files under
`<project>/.kettle/issues/` and need no login and no network. Moving them to or
from the tracker is `/kettle:sync`.
@@ -0,0 +1,13 @@
---
description: Look up the exact flags and defaults of the project-level kettle commands — init, auth, config, gen, mirror.
argument-hint: "[the command whose flags you want]"
---
Load the **`kettle-project`** skill and follow it. It carries the generated flag
table for every project-level command, written from the registry the binary was
built from.
$ARGUMENTS
`kettle help <command>` prints the same thing in a terminal, and is the faster
answer when the operator is already in one.
@@ -0,0 +1,13 @@
---
description: Move issues between this project's local store and its Gitea tracker — pull, push, list, comment, close, evict.
argument-hint: "[what to pull, push, comment on or close]"
---
Load the **`kettle-sync`** skill and follow it. The round trip and the rule that
holds it — the store keeps what has not left this machine, so a successful push
deletes the local file — are stated there in full.
$ARGUMENTS
What an issue *is* belongs to `/kettle:issue` and needs no network. Everything
Gitea has that is not an issue is `/kettle:api`.
@@ -0,0 +1,246 @@
---
name: kettle-api
description: Everything Gitea has that is not an issue — pull requests, releases, tags, branches, milestones, labels, commits, actions, webhooks, notifications, tracked times, users, repositories — reached with `kettle api`, one endpoint per request, under the login the project already pins. Load when the user asks to open or review a PR, cut or edit a release, make a milestone or a tag, look at branches or commits, read notifications or actions, or hit any Gitea endpoint by hand. Issues are NOT handled here: /kettle:issue works on them offline and /kettle:sync moves them to and from the tracker.
---
# /kettle:api — Gitea beyond issues
`kettle api <endpoint>` is one authenticated request to the Gitea this project is
pinned to. No second tool, no second login: the URL, the token and the repository
are the ones `/kettle:auth` and `kettle init` already resolved, and the request
body is filed under `.kettle/payload/` like every other request kettle makes.
This skill is the map of what to ask for. `kettle help api` is the flag
reference, and it is generated from the binary — the block at the bottom of this
file is the same text.
## Issues are somewhere else
`kettle api` can reach an issue and must not be used to. An issue read this way
comes back as a full JSON payload — every label object, every URL, the whole
comment thread — which is precisely what the other two skills exist to keep out
of the context window.
| Skill | Scope |
|---|---|
| `/kettle:issue` | issues as units of work — create, read, grep, validate, tick, dependency graph. Offline. |
| `/kettle:sync` | moving issues between the local store and the tracker — pull, push, comment, close, evict. |
The one exception is an issue endpoint that is not about the issue's content:
`issues/{n}/comments` is also **a pull request's** comment thread, and
`PATCH issues/{n}` is also how a pull request's title and body are edited. Gitea
numbers issues and pull requests in one sequence and serves both under
`/issues/`.
## What is a command and what is a request
Reach for the command where there is one: it knows the format, the store and the
round trip. Everything else is an endpoint.
| Subject | How to reach it | Pages? |
|---|---|---|
| issues (create, read, tick, validate) | `/kettle:issue` — offline, no request at all | — |
| issues (pull, push, comment, close, evict) | `/kettle:sync` | handled |
| the canonical `type/*` and `severity/*` labels | `kettle labels` | handled |
| this repository's own releases, with binaries | `cd cli && make release TAG=v1.2.3` | — |
| everything below | `kettle api` | see the column |
| Entity | Endpoint | Pages? |
|---|---|---|
| pull requests | `repos/{owner}/{repo}/pulls` | **yes** |
| one pull request | `repos/{owner}/{repo}/pulls/{n}` | no |
| create a pull request | `POST repos/{owner}/{repo}/pulls` | no |
| edit a PR's title or body | `PATCH repos/{owner}/{repo}/issues/{n}` | no |
| a PR's or issue's comments | `repos/{owner}/{repo}/issues/{n}/comments` | **yes** |
| edit one comment | `PATCH repos/{owner}/{repo}/issues/comments/{id}` | no |
| reviews on a PR | `repos/{owner}/{repo}/pulls/{n}/reviews` | **yes** |
| merge a PR | `POST repos/{owner}/{repo}/pulls/{n}/merge` | no |
| releases | `repos/{owner}/{repo}/releases` | **yes** |
| one release by tag | `repos/{owner}/{repo}/releases/tags/{tag}` | no |
| tags | `repos/{owner}/{repo}/tags` | **yes** |
| branches | `repos/{owner}/{repo}/branches` | **yes** |
| commits | `repos/{owner}/{repo}/commits` | **yes** |
| milestones | `repos/{owner}/{repo}/milestones` | **yes** |
| labels (all of them, not just canonical) | `repos/{owner}/{repo}/labels` | **yes** |
| webhooks | `repos/{owner}/{repo}/hooks` | **yes** |
| action tasks | `repos/{owner}/{repo}/actions/tasks` | **yes** |
| tracked times | `repos/{owner}/{repo}/times` | **yes** |
| the repository itself | `repos/{owner}/{repo}` | no |
| notifications | `notifications` | **yes** |
| who this token is | `user` | no |
| an organization's repositories | `orgs/{org}/repos` | **yes** |
`{owner}` and `{repo}` are filled in from the project's configuration. A path
that names a repository in full is left alone — `repos/other-owner/other/releases`
reads another repository on the same instance, which is why there is no `--repo`
flag. Another **instance** is `KETTLE_URL` and `KETTLE_TOKEN`, not a flag.
What the instance actually serves is its own version's business; its API docs at
`<instance-url>/api/swagger` are the authority when an endpoint answers 404.
## Pagination is yours
**One invocation is one HTTP request.** `kettle api` never follows a list to its
end, because a passthrough that silently stitched pages together would report as
one answer something that was several.
So for every row marked **yes** above:
```bash
kettle api 'repos/{owner}/{repo}/pulls?state=open&limit=50' # first page, 50 rows
kettle api 'repos/{owner}/{repo}/pulls?state=open&limit=50&page=2'
```
- `limit` is capped by the instance (`MAX_RESPONSE_ITEMS`, 50 by default); the
default page size is 30.
- **A short page is the last one.** Ask for 50, count what came back: fewer than
50 means there is no page 3. That is the same rule the binary's own listings
use, and it needs no response headers.
- Quote any endpoint holding `?` or `&`, or the shell takes it apart.
- Walking many pages of anything into your own context is a mistake before it is
a request. Narrow the query (`state=`, `since=`, `q=`), or pipe through `jq`
and keep the two fields you needed.
## Writing a body
Two ways, and the choice is about the body, not the endpoint:
```bash
# small and flat: every value is a string
kettle api --field body=lgtm repos/{owner}/{repo}/issues/7/comments
# anything real — multi-line, markdown, booleans, numbers, nesting
mkdir -p tmp/release
cat > tmp/release/v0-2-0.json <<'EOF'
{"tag_name": "v0.2.0", "name": "v0.2.0", "draft": false,
"body": "## Changes\n\nMulti-line markdown with `code`."}
EOF
kettle api --data @tmp/release/v0-2-0.json repos/{owner}/{repo}/releases
```
- A body implies `POST`; anything else is `-X PUT`, `-X PATCH`, `-X DELETE`.
- Newlines inside a JSON string are `\n`. Composing from a file:
`jq -Rs '{body: .}' < body.md > tmp/pull/x.json`.
- `--field` values are **always strings**. A `draft: false` or a number is a
`--data` body — guessing types is how a `tag_name` of `1.0` goes up as a
number.
- Keep `tmp/` gitignored and keep the file: a `PATCH` is usually the same body
with one line changed. `kettle` files its own copy under `.kettle/payload/`
automatically; that directory is the transport's and nothing hand-made goes in
it.
- Attachments are `multipart/form-data` and this command sends JSON. Upload
release binaries with the release tooling (`make release`), or the web UI.
## Deleting
`-X DELETE` needs `--yes` in the same invocation, and the refusal happens before
anything is sent:
```bash
kettle api -X DELETE --yes repos/{owner}/{repo}/releases/12
```
That flag is the whole gate. **Whether a thing should be deleted is the
operator's call, not a step in a plan** — ask, do not assume, and never widen a
deletion past what was named.
## When it says 403
Gitea scopes a token as `<read|write>:<category>`, and a token minted to file
issues carries `write:issue` and nothing else. Releases, pull requests, branches,
tags and actions are all `repository`, so that token answers **403 on the first
`kettle api` outside issues** — and the 403 names no scope.
`kettle auth list` shows what each login on this machine recorded; `kettle config`
shows what this project resolved. Nothing can be read back off the instance
(Gitea's own token listing needs a password, not a token), so a scope that was
never written down is a scope nobody knows. Minting a new token is the operator's
job, in the web UI — `/kettle:auth` has the procedure.
## What is not an API call at all
| Want | Do |
|---|---|
| check out a PR branch, clone, push | `git`. This is git's job and always was |
| who am I | `kettle api user` |
| open something in a browser | nothing here; hand the user the URL |
| add a login, list logins, ssh keys | `/kettle:auth`, and adding one is the operator's |
| administer users or the instance | nothing here. Not an agent's work |
The canonical issue format lives in
[`../issue/references/format.md`](../issue/references/format.md) — it describes
local files, not requests.
<!-- kettle:gen -->
**Generated from the kettle command registry by `kettle gen scaffold`.** Everything between the two markers is replaced on the next run — the prose around it is embedded in the binary and replaced with it.
## `kettle api <endpoint>`
one request to this project's Gitea, for everything that is not an issue
Releases, pull requests, milestones, branches, tags, actions, webhooks,
notifications: everything Gitea has that this binary has no command for. One
invocation is ONE request — the credentials, the repository and the payload
scratchpad are the ones this project already resolved, so there is nothing to
configure and no second tool to log in.
THE ENDPOINT IS SPELLED THE WAY GITEA'S OWN DOCUMENTATION SPELLS IT. A bare path
is taken as relative to `/api/v1/`; a path that already begins `/api/` is sent as it
stands, which is how anything outside v1 is reached; a full URL is allowed only
on the instance this project points at, because every request here carries the
project's token in a header and a URL somewhere else would hand that token over.
`{owner}` and `{repo}` are filled in from the project's configuration. Quote an
endpoint that contains ? or & or the shell will take it apart.
ANOTHER REPOSITORY NEEDS NO FLAG — write its address into the path
(`repos/other-owner/other-repo/releases`) and nothing is substituted. There is no
--repo and no --login here for the same reason there is none on push or pull:
which login a project runs under is a fact about the project. Another INSTANCE
is KETTLE_URL and KETTLE_TOKEN, which is also what a CI run uses.
THE ANSWER IS THE SERVER'S BYTES ON STDOUT, unparsed and unreformatted — pipe it
to jq, redirect it to a file. There is no flag that names an output file: in
this tree --out is the issue store, and one word meaning two things is exactly
the trap the tool this replaces set with an -o that wrote a file called "json".
IT DOES NOT PAGINATE. One call is one request, so a listing answers with one
page: ask for the next with ?page=2, and for a bigger one with ?limit=50 (the
server's own default is 30, its maximum is usually 50). A passthrough that
stitched pages together silently would report as one answer something that was
several.
ISSUES ARE NOT THIS COMMAND'S JOB even though it can reach them. An issue read
this way arrives as a full JSON payload — every comment, every label object,
every URL — which is what /kettle:issue and /kettle:sync exist to keep out of a
context window. Use pull, push, comment and close.
A 403 here is usually the token rather than the request: a token minted for
issues carries write:issue, and releases, pull requests, branches and tags are
all under repository. `kettle auth list` shows what each login records.
-X DELETE NEEDS --yes. Everything else goes through as typed; a deletion does
not, because a flag typed on purpose is an operator's decision and the URL of a
release is one character away from the URL of the wrong release.
What it cannot do: an upload. Release attachments are multipart/form-data and
this sends JSON — the release tooling in cmd/release does those.
| flag | default | what it does |
| --- | --- | --- |
| `--X` | — | the same flag as --method, spelled the way curl and the tool this replaces spell it |
| `--data` | — | the request body: @file, @- for standard input, or the JSON itself |
| `--field` | — | key=value, added to a JSON body as a string; repeatable |
| `--method` | — | GET, POST, PUT, PATCH or DELETE (default GET, or POST when there is a body) |
| `--status` | `false` | print the status line on standard error |
| `--yes` | `false` | confirm a DELETE |
```bash
kettle api repos/{owner}/{repo}/releases # the latest page of releases, as JSON
kettle api user # who this project's token belongs to
kettle api 'repos/{owner}/{repo}/pulls?state=open&limit=50' # quote anything with ? or & in it
kettle api --data @tmp/release/v0-2-0.json repos/{owner}/{repo}/releases # a body from a file; POST is implied
kettle api --field body=lgtm repos/{owner}/{repo}/issues/7/comments # a small body without a file
kettle api -X DELETE --yes repos/{owner}/{repo}/releases/12 # a deletion, said out loud
kettle api repos/{owner}/{repo}/milestones | jq '.[].title' # the bytes are the server's; jq is yours
```
<!-- /kettle:gen -->
@@ -0,0 +1,433 @@
---
name: kettle-issue
description: Work with this project's issues as units of work — create, read, grep, validate, tick checkboxes, evict closed ones, and walk their dependency graph, with the `kettle` binary's offline commands (new, check, ac, tree, index, evict). Entirely offline; issues are local markdown files in `.kettle/issues/` and need no tracker, no login and no network. Load when the user asks to file or create an issue, read or find issues, check one against the format, or see what depends on what. Pushing to or pulling from Gitea is /kettle:sync.
---
# /kettle:issue — issues as units of work
An issue is a markdown file in `<project>/.kettle/issues/`. This skill covers
everything you do **with** an issue: writing one, reading one, checking it
against the canonical format, ticking its boxes, and walking the dependency
graph.
**Nothing here touches the network.** No tracker, no login, no token. An issue
that lives only on this machine is a first-class issue, not a draft waiting to
be uploaded. Synchronizing with a tracker is a separate, optional layer —
`/kettle:sync`.
Read [`references/format.md`](references/format.md) before creating or editing
an issue. It is the single source of truth for identity, metadata, types,
labels, templates, and language rules.
**No `kettle` on PATH?** `command not found: kettle` is the whole story — the
Python scripts this plugin used to ship are gone and no other CLI is a
substitute.
Stop and tell the operator to install it: `cd cli && go build -o
~/.local/bin/kettle ./cmd/kettle` in the marketplace repository (go.mod requires
**go 1.26**), or `go install
git.noodles.cam/claude-skills/marketplace/cli/cmd/kettle@latest`.
## Identity: the slug
The file name is the id and the id is a slug —
`.kettle/issues/wire-sqlc-appclick.md`. It never changes: not when the title
changes, not when the issue is pushed somewhere. Tracker numbers live in a
metadata field (`gitea: owner/repo#42`), never in a file name and never in
`depends:`.
Consequence worth internalizing: **`#42` means nothing in this layer.** Refer to
issues by id.
```
.kettle/issues/INDEX.md table of every issue — read this first
.kettle/issues/wire-sqlc-appclick.md metadata block + `# Title` + body
.kettle/issues/wire-sqlc.comments.md comment thread (written by /kettle:sync only)
.kettle/issues/tree-<id>.md saved graph (kettle tree --write)
```
## Where the store is
`<project root>/.kettle/issues`**not** `.kettle/issues` relative to wherever
you are standing. The project root is the nearest directory up from where you
are that holds a `.kettle/` marker: the binary walks up from
`$CLAUDE_PROJECT_DIR`, then from the working directory, and out of a linked
worktree to its main checkout. Every command sees one store no matter which
subdirectory it runs in, and a `cd` into a *different* project correctly answers
with that project's issues.
**A project has a store because an operator ran `/kettle:init` in it.** The
marker is never inferred from the tree — `.git` is in every clone. **With no
marker anywhere, every command stops and names the directories it searched.** It
does not fall back to a plausible directory. If you see that, either you are not
in the project you think you are, or nobody has initialized it: tell the operator
to run `/kettle:init`. It is theirs to run, and it carries the worktree and
migration-clash rules a bare `kettle init` does not.
`--out` overrides all of it and is taken **literally**: an absolute path as
given, a relative one relative to the working directory. `kettle config` prints
every path this directory resolved to and is the fastest way to explain a run
that went somewhere unexpected.
Two things follow, both deliberate: a store that is not there reports `does not
exist` while a store with nothing in it reports `is empty` — different problems —
and nothing conjures a store as a side effect of a write.
## Reading: grep, don't parse
Metadata is one field per line with inline lists precisely so plain `grep`
works. `INDEX.md` first, then the files:
```bash
grep -l 'labels:.*type/bug' .kettle/issues/*.md # all bugs
grep -l 'origin: local' .kettle/issues/*.md # never pushed anywhere
grep -ln 'depends:.*migrate-schema' .kettle/issues/*.md # who depends on it
grep -A3 '## Acceptance criteria' .kettle/issues/wire-*.md
grep -c '^- \[ \]' .kettle/issues/wire-sqlc-appclick.md # open checkboxes
```
Read whole files only for the issues the task actually needs.
## Creating an issue
1. **Read the format**: [`references/format.md`](references/format.md).
2. **Pick the type**`bug`, `task`, `refactor`, `test`, `feature` (a container
for several issues with one business value), or `draft` (an idea not ready
for work). If it is not obvious from the request, ask the user; one question.
3. **Scaffold it** with `kettle new` — English imperative title, no type prefix,
`--depends` takes ids.
4. **Fill the sections** with Edit — every section of the template present and
in order, **headers English, prose Russian**. `## Spec` gets a repo path, a
URL, or the literal `none`; ask the user if you cannot determine which.
5. **Check it** with `kettle check <id>`.
One file = one issue. Several related issues = several files, linked through
`depends:`.
The issue is real and complete the moment the file exists. `origin: local` is a
finished state, not a draft — and while it says local, **that file is the only
copy of the work.** Publishing it to Gitea is a separate decision
(`/kettle:sync`) and it ends that state: a push hands the issue over and deletes
the file.
## Editing an issue
Edit the file. Change `state:` to close it, edit `labels:`, add ids to
`depends:`. Re-run `kettle check` afterwards, and `kettle index` to refresh the
table. Checkboxes are the exception — use `kettle ac`.
If the issue is synced (`origin:` names a tracker), the file is a working copy:
your edit is local until `kettle push --update`, and that push **deletes the
file** once the tracker has it. Closing one of those is `kettle close` — it moves
the state on both sides in one run, where editing `state:` here alone would only
ever tell this machine. Get the file back with `kettle pull <n>`; the slug does
not change.
## Ticking checkboxes
A checkbox is the one part of a body that is **state** and not prose, so it has
a command of its own. Never rewrite a body to tick a box: the rewrite re-flows
lines and re-words sentences, and the issue's diff swells around a change that
means one character. `kettle ac <id>` lists them numbered with their state,
`--check` / `--uncheck` take a number or a substring.
- **Every checkbox in the body counts, not just `## Acceptance criteria`.** A
`type/feature` keeps its children as checkboxes under `## Issues` and they are
in the same numbering.
- **A substring must match exactly one item.** Two matches is an error listing
both; pick by number. It never guesses.
- **Exactly one character of the file changes.** Wording, wrapping and trailing
whitespace come back byte for byte, so both `git diff` and the tracker's diff
show the tick and nothing else.
- Examples inside a ``` fence are markup, not state — they are skipped.
- Whether a criterion is actually *met* is a judgement about content. Tick what
the caller named, never what looks done.
Getting the tick to the tracker is a separate step — `kettle push --update`.
## Writing a proper description
Issues get filed on the run — "comments aren't pulled", "the guard broke". That
is a request, not a statement of work: no reproduction steps, no
`path/file:line`, acceptance criteria nobody can check. Rewriting one into the
canonical format is a procedure, not improvisation.
1. **Read the issue whole**, and everything it points at — the ids in
`depends:`, the `## Spec` target, the files it names.
2. **Determine the type and its template.** The `type/*` label selects one of
the templates in [`references/format.md`](references/format.md), and that
template's section list is the shape you are aiming at. If the label is
missing or wrong, decide it now and fix `labels:`; promoting a `type/draft`
to a concrete type is this same step.
3. **Locate the anchor points in the code.** Grep the repo for every file,
symbol, command and error string the issue mentions, until you can name
lines. Work that does not exist yet still has anchor points — the files the
change will land in, and the ones that will call it.
4. **Gather the missing context.** What has to be there when you are done:
- code references in the `path/file.ext:line` form, for every place the
change lands;
- reproduction steps — exact commands and their real output (`type/bug`
splits them across `## Steps to reproduce` / `## Expected` / `## Actual`);
- acceptance criteria that are objectively checkable: a command that exits 0,
a file that exists, a section that is present — not aspirations;
- a real value for `## Spec` — a repo path, a URL, or the literal `none`.
**A missing fact is either found in the repository or becomes a question to
the user. Inventing one is forbidden.** Ask in one batch, and keep `none` in
`## Spec` as the legitimate answer it is — never a plausible-looking link.
5. **Rewrite the sections** with Edit: every section of the template, in the
template's order, English headers and Russian prose. Replace the body; do not
append a second telling of the same issue below the old one.
6. **Check it** with `kettle check <id>`. Errors mean malformed, warnings mean
the type's template is not fully filled in. Re-run `kettle index` if the
labels changed.
The procedure is identical for a local issue and a synced one — it works on
`.kettle/issues/<id>.md` and this layer does not know the difference. Getting the
rewritten body into the tracker is `kettle push --update` and is no part of this.
## Evicting closed issues
The store is a working set, not an archive. A closed issue is not a unit of work
any more, and `kettle evict` takes it out — no `rm`, no rebuilding `INDEX.md` by
hand. Two conditions, both read off the file, and the second is the whole safety
argument:
| `state:` | `origin:` | what eviction does |
|---|---|---|
| `closed` | a tracker | removes `<id>.md` and every sidecar under that slug |
| `closed` | `local` | **keeps it, always**, and says why |
| `open` | anything | keeps it |
**`origin: local` is never evicted, in any state, not even when you name it on
the command line.** That file *is* the issue; there is no copy to fetch back.
Only a file whose own metadata says the work lives somewhere else may go — the
same trade a push makes when it drops a file the tracker just confirmed.
`.remote.json` is deliberately **not** pruned: it is the number → slug ledger and
its entries are supposed to outlive the files they name, which is what makes a
later `kettle pull <n>` land on the same slug. And eviction is **not a one-off
migration** — a pull by number fetches an issue in any state, so a closed issue
pulled after an eviction lands on disk again. Evict it again when you are done.
This command decides from `state:` in the file, which is only as fresh as the
last pull. To have the tracker's answer instead — an issue closed in the web UI
five minutes ago — use `kettle sync-evict` from `/kettle:sync`, which refreshes
`state:` first and then makes exactly this decision.
## Dependency graph
`depends:` is the authoritative edge list; the body's `## Depends on` section is
prose for humans, and `kettle check` warns when they disagree. `kettle tree`
draws downwards — what an issue depends on. The other direction is a grep, not a
flag:
```bash
grep -ln 'depends:.*migrate-schema' .kettle/issues/*.md
```
A `type/feature` plus its children read as one document: draw the tree once for
the shape, then grep the files.
## Layering rule
Everything below is offline. No command in this skill opens a socket, reads a
token, or knows what an issue number is — that is `/kettle:sync`, and the domain
would not notice if the tracker did not exist. If you find yourself wanting a
tracker concept here — a number, a login, an HTTP call, a label colour — it
belongs on the other side of that line.
The commands themselves follow. Their usage lines, flags, defaults and examples
are generated from the binary's own command registry, so they cannot disagree
with the binary; `kettle help <command>` prints the same text. Editing them here
changes nothing.
<!-- kettle:gen -->
**Generated from the kettle command registry by `kettle gen scaffold`.** Everything between the two markers is replaced on the next run — the prose around it is embedded in the binary and replaced with it.
## `kettle ac <id>`
list and tick an issue's checkboxes
A checkbox is the one part of a body that is *state* and not prose. Everything
else is written once; boxes get ticked as the work goes, and the only other ways
to tick one are a human with an editor or a model rewriting the whole body — the
second worse than the first, because the rewrite re-flows the text and the
issue's diff swells around a change of one character. This changes that one
character and nothing else.
Named after `## Acceptance criteria`, where most boxes live, but every checkbox in
the body is listed and tickable: a type/feature keeps its children under
`## Issues`, and binding this to one heading would silently lose half of them.
A substring picks an item only when it picks exactly one. Two matches is an
error listing both — a coin flip would tick the wrong box and look like it
worked.
Delivering the changed body to a tracker is not part of this; that is
`kettle push --update`.
| flag | default | what it does |
| --- | --- | --- |
| `--check` | — | tick one item: number or substring |
| `--out` | — | store root (default: <project>/.kettle/issues) |
| `--uncheck` | — | untick one item: number or substring |
```bash
kettle ac wire-sqlc-appclick # numbered list with state
kettle ac wire-sqlc-appclick --check 3 # tick by number
kettle ac wire-sqlc-appclick --check регресс # tick by substring
kettle ac wire-sqlc-appclick --uncheck 3 # untick it again
```
## `kettle check [<id>…]`
validate issues against the canonical format
The same check the sync layer runs before it pushes anything, available on its
own so a local-only issue can be held to the format without a tracker being
involved.
Errors mean malformed; warnings mean it deviates from its type's template or its
graph looks suspect. An unticked checkbox is neither: work not done yet is the
normal state of a perfectly well-formed issue.
Exit status is 1 when anything has errors, which is what makes this usable in a
hook or a CI step.
| flag | default | what it does |
| --- | --- | --- |
| `--out` | — | store root (default: <project>/.kettle/issues) |
| `--quiet` | `false` | exit status only, print nothing |
| `--strict` | `false` | treat warnings as errors |
```bash
kettle check # every issue in the store
kettle check wire-sqlc-appclick # one issue
kettle check --quiet # exit status only
kettle check --strict # treat warnings as errors
```
## `kettle evict [<id>…]`
remove closed issues from the local store
The store is a working set, not an archive. What is evicted is two conditions,
both read off the file:
state: closed the work is done
origin: <tracker> the work is somewhere else too
THE SECOND CONDITION IS THE WHOLE SAFETY ARGUMENT. `origin: local` means this
file IS the issue — there is no other copy and deleting it deletes the work. It
is never evicted, in any state, not even when named explicitly on the command
line: a closed local issue is reported and kept.
Eviction asks the file rather than the tracker, because state and origin are
domain fields and the answer is already in the store — which is why this needs
no network and no login. `kettle sync-evict` is the variant that refreshes state
from the tracker first and then makes the same decision.
Not a one-off migration: a pull by number fetches an issue in any state, so a
closed issue pulled after an eviction lands on disk again. Evict it again when
you are done with it.
INDEX.md is rebuilt, because it IS a view of the directory. The number -> slug
ledger is deliberately not pruned: its entries outlive the files they name, and
that is what makes a pull land on the same slug afterwards.
| flag | default | what it does |
| --- | --- | --- |
| `--dry-run` | `false` | print what would be removed; touch nothing |
| `--out` | — | store root (default: <project>/.kettle/issues) |
```bash
kettle evict # every closed issue that is not origin: local
kettle evict old-thing another-thing # only these
kettle evict --dry-run # print what would go; touch nothing
```
## `kettle index`
rebuild INDEX.md from what is on disk
A map of the local store, nothing else. The `origin` column is the only place
the index acknowledges that a tracker exists: `local` means the issue has never
left this machine, anything else names the tracker it also lives in. Both are
ordinary issues here.
`progress` counts the body's checkboxes, ticked over total, and is read off the
body at build time rather than stored — a second copy of that state in a
metadata field would be wrong by the next edit.
An existing store with nothing in it is a legitimate thing to index and gets an
"_empty_" table. A store that is not there is an error, not a directory to
create.
| flag | default | what it does |
| --- | --- | --- |
| `--out` | — | store root (default: <project>/.kettle/issues) |
```bash
kettle index # rebuild the index for this project
```
## `kettle new`
create a local issue from its type template
The issue is real the moment this writes the file. Nothing is pending, nothing
is a draft awaiting a tracker: `origin: local` is a complete state and pushing it
later is optional.
While it says local, this file is the ONLY copy of the work — the store, not a
cache of anything. That is what a push changes: it hands the issue to the
tracker and removes the file.
Writes .kettle/issues/<slug>.md prefilled with the type's template, prints the
path, and rebuilds INDEX.md. Fill the sections in an editor, then run
`kettle check <id>`.
Body prose is Russian, section headers and the title are English.
| flag | default | what it does |
| --- | --- | --- |
| `--assignee` | — | assignee login; repeat |
| `--depends` | — | id this issue depends on; repeat |
| `--id` | — | slug (default: derived from the title) |
| `--label` | — | extra label, e.g. tech/sql; repeat |
| `--milestone` | — | milestone title |
| `--out` | — | store root (default: <project>/.kettle/issues) |
| `--severity` | — | severity/* label, one of: low, medium, high, showstopper, critical |
| `--title` | — | English, imperative, no type prefix |
| `--type` | — | issue type, one of: bug, task, refactor, test, feature, draft (becomes the exclusive type/* label) |
```bash
kettle new --type task --title "Wire sqlc into the appclick repo layer" --label tech/sql --label comp/appclick # a task with two free-form labels
kettle new --type bug --title "Fix the index rebuild on an empty store" --depends wire-sqlc-appclick --milestone v0.2 # a bug that is blocked by another issue
```
## `kettle tree [<id>…]`
draw the dependency graph of the local store
Edges come from the `depends:` metadata, which is the authoritative edge list;
prose in the body is never walked. Because the graph is slugs all the way down,
this works identically for issues that were never pushed anywhere.
Downwards is what this draws — what an issue depends on. The other direction is
a grep, not a flag:
grep -ln 'depends:.*migrate-schema' .kettle/issues/*.md
| flag | default | what it does |
| --- | --- | --- |
| `--depth` | `6` | maximum depth |
| `--out` | — | store root (default: <project>/.kettle/issues) |
| `--write` | `false` | also write <store>/tree-<slug>.md |
```bash
kettle tree # every root (nothing depends on it)
kettle tree wire-sqlc-appclick # one subtree
kettle tree --depth 2 --write # shallow, and saved beside the issues
```
<!-- /kettle:gen -->
@@ -0,0 +1,372 @@
# Issue format
Canonical format for every issue in this project, whether it ever reaches a
tracker or not. Designed to be unambiguous for both humans and LLMs: fixed
English section headers in a fixed order, verifiable acceptance criteria, one
issue = one deliverable.
Nothing here depends on Gitea. How these files are mapped onto a tracker is the
sync layer's business — see `/kettle:sync`.
## Identity
An issue is one file, `.kettle/issues/<id>.md`, and `id` is a slug: lowercase
ASCII, digits, single dashes, derived from the title. **The slug is the
identity.** It is stable for the life of the issue — a retitled issue keeps its
slug; an issue pushed to a tracker, deleted locally and fetched back a month
later keeps it too. Tracker numbers are a foreign key stored in a field, never
the name of anything.
```
.kettle/issues/wire-sqlc-appclick.md
```
A slug never contains a dot, which is how the store tells an issue from the
files parked beside it (`<id>.comments.md`).
Stability is a promise the format makes, so something has to keep it once the
file is gone. That is the sync layer's problem and its answer is a marker in the
body — see `/kettle:sync`; the domain neither writes nor reads it, and it never
appears in the file on disk.
## Metadata block
One field per line, lists inline, so plain `grep` works without a parser:
```markdown
---
id: wire-sqlc-appclick
state: open
labels: [type/task, tech/sql]
assignees: [naudachu]
milestone: v0.2
depends: [migrate-schema]
origin: gitea
branch: feat/wire-sqlc
gitea: claude-skills/marketplace#42
remote-updated: 2026-08-09T18:24:01Z
synced: 2026-08-09T18:40:00Z
url: https://git.noodles.cam/claude-skills/marketplace/issues/42
---
# Wire sqlc into the appclick repo layer
## Summary
```
| Field | Owner | Meaning |
|---|---|---|
| `id` | domain | slug; equals the file name |
| `state` | domain | `open` or `closed` |
| `labels` | domain | see namespaces below; exactly one `type/*` |
| `assignees` | domain | logins; may be empty |
| `milestone` | domain | title, or `none` |
| `depends` | domain | ids this issue depends on — **the authoritative graph** |
| `origin` | domain | `local`, or the name of a tracker this also lives in |
| `gitea` | sync | the handle in that tracker: `owner/repo#N` |
| `branch` | sync | the tracker's branch link (Gitea `ref`); push fills an empty one with the current git branch, and never overwrites a filled one |
| `url`, `synced`, `remote-updated`, `comments` | sync | bookkeeping |
Domain fields render first, in the order above; sync fields follow, sorted.
`origin` is domain-owned on purpose: *whether* a piece of work exists anywhere
but here is a fact about the work. *Where* that is, and how to reach it, is the
sync layer's business — the domain carries `gitea:` and the rest through
load/save verbatim and never reads them. That passthrough is why one file can
represent a local issue and a synced one without a second format.
`origin: local` is a **complete state, not a pending one.** An issue that never
leaves this machine is valid and finished work; pushing it is optional and
nothing here treats it as a draft.
It is not a *permanent* state, and it is what the file's fate depends on:
| `origin:` | what the file is | what a push does to it | what eviction does to it |
|---|---|---|---|
| `local` | the issue itself — the only copy there is | creates it in the tracker, then deletes the file | **nothing, ever** — in any state, named or not |
| a tracker | a working copy of something the tracker already has | updates the tracker, then deletes the file | removes it once `state: closed` |
**A successful push deletes `.kettle/issues/<id>.md`** (and `<id>.comments.md`), on
create and on `--update` alike. What is in the store is what has not left this
machine; everything else is fetched again when it is needed. The rule, its
safety conditions, and how the slug survives are `/kettle:sync`'s to state.
**A closed issue is evicted from the store** by `kettle evict` — same trade,
one condition more: the work is done *and* it exists somewhere else. An
`origin: local` issue is never evicted, because there is nowhere to fetch it
back from. The store is a working set, not an archive; `kettle pull <n>` fetches a
closed issue again whenever it is wanted.
The `id` never changes across that round trip, which is why `depends:` in other
issues keeps working. That is the format's promise; the mechanism is not.
## Language rules
- **Issue title**: English, imperative mood, no type prefix — the type lives in
the label, not the title. Good: `Fix the index rebuild on an empty store`.
Bad: `fix: crash`, `[bug] crash`, `Крашится гвард`.
- **Section headers**: the exact English literals below, as `##` headings, in
the given order. Do not translate, rename, or reorder them.
- **Body prose** (text inside sections): Russian.
## Label namespaces
Four namespaces classify an issue. Two are exclusive (at most one label from
the namespace), two are free-form:
| Namespace | Exclusive | Purpose |
|---|---|---|
| `type/*` | yes | What kind of work; primarily its business value. Mandatory, exactly one. |
| `severity/*` | yes | Business impact. At most one; apply when the impact is known. |
| `tech/*` | no | Technology the issue is bound to. Any number. |
| `comp/*` | no | System component of this repo. Any number; no preset — project-specific. |
### `type/*` — mandatory, exactly one
| Label | Meaning |
|---|---|
| `type/bug` | Something behaves incorrectly in existing code |
| `type/task` | Implementation of new functionality |
| `type/refactor` | Internal restructuring: file moves, architecture; behavior must not change |
| `type/test` | Writing or fixing tests |
| `type/feature` | Container: several issues delivering one unit of business value |
| `type/draft` | Idea captured for later; not ready for work |
### `severity/*` — at most one
`severity/low`, `severity/medium`, `severity/high`, `severity/showstopper`,
`severity/critical`.
### `tech/*` — any number
Technology-bound labels, e.g. `tech/sql` (pgx, sqlc, sql-migrate — persistent
storage), `tech/obs` (grafana, loki, prometheus, alloy — observability),
`tech/postgres`.
### `comp/*` — any number
Components of this repo's system, e.g. `comp/appclick`. No preset list —
derive from the project.
> Label **colors** are not part of the format: a hex code is how a tracker
> paints a chip, not what an issue is. They live in the binary's mapping layer
> (`cli/internal/mapping`) and are applied on push.
## Dependencies
`depends:` in the metadata block is the graph, and it holds **ids**:
```markdown
depends: [migrate-schema, add-pool-cfg]
```
An optional `## Depends on` section, placed right after `## Spec`, carries the
human explanation — one reference per line, with a reason where it helps:
```markdown
## Depends on
- migrate-schema — нужна схема БД из этого issue
- add-pool-cfg
```
The section is prose and is passed to and from a tracker unchanged; only
`depends:` is walked when the graph is computed. Keeping them consistent is on
you — `kettle check` warns when the section names an id that `depends:` does
not list. Omit the section when there are no dependencies; never write an empty
one.
A `type/feature` container writes the same relation under `## Issues` instead
(see the template below). Same direction, same rule: every id named there also
belongs in that issue's `depends:`. The warning names whichever of the two
sections the reference actually came from.
Draw the graph with `kettle tree`. The reverse direction is a grep:
```bash
grep -ln 'depends:.*migrate-schema' .kettle/issues/*.md
```
## Shared rules
- `## Summary` is always the first section; `## Acceptance criteria` is always
present (exception: `type/draft`). These two are the anchors every reader
(human or LLM) relies on.
- `## Spec` is **mandatory in every type**. Its value is a repo path
(`docs/specs/auth.md`), a URL, or the literal `none` when no spec exists.
Never omit the section and never invent a link — `none` is an explicit,
valid answer.
- Acceptance criteria are `- [ ]` checkboxes; each item is an objectively
checkable condition, not an aspiration.
- A checkbox is **item markup, not a property of one section**: `- [ ]`
unticked, `- [x]` ticked, and it means the same under `## Issues` as under
`## Acceptance criteria`. An item that wraps continues on an indented line
and is still one item. A `- [ ]` inside a ``` code fence is an example of the
markup, not state. Tick them with `kettle ac`, which reads the whole body
on exactly these rules and rewrites one character; progress (`3/7`) is
counted off the body and is never a metadata field.
- Code references use the `path/file.ext:line` form; related issues by id.
- Screenshots are allowed but their content must be duplicated as text — an
LLM reading these files cannot see images.
- If acceptance criteria grow past ~5 unrelated items, split the issue (or
promote it to a `type/feature` container with child issues).
## Template: `type/bug`
```markdown
## Summary
Что сломано и где проявляется, одно-два предложения.
## Spec
`docs/specs/auth.md`, URL — или `none`.
## Steps to reproduce
1. …
2. …
## Expected
Что должно было произойти.
## Actual
Что происходит на самом деле: вывод команды, лог.
## Environment
Только релевантное: версии, ОС, конфигурация.
## Acceptance criteria
- [ ] баг не воспроизводится по шагам выше
- [ ] добавлена проверка на регрессию (если применимо)
```
## Template: `type/task`
```markdown
## Summary
Что нужно сделать, одно-два предложения.
## Spec
Ссылка или `none`.
## Motivation
Какую проблему пользователя/системы это решает.
## Acceptance criteria
- [ ] проверяемое условие
- [ ] …
## Constraints
Что НЕ входит в объём; технические рамки. (опционально)
```
## Template: `type/refactor`
```markdown
## Summary
Что перестраиваем и в каких файлах (`path/file:line`).
## Spec
Ссылка или `none`.
## Motivation
Чем плохо текущее состояние: дублирование, связность, читаемость.
## Invariants
Что НЕ должно измениться: поведение, публичные API, форматы данных.
## Acceptance criteria
- [ ] проверяемое условие (тесты зелёные, старый путь удалён, …)
```
## Template: `type/test`
```markdown
## Summary
Что покрываем тестами и где (`path/file:line`).
## Spec
Ссылка или `none`.
## Motivation
Зачем: регрессия после бага, пробел в покрытии, флаки-тест.
## Test cases
- сценарий → ожидаемый результат
- …
## Acceptance criteria
- [ ] перечисленные кейсы покрыты и зелёные
- [ ] тесты проходят в CI
```
## Template: `type/feature`
A container: one unit of business value delivered by several child issues.
Child issues carry their own `type/*` (`task`, `bug`, `test`, …) and know
nothing about the container.
**The container depends on its children, never the reverse.** Every child id
goes in the container's own `depends:` and, as prose, in its `## Issues`
section; a child's `depends:` is for that child's real dependencies and must
not point back at the container. Keep implementation detail in the children;
the feature body stays at business level.
That direction is not a convention picked at random. "The container is closed
when its children are closed" *is* a dependency relation. "This child belongs
to that feature" is a membership relation, and membership has no place in a
dependency graph. Pointed the other way the two rules contradict each other:
the moment the container listed a child that already depended on it,
`kettle check` would report `ERROR cycle`. With the edge going down, the
graph reads as nesting — `kettle tree` draws the container as the root with
its children beneath it — and the check is green.
So the container's metadata block carries the children:
```markdown
depends: [wire-sqlc-appclick, add-pool-cfg]
```
and its body repeats them for a human:
```markdown
## Summary
Бизнес-ценность одним-двумя предложениями.
## Spec
Ссылка или `none`.
## Motivation
Какую проблему пользователя/системы это решает.
## Issues
- [ ] wire-sqlc-appclick — краткое описание части
- [ ] add-pool-cfg — краткое описание части
## Acceptance criteria
- [ ] все дочерние issues закрыты
- [ ] проверяемое условие уровня фичи (например, e2e-сценарий работает)
```
## Template: `type/draft`
A parking spot for ideas that are not fleshed out yet. Minimal structure, no
acceptance criteria required. Before implementation starts, a draft MUST be
promoted: relabeled to a concrete type and rewritten into that type's template.
```markdown
## Summary
Идея одним-двумя предложениями.
## Spec
Ссылка или `none` (для драфтов обычно `none`).
## Notes
Свободные заметки: что известно, открытые вопросы, варианты.
```
## Containers beyond `type/feature`
- **Milestone** — a set of issues with an optional time bound. Locally it is
just the `milestone:` field; a tracker-side milestone must already exist for
a push to attach the issue to it.
- **Project** — a set of issues tracked by status columns (Backlog, ToDo,
InProgress, Ready, Done). Not represented in this format and not reachable
through the Gitea API — web UI only.
@@ -0,0 +1,273 @@
---
name: kettle-project
description: Generated flag reference for the project-level `kettle` commands — `kettle init`, `kettle auth`, `kettle config`, `kettle gen`, `kettle mirror`. Load it to look up the exact flags and defaults of one of those, or when a command answers "no project" / "no login" and you need `kettle config` to say what this directory resolved to. The rules around initializing are /kettle:init and the credential workflow is /kettle:auth; this file is the flag table both of them point at.
---
# kettle project — the project itself
`kettle` resolves everything from one marker. `<project>/.kettle/` is created by
`kettle init` and never inferred: `.git` is in every clone, so a tool that
guessed a root from one would write issues into whatever tree it happened to be
standing in. With no marker anywhere the command stops and names the directories
it searched — that is an answer, not a fallback.
Two configuration files, and the split is the point. `<project>/.kettle/config.yaml`
holds the tracker repository and the **name** of a login; the name is worth
nothing on its own, which is what makes it safe inside a working tree.
`~/.config/kettle/logins.yaml` (0600, one per machine, `$KETTLE_CONFIG_HOME` or
`$XDG_CONFIG_HOME` move it) holds the tokens. `KETTLE_LOGIN`, `KETTLE_REPO`,
`KETTLE_URL` and `KETTLE_TOKEN` each override the file they shadow.
**No `kettle` on PATH?** `command not found: kettle` is the whole story — no
script and no other CLI substitutes for it, and it means the binary that wrote
this file has since moved or been removed, because nothing else could have put it
here. Stop and tell the operator to reinstall it:
`go install git.noodles.cam/claude-skills/marketplace/cli/cmd/kettle@latest`, or
`cd cli && make install` in a checkout of that repository (go.mod requires
**go 1.26**).
**This file is written by the binary and is replaced whole on the next
`kettle gen scaffold`.** Every word of it — the prose as much as the flag table
below — is embedded in the `kettle` that wrote it, which is why the two cannot
disagree about a flag. Editing it here changes nothing durable; the fix for a
wrong sentence is a newer `kettle`, and the fix for a stale one is
`kettle gen scaffold`. `kettle config` says which version wrote this tree.
<!-- kettle:gen -->
**Generated from the kettle command registry by `kettle gen scaffold`.** Everything between the two markers is replaced on the next run — the prose around it is embedded in the binary and replaced with it.
## `kettle auth list | add | remove <name>`
manage the tokens this machine holds
Credentials live in one file per machine, outside every working tree, mode
0600. A project 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 the repository.
The token is read from standard input unless --token is given, because an
argument is in the shell history the moment it is typed:
kettle auth add --name noodles --url https://git.example.com < token.txt
pass show gitea/token | kettle auth add --name noodles --url https://git.example.com
`list` never prints a token. There is no flag to make it.
--scopes RECORDS WHAT THE TOKEN WAS MINTED WITH, and records is all it does:
nothing is checked against it and nothing is refused because of it. It is worth
writing down because the instance will not answer the question — Gitea's own
token listing needs a password, not a token, so a token cannot be asked what it
may do. Gitea spells them <read|write>:<category>; issues need `write:issue`,
and everything `kettle api` reaches outside issues — releases, pull requests,
branches, tags, actions — is `repository`. A token minted for issues alone
answers 403 there, and the 403 names no scope.
| flag | default | what it does |
| --- | --- | --- |
| `--name` | — | login name (add) |
| `--scopes` | — | what the token was minted with, comma separated, e.g. write:issue,write:repository; documentation only (add) |
| `--token` | — | token, if you would rather not use stdin (add) |
| `--url` | — | instance URL, e.g. https://git.example.com (add) |
| `--user` | — | account this token belongs to; documentation only (add) |
```bash
kettle auth list # what this machine holds
pass show gitea | kettle auth add --name noodles --url https://git.example.com # add one, token on stdin
kettle auth add --name noodles --url https://git.example.com --scopes write:issue,write:repository < t.txt # and write down what it can do
kettle auth remove noodles # forget it
```
## `kettle config`
show what this project resolved to
Every path and every setting, with the overrides already applied, so a run that
went somewhere unexpected can be explained without guessing.
The token is never printed — only whether one was found.
This is the command to reach for when the store looks empty, when a push says
401, or when two directories disagree about which project they are in.
```bash
kettle config # resolved paths and settings
```
## `kettle gen scaffold`
write this project's .claude/ commands, skills and subagent
A skill tells an agent how to invoke this binary, and a command is how an
operator invokes one by hand. Both are written from here, whole, because both
travel INSIDE the binary: the prose is embedded next to the code it describes
and the flag tables are rendered from the command registry the binary is built
from, so neither can be a version behind the other.
That is the whole reason these documents are not a plugin any more. A plugin
ships on its own cadence, and nothing on an operator's machine ever checked that
the one they installed described the binary they installed — so a renamed flag
could still arrive with documentation recommending the old one, which is exactly
the failure the generated block was invented to prevent, one hop further
downstream.
EVERY FILE IS WRITTEN 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. It is not any more: it is
embedded, so there is no hand-written half left to protect, and preserving local
edits would mean freezing a project's documentation at whatever version first
initialized it. The markers stay in the output so a reader can see which half
came from the registry.
WHAT THIS MEANS FOR A LOCAL EDIT: it does not survive. Run --check before an
upgrade if you have made one; the fix for a sentence that is wrong is a newer
kettle, not a patch that the next run silently discards.
The output is deterministic to the byte — no timestamps, no map iteration — so
regenerating something that has not changed produces no diff. --check is that
property made useful: it writes nothing and exits 1 when any file on disk
differs from what would be written, which is what a pre-commit hook or a CI step
calls. It wins over --dry-run when both are given.
| flag | default | what it does |
| --- | --- | --- |
| `--check` | `false` | write nothing, exit 1 if anything is out of date |
| `--dry-run` | `false` | print what would change; write nothing |
| `--out` | — | directory to write into (default: <project>/.claude) |
```bash
kettle gen scaffold # write .claude/ under this project
kettle gen scaffold --out ~/code/x/.claude # write it somewhere else
kettle gen scaffold --dry-run # print what would change; write nothing
kettle gen scaffold --check # exit 1 if the documents are out of date
```
## `kettle init`
make this directory a project that tracks issues
Creates `.kettle/` — the marker every other command resolves the store from,
and `.kettle/config.yaml`, which says which tracker repository these issues
belong to and which login to reach it under — and writes `.claude/`: the slash
commands an operator invokes, the skills a model loads, and the runner subagent.
The marker is deliberately something an operator makes, not something inferred
from the tree: `.git` is in every clone, so anything that inferred a root from
one would write issues into whatever it happened to be installed in.
--login pins a name, never a credential. The tokens live in one file per
machine, outside every working tree, managed with `kettle auth`.
All of it is idempotent: it creates .kettle/issues and .kettle/payload, migrates
an older store in if it finds one (either layout the tea plugin used, oldest
first), writes the config without disturbing settings it was not given, writes
the .claude/ tree, and adds .kettle/ to .gitignore. Each migration is a move,
not a copy — two stores is the state the marker exists to prevent — and it
refuses to pick a winner when both sides hold a file of the same name.
IT REFUSES TO RUN IN A LINKED WORKTREE, and names the main checkout instead. A
worktree is the same project on another branch and reaches the store by a hop
out to the main checkout; a marker here would give one project two stores, and
the directory holding the second one disappears with the branch.
--interactive walks a person through the whole thing — the login, the token with
the echo turned off, the repository, the .claude/ tree and the AGENTS.md mirror
hook. IT REQUIRES A TERMINAL and refuses a standard input that is not one, which
is deliberate: every question it asks has a flag beside it, so nothing that is
not a person ever needs to answer a prompt.
| flag | default | what it does |
| --- | --- | --- |
| `--at` | — | directory to initialize (default: the working directory) |
| `--dry-run` | `false` | report what would happen; change nothing |
| `--force-settings` | `false` | let the hook be merged into an existing settings.json, reformatting it |
| `--interactive` | `false` | ask, one question at a time; requires a terminal |
| `--login` | — | name of a login in the machine-wide file (see `kettle auth`) |
| `--mirror-hook` | `false` | register `kettle mirror --hook` on PreToolUse(Bash) |
| `--no-scaffold` | `false` | do not write the .claude/ commands, skills and subagent |
| `--repo` | — | tracker repository, as owner/name |
| `--scaffold-out` | — | where the .claude/ tree goes (default: <project>/.claude) |
```bash
kettle init # initialize the current directory
kettle init --interactive # be walked through it, at a terminal
kettle init --login noodles --repo claude-skills/marketplace # and point it at a tracker
kettle init --mirror-hook # register the AGENTS.md mirror on PreToolUse(Bash)
kettle init --at ~/code/x # initialize somewhere else
kettle init --dry-run # say what it would do, touch nothing
```
## `kettle mirror [<dir>]`
keep CLAUDE.md a symlink to AGENTS.md in every directory below here
Two agent harnesses read two different filenames for the same document. A
repository that keeps both as real files keeps TWO DOCUMENTS, and they drift —
silently, until somebody reads the stale one and believes it. This walks a tree
and leaves one arrangement behind everywhere:
AGENTS.md is the real file; CLAUDE.md is a symlink pointing at it.
The link is relative, so a tree that is moved, copied or cloned keeps working.
AGENTS.md is the real one because the convention is not one vendor's: a
repository that names its documents after a single tool has picked a side it did
not need to pick.
NOTHING HERE DELETES CONTENT. Six of the seven states it can find are repaired
losslessly — a missing link is created, a reversed layout is swapped round, a
duplicate whose bytes match its original is replaced by the link. The seventh,
two real files whose contents DIFFER, is reported and left exactly as it was:
one of them is somebody's writing and no rule here knows which.
It walks the directory given, or the working directory. node_modules, vendor,
venv, __pycache__ and every dot-directory are skipped, because somebody else's
tree is somebody else's business.
--hook is the PreToolUse form: it reads the hook payload on standard input,
writes any report back as additionalContext, and ALWAYS EXITS 0 — including when
it fails. A tool that broke somebody's Bash call because its documentation
helper crashed would be worse than no tool. --check is the opposite end: it
writes nothing and exits 1 when the tree is not canonical, which is what a
pre-commit hook or a make target calls.
`kettle init --interactive` offers to register the --hook form in
.claude/settings.json. It is offered rather than assumed: this is one
repository's documentation convention, and a project that does not keep AGENTS.md
files wants nothing to do with it.
| flag | default | what it does |
| --- | --- | --- |
| `--check` | `false` | write nothing, exit 1 if the tree is not canonical |
| `--hook` | `false` | PreToolUse form: payload on stdin, report as additionalContext, always exit 0 |
| `--quiet` | `false` | repair without printing what was repaired |
```bash
kettle mirror # repair the working directory and everything below it
kettle mirror ~/code/x # repair somewhere else
kettle mirror --check # exit 1 if anything is out of place; write nothing
kettle mirror --hook # the PreToolUse form; reads a payload, always exits 0
```
## `kettle version`
print the version this binary was built as
A binary that cannot say what it is, is a support problem: an operator with an
old copy on PATH and a skill written against a newer one has no way to see the
mismatch, and neither does anybody reading their transcript.
The version is stamped at link time. A build from source says "dev" and means
it — that is not a placeholder to be edited, it is the answer for a binary that
came out of somebody's working tree rather than off a tag.
The commit is reported when the build recorded one, which `go build` does from
git and a build from an unpacked tarball cannot. A tree with uncommitted
changes in it says so beside the commit.
| flag | default | what it does |
| --- | --- | --- |
| `--short` | `false` | print the version alone, with nothing around it |
```bash
kettle version # the version, the toolchain and the commit
kettle version --short # just the version, for a script
```
<!-- /kettle:gen -->
@@ -0,0 +1,570 @@
---
name: kettle-sync
description: Move issues between this project's local store and its Gitea tracker with the `kettle` binary — pull issues into `.kettle/issues/`, push local ones up (which deletes the local file), list what the tracker holds, post comments, close and reopen, and evict what the tracker says is closed. Load when the user asks to fetch or publish an issue, see what exists in the tracker, comment on one, or close one. Writing, grepping, validating and graphing an issue's content is /kettle:issue and needs no network.
---
# /kettle:sync — the bridge between the local store and the tracker
One job: carry issues between `<project>/.kettle/issues/<id>.md` and Gitea.
Everything about **what an issue is** — format, types, validation, the dependency
graph — belongs to `/kettle:issue`, and this layer neither redefines nor
second-guesses it. Knowledge flows one way: delete the tracker from the world and
the issue domain does not notice.
**No `kettle` on PATH?** `command not found: kettle` is the whole story — the
Python scripts this plugin used to ship are gone and no other CLI is a
substitute. Stop and tell the operator to install it: `cd cli && go build -o
~/.local/bin/kettle ./cmd/kettle` in the marketplace repository (go.mod requires
**go 1.26**), or `go install
git.noodles.cam/claude-skills/marketplace/cli/cmd/kettle@latest`.
## No `--login`, no `--repo`, no guard
Which login this project runs under and which repository its issues belong to
are facts about the project, stated once by `kettle init` and kept in
`.kettle/config.yaml`; the token lives in one file per machine that no working
tree can see. There is nothing to pass and nothing to police — the old PreToolUse
guard hook and its `--login "$GITEA_LOGIN"` placeholder are gone, along with the
failure they existed to catch. `kettle labels --repo owner/name` is the
single exception, because bootstrapping a repository's label set is the one
operation whose target is not this project.
A cross-repository *address* is still an address: `kettle pull owner/repo#42`
re-points the client for that one call and comes back with the same credentials
and the same scratchpad. `42`, `#42`, `owner/repo#42` and a full issue URL are
four spellings of one key.
No login, an unknown login name, a 401: report it and stop — `/kettle:auth`.
## Never read an issue through a raw API dump
`kettle api repos/{owner}/{repo}/issues/42` will answer, and answering is the
problem: the whole payload — avatars, nested user objects, every comment body —
lands in your context whether you need it or not. `kettle pull` writes flat
markdown and prints a compact line per issue; `kettle remote` lists the tracker
without writing anything at all. Use those. `/kettle:api` is for the entities
that have no command, and it says the same thing from its side.
## The round trip is one rule
**The store holds what has not left this machine.**
A successful `kettle push` deletes `<id>.md` and every sidecar under that slug —
on create and on `--update` alike, one rule with no exception — and prints the
number and URL the issue now lives at. Once the tracker has the issue, the
tracker *is* the issue.
The deletion is the last thing that happens, and only after all three of: the
call came back 2xx, the answer carries the number that was written, and the
number → slug ledger has been written. Network down, a 422, an answer about
another issue — the file stays exactly where it is and the run stops.
**An `origin: local` issue that has never been pushed is never touched by any of
this.** That file is the only copy of that work.
The slug survives the round trip two ways over, which is why the file can be
deleted at all:
| where | survives |
|---|---|
| `<!-- kettle:id wire-sqlc-appclick -->`, first line of the **tracker-side** body | a rename in the web UI, a lost ledger, a fresh clone, another machine |
| `.kettle/issues/.remote.json`, number → slug | the local file being deleted |
The marker never appears in the local file: one is put at the top on the way up
and every one is stripped on the way down. A pull consults the ledger first (it
is the one that knows what is on disk *now*), then the marker, then slugifies the
title for an issue filed in the web UI that never had a local name — and a marker
is taken at its word only when that slug is free, because a name already in use
is a collision and not an identity.
Nothing prunes the ledger — not a push, not an eviction. Its entries are meant to
outlive the files they name.
## Pulling is a fetch, not a merge
A pull overwrites the body. Unpushed local edits are lost, with exactly one
exception: **checkbox state**. A tick is monotone, so for a checkbox line whose
text matches on both sides `[x]` wins from either — tick it in the web UI, tick
it locally, tick it in both, the tick survives. The price is real and stated:
**a box unticked in the web UI comes back on the next pull.** Untick locally, then
`kettle push --update`.
Two spellings, and they are different operations:
- **by key** — an address. It fetches the issue in **any** state, because a
number is not a question about state.
- **by filter** (`--milestone`, `--label`, `-q`) — a query. Closed issues are
enumerated and left out, and `--limit` bounds what is **stored**, never what is
read.
Do not loop over numbers to fetch a group; pass the filter. And a pull returns
the **unit of work**, not one row of it: blockers come down with it recursively
to `--depth`, which costs a request per issue and per outside blocker. That is
what `--no-deps` buys back. A blocker the filter did not select still lands on
disk, deliberately — it is there because a stored issue named it.
Comments ride along into `<id>.comments.md` with no flag, and cost nothing when
the payload says the thread is empty. **They are pull-only in the store**: editing
that file changes nothing in the tracker. `kettle comment` is the way, and it
refetches the thread after writing so the local copy is not stale by the comment
it just made.
## Closing, and evicting what the tracker says is closed
`kettle close` sends `{"state": …}` and nothing else — no title, no body, no
labels. **Closing is not an edit**; editing is pull → change → `push --update`.
Explicit ids only: there is no `--milestone` and no `--label`, because which
issues are finished is a judgement about content and this command only carries
one out. The local file is rewritten only after the tracker confirms that very
write. A tracker that refuses to close an issue its own dependency graph still
blocks says so in its own words — close the blockers first, or unlink them.
`kettle sync-evict` is `kettle evict` with one thing in front of it: a `state:`
that is not stale. Every candidate is asked about **before anything is removed**,
and one bad answer evicts nothing at all — not even the issues whose answers had
already arrived. `origin: local` is never asked about and never evicted; a
tracked issue whose handle is unreadable is reported and kept.
## Labels
Push creates the labels its issues happen to use, which means a repository grows
the set in pieces and nobody can filter by `type/bug` in the web UI until
somebody pushes a bug. `kettle labels` lays the canonical `type/*` and
`severity/*` set down in one run instead. An exact name is left alone; a
**lookalike** (`bug`, `Bug`, `kind/bug`, `type: bug`) is reported with its id and
never touched, because renaming somebody else's label is a decision and not a
step; colour or `exclusive` drift is corrected only under `--fix`. `tech/*` and
`comp/*` stay open-ended and push-created. A milestone must already exist — push
attaches, it never creates.
## What crosses the boundary, and what does not
| domain | tracker | note |
|---|---|---|
| `id` (slug) | `<!-- kettle:id … -->` | first line of the tracker-side body; stripped out of the local copy |
| title, body | `title`, `body` | verbatim, both ways, except the marker and the checkbox union |
| `state` | `state` | same vocabulary |
| `labels` | `labels[]` | names both ways |
| `assignees` | `assignees[]` | logins |
| `milestone` | `milestone.title` | resolved to an id on write |
| `depends` | native issue links | slugs here, `{index, owner, repo}` there; push writes them, a pull reads them back |
| — | `ref` | lands in `branch:`; push fills an empty one with the current git branch |
| — | `number`, `html_url` | land in `gitea:` / `url:` |
`depends:` is always slugs, and the body's `## Depends on` prose is passed
through **unchanged** in both directions — a translator that edits prose churns
the body on every round trip. The edge the tracker acts on is the native link,
not the text, which is exactly why the text can be left alone. Push only ever
**adds** links: a dependency deleted from `depends:` leaves its tracker link
standing, and unlinking is a web UI job.
Dependencies go up in topological order so a blocker has its number before the
issue that names it. One that is still local-only gets no link and is reported,
never silently dropped.
## Drift
There is none tracked, and there is very little left to track: a published issue
has **one** copy — the tracker's — except while somebody is working on it, and
that window closes at the next push. Nothing watches the tracker, nothing
reconciles, nothing warns that a synced issue changed upstream. `synced:` says how
old your working copy is, `remote-updated:` what the server said at that moment.
The old question — "I edited this locally, does the server have it, whose text is
newer?" — is answered by the store's contents rather than by a mechanism: **a file
that is here has not been pushed.**
The checkbox union is not an exception. It reads only the two bodies in front of
it; there is no base version and no way for it to report that anything diverged.
## Payloads
Every request body goes to `<project>/.kettle/payload/` first and is kept there
for a retry or a post-mortem. It is a **sibling** of the store and never a child:
request bodies are debris of the transport, and a scratchpad inside a store makes
`ls .kettle/issues` lie about what exists. Nothing in it is anybody's only copy —
deleting it costs nothing. A run that sends nothing leaves no directory behind.
For Gitea entities `kettle` has no command for — releases, webhooks, actions,
pull requests — `kettle api` sends the request under this same login, into this
same scratchpad. `/kettle:api`.
The commands themselves follow. Their usage lines, flags, defaults and examples
are generated from the binary's own command registry, so they cannot disagree
with the binary; `kettle help <command>` prints the same text. Editing them here
changes nothing.
<!-- kettle:gen -->
**Generated from the kettle command registry by `kettle gen scaffold`.** Everything between the two markers is replaced on the next run — the prose around it is embedded in the binary and replaced with it.
## `kettle close <id|number> [<id|number>…]`
close or reopen issues in the tracker, and on disk with them
STATE ONLY. This sends `{"state": …}` and nothing else: no title, no body, no
labels, no milestone. Editing an issue is `kettle pull` -> edit ->
`kettle push --update`; closing it is not an edit.
EXPLICIT IDS ONLY. No --milestone, no --label, no "close everything that looks
done". Which issues are finished is a judgement about content; this carries that
judgement out, one named id at a time. Nothing here deletes an issue either —
the tracker can, and it is not an operation of this workflow.
WHAT MAY BE NAMED: a local slug, or a tracker key (42, #42, owner/repo#42, an
issue URL). Both, and for the same reason: a push deletes the local file, so
most issues in the tracker have no slug on disk to name them by. A slug is
resolved through the file's `gitea:` handle when the file is there, and through
the ledger (`.remote.json`) when push has already dropped it. A bare number is
this project's repository; a qualified key names its own, so a foreign #42 can
never be closed against the repository that happens to be configured here.
An `origin: local` issue cannot be closed. It is not in the tracker, so there is
no state there to change, and the run stops naming the id rather than quietly
editing one field of a local file. Push it first, or delete it.
THE LOCAL FILE IS WRITTEN ONLY AFTER THE TRACKER CONFIRMS: the answer has to be
the very issue that was patched, in the state that was asked for. Anything else
and the file is left exactly as it was. An issue whose local copy is gone
(pushed and dropped) is closed in the tracker and nothing is written; the state
comes down with the next pull.
A tracker that refuses to close an issue its own dependency graph still blocks
says so in the answer, and the run stops with its words: close the blockers
first, or unlink them.
| flag | default | what it does |
| --- | --- | --- |
| `--dry-run` | `false` | print what would change; makes no request |
| `--out` | — | store root (default: <project>/.kettle/issues) |
| `--reopen` | `false` | set the state back to open instead of closed |
```bash
kettle close wire-sqlc-appclick # one issue, by slug
kettle close wire-sqlc-appclick 42 #43 # several, by slug or number
kettle close --reopen 42 # the same thing backwards
kettle close --dry-run 42 43 # what would change; no request at all
```
## `kettle comment <id>`
post or edit a comment on a synced issue
The target is a LOCAL ID, not a number. Which issue this is, is a fact about the
work; where it lives in the tracker is bookkeeping, and the `gitea:` handle on the
file is what turns one into the other. An `origin: local` issue cannot be
commented on at all — it is not in the tracker, so there is nothing there to
comment on; push it first.
The body comes from a file or from --body, and multi-line prose is what --file
is for. This is why comments go through the API rather than through a tracker
CLI: an entity command with an empty-looking positional opens $EDITOR, and on a
TTY that does not exist it hangs forever.
After the write the whole thread is refetched into `<id>.comments.md`, so the
local copy is not stale by one comment — the one this run just made.
COMMENTS ARE PULL-ONLY IN THE STORE. Nothing round-trips them back: editing
`<id>.comments.md` by hand changes nothing in the tracker. Use --edit with a
comment id for that.
| flag | default | what it does |
| --- | --- | --- |
| `--body` | — | comment body inline (short, single-line) |
| `--edit` | `0` | comment id to rewrite, instead of posting a new one |
| `--file` | — | markdown file holding the comment body |
| `--out` | — | store root (default: <project>/.kettle/issues) |
```bash
kettle comment wire-sqlc-appclick --file notes.md # post the contents of a file
kettle comment wire-sqlc-appclick --body "готово, задеплоено" # post one line
kettle comment wire-sqlc-appclick --file fix.md --edit 1234 # rewrite comment 1234 instead
```
## `kettle labels`
put the canonical type/* and severity/* labels into a repository
Every `type/*` and every `severity/*` the domain taxonomy defines, created up
front instead of trickling in as a side effect of whichever push first happens
to use one. Until a name exists in the repository nobody can filter by it in the
web UI, so somebody makes their own — foreign colour, no `exclusive` — and the
set arrives in pieces over months.
NO LABEL NAME IS SPELLED OUT HERE. The names come from the domain taxonomy and
are painted by the mapping layer, because a hex code is how a tracker paints a
chip and not what an issue is. Add a type over in the domain and the next run
creates it.
THE REPOSITORY'S OWN LABELS ARE READ BEFORE ANYTHING IS WRITTEN, and read from
the repository, never from a cache — a cache answers "what did we create last
time" and the question here is "what does this repository have right now". A
name that matches exactly is left alone; a colour or `exclusive` that disagrees
with the spec is reported, and corrected only under --fix. A name that merely
RESEMBLES a canonical one (the same tail, up to case, separator and whatever
namespace is in front: `x`, `X`, `kind/x`, `type: x` against `type/x`) is
reported with its id and never touched — renaming somebody else's label is a
decision, not a step.
Out of scope by design: `tech/*` and `comp/*`, which are open-ended and are
created by push as they come up, and deleting or renaming anything at all. Only
repository labels are read; an organization's own labels sit behind a different
endpoint and are neither read nor written.
The issue store is out of scope too, and not incidentally: a label belongs to
the repository and not to any issue, so this neither reads the store nor creates
it. Request bodies go to the transport's own scratchpad, which is a sibling of
the store and never a child.
| flag | default | what it does |
| --- | --- | --- |
| `--dry-run` | `false` | print the plan; not one writing request |
| `--fix` | `false` | also patch colour/exclusive on labels that already exist |
| `--repo` | — | repository to bootstrap, as owner/name (default: this project's) |
```bash
kettle labels --dry-run # print the plan; not one writing request
kettle labels # create whatever is missing
kettle labels --fix # also patch colour / exclusive drift
kettle labels --repo owner/name # bootstrap another repository
```
## `kettle pull [<key>…]`
fetch issues from the tracker into the local store
THIS IS HOW A PUSHED ISSUE COMES BACK. `kettle push` deletes the local file the
moment the tracker confirms the write, so a pull is not a refresh of a copy you
kept — it is how the copy comes to exist at all.
It lands under the SAME slug it had before, after a rename in the web UI and on
a machine that has never seen the issue. Three sources answer "what is this
issue called here", in this order:
.remote.json the number -> slug ledger; the only one that knows what
is on disk right now, so it wins
<!-- kettle:id … --> the marker in the tracker-side body; it survives a lost
ledger, a fresh clone, another machine, and a retitling
the title slugified — where an issue filed in the web UI gets its
first local name
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 rather than allowed to
overwrite somebody else's issue. The marker itself is stripped out of what lands
on disk.
TWO WAYS TO NAME WHAT TO PULL, and they are not the same operation:
kettle pull 42 #43 owner/repo#44 by key — an ADDRESS
kettle pull --milestone v0.2 by filter — a QUERY
A key fetches an issue in ANY state, because a number is an address and not a
question about state. Only filter mode leaves closed issues out — a closed issue
is not a unit of work — and only `--state closed` puts one in the store. An issue
already on disk is refreshed either way, so a local copy learns it was closed
instead of staying open forever, and the count that stayed out goes to stderr.
`--limit` IS ON THE WRITE, NOT ON THE SELECTION. It counts the issues this run puts
in the store and never the closed ones it enumerated and threw away, so pages
keep coming until the budget is full — and stop the moment it is. A filter that
matches almost only closed issues ends in a warning and a short answer rather
than a walk of the whole tracker.
A PULL RETURNS THE UNIT OF WORK, NOT ONE ROW OF IT. `depends:` is filled from the
tracker's own dependency graph and every blocker comes down with it, recursively,
to --depth. What that costs, stated rather than hidden: one request per issue
that lands in the store, plus one per blocker the selection did not already
carry. `--no-deps` is the way back to one request, and narrows the answer to the
one issue you asked for. Dependencies are outside --limit: a blocker is followed
because a stored issue named it, not because the filter selected it, so a
filtered pull can leave more files behind than its limit — including one from
another milestone. The one blocker that does not land is a closed one.
PULLING OVERWRITES THE BODY: a fetch, not a merge. Local edits you have not
pushed are lost, with exactly one exception — checkbox state. A tick is monotone,
so a `[x]` on either side wins for any item whose text matches; unticking is not,
so untick locally and push. `--cached` skips an issue before any of that.
Comments ride along: the thread lands beside the issue in <id>.comments.md. It
costs no request when the payload says there are none, and a file left over from
an earlier pull is deleted — so no file means "no comments", never "not asked
for". The thread is pull-only; post with `kettle comment`.
| flag | default | what it does |
| --- | --- | --- |
| `--cached` | `false` | skip issues already on disk instead of refetching |
| `--depth` | `3` | how deep to follow blockers |
| `--label` | — | filter by label; repeat for AND |
| `--limit` | `100` | filter mode: how many issues to STORE, not to enumerate |
| `--milestone` | — | pull a whole milestone (id or title) |
| `--no-deps` | `false` | do not fill depends: and do not follow blockers |
| `--out` | — | store root (default: <project>/.kettle/issues) |
| `--q` | — | search text in title and body |
| `--query` | — | the long spelling of -q |
| `--state` | `open` | filter mode only: open, closed or all |
```bash
kettle pull 42 # the issue and everything blocking it, in any state
kettle pull 42 --no-deps # just that one issue — one request
kettle pull owner/repo#42 # an issue in another repository
kettle pull --milestone v0.2 --limit 20 # 20 open issues from a milestone, blockers included
kettle pull --label type/bug --state all # every bug; the closed ones are enumerated, not stored
```
## `kettle push [<id>…]`
send local issues to the tracker; the local copy goes with them
A SUCCESSFUL PUSH DELETES THE LOCAL FILE — <id>.md and every sidecar under that
slug — and prints the number and the URL the issue now lives at. Once the tracker
has the issue, the tracker IS the issue: what is left in the store is what has
not left this machine. Get it back with `kettle pull <n>`, which returns it under
the same slug, because the slug travelled up in the body as <!-- kettle:id … -->
and was recorded in the number -> slug ledger.
ONE RULE, NO EXCEPTION: --update deletes as well. A PATCH is a push, and an issue
that has just been sent is no more local than one that was just created. Two
rules would put back exactly the question this removes — "is my copy the fresh
one?".
THE DELETION IS THE LAST THING THAT HAPPENS TO AN ISSUE, 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 PATCHed, and
3. the ledger has been written with number -> slug.
Network down, non-2xx, an answer that does not confirm the write: the file stays
and the run stops. Nothing removes a file it has not just watched the tracker
accept, and nothing removes a file for an issue it did not send — `origin: local`
work that has never been pushed is never touched by any of this. 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 and not
after.
Every issue is validated against the canonical format first, offline and before
a socket is opened. --force posts anyway; say why when you use it.
DEPENDENCIES GO FIRST, in topological order, so a blocker has its number before
the issue that names it. Every `depends:` entry that has a number becomes a NATIVE
tracker link — the same /dependencies a pull reads back, so the tracker shows the
blocking panel and refuses to close a blocked issue first. A link that is already
there is skipped, not re-POSTed, which is what makes a repeat push a no-op. A
dependency that is still local-only has no number and becomes no link: it is
reported, never silently dropped.
REMOVING a link is out of scope — push only ever adds. A dependency deleted from
`depends:` leaves its tracker link standing; unlink it in the web UI.
The `## Depends on` prose is never touched: slugs stay slugs and are not rewritten
to #N, so a pull -> push round trip is byte for byte.
Labels the repository is missing are created with the canonical colour and, for
type/* and severity/*, exclusive: true. `branch:` carries the tracker's `ref`: an
empty one is filled with the current git branch and an already-set one is sent as
written. Detached HEAD or no repository at all is not an error — no ref is sent
and a warning says so.
| flag | default | what it does |
| --- | --- | --- |
| `--dry-run` | `false` | validate and print the plan; no network, nothing deleted |
| `--force` | `false` | push despite format violations |
| `--out` | — | store root (default: <project>/.kettle/issues) |
| `--update` | `false` | PATCH issues that already carry a gitea: field |
```bash
kettle push # every issue the tracker does not have yet, blockers first
kettle push wire-sqlc-appclick # one issue
kettle push --update wire-sqlc-appclick # PATCH one that is already there — the file still goes
kettle push --dry-run # validate and print the plan; no network, nothing deleted
```
## `kettle remote`
list what exists in the tracker, one line each
Discovery only: this prints and WRITES NOTHING. The local store is a store, not a
search-results folder, and a listing that landed in it would leave files nobody
asked for beside the issues somebody did. Pick the numbers here, then pull them.
#42 open type/task, tech/sql Wire sqlc into the repo layer
└─ local: wire-sqlc-appclick
The second line appears when the number is already in the local ledger, so it is
obvious what a pull would refresh and what it would add.
--limit here caps the LISTING: N lines out, closed ones among them. That is not
what the same flag means to `kettle pull`, and the difference is not an oversight —
pull bounds what it WRITES, this command writes nothing, and enumeration is the
whole job.
Projects are not filterable: the projects API is not exposed by Gitea. Use
milestones or labels, or the web UI.
| flag | default | what it does |
| --- | --- | --- |
| `--label` | — | filter by label; repeat for AND |
| `--limit` | `30` | how many lines to print |
| `--milestone` | — | milestone id or title |
| `--out` | — | store root (default: <project>/.kettle/issues) |
| `--q` | — | search text in title and body |
| `--query` | — | the long spelling of -q |
| `--state` | `open` | open, closed or all |
```bash
kettle remote # the open issues, 30 of them
kettle remote --state all --label type/bug --limit 50 # every bug, open and closed
kettle remote --milestone v0.2 # what is in a milestone
kettle remote -q sqlc # keyword search over title and body
```
## `kettle sync-evict [<id>…]`
refresh state from the tracker, then evict what is closed
`kettle evict` is the command that decides and deletes. This adds exactly one
thing in front of it: a `state:` that is not stale. A local `state:` is only as
fresh as the last pull, so an issue closed in the web UI an hour ago still reads
`open` here and the offline command will — correctly — leave it alone. That is
the gap this closes, and before it existed the operator had to pull the five
closed issues back onto disk before anything could remove them.
ORDER OF OPERATIONS, AND IT IS THE WHOLE SAFETY ARGUMENT:
1. every candidate's state is fetched — ALL of them, before anything is
removed;
2. each answer must be the issue that was asked about, in a state the domain
recognizes;
3. only then is the eviction run, by handing the refreshed issues to the
domain — the same decision, the same deletion, the same protection of
`origin: local`, in one place.
A dead connection, a non-2xx, an answer about another issue, a state nobody
recognizes: the run stops at step 2 and NOTHING is deleted, not even the issues
whose answers had already arrived. That is stricter than push, which deletes as
it goes, and it costs nothing here — there is no ordering constraint between
evictions, so there is no reason to start before every answer is in.
A candidate is an issue carrying a `gitea:` handle. `origin: local` work has
none, is never asked about, and is never evicted — it is not in the tracker to
be closed. A tracked issue whose handle is missing or unreadable cannot be
verified, so it is reported and kept rather than guessed at.
Cost: one request per candidate. The store is a working set that push keeps
small, and a wrong answer here deletes a file, so each issue is asked about by
its own address rather than inferred from a list a limit could have truncated.
The refreshed state is written back even for the issues that stay: the answer is
already paid for, and a store that keeps a state the tracker has disowned is the
thing this command exists to fix.
| flag | default | what it does |
| --- | --- | --- |
| `--dry-run` | `false` | ask the tracker and report; write and delete nothing |
| `--out` | — | store root (default: <project>/.kettle/issues) |
```bash
kettle sync-evict # ask about every synced issue; evict the closed ones
kettle sync-evict old-thing another-thing # only these
kettle sync-evict --dry-run # ask, report, write and delete nothing
```
<!-- /kettle:gen -->
+59
View File
@@ -0,0 +1,59 @@
package scaffold
import (
"os/exec"
"strings"
"testing"
)
// This package is prose and a table of contents. It hands out embedded bytes and
// says which of them carry a generated region; it renders nothing, resolves
// nothing and reads no file off the disk.
//
// That matters because of what sits above it: internal/cmd imports this to write
// a project's `.claude/` tree, and if this package imported the registry back the
// two would be a cycle. The dependency walk, so a helper pulled in three packages
// deep is caught as the same violation as one written at the top of a file.
func TestScaffoldDependsOnNothing(t *testing.T) {
out, err := exec.Command("go", "list", "-deps", ".").Output()
if err != nil {
t.Fatalf("go list: %v", err)
}
for _, dep := range strings.Fields(string(out)) {
if dep == "git.noodles.cam/claude-skills/marketplace/cli/internal/scaffold" {
continue
}
// A standard-library import path has no dot in its first element,
// because it has no domain name in front of it.
if first, _, _ := strings.Cut(dep, "/"); strings.Contains(first, ".") {
t.Errorf("scaffold imports %s — these are embedded documents, and nothing else belongs here", dep)
}
}
}
// The other half: os and net/http are standard library, so "no third-party
// imports" would not catch a read off the disk written by hand here. The whole
// premise is that these documents travel INSIDE the binary — one os.ReadFile and
// they are back to being files on a machine that may not have them.
//
// DIRECT imports, not the dependency walk — embed reaches io/fs on its own, and
// the question this asks is what THIS package reaches for.
func TestScaffoldReadsNothingOffTheDisk(t *testing.T) {
forbidden := map[string]string{
"os": "these documents are embedded; a file read here is a file that can be missing",
"os/exec": "nothing here shells out",
"net/http": "nothing here is fetched",
"net": "nothing here is fetched",
"time": "a document has no clock in it",
}
out, err := exec.Command("go", "list", "-f", `{{join .Imports "\n"}}`, ".").Output()
if err != nil {
t.Fatalf("go list: %v", err)
}
for _, dep := range strings.Fields(string(out)) {
if why, bad := forbidden[dep]; bad {
t.Errorf("scaffold imports %s — %s", dep, why)
}
}
}
+131
View File
@@ -0,0 +1,131 @@
// Package scaffold holds the documents kettle writes into a project: the slash
// commands an operator invokes, the skills a model loads, and the runner
// subagent.
//
// They live inside the binary rather than beside it. A plugin shipped this prose
// once, on its own release cadence, and nothing on an operator's machine checked
// that the plugin they had installed described the binary they had installed —
// so a renamed flag could ship with documentation recommending the old one,
// which is the exact failure the generated block was invented to prevent, one
// hop further downstream. Prose that travels inside the binary cannot be a
// version behind it.
//
// This package depends on nothing but the standard library and holds no
// rendering logic: it hands out embedded files and says which of them carry a
// generated region. Splicing the command registry into that region is
// internal/cmd's, because the registry is.
package scaffold
import (
"embed"
"io/fs"
"path"
"sort"
"strings"
)
//go:embed all:assets
var assets embed.FS
const assetRoot = "assets"
// Marker is the directory these files are written into, relative to the project
// root. It belongs to the agent harness, not to kettle: everything kettle owns
// is under `.kettle/`, and this is the one tree it writes that somebody else
// defines the shape of.
const Marker = ".claude"
// generated maps an output path to the command group whose flag table belongs
// in it. A file that is not in here carries no generated region and is shipped
// exactly as embedded.
//
// Explicit rather than derived from the directory name: the group ⇄ skill
// correspondence is a decision, and one that has not always held — `init` and
// `auth` are commands with no skill of their own, and `project` is a skill
// covering four commands. A test in internal/cmd asserts every group in the
// registry is named here exactly once, so adding a group fails loudly rather
// than silently shipping a skill nobody can find.
var generated = map[string]string{
"skills/kettle-project/SKILL.md": "project",
"skills/kettle-issue/SKILL.md": "issue",
"skills/kettle-sync/SKILL.md": "sync",
"skills/kettle-api/SKILL.md": "api",
}
// File is one document, ready to be written under the output directory.
type File struct {
// Path is relative to the output directory, always with forward slashes:
// "commands/kettle/init.md", "skills/kettle-issue/SKILL.md".
Path string
// Body is the file as embedded — before any generated region is spliced in.
Body string
// Group is the command group whose flag table belongs in this file, or "".
Group string
}
// Files is every document, sorted by path.
//
// Sorted, not in walk order, because the sort is the promise: two runs of the
// same binary produce the same list, so a receipt and a --check diff are
// comparable between machines.
func Files() []File {
var out []File
_ = fs.WalkDir(assets, assetRoot, func(p string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
return err
}
rel := strings.TrimPrefix(p, assetRoot+"/")
body, err := assets.ReadFile(p)
if err != nil {
return err
}
out = append(out, File{Path: rel, Body: string(body), Group: generated[rel]})
return nil
})
sort.Slice(out, func(i, j int) bool { return out[i].Path < out[j].Path })
return out
}
// Groups lists every command group that has a file here, sorted.
func Groups() []string {
out := make([]string, 0, len(generated))
for _, g := range generated {
out = append(out, g)
}
sort.Strings(out)
return out
}
// PathFor is the output path carrying a group's flag table, or "".
func PathFor(group string) string {
for p, g := range generated {
if g == group {
return p
}
}
return ""
}
// Dirs lists the directories the output tree is made of, parents first, so a
// caller can create them in order.
func Dirs() []string {
seen := map[string]bool{}
var out []string
for _, f := range Files() {
for _, d := range parents(path.Dir(f.Path)) {
if !seen[d] {
seen[d] = true
out = append(out, d)
}
}
}
sort.Strings(out)
return out
}
func parents(dir string) []string {
if dir == "." || dir == "" {
return nil
}
return append(parents(path.Dir(dir)), dir)
}
+224
View File
@@ -0,0 +1,224 @@
package scaffold
import (
"path"
"strings"
"testing"
)
// frontmatter returns the YAML block at the top of a document, or "" if there
// is none. Every file here is read by an agent harness that will not load a
// document without one.
func frontmatter(body string) string {
if !strings.HasPrefix(body, "---\n") {
return ""
}
rest := body[len("---\n"):]
end := strings.Index(rest, "\n---\n")
if end < 0 {
return ""
}
return rest[:end+1]
}
func field(fm, key string) string {
for _, line := range strings.Split(fm, "\n") {
if v, ok := strings.CutPrefix(line, key+":"); ok {
return strings.TrimSpace(v)
}
}
return ""
}
// routed reports whether a document is one the harness loads by itself — a
// skill, a command, a subagent. Everything under references/ is prose that a
// skill names by path and reads in full, so it carries no frontmatter and needs
// none.
func routed(p string) bool {
return !strings.Contains(p, "/references/")
}
// A routed document with no frontmatter, or with an empty description, is a
// document the harness either refuses to load or never routes to. Either way it
// is dead weight in the binary, and neither failure shows up until somebody's
// project is quietly missing a skill.
func TestEveryDocumentIsLoadable(t *testing.T) {
files := Files()
if len(files) == 0 {
t.Fatal("no assets embedded — check the //go:embed directive")
}
for _, f := range files {
if !routed(f.Path) {
if frontmatter(f.Body) != "" {
t.Errorf("%s: a reference is read by path and needs no frontmatter", f.Path)
}
continue
}
fm := frontmatter(f.Body)
if fm == "" {
t.Errorf("%s: no frontmatter", f.Path)
continue
}
if field(fm, "description") == "" {
t.Errorf("%s: no description — nothing will route to it", f.Path)
}
}
}
// A skill is addressed by its name, and the harness resolves that name from the
// directory. The two disagreeing is a skill that cannot be loaded by the name it
// calls itself.
func TestSkillNamesMatchTheirDirectories(t *testing.T) {
for _, f := range Files() {
if !strings.HasPrefix(f.Path, "skills/") || path.Base(f.Path) != "SKILL.md" {
continue
}
dir := path.Base(path.Dir(f.Path))
if got := field(frontmatter(f.Body), "name"); got != dir {
t.Errorf("%s: name is %q, directory is %q", f.Path, got, dir)
}
if !strings.HasPrefix(dir, "kettle-") {
t.Errorf("%s: a project skill has no namespace of its own, so the prefix is the whole of it", f.Path)
}
}
}
// A command is invoked by an operator who typed it, so it needs no description
// to be routed on — but it gets one anyway, because that is what the operator
// reads in the command list. What it must NOT carry is a name: a project command
// is named by its filename, and a `name:` here would be a second spelling of the
// same identity, free to drift.
func TestCommandsAreNamedByTheirFilenames(t *testing.T) {
found := 0
for _, f := range Files() {
if !strings.HasPrefix(f.Path, "commands/") {
continue
}
found++
if got := field(frontmatter(f.Body), "name"); got != "" {
t.Errorf("%s: carries name: %q — the filename is the name", f.Path, got)
}
}
if found == 0 {
t.Error("no commands embedded")
}
}
// The generated region and the map that declares it are one fact written twice,
// and this is the test that keeps them equal. A file that grew a region without
// being declared would have it silently ignored; a file declared without one
// would fail at splice time, in somebody's project rather than here.
func TestDeclaredRegionsAreTheRealOnes(t *testing.T) {
const open, close = "<!-- kettle:gen -->", "<!-- /kettle:gen -->"
seen := map[string]bool{}
for _, f := range Files() {
has := strings.Contains(f.Body, open)
switch {
case has && f.Group == "":
t.Errorf("%s carries a generated region but is in no group", f.Path)
case !has && f.Group != "":
t.Errorf("%s is declared for group %q but has no region", f.Path, f.Group)
}
if has && !strings.Contains(f.Body, close) {
t.Errorf("%s opens a region and never closes it", f.Path)
}
if f.Group != "" {
seen[f.Path] = true
}
}
for p := range generated {
if !seen[p] {
t.Errorf("generated names %s, which is not embedded", p)
}
}
}
// One group, one file. Two files claiming the same group would both be written
// from the same registry block, and only one of them would be the one anybody
// read.
func TestEachGroupHasExactlyOneFile(t *testing.T) {
for _, g := range Groups() {
var paths []string
for p, group := range generated {
if group == g {
paths = append(paths, p)
}
}
if len(paths) != 1 {
t.Errorf("group %q is claimed by %v", g, paths)
}
if PathFor(g) == "" {
t.Errorf("PathFor(%q) found nothing", g)
}
}
}
// Two calls, one list. Every receipt, every --check diff and every golden test
// downstream is built on this holding.
func TestFilesAreDeterministic(t *testing.T) {
a, b := Files(), Files()
if len(a) != len(b) {
t.Fatalf("two calls returned %d and %d files", len(a), len(b))
}
for i := range a {
if a[i] != b[i] {
t.Fatalf("call %d differs at %d: %s vs %s", i, i, a[i].Path, b[i].Path)
}
if i > 0 && a[i-1].Path >= a[i].Path {
t.Errorf("not sorted: %s before %s", a[i-1].Path, a[i].Path)
}
}
}
// Dirs is what a writer creates before it writes, so a parent that came after
// its child would be a mkdir that fails on a cold directory.
func TestDirsListsParentsBeforeChildren(t *testing.T) {
dirs := Dirs()
seen := map[string]bool{}
for _, d := range dirs {
if parent := path.Dir(d); parent != "." && !seen[parent] {
t.Errorf("%s comes before its parent %s", d, parent)
}
seen[d] = true
}
for _, f := range Files() {
if d := path.Dir(f.Path); d != "." && !seen[d] {
t.Errorf("%s lives in %s, which Dirs does not list", f.Path, d)
}
}
}
// Nothing authored here may name the plugin it replaced. A path into
// `plugins/kettle` is a path that no longer exists, and an operator who follows
// one is an operator reading a document that outlived its subject.
//
// Authored, so the generated region is cut out first: what is between the
// markers came from the command registry and is that registry's to get right.
// Asserting over it here would fail on a stale embedded block rather than on the
// sentence somebody actually wrote.
func TestNothingPointsAtTheOldPlugin(t *testing.T) {
for _, f := range Files() {
body := withoutRegion(f.Body)
for _, dead := range []string{"plugins/kettle", "gen skills", "${CLAUDE_PLUGIN_ROOT}"} {
if strings.Contains(body, dead) {
t.Errorf("%s still mentions %q", f.Path, dead)
}
}
}
}
func withoutRegion(body string) string {
const open, close = "<!-- kettle:gen -->", "<!-- /kettle:gen -->"
start := strings.Index(body, open)
if start < 0 {
return body
}
end := strings.Index(body, close)
if end < 0 {
return body[:start]
}
return body[:start] + body[end+len(close):]
}