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: "[]", 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)) } }