8b1b11001a
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>
233 lines
7.3 KiB
Go
233 lines
7.3 KiB
Go
// 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)
|
|
}
|