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))
}
}