diff --git a/README.md b/README.md index af85f89..0bbf938 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,7 @@ Run without `--apply` first to see what a command would do. | `reconcile` | act on that drift, gated on what agentsync itself recorded | | `inventory` | regenerate `inventory.json`, a record of every agent on the machine | | `optimize` | ask an OpenRouter advisor which model and effort each agent should use; report only | +| `override` | set one checkout's model and effort for a Claude agent without forking it | `agentsync help` lists every flag. @@ -82,6 +83,7 @@ Run without `--apply` first to see what a command would do. - [reconcile](docs/reconcile.md) - [inventory](docs/inventory.md) - [optimize](docs/optimize.md) +- [override](docs/override.md) - [State file](docs/state-file.md) - [Exit codes](docs/exit-codes.md) - [Development](docs/development.md): toolchain, CI, releases diff --git a/cmd/agentsync/doctor.go b/cmd/agentsync/doctor.go index 68d34a8..0f806ea 100644 --- a/cmd/agentsync/doctor.go +++ b/cmd/agentsync/doctor.go @@ -69,6 +69,9 @@ func runDoctor(args []string, stdout, stderr io.Writer) int { check: repo.CheckHome(root.Path, res.Dirs), report: doctor.Diagnose(repo, res), } + findings, problems := overrideFindings(repo.Home, res.Dirs) + run.report.Findings = append(run.report.Findings, findings...) + run.report.Problems = append(run.report.Problems, problems...) // Both home diagnostics, after the diagnosis and before the report, so a // terminal user reads them before the numbers they apply to. @@ -220,22 +223,25 @@ var doctorKindOrder = []doctor.Kind{ doctor.KindStaleCapture, doctor.KindOrphanedCaptures, doctor.KindForeignLink, + doctor.KindOverrideCannotRun, } var doctorKindMarker = map[doctor.Kind]string{ - doctor.KindBrokenLink: "!!", - doctor.KindMismatch: "!!", - doctor.KindStaleCapture: "!!", - doctor.KindOrphanedCaptures: "!!", - doctor.KindForeignLink: " ", + doctor.KindBrokenLink: "!!", + doctor.KindMismatch: "!!", + doctor.KindStaleCapture: "!!", + doctor.KindOrphanedCaptures: "!!", + doctor.KindForeignLink: " ", + doctor.KindOverrideCannotRun: "!!", } var doctorKindNote = map[doctor.Kind]string{ - doctor.KindBrokenLink: "link into this repo whose target is gone — NOT unlinked", - doctor.KindMismatch: "linked to the wrong file in this repo — NOT repointed", - doctor.KindStaleCapture: "captured here, but no live file in the project — NOT deleted", - doctor.KindOrphanedCaptures: "captured here, but the whole project is gone — NOT deleted", - doctor.KindForeignLink: "points outside this repo — not ours, and not a fault", + doctor.KindBrokenLink: "link into this repo whose target is gone — NOT unlinked", + doctor.KindMismatch: "linked to the wrong file in this repo — NOT repointed", + doctor.KindStaleCapture: "captured here, but no live file in the project — NOT deleted", + doctor.KindOrphanedCaptures: "captured here, but the whole project is gone — NOT deleted", + doctor.KindForeignLink: "points outside this repo — not ours, and not a fault", + doctor.KindOverrideCannotRun: "override hook that cannot run — the overrides do nothing", } func (r doctorRun) emitText(w io.Writer) { @@ -310,6 +316,10 @@ func (r doctorRun) emitSummary(w io.Writer) { fmt.Fprintf(w, "%d foreign link%s reported for completeness; %s belong%s to something else and %s not affect this exit code.\n", n, plural(n, "", "s"), plural(n, "it", "they"), plural(n, "s", ""), plural(n, "does", "do")) } + if n := counts[doctor.KindOverrideCannotRun]; n > 0 { + fmt.Fprintf(w, "%d project%s override%s agents through a hook that cannot run here. Put a current agentsync on PATH, or remove the overrides with agentsync override --clear.\n", + n, plural(n, "", "s"), plural(n, "s", "")) + } if n := len(r.report.Problems); n > 0 { fmt.Fprintf(w, "%d path%s could not be judged, so this diagnosis is incomplete.\n", n, plural(n, "", "s")) diff --git a/cmd/agentsync/main.go b/cmd/agentsync/main.go index 51ef0b4..8eafd0f 100644 --- a/cmd/agentsync/main.go +++ b/cmd/agentsync/main.go @@ -38,6 +38,8 @@ // // optimize is the only command that uses the network: it asks an OpenRouter // advisor about each agent's model and effort, and writes nothing. +// +// override writes only the project's .claude/settings.local.json, with --apply. package main import ( @@ -52,6 +54,7 @@ import ( "unicode/utf8" "github.com/agentic-tooling/agentsync/internal/config" + "github.com/agentic-tooling/agentsync/internal/override" "github.com/agentic-tooling/agentsync/internal/scan" ) @@ -67,6 +70,7 @@ Usage: agentsync inventory [flags] regenerate inventory.json: every agent on this machine agentsync optimize [flags] ask an OpenRouter advisor which model and effort each agent should use (report only; the one networked command) + agentsync override [flags] set this checkout's model and effort for a Claude agent agentsync version Common flags: @@ -236,10 +240,24 @@ OpenRouter, one request per agent with no retries. It reads model: and effort: from each agent's frontmatter and writes nothing. Exit 1 when a row is unresolved or skipped. +override flags: + --agent NAME the agent to override, by its frontmatter name; it must be + in the root's .claude/agents/ (--all is refused) + --model M haiku, sonnet, opus or fable + --effort E low, medium, high, xhigh or max (give --model, --effort or both) + --clear NAME remove that agent's override + --apply write the change; without it override is a dry run + +With no flags, override lists the root's overrides. They live in one +PreToolUse hook in /.claude/settings.local.json, which Claude Code keeps +per user. The hook runs agentsync from PATH; doctor checks that it can. + scan and doctor are read-only. import writes only into this repo, only with --apply, and never into a project's .claude/agents directory. link is the one -command that writes into a project, only with --apply, and it refuses any -project holding a file this repo has no copy of — import that first. +command that writes into a project's .claude/agents, only with --apply, and it +refuses any project holding a file this repo has no copy of — import that +first. override writes only the project's .claude/settings.local.json, also +only with --apply. inventory also writes only into this repo, and takes no --apply: its sole output is inventory.json, which it owns, recomputes from scratch every run, and @@ -306,6 +324,10 @@ func run(args []string, stdout, stderr io.Writer) int { return runInventory(args[1:], stdout, stderr) case "optimize": return runOptimize(args[1:], stdout, stderr) + case "override": + return runOverride(args[1:], stdout, stderr) + case "hook": + return runHook(args[1:], stdout, stderr) case "-h", "--help", "help": fmt.Fprint(stdout, usage) return exitOK @@ -424,7 +446,7 @@ func runScan(args []string, stdout, stderr io.Writer) int { return exitFailure } } else { - emitText(stdout, res, repo.Root, root.Path, *atRiskOnly) + emitText(stdout, res, repo.Root, root.Path, *atRiskOnly, entryOverrides(res, stderr)) } if *failOnRisk && len(res.AtRisk()) > 0 { @@ -505,7 +527,7 @@ var stateNote = map[scan.State]string{ scan.StateHealthyLink: "already linked to the repo", } -func emitText(w io.Writer, res scan.Result, repoRoot, root string, atRiskOnly bool) { +func emitText(w io.Writer, res scan.Result, repoRoot, root string, atRiskOnly bool, ov map[string]override.Value) { shown := res if atRiskOnly { shown = filterAtRisk(res) @@ -524,7 +546,7 @@ func emitText(w io.Writer, res scan.Result, repoRoot, root string, atRiskOnly bo } fmt.Fprintln(w) } else { - emitGroups(w, shown.Entries) + emitGroups(w, shown.Entries, ov) } emitProblems(w, res.Problems) @@ -565,7 +587,7 @@ func projectColumn(entries []scan.Entry) int { return width } -func emitGroups(w io.Writer, entries []scan.Entry) { +func emitGroups(w io.Writer, entries []scan.Entry, ov map[string]override.Value) { width := projectColumn(entries) byState := make(map[scan.State][]scan.Entry) @@ -614,6 +636,9 @@ func emitGroups(w io.Writer, entries []scan.Entry) { note = " (committed in its own repo)" } } + if v, ok := ov[e.Path]; ok { + note += " override: " + v.String() + } fmt.Fprintf(w, " %-*s %-28s%s\n", width, e.Project, e.Name, note) } fmt.Fprintln(w) diff --git a/cmd/agentsync/main_test.go b/cmd/agentsync/main_test.go index 5754e40..9f8a333 100644 --- a/cmd/agentsync/main_test.go +++ b/cmd/agentsync/main_test.go @@ -84,7 +84,7 @@ func atRiskNames(res scan.Result) []string { func renderText(t *testing.T, res scan.Result, atRiskOnly bool) string { t.Helper() var buf bytes.Buffer - emitText(&buf, res, "/repo", "/root", atRiskOnly) + emitText(&buf, res, "/repo", "/root", atRiskOnly, nil) return buf.String() } diff --git a/cmd/agentsync/optimize.go b/cmd/agentsync/optimize.go index 398b92d..9e671f6 100644 --- a/cmd/agentsync/optimize.go +++ b/cmd/agentsync/optimize.go @@ -10,6 +10,7 @@ import ( "math" "os" "path/filepath" + "slices" "strings" "text/tabwriter" @@ -114,6 +115,13 @@ func runOptimize(args []string, stdout, stderr io.Writer) int { fmt.Fprintf(notes, "Note: $%s is set; Claude Code uses it over the frontmatter at run time.\n", v) } } + ov := entryOverrides(res, stderr) + for _, e := range res.Entries { + if v, ok := ov[e.Path]; ok && (len(names) == 0 || slices.Contains(names, e.Name)) { + fmt.Fprintf(notes, "Note: %s/%s has a project override (%s) in %s; Claude Code uses it over the frontmatter there.\n", + e.Project, e.Name, v, displayPath(repo.Home, settingsPath(filepath.Dir(e.Path)))) + } + } rep := optimize.Run(ctx, client, agents, cfg) rep.Overrides = overrides diff --git a/cmd/agentsync/override.go b/cmd/agentsync/override.go new file mode 100644 index 0000000..82de48a --- /dev/null +++ b/cmd/agentsync/override.go @@ -0,0 +1,289 @@ +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "io" + "maps" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/agentic-tooling/agentsync/internal/discover" + "github.com/agentic-tooling/agentsync/internal/doctor" + "github.com/agentic-tooling/agentsync/internal/optimize" + "github.com/agentic-tooling/agentsync/internal/override" + "github.com/agentic-tooling/agentsync/internal/scan" +) + +// hookStdin is where the hook reads Claude Code's payload; tests replace it. +var hookStdin io.Reader = os.Stdin + +// exitHookFailed is the hook's only failure code. Claude Code reads exit 2 +// from a PreToolUse hook as "block this call", so a broken override exits 1 +// and the subagent runs on its own settings instead. +const exitHookFailed = 1 + +// runHook is the hidden command Claude Code runs before every Agent call. +func runHook(args []string, stdout, stderr io.Writer) int { + if len(args) == 0 || args[0] != "claude-agent" { + fmt.Fprintln(stderr, "agentsync: usage: agentsync hook claude-agent --set NAME=MODEL/EFFORT ...") + return exitHookFailed + } + sets, err := override.ParseArgs(args[1:]) + var out []byte + if err == nil { + var in []byte + if in, err = io.ReadAll(hookStdin); err == nil { + out, err = override.Rewrite(in, sets) + } + } + if err == nil { + _, err = stdout.Write(out) + } + if err != nil { + fmt.Fprintf(stderr, "agentsync hook: %v\n", err) + return exitHookFailed + } + return exitOK +} + +// settingsPath is the settings file beside a project's .claude/agents. +func settingsPath(agentsDir string) string { + return filepath.Join(filepath.Dir(agentsDir), "settings.local.json") +} + +// runOverride lists, sets or clears a project's agent overrides. Only --apply +// writes, and only /.claude/settings.local.json. +func runOverride(args []string, stdout, stderr io.Writer) int { + fs := flag.NewFlagSet("override", flag.ContinueOnError) + fs.SetOutput(stderr) + fs.Usage = func() { fmt.Fprint(stderr, usage) } + + rootF := registerRoot(fs) + repoPath := fs.String("repo", "", "canonical agent repo") + agent := fs.String("agent", "", "agent to override") + model := fs.String("model", "", "model the agent runs on in this project") + effort := fs.String("effort", "", "effort the agent runs at in this project") + clearName := fs.String("clear", "", "remove this agent's override") + apply := fs.Bool("apply", false, "write the settings file; without it override is a dry run") + + if err := fs.Parse(args); err != nil { + if errors.Is(err, flag.ErrHelp) { + return exitOK + } + return exitFailure + } + set := override.Value{Model: *model, Effort: *effort} + err := overrideFlagsErr(fs, *agent, *clearName, set, *apply) + if err == nil && *rootF.all { + err = fmt.Errorf("override works on one project, so --%s is refused; pass --root PROJECT or run inside it", allFlag) + } + var repo *scan.Repo + var repoFrom string + var root rootChoice + var f *override.File + if err == nil { + repo, repoFrom, err = openRepo(*repoPath) + } + if err == nil { + root, err = rootF.resolve(repo.Home) + } + agentsDir := filepath.Join(root.Path, ".claude", "agents") + if err == nil { + f, err = override.Load(settingsPath(agentsDir)) + } + if err == nil && *agent != "" { + err = requireAgent(repo, root.Path, agentsDir, *agent) + } + if err != nil { + fmt.Fprintf(stderr, "agentsync: %v\n", err) + return exitFailure + } + + emitRepoChoice(stdout, repo, repoFrom) + emitRootChoice(stdout, repo.Home, root) + shown := displayPath(repo.Home, f.Path) + if *agent == "" && *clearName == "" { + emitOverrides(stdout, shown, f.Sets) + return exitOK + } + + sets := maps.Clone(f.Sets) + if *agent != "" { + sets[*agent] = set + } else if _, ok := sets[*clearName]; ok { + delete(sets, *clearName) + } else { + fmt.Fprintf(stdout, "%s has no override in %s; nothing to change.\n", *clearName, shown) + return exitOK + } + after := override.Command(sets) + if after == f.Command { + fmt.Fprintf(stdout, "%s already has that override in %s; nothing to change.\n", *agent, shown) + return exitOK + } + fmt.Fprintf(stdout, "%s, PreToolUse hook:\n before: %s\n after: %s\n", shown, orNoEntry(f.Command), orNoEntry(after)) + if !*apply { + fmt.Fprintln(stdout, "Dry run: nothing written. Pass --apply to write it.") + return exitOK + } + if err := f.Save(sets); err != nil { + fmt.Fprintf(stderr, "agentsync: writing %s: %v\n", f.Path, err) + return exitFailure + } + fmt.Fprintf(stdout, "Wrote %s. A Claude Code session that is already running may need a restart to pick it up.\n", shown) + return exitOK +} + +func overrideFlagsErr(fs *flag.FlagSet, agent, clear string, set override.Value, apply bool) error { + switch { + case fs.NArg() > 0: + return fmt.Errorf("override takes no arguments, got %q", fs.Arg(0)) + case clear != "" && (agent != "" || set != override.Value{}): + return errors.New("--clear takes no --agent, --model or --effort") + case agent == "" && set != override.Value{}: + return errors.New("--model and --effort need --agent") + case agent != "": + return override.Validate(agent, set) + case clear == "" && apply: + return errors.New("--apply needs --agent or --clear; without them override only lists") + } + return nil +} + +// requireAgent refuses a name no agent in the root's own agents directory +// declares, so a typo cannot write an override that never matches. +func requireAgent(repo *scan.Repo, root, agentsDir, name string) error { + res, err := repo.Scan(root) + if err != nil { + return err + } + for _, e := range res.Entries { + if filepath.Dir(e.Path) != agentsDir { + continue + } + if n, _ := agentName(e.Path); n == name { + return nil + } + } + return fmt.Errorf("--agent %s: no agent in %s has that frontmatter name", name, agentsDir) +} + +// agentName is the frontmatter name Claude Code matches subagent_type +// against, and false for a file that is not an agent. +func agentName(path string) (string, bool) { + data, err := os.ReadFile(path) + if err != nil || !discover.IsAgent(string(data)) { + return "", false + } + return optimize.Field(string(data), "name"), true +} + +func emitOverrides(w io.Writer, shown string, sets map[string]override.Value) { + if len(sets) == 0 { + fmt.Fprintf(w, "No overrides in %s.\n", shown) + return + } + names := make([]string, 0, len(sets)) + for n := range sets { + names = append(names, n) + } + sort.Strings(names) + fmt.Fprintf(w, "Overrides in %s (model / effort, - keeps the agent's own):\n", shown) + for _, n := range names { + fmt.Fprintf(w, " %-28s %s\n", n, sets[n]) + } +} + +func orNoEntry(cmd string) string { + if cmd == "" { + return "(no agentsync entry)" + } + return cmd +} + +// entryOverrides maps each scanned agent file that has an override to it. A +// settings file it cannot read is warned about, never shown as "no override". +func entryOverrides(res scan.Result, stderr io.Writer) map[string]override.Value { + out := map[string]override.Value{} + for _, d := range res.Dirs { + f, err := override.Load(settingsPath(d)) + if err != nil { + fmt.Fprintf(stderr, "agentsync: warning: overrides not shown: %v\n", err) + continue + } + if len(f.Sets) == 0 { + continue + } + for _, e := range res.Entries { + if filepath.Dir(e.Path) != d { + continue + } + // A non-agent's name is "", which no override can carry. + n, _ := agentName(e.Path) + if v, ok := f.Sets[n]; ok { + out[e.Path] = v + } + } + } + return out +} + +// hookProbe runs the agentsync on PATH with a stored hook command's arguments +// and returns why it cannot serve them, or "" when it can. +func hookProbe(command string) string { + bin, err := exec.LookPath("agentsync") + if err != nil { + return "the override hook runs agentsync, which is not on PATH" + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + args := strings.Fields(strings.TrimSuffix(command, override.Guard))[1:] + cmd := exec.CommandContext(ctx, bin, args...) + cmd.Stdin = strings.NewReader(`{"tool_input":{"subagent_type":"x"}}`) + if err := cmd.Run(); err != nil { + return fmt.Sprintf("%s fails as the override hook (%v); it may predate overrides", bin, err) + } + return "" +} + +// overrideFindings reports projects whose override hook cannot run, which +// Claude Code treats as no hook: the agents run unchanged. Each distinct hook +// command is probed once. +func overrideFindings(home string, dirs []string) ([]doctor.Finding, []scan.ScanProblem) { + var findings []doctor.Finding + var problems []scan.ScanProblem + probed := map[string]string{} + for _, d := range dirs { + f, err := override.Load(settingsPath(d)) + if err != nil { + problems = append(problems, scan.ScanProblem{Path: settingsPath(d), Err: err.Error()}) + continue + } + if f.Command == "" { + continue + } + why, ok := probed[f.Command] + if !ok { + why = hookProbe(f.Command) + probed[f.Command] = why + } + if why == "" { + continue + } + findings = append(findings, doctor.Finding{ + Kind: doctor.KindOverrideCannotRun, + KindName: doctor.KindOverrideCannotRun.String(), + Project: scan.ProjectKey(home, d), + Path: f.Path, + Detail: why, + }) + } + return findings, problems +} diff --git a/cmd/agentsync/override_test.go b/cmd/agentsync/override_test.go new file mode 100644 index 0000000..5292b3d --- /dev/null +++ b/cmd/agentsync/override_test.go @@ -0,0 +1,434 @@ +package main + +import ( + "bytes" + "encoding/json" + "errors" + "io/fs" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "testing/iotest" +) + +func runHookWith(t *testing.T, stdin string, args ...string) (code int, stdout, stderr string) { + t.Helper() + old := hookStdin + hookStdin = strings.NewReader(stdin) + t.Cleanup(func() { hookStdin = old }) + return runCLI(t, append([]string{"hook"}, args...)...) +} + +func TestHook_RewritesMatchingAgent(t *testing.T) { + code, stdout, stderr := runHookWith(t, + `{"tool_name":"Agent","tool_input":{"subagent_type":"probe","prompt":"p","model":"haiku"}}`, + "claude-agent", "--set", "probe=sonnet/high", "--set", "other=/low") + if code != exitOK || stderr != "" { + t.Fatalf("exit %d, stderr %q", code, stderr) + } + want := `{"hookSpecificOutput":{"hookEventName":"PreToolUse","updatedInput":{"effort":"high","model":"sonnet","prompt":"p","subagent_type":"probe"}}}` + "\n" + if stdout != want { + t.Errorf("stdout = %s, want %s", stdout, want) + } + + code, stdout, _ = runHookWith(t, `{"tool_input":{"subagent_type":"bystander"}}`, "claude-agent", "--set", "probe=sonnet/high") + if code != exitOK || stdout != "" { + t.Errorf("no match: exit %d, stdout %q; want 0 and nothing", code, stdout) + } +} + +// Exit 2 from a PreToolUse hook blocks the Agent call, so every failure must +// be exit 1. +func TestHook_FailuresNeverExit2(t *testing.T) { + tests := []struct { + name, stdin string + args []string + }{ + {"malformed stdin", "not json", []string{"claude-agent", "--set", "a=sonnet/"}}, + {"no tool_input", "{}", []string{"claude-agent", "--set", "a=sonnet/"}}, + {"bad --set", `{"tool_input":{}}`, []string{"claude-agent", "--set", "a=gpt/"}}, + {"unknown target", "{}", []string{"codex-agent"}}, + {"no target", "{}", nil}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + code, stdout, stderr := runHookWith(t, tc.stdin, tc.args...) + if code == 2 || code != 1 || stdout != "" || stderr == "" { + t.Errorf("exit %d, stdout %q, stderr %q; want 1, nothing, a message", code, stdout, stderr) + } + }) + } + + t.Run("unreadable stdin", func(t *testing.T) { + old := hookStdin + hookStdin = iotest.ErrReader(errors.New("boom")) + t.Cleanup(func() { hookStdin = old }) + if code := run([]string{"hook", "claude-agent"}, &bytes.Buffer{}, &bytes.Buffer{}); code == 2 || code != 1 { + t.Errorf("exit %d, want 1", code) + } + }) + t.Run("stdout closed", func(t *testing.T) { + old := hookStdin + hookStdin = strings.NewReader(`{"tool_input":{"subagent_type":"a"}}`) + t.Cleanup(func() { hookStdin = old }) + if code := run([]string{"hook", "claude-agent", "--set", "a=opus/"}, failWriter{}, &bytes.Buffer{}); code == 2 || code != 1 { + t.Errorf("exit %d, want 1", code) + } + }) +} + +// overrideFixture is a project with agents a and b, and its settings path. +func overrideFixture(t *testing.T) (project, repoRoot, settings string) { + t.Helper() + project, repoRoot = optimizeProject(t, map[string]string{ + "a.md": agentFile("a", "haiku", "low"), + "b.md": agentFile("b", "haiku", "low"), + }) + return project, repoRoot, filepath.Join(project, ".claude", "settings.local.json") +} + +func overrideCLI(t *testing.T, project, repoRoot string, args ...string) (int, string, string) { + t.Helper() + return runCLI(t, append([]string{"override", "--root", project, "--repo", repoRoot}, args...)...) +} + +func assertAbsent(t *testing.T, path string) { + t.Helper() + if _, err := os.Lstat(path); !errors.Is(err, fs.ErrNotExist) { + t.Fatalf("%s should not exist (stat: %v)", path, err) + } +} + +func hookCommand(t *testing.T, path string) string { + t.Helper() + var doc struct { + Hooks struct { + PreToolUse []struct { + Matcher string `json:"matcher"` + Hooks []struct{ Type, Command string } + } `json:"PreToolUse"` + } `json:"hooks"` + } + if err := json.Unmarshal([]byte(readFileT(t, path)), &doc); err != nil { + t.Fatal(err) + } + pre := doc.Hooks.PreToolUse + if len(pre) != 1 || pre[0].Matcher != "Agent" || len(pre[0].Hooks) != 1 || pre[0].Hooks[0].Type != "command" { + t.Fatalf("PreToolUse = %+v", pre) + } + return pre[0].Hooks[0].Command +} + +func TestOverride_SetApplyClear(t *testing.T) { + project, repoRoot, settings := overrideFixture(t) + + code, stdout, stderr := overrideCLI(t, project, repoRoot, "--agent", "b", "--effort", "high") + if code != exitOK || !strings.Contains(stdout, "after: agentsync hook claude-agent --set b=/high || exit 1") || !strings.Contains(stdout, "Dry run") { + t.Fatalf("dry run: exit %d\n%s%s", code, stdout, stderr) + } + assertAbsent(t, settings) + + for _, args := range [][]string{ + {"--agent", "b", "--effort", "high", "--apply"}, + {"--agent", "a", "--model", "sonnet", "--effort", "medium", "--apply"}, + } { + if code, stdout, stderr := overrideCLI(t, project, repoRoot, args...); code != exitOK || !strings.Contains(stdout, "Wrote ") { + t.Fatalf("%v: exit %d\n%s%s", args, code, stdout, stderr) + } + } + if got, want := hookCommand(t, settings), "agentsync hook claude-agent --set a=sonnet/medium --set b=/high || exit 1"; got != want { + t.Fatalf("command = %q, want %q", got, want) + } + before := readFileT(t, settings) + + code, stdout, _ = overrideCLI(t, project, repoRoot, "--agent", "a", "--model", "sonnet", "--effort", "medium", "--apply") + if code != exitOK || !strings.Contains(stdout, "already has that override") || readFileT(t, settings) != before { + t.Fatalf("re-apply: exit %d\n%s", code, stdout) + } + + code, stdout, _ = overrideCLI(t, project, repoRoot) + if code != exitOK || !strings.Contains(stdout, "a sonnet / medium") || !strings.Contains(stdout, "b - / high") { + t.Fatalf("list: exit %d\n%s", code, stdout) + } + + if code, stdout, _ := overrideCLI(t, project, repoRoot, "--clear", "a", "--apply"); code != exitOK || !strings.Contains(stdout, "Wrote ") { + t.Fatalf("clear a: exit %d\n%s", code, stdout) + } + if got, want := hookCommand(t, settings), "agentsync hook claude-agent --set b=/high || exit 1"; got != want { + t.Fatalf("after clear: %q, want %q", got, want) + } + if code, stdout, _ := overrideCLI(t, project, repoRoot, "--clear", "a"); code != exitOK || !strings.Contains(stdout, "a has no override") { + t.Fatalf("clear again: exit %d\n%s", code, stdout) + } + code, stdout, _ = overrideCLI(t, project, repoRoot, "--clear", "b", "--apply") + if code != exitOK || !strings.Contains(stdout, "after: (no agentsync entry)") { + t.Fatalf("clear b: exit %d\n%s", code, stdout) + } + assertAbsent(t, settings) + + if code, stdout, _ := overrideCLI(t, project, repoRoot); code != exitOK || !strings.Contains(stdout, "No overrides in ") { + t.Fatalf("empty list: exit %d\n%s", code, stdout) + } +} + +func TestOverride_KeepsOtherSettings(t *testing.T) { + project, repoRoot, settings := overrideFixture(t) + mustWriteT(t, settings, `{"permissions":{"allow":["Bash(ls)"]},"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"echo hi"}]}]}}`) + + for _, args := range [][]string{{"--agent", "a", "--model", "opus", "--apply"}, {"--clear", "a", "--apply"}} { + if code, stdout, stderr := overrideCLI(t, project, repoRoot, args...); code != exitOK { + t.Fatalf("%v: exit %d\n%s%s", args, code, stdout, stderr) + } + } + var got any + if err := json.Unmarshal([]byte(readFileT(t, settings)), &got); err != nil { + t.Fatal(err) + } + var want any + _ = json.Unmarshal([]byte(`{"permissions":{"allow":["Bash(ls)"]},"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"echo hi"}]}]}}`), &want) + if !jsonEqual(got, want) { + t.Fatalf("settings changed:\n%s", readFileT(t, settings)) + } +} + +func jsonEqual(a, b any) bool { + x, _ := json.Marshal(a) + y, _ := json.Marshal(b) + return bytes.Equal(x, y) +} + +func TestOverride_RefusesWithoutWriting(t *testing.T) { + project, repoRoot, settings := overrideFixture(t) + tests := []struct { + name string + args []string + }{ + {"unknown agent", []string{"--agent", "nope", "--model", "opus", "--apply"}}, + {"bad model", []string{"--agent", "a", "--model", "gpt", "--apply"}}, + {"full model id", []string{"--agent", "a", "--model", "claude-sonnet-5-5", "--apply"}}, + {"all", []string{"--all", "--agent", "a", "--model", "opus", "--apply"}}, + {"inherit", []string{"--agent", "a", "--model", "inherit", "--apply"}}, + {"bad effort", []string{"--agent", "a", "--effort", "huge", "--apply"}}, + {"nothing to set", []string{"--agent", "a", "--apply"}}, + {"model without agent", []string{"--model", "opus"}}, + {"clear with agent", []string{"--clear", "a", "--agent", "a"}}, + {"apply alone", []string{"--apply"}}, + {"positional", []string{"a"}}, + {"unknown flag", []string{"--bogus"}}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + code, stdout, stderr := overrideCLI(t, project, repoRoot, tc.args...) + if code != exitFailure || stdout != "" || stderr == "" { + t.Errorf("exit %d, stdout %q, stderr %q; want 2, nothing, a message", code, stdout, stderr) + } + assertAbsent(t, settings) + }) + } + + t.Run("invalid JSON", func(t *testing.T) { + mustWriteT(t, settings, "{not json") + code, _, stderr := overrideCLI(t, project, repoRoot, "--agent", "a", "--model", "opus", "--apply") + if code != exitFailure || !strings.Contains(stderr, "not a JSON object") || readFileT(t, settings) != "{not json" { + t.Errorf("exit %d, stderr %q, file %q", code, stderr, readFileT(t, settings)) + } + }) + + t.Run("no repo", func(t *testing.T) { + useConfig(t, "") + if code, _, _ := runCLI(t, "override", "--root", project); code != exitFailure { + t.Errorf("exit %d, want 2", code) + } + }) + + t.Run("help", func(t *testing.T) { + if code, _, _ := runCLI(t, "override", "-h"); code != exitOK { + t.Errorf("exit %d, want 0", code) + } + }) +} + +const oneOverride = `{"hooks":{"PreToolUse":[{"matcher":"Agent","hooks":[{"type":"command","command":"agentsync hook claude-agent --set a=sonnet/ --set b=/max || exit 1"}]}]}}` + +func TestScan_ShowsOverrides(t *testing.T) { + project, repoRoot, settings := overrideFixture(t) + mustWriteT(t, settings, oneOverride) + code, stdout, stderr := runCLI(t, scanArgs(repoRoot, project)...) + if code != exitOK || !strings.Contains(stdout, "override: sonnet / -") || !strings.Contains(stdout, "override: - / max") { + t.Fatalf("exit %d\n%s%s", code, stdout, stderr) + } + + mustWriteT(t, settings, "{") + code, stdout, stderr = runCLI(t, scanArgs(repoRoot, project)...) + if code != exitOK || strings.Contains(stdout, "override:") || !strings.Contains(stderr, "warning: overrides not shown") { + t.Fatalf("unreadable settings: exit %d\n%s%s", code, stdout, stderr) + } +} + +func TestOptimize_NotesProjectOverride(t *testing.T) { + project, repoRoot, settings := overrideFixture(t) + mustWriteT(t, settings, oneOverride) + (&fakeOpenRouter{models: orFixture(t, "models.json"), decisions: []string{orFixture(t, "decision-clef.json")}}).start(t) + + code, stdout, stderr := runCLI(t, "optimize", "--agent", "a", "--root", project, "--repo", repoRoot) + if code != exitOK { + t.Fatalf("exit %d\n%s", code, stderr) + } + if !strings.Contains(stdout, "Note: Web/proj/a has a project override (sonnet / -) in ~/Web/proj/.claude/settings.local.json") { + t.Errorf("no override note:\n%s", stdout) + } + if strings.Contains(stdout, "/b has a project override") { + t.Errorf("note for an agent not asked about:\n%s", stdout) + } +} + +// pathWith makes PATH a directory holding only git and, unless script is "", +// an agentsync that runs it. +func pathWith(t *testing.T, script string) { + t.Helper() + dir := t.TempDir() + if git, err := exec.LookPath("git"); err == nil { + if err := os.Symlink(git, filepath.Join(dir, "git")); err != nil { + t.Fatal(err) + } + } + if script != "" { + if err := os.WriteFile(filepath.Join(dir, "agentsync"), []byte("#!/bin/sh\n"+script+"\n"), 0o755); err != nil { + t.Fatal(err) + } + } + t.Setenv("PATH", dir) +} + +// servesHook succeeds only when run with oneOverride's stored arguments and a +// tool_input payload on stdin, so the probe's contract is what is tested. +const servesHook = `[ "$*" = "hook claude-agent --set a=sonnet/ --set b=/max" ] || exit 1 +read -r line +case "$line" in *'"tool_input"'*) exit 0 ;; esac +exit 1` + +func TestDoctor_OverrideNeedsAgentsyncOnPath(t *testing.T) { + project, repoRoot, settings := overrideFixture(t) + mustWriteT(t, settings, oneOverride) + + pathWith(t, servesHook) + if code, stdout, stderr := runCLI(t, doctorArgs(repoRoot, project)...); code != exitOK || strings.Contains(stdout, "override-cannot-run") { + t.Fatalf("hook-capable agentsync on PATH: exit %d\n%s%s", code, stdout, stderr) + } + + for _, tc := range []struct{ name, script, detail string }{ + {"not on PATH", "", "which is not on PATH"}, + {"too old for the hook", `echo 'unknown command "hook"' >&2; exit 2`, "fails as the override hook (exit status 2)"}, + {"rejects the stored sets", `[ "$*" = "hook claude-agent" ] || exit 1`, "fails as the override hook (exit status 1)"}, + } { + pathWith(t, tc.script) + code, stdout, stderr := runCLI(t, doctorArgs(repoRoot, project)...) + if code != exitAtRisk { + t.Fatalf("%s: exit %d, want 1\n%s%s", tc.name, code, stdout, stderr) + } + for _, w := range []string{"!! override-cannot-run (1)", "Web/proj", tc.detail, "1 project overrides agents through a hook that cannot run here"} { + if !strings.Contains(stdout, w) { + t.Errorf("%s: missing %q:\n%s", tc.name, w, stdout) + } + } + } + + pathWith(t, servesHook) + mustWriteT(t, settings, "{") + code, stdout, _ := runCLI(t, doctorArgs(repoRoot, project)...) + if code != exitFailure || !strings.Contains(stdout, "settings.local.json") { + t.Fatalf("unreadable settings: exit %d, want 2\n%s", code, stdout) + } + + mustRemoveAll(t, settings) + if code, stdout, _ := runCLI(t, doctorArgs(repoRoot, project)...); code != exitOK { + t.Fatalf("no overrides: exit %d, want 0\n%s", code, stdout) + } +} + +func TestOverride_RootThatCannotBeScanned(t *testing.T) { + _, repoRoot, _ := overrideFixture(t) + missing := filepath.Join(t.TempDir(), "gone") + if code, _, stderr := overrideCLI(t, missing, repoRoot, "--agent", "a", "--model", "opus"); code != exitFailure || stderr == "" { + t.Fatalf("exit %d, stderr %q; want 2", code, stderr) + } + assertAbsent(t, missing) +} + +func TestOverride_MatchesFrontmatterName(t *testing.T) { + project, repoRoot := optimizeProject(t, map[string]string{ + "reviewer.md": agentFile(`"code-reviewer" # the reviewer`, "haiku", "low"), + "README.md": "not an agent\n", + }) + settings := filepath.Join(project, ".claude", "settings.local.json") + for _, name := range []string{"reviewer", "README"} { + if code, _, stderr := overrideCLI(t, project, repoRoot, "--agent", name, "--model", "opus", "--apply"); code != exitFailure || !strings.Contains(stderr, "frontmatter name") { + t.Fatalf("--agent %s: exit %d, stderr %q; want 2", name, code, stderr) + } + } + assertAbsent(t, settings) + if code, stdout, stderr := overrideCLI(t, project, repoRoot, "--agent", "code-reviewer", "--model", "opus", "--apply"); code != exitOK { + t.Fatalf("exit %d\n%s%s", code, stdout, stderr) + } + if code, stdout, _ := runCLI(t, scanArgs(repoRoot, project)...); code != exitOK || !strings.Contains(stdout, "reviewer UNPROTECTED override: opus / -") { + t.Fatalf("scan: exit %d\n%s", code, stdout) + } +} + +func TestOverride_RefusesNestedProjectAgent(t *testing.T) { + project, repoRoot, settings := overrideFixture(t) + mustWriteT(t, filepath.Join(project, "sub", ".claude", "agents", "n.md"), agentFile("n", "haiku", "low")) + code, _, stderr := overrideCLI(t, project, repoRoot, "--agent", "n", "--model", "opus", "--apply") + if code != exitFailure || !strings.Contains(stderr, "--agent n") { + t.Fatalf("exit %d, stderr %q; want 2", code, stderr) + } + assertAbsent(t, settings) + assertAbsent(t, filepath.Join(project, "sub", ".claude", "settings.local.json")) +} + +func TestOverride_RefusesSymlinkedSettings(t *testing.T) { + project, repoRoot, settings := overrideFixture(t) + elsewhere := filepath.Join(t.TempDir(), "shared.json") + mustWriteT(t, elsewhere, "{}") + if err := os.Symlink(elsewhere, settings); err != nil { + t.Fatal(err) + } + code, _, stderr := overrideCLI(t, project, repoRoot, "--agent", "a", "--model", "opus", "--apply") + if code != exitFailure || !strings.Contains(stderr, "symlink") || readFileT(t, elsewhere) != "{}" { + t.Fatalf("exit %d, stderr %q, target %q", code, stderr, readFileT(t, elsewhere)) + } +} + +func TestScan_OverrideStaysInItsProject(t *testing.T) { + project, repoRoot, settings := overrideFixture(t) + mustWriteT(t, settings, oneOverride) + mustWriteT(t, filepath.Join(project, "sub", ".claude", "agents", "a.md"), agentFile("a", "haiku", "low")) + code, stdout, _ := runCLI(t, scanArgs(repoRoot, project)...) + if code != exitOK || strings.Count(stdout, "override: sonnet / -") != 1 { + t.Fatalf("exit %d; want the override on the root project's a only\n%s", code, stdout) + } +} + +func TestSymlinkedSettings_ReadOnlyCommandsFollowIt(t *testing.T) { + project, repoRoot, settings := overrideFixture(t) + elsewhere := filepath.Join(t.TempDir(), "shared.json") + mustWriteT(t, elsewhere, `{"permissions":{"allow":[]}}`) + if err := os.Symlink(elsewhere, settings); err != nil { + t.Fatal(err) + } + pathWith(t, "") + if code, stdout, stderr := runCLI(t, doctorArgs(repoRoot, project)...); code != exitOK || strings.Contains(stderr, "settings") { + t.Fatalf("doctor: exit %d\n%s%s", code, stdout, stderr) + } + if code, stdout, stderr := runCLI(t, scanArgs(repoRoot, project)...); code != exitOK || strings.Contains(stderr, "warning: overrides") { + t.Fatalf("scan: exit %d\n%s%s", code, stdout, stderr) + } + + mustWriteT(t, elsewhere, oneOverride) + code, stdout, stderr := overrideCLI(t, project, repoRoot) + if code != exitOK || !strings.Contains(stdout, "sonnet / -") { + t.Fatalf("list: exit %d\n%s%s", code, stdout, stderr) + } +} diff --git a/coverage-floors.txt b/coverage-floors.txt index f522875..5bc7f11 100644 --- a/coverage-floors.txt +++ b/coverage-floors.txt @@ -9,6 +9,7 @@ internal/inventory 91.2 internal/linker 94.9 internal/openrouter 100.0 internal/optimize 100.0 +internal/override 98.3 internal/reconcile 84.3 internal/releasepolicy 96.4 internal/scan 91.6 diff --git a/docs/doctor.md b/docs/doctor.md index 1cfde22..0c35f84 100644 --- a/docs/doctor.md +++ b/docs/doctor.md @@ -9,7 +9,7 @@ agentsync doctor agentsync doctor --json ``` -It reports five things: +It reports six things: - **Broken links.** A symlink into the canonical repo whose target is gone, usually an agent renamed or deleted there. Reported with the missing target. @@ -26,6 +26,12 @@ It reports five things: are path-derived, so a move re-keys it). - **Orphaned capture directories.** A whole `project-agents//` whose project directory is gone, reported once for the directory. +- **Overrides that cannot run.** A project whose `.claude/settings.local.json` + has an [override](override.md) hook, while `agentsync` is not on `PATH` or + fails when run as that hook (an older agentsync, or one that rejects the + stored overrides). The hook fails and the agents run on their frontmatter + values. doctor finds out by running the `agentsync` on `PATH` with each + distinct stored hook command's arguments and a minimal call on stdin. `doctor` never recommends deleting a capture and never performs one: a stale capture may be the only surviving copy. Repair is [reconcile](reconcile.md), @@ -46,4 +52,6 @@ nothing walked. Read both; `status: "ok"` with `suspect: true` is the normal shape of the problem this check exists to catch. Exit 1 on a broken or mismatched link, a stale capture, an orphaned capture -directory, or an unreadable path. See [exit codes](exit-codes.md). +directory, an override that cannot run, or a project path the scan could not +read. Exit 2 when a capture or a project's `settings.local.json` could not be +judged. See [exit codes](exit-codes.md). diff --git a/docs/exit-codes.md b/docs/exit-codes.md index 1fd43d3..a792d92 100644 --- a/docs/exit-codes.md +++ b/docs/exit-codes.md @@ -14,10 +14,18 @@ What counts as exit 1, by command: | `scan` | never by itself; with `--fail-on-risk`, any at-risk file | | `import` | a conflicting capture, an unreadable path, or (with `--fail-on-pending`) anything uncaptured | | `link` | a blocked project, a broken or mismatched link, or an unreadable path | -| `doctor` | a broken or mismatched link, a stale capture, an orphaned capture directory, or an unreadable path | +| `doctor` | a broken or mismatched link, a stale capture, an orphaned capture directory, an override that cannot run, or a project path the scan could not read (a capture or settings file it could not judge is exit 2) | | `reconcile` | a refused selector, a conflict, a diverged or dereferenced link, or a removable link left in place | | `inventory --check` | the file is stale or absent | | `optimize` | an agent's advice is unresolved, or it was skipped by the cost cap or a stopped run | +| `override` | never | + +One exception: the hidden `agentsync hook claude-agent`, which Claude Code runs +before each subagent call, never exits 2. Claude Code reads exit 2 from that +hook as "block this call", so the hook exits 1 on any failure, and the stored +command ends in `|| exit 1` so an agentsync too old to have the hook cannot +exit 2 either. The subagent then runs on its own settings. See +[override](override.md). `doctor`'s exit code is the one that asserts nothing has drifted; `reconcile`'s is deliberately narrower (see [reconcile](reconcile.md)). Tests assert these diff --git a/docs/link.md b/docs/link.md index 4c2ad80..c2e66ac 100644 --- a/docs/link.md +++ b/docs/link.md @@ -2,7 +2,9 @@ `agentsync link` replaces a project's real agent files with per-file symlinks into the canonical repo, so one copy serves every project. It is the only -command that writes into a project, and it is a dry run without `--apply`. +command that writes into a project's `.claude/agents/` ([override](override.md) +writes only `.claude/settings.local.json`), and it is a dry run without +`--apply`. ```bash agentsync link # dry run diff --git a/docs/optimize.md b/docs/optimize.md index b244825..e59afd3 100644 --- a/docs/optimize.md +++ b/docs/optimize.md @@ -27,6 +27,8 @@ several projects is asked about once. A `.md` file without `name` and For each agent it shows `model:` and `effort:` from the frontmatter as written (without quotes or a trailing `# comment`), or `(not set)`. If `$CLAUDE_CODE_SUBAGENT_MODEL` or `$CLAUDE_CODE_EFFORT_LEVEL` is set, it notes that Claude Code uses it over the frontmatter at run time. +It also notes each agent asked about that has a project [override](override.md); +these notes are not in the JSON. Each agent costs one `POST /api/alpha/decisions` request, with no retries. The request holds one framing sentence, the first 24 KB of the agent file and two diff --git a/docs/override.md b/docs/override.md new file mode 100644 index 0000000..eaee735 --- /dev/null +++ b/docs/override.md @@ -0,0 +1,87 @@ +# override + +`agentsync override` sets the model and effort one Claude agent runs on in one +checkout, without forking the agent. The agent stays a link to the canonical +repo and keeps receiving its updates. Claude Code only; Codex has no way to do +this without a fork. + +```bash +agentsync override # list this project's overrides +agentsync override --agent go-merge-reviewer --model sonnet --effort medium +agentsync override --agent go-merge-reviewer --effort high --apply +agentsync override --clear go-merge-reviewer --apply +``` + +Root and repo resolve as for every other command (see +[configuration](configuration.md)). Without `--apply` it prints the change and +writes nothing. + +## Flags + +| Flag | Meaning | +|---|---| +| `--agent NAME` | the agent to override, by the `name:` in its frontmatter; it must be in the root's `.claude/agents/` | +| `--model M` | `haiku`, `sonnet`, `opus` or `fable` | +| `--effort E` | `low`, `medium`, `high`, `xhigh` or `max` | +| `--clear NAME` | remove that agent's override | +| `--apply` | write the change | + +`--agent` needs `--model`, `--effort` or both. Each set replaces the agent's +whole override, so `--agent x --effort high` leaves the model to the agent's +frontmatter. `inherit` is refused: clearing the override does that. Full model +IDs are refused because the Agent tool accepts only the aliases. `--all` is +refused: an override belongs to one project. + +## How it works + +The overrides live in one `PreToolUse` hook in +`/.claude/settings.local.json`: + +```json +{"matcher": "Agent", "hooks": [{"type": "command", + "command": "agentsync hook claude-agent --set go-merge-reviewer=sonnet/medium || exit 1"}]} +``` + +Before each subagent call, Claude Code runs that command. If the call's +`subagent_type` has an override, the hook hands back the call's input with +`model` and `effort` replaced, which Claude Code uses over the agent's +frontmatter and over a model the parent passed. Other agents are untouched. + +agentsync owns only the hook whose command starts with +`agentsync hook claude-agent`, and changes only its `command`, so fields such +as a `timeout` you add stay. Every other setting and hook in the file is kept, +though the file is rewritten with its keys sorted and two-space indents. A file +that is not valid JSON is refused with exit 2 and left alone. A symlinked file +is read through the link, but `--apply` refuses to write through it (exit 2). +Clearing the last override removes the hook, and the file too if nothing else +is in it. + +The hook runs `agentsync` from `PATH`. If it is missing, or too old to have the +hook, the agent runs on its frontmatter values; [doctor](doctor.md) reports +this. The hook exits 1 on any failure and never 2, because exit 2 would block +the subagent call; the `|| exit 1` keeps an old agentsync from exiting 2. + +A running Claude Code session may need a restart to pick up a change. + +## Limits + +- Per user, per checkout: teammates and other worktrees do not get the + override. Claude Code adds `settings.local.json` to your global git ignore + only once it has written one itself, so if `git status` shows the file, add + it to `.gitignore`. +- `$CLAUDE_CODE_EFFORT_LEVEL` still beats the effort override, and + `$CLAUDE_CODE_SUBAGENT_MODEL_FORCE` beats both. +- Forked subagents ignore a model override. +- Resuming a subagent with SendMessage does not run the hook again. + +[scan](scan.md) shows each override beside its agent, and +[optimize](optimize.md) notes when one masks the agent's own values. + +## Exit codes + +- `0`: listed, changed, or nothing to change. +- `2`: bad flags, an unknown agent, a model or effort Claude Code does not + accept, no repo or root configured, a settings file that cannot be read or + edited, or a failed write. + +See [exit codes](exit-codes.md). diff --git a/docs/scan.md b/docs/scan.md index ac70d4c..067a8c5 100644 --- a/docs/scan.md +++ b/docs/scan.md @@ -25,4 +25,9 @@ report. Use `--fail-on-risk` when you want an assertion. A path the scan could not read or classify is reported and qualifies the summary, but does not change the exit code. +An agent with an [override](override.md) in its project's +`.claude/settings.local.json` shows it on its line, as `override: sonnet / +medium` (`-` for a part the override leaves alone). A settings file that cannot +be read is a warning on stderr. The JSON output does not include overrides. + Root selection and `--all` are covered in [configuration](configuration.md). diff --git a/internal/doctor/doctor.go b/internal/doctor/doctor.go index d5c01ee..09a2056 100644 --- a/internal/doctor/doctor.go +++ b/internal/doctor/doctor.go @@ -76,6 +76,10 @@ const ( // KindForeignLink is state 4: a symlink pointing outside this repo. // Informational only — see NeedsHuman. KindForeignLink + + // KindOverrideCannotRun is a project override hook whose agentsync is + // missing from PATH or cannot serve the hook, so the overrides do nothing. + KindOverrideCannotRun ) func (k Kind) String() string { @@ -90,6 +94,8 @@ func (k Kind) String() string { return "orphaned-captures" case KindForeignLink: return "foreign-link" + case KindOverrideCannotRun: + return "override-cannot-run" default: return "invalid" } diff --git a/internal/doctor/doctor_test.go b/internal/doctor/doctor_test.go index 4b2f704..57d8f75 100644 --- a/internal/doctor/doctor_test.go +++ b/internal/doctor/doctor_test.go @@ -759,6 +759,9 @@ func TestReport_CountsAndPrintableForms(t *testing.T) { if got, want := rep.Findings[2].String(), "orphaned-captures b: no project"; got != want { t.Errorf("String() = %q, want %q", got, want) } + if got := KindOverrideCannotRun.String(); got != "override-cannot-run" || !KindOverrideCannotRun.NeedsHuman() { + t.Errorf("KindOverrideCannotRun = %q, needs human %v; want override-cannot-run, true", got, KindOverrideCannotRun.NeedsHuman()) + } if got := KindInvalid.String(); got != "invalid" { t.Errorf("KindInvalid.String() = %q, want invalid", got) } diff --git a/internal/optimize/optimize.go b/internal/optimize/optimize.go index 0302d26..eb7ca08 100644 --- a/internal/optimize/optimize.go +++ b/internal/optimize/optimize.go @@ -31,7 +31,8 @@ type Family struct { Description string } -var families = []Family{ +// Families are the aliases optimize offers and override accepts. +var Families = []Family{ {"haiku", "Claude Haiku: fastest and cheapest, for simple, mechanical or high-volume work"}, {"sonnet", "Claude Sonnet: balanced quality and cost, strong at most coding and review work"}, {"opus", "Claude Opus: highest-quality reasoning for hard, high-stakes judgement"}, @@ -114,16 +115,21 @@ type Config struct { Families []Family } -// Current returns an agent's model and effort as written, without a trailing -// YAML comment or quotes, or NotSet. -func Current(content string) (model, effort string) { +// Field returns frontmatter key k as written, without a trailing YAML comment +// or quotes, or "". +func Field(content, k string) string { fm, _ := discover.Frontmatter(content) + v := " " + fm[k] + for _, sep := range []string{" #", "\t#"} { + v, _, _ = strings.Cut(v, sep) + } + return strings.Trim(strings.TrimSpace(v), `"'`) +} + +// Current returns an agent's model and effort as Field reads them, or NotSet. +func Current(content string) (model, effort string) { get := func(k string) string { - v := " " + fm[k] - for _, sep := range []string{" #", "\t#"} { - v, _, _ = strings.Cut(v, sep) - } - if v = strings.Trim(strings.TrimSpace(v), `"'`); v != "" { + if v := Field(content, k); v != "" { return v } return NotSet @@ -134,7 +140,7 @@ func Current(content string) (model, effort string) { // Available keeps the aliases OpenRouter lists an anthropic/claude-- model for. func Available(models []openrouter.Model) []Family { var out []Family - for _, f := range families { + for _, f := range Families { for _, m := range models { v, ok := strings.CutPrefix(m.ID, "anthropic/claude-"+f.Alias+"-") if ok && v != "" && v[0] >= '0' && v[0] <= '9' { diff --git a/internal/optimize/optimize_test.go b/internal/optimize/optimize_test.go index 6f46561..01ed8e9 100644 --- a/internal/optimize/optimize_test.go +++ b/internal/optimize/optimize_test.go @@ -91,7 +91,7 @@ func agent(label, model, effort string) Agent { } func config() Config { - return Config{Advisor: "~typesafe/jev-latest", Price: 4.2e-8, MaxCost: 0.5, MinConfidence: 0.6, Families: families} + return Config{Advisor: "~typesafe/jev-latest", Price: 4.2e-8, MaxCost: 0.5, MinConfidence: 0.6, Families: Families} } func TestCurrent(t *testing.T) { @@ -111,7 +111,7 @@ func TestCurrent(t *testing.T) { } func TestAvailable(t *testing.T) { - if got := Available(models(t)); !reflect.DeepEqual(got, families) { + if got := Available(models(t)); !reflect.DeepEqual(got, Families) { t.Errorf("real list: %v, want all four", got) } got := Available([]openrouter.Model{{ID: "anthropic/claude-opus-5.5"}, {ID: "anthropic/claude-sonnet-x"}, {ID: "anthropic/claude-haiku-"}, {ID: "openai/fable-5"}}) @@ -143,7 +143,7 @@ func TestInputPrice(t *testing.T) { func TestBody_MatchesTheSpikeRequestAndCapsState(t *testing.T) { var req openrouter.DecisionRequest content := strings.Repeat("a", stateCap-1) + "é" + "tail" - if err := json.Unmarshal(Body("cloudflare/clef", content, families), &req); err != nil { + if err := json.Unmarshal(Body("cloudflare/clef", content, Families), &req); err != nil { t.Fatal(err) } if req.Model != "cloudflare/clef" || len(req.State) != 2 || req.State[0] != framing { diff --git a/internal/override/override.go b/internal/override/override.go new file mode 100644 index 0000000..30a43c7 --- /dev/null +++ b/internal/override/override.go @@ -0,0 +1,354 @@ +// Package override keeps per-checkout model and effort overrides for Claude +// agents in one PreToolUse hook entry of .claude/settings.local.json. The +// overrides live in the hook's own command line, so there is no second file to +// drift from it. +package override + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "slices" + "sort" + "strings" + + "github.com/agentic-tooling/agentsync/internal/optimize" +) + +// Prefix starts the one hook command agentsync owns in a settings file. +const Prefix = "agentsync hook claude-agent" + +// Guard ends the stored command. Claude Code blocks the subagent call when the +// hook exits 2, as an agentsync too old to know "hook" does, so the shell +// turns every failure into exit 1. +const Guard = "|| exit 1" + +// Value is one agent's override. An empty part leaves the agent's own value. +type Value struct { + Model, Effort string +} + +// String is "model / effort", with "-" for a part left alone. +func (v Value) String() string { + dash := func(s string) string { + if s == "" { + return "-" + } + return s + } + return dash(v.Model) + " / " + dash(v.Effort) +} + +// safe limits names to characters a shell passes through unquoted, since +// Claude Code runs the hook command through one. +func safe(s string) bool { + for _, r := range s { + if !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '-' || r == '_' || r == '.') { + return false + } + } + return s != "" +} + +// Validate refuses an agent name or value the hook could not carry or Claude +// Code would not accept. +func Validate(name string, v Value) error { + if !safe(name) { + return fmt.Errorf("agent name %q: only letters, digits, '-', '_' and '.' can go in the hook command", name) + } + if v.Model == "" && v.Effort == "" { + return fmt.Errorf("%s: an override needs a model, an effort or both", name) + } + if err := validModel(v.Model); err != nil { + return fmt.Errorf("%s: %w", name, err) + } + if v.Effort != "" && !slices.Contains(optimize.Efforts, v.Effort) { + return fmt.Errorf("%s: effort %q is not one of %s", name, v.Effort, strings.Join(optimize.Efforts, ", ")) + } + return nil +} + +func validModel(m string) error { + if m == "" { + return nil + } + if m == "inherit" { + return errors.New("model inherit means no override; use --clear instead") + } + // Aliases only: the Agent tool's model parameter is an enum of them. + var aliases []string + for _, f := range optimize.Families { + if f.Alias == m { + return nil + } + aliases = append(aliases, f.Alias) + } + return fmt.Errorf("model %q is not one of %s", m, strings.Join(aliases, ", ")) +} + +// ParseArgs reads the hook's arguments, "--set NAME=MODEL/EFFORT" pairs with +// an optional trailing Guard. It is the one parser for both the running hook +// and the command stored in settings. +func ParseArgs(args []string) (map[string]Value, error) { + sets := map[string]Value{} + if g := strings.Fields(Guard); len(args) >= len(g) && slices.Equal(args[len(args)-len(g):], g) { + args = args[:len(args)-len(g)] + } + for i := 0; i < len(args); i += 2 { + if args[i] != "--set" || i+1 == len(args) { + return nil, fmt.Errorf("want --set NAME=MODEL/EFFORT, got %q", strings.Join(args[i:], " ")) + } + name, rest, ok := strings.Cut(args[i+1], "=") + model, effort, ok2 := strings.Cut(rest, "/") + if !ok || !ok2 { + return nil, fmt.Errorf("--set %s: want NAME=MODEL/EFFORT", args[i+1]) + } + if _, dup := sets[name]; dup { + return nil, fmt.Errorf("--set %s: %s is set twice", args[i+1], name) + } + v := Value{Model: model, Effort: effort} + if err := Validate(name, v); err != nil { + return nil, err + } + sets[name] = v + } + return sets, nil +} + +// Command is the hook command carrying sets, sorted by agent name, or "" for +// none. +func Command(sets map[string]Value) string { + if len(sets) == 0 { + return "" + } + names := make([]string, 0, len(sets)) + for n := range sets { + names = append(names, n) + } + sort.Strings(names) + var b strings.Builder + b.WriteString(Prefix) + for _, n := range names { + fmt.Fprintf(&b, " --set %s=%s/%s", n, sets[n].Model, sets[n].Effort) + } + return b.String() + " " + Guard +} + +// Rewrite returns the PreToolUse hook output that applies sets to an Agent +// call, or nil when the call's agent has no override. Every other tool_input +// field is passed back byte for byte. +func Rewrite(payload []byte, sets map[string]Value) ([]byte, error) { + var in struct { + ToolInput map[string]json.RawMessage `json:"tool_input"` + } + if err := json.Unmarshal(payload, &in); err != nil { + return nil, fmt.Errorf("hook input: %w", err) + } + if in.ToolInput == nil { + return nil, errors.New("hook input has no tool_input object") + } + var agent string + if raw, ok := in.ToolInput["subagent_type"]; ok { + if err := json.Unmarshal(raw, &agent); err != nil { + return nil, fmt.Errorf("hook input subagent_type: %w", err) + } + } + v, ok := sets[agent] + if !ok { + return nil, nil + } + // Strings always marshal. + if v.Model != "" { + in.ToolInput["model"], _ = json.Marshal(v.Model) + } + if v.Effort != "" { + in.ToolInput["effort"], _ = json.Marshal(v.Effort) + } + out := map[string]any{"hookSpecificOutput": map[string]any{ + "hookEventName": "PreToolUse", + "updatedInput": in.ToolInput, + }} + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) + err := enc.Encode(out) + return buf.Bytes(), err +} + +// File is a settings file and the agentsync entry found in it. +type File struct { + Path string + // Command is the agentsync hook command as written, "" when there is none. + Command string + // Sets are the overrides Command carries; never nil. + Sets map[string]Value + + doc map[string]any + hook map[string]any + exists bool + mode fs.FileMode + index int +} + +// Load reads a settings file, following a symlink as Claude Code does; a +// missing one is empty. Anything it cannot read as a settings file is an +// error rather than "no overrides". +func Load(path string) (*File, error) { + f := &File{Path: path, Sets: map[string]Value{}, doc: map[string]any{}, mode: 0o600, index: -1} + data, err := os.ReadFile(path) + if errors.Is(err, fs.ErrNotExist) { + return f, nil + } + if err != nil { + return nil, err + } + fi, err := os.Stat(path) + if err != nil { + return nil, err + } + f.exists, f.mode = true, fi.Mode().Perm() + + dec := json.NewDecoder(bytes.NewReader(data)) + dec.UseNumber() + if err := dec.Decode(&f.doc); err != nil { + return nil, fmt.Errorf("%s is not a JSON object: %w", path, err) + } + if _, err := dec.Token(); err != io.EOF { + return nil, fmt.Errorf("%s has data after its JSON object", path) + } + if f.doc == nil { + return nil, fmt.Errorf("%s is not a JSON object", path) + } + pre, err := preToolUse(f.doc) + if err != nil { + return nil, fmt.Errorf("%s: %w", path, err) + } + for i, e := range pre { + entry, _ := e.(map[string]any) + hooks, _ := entry["hooks"].([]any) + for _, h := range hooks { + hook, _ := h.(map[string]any) + cmd, _ := hook["command"].(string) + fields := strings.Fields(cmd) + if len(fields) < 3 || strings.Join(fields[:3], " ") != Prefix { + continue + } + switch { + case f.index >= 0: + return nil, fmt.Errorf("%s has more than one %q hook; remove all but one", path, Prefix) + case len(hooks) != 1: + return nil, fmt.Errorf("%s: the %q hook shares its PreToolUse entry with other hooks; give it an entry of its own", path, Prefix) + } + if f.Sets, err = ParseArgs(fields[3:]); err != nil { + return nil, fmt.Errorf("%s: %q hook: %w", path, Prefix, err) + } + f.index, f.Command, f.hook = i, cmd, hook + } + } + return f, nil +} + +// preToolUse returns doc's hooks.PreToolUse list, refusing a shape agentsync +// could not edit without losing something. +func preToolUse(doc map[string]any) ([]any, error) { + h, ok := doc["hooks"] + if !ok { + return nil, nil + } + hooks, ok := h.(map[string]any) + if !ok { + return nil, errors.New("hooks is not an object") + } + p, ok := hooks["PreToolUse"] + if !ok { + return nil, nil + } + pre, ok := p.([]any) + if !ok { + return nil, errors.New("hooks.PreToolUse is not a list") + } + return pre, nil +} + +// Save writes the file with sets as its agentsync entry, or without the entry +// when sets is empty. An existing entry keeps every field but its command. +// Containers that end up empty go too, and a file left holding nothing is +// removed. Everything else is kept, though not its key order or formatting. +func (f *File) Save(sets map[string]Value) error { + // Writing through a symlink would edit a file outside the project. + if fi, err := os.Lstat(f.Path); err == nil && fi.Mode()&fs.ModeSymlink != 0 { + return fmt.Errorf("%s is a symlink; agentsync writes only a regular file", f.Path) + } + pre, _ := preToolUse(f.doc) // Load already refused a bad shape. + switch { + case len(sets) == 0 && f.index >= 0: + pre = slices.Delete(pre, f.index, f.index+1) + case f.index >= 0: + f.hook["command"] = Command(sets) + case len(sets) > 0: + pre = append(pre, map[string]any{"matcher": "Agent", "hooks": []any{ + map[string]any{"type": "command", "command": Command(sets)}, + }}) + } + hooks, _ := f.doc["hooks"].(map[string]any) + if len(pre) > 0 { + if hooks == nil { + hooks = map[string]any{} + f.doc["hooks"] = hooks + } + hooks["PreToolUse"] = pre + } else if hooks != nil { + delete(hooks, "PreToolUse") + if len(hooks) == 0 { + delete(f.doc, "hooks") + } + } + + if len(f.doc) == 0 { + if !f.exists { + return nil + } + return os.Remove(f.Path) + } + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) + enc.SetIndent("", " ") + if err := enc.Encode(f.doc); err != nil { + return err + } + return writeAtomic(f.Path, buf.Bytes(), f.mode) +} + +// writeAtomic replaces path through a temp file in the same directory, so a +// reader sees the old file or the new one and never half of either. +func writeAtomic(path string, data []byte, mode fs.FileMode) error { + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + tmp, err := os.CreateTemp(dir, ".agentsync-settings-*.tmp") + if err != nil { + return err + } + defer func() { _ = os.Remove(tmp.Name()) }() + _, err = tmp.Write(data) + if err == nil { + err = tmp.Chmod(mode) + } + if err == nil { + err = tmp.Sync() + } + if cerr := tmp.Close(); err == nil { + err = cerr + } + if err == nil { + err = os.Rename(tmp.Name(), path) + } + return err +} diff --git a/internal/override/override_test.go b/internal/override/override_test.go new file mode 100644 index 0000000..ae8289f --- /dev/null +++ b/internal/override/override_test.go @@ -0,0 +1,426 @@ +package override + +import ( + "encoding/json" + "errors" + "io/fs" + "os" + "path/filepath" + "reflect" + "strings" + "testing" +) + +func TestValidate(t *testing.T) { + tests := []struct { + name, agent string + v Value + wantErr string + }{ + {"alias and effort", "rev", Value{"sonnet", "medium"}, ""}, + {"model alone", "rev", Value{"fable", ""}, ""}, + {"effort alone", "rev", Value{"", "max"}, ""}, + {"nothing set", "rev", Value{}, "needs a model, an effort or both"}, + {"inherit", "rev", Value{"inherit", ""}, "use --clear"}, + {"unknown alias", "rev", Value{"gpt", ""}, `model "gpt" is not one of haiku, sonnet, opus, fable`}, + {"full model id", "rev", Value{"claude-sonnet-5-5", ""}, `model "claude-sonnet-5-5" is not one of`}, + {"unknown effort", "rev", Value{"", "huge"}, `effort "huge" is not one of low, medium, high, xhigh, max`}, + {"empty name", "", Value{"sonnet", ""}, "agent name"}, + {"name a shell would split", "a;rm", Value{"sonnet", ""}, "agent name"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := Validate(tc.agent, tc.v) + if tc.wantErr == "" { + if err != nil { + t.Fatalf("Validate = %v, want nil", err) + } + return + } + if err == nil || !strings.Contains(err.Error(), tc.wantErr) { + t.Fatalf("Validate = %v, want it to mention %q", err, tc.wantErr) + } + }) + } +} + +func TestParseArgs(t *testing.T) { + got, err := ParseArgs([]string{"--set", "b=/high", "--set", "a=sonnet/medium"}) + want := map[string]Value{"a": {"sonnet", "medium"}, "b": {"", "high"}} + if err != nil || !reflect.DeepEqual(got, want) { + t.Fatalf("ParseArgs = %v, %v; want %v", got, err, want) + } + got, err = ParseArgs([]string{"--set", "a=sonnet/medium", "||", "exit", "1"}) + if err != nil || !reflect.DeepEqual(got, map[string]Value{"a": {"sonnet", "medium"}}) { + t.Fatalf("ParseArgs with the guard = %v, %v", got, err) + } + if got, err := ParseArgs(nil); err != nil || len(got) != 0 { + t.Fatalf("ParseArgs(nil) = %v, %v; want empty", got, err) + } + + for _, args := range [][]string{ + {"--set"}, + {"--sett", "a=sonnet/"}, + {"--set", "a=sonnet"}, + {"--set", "sonnet/high"}, + {"--set", "a=sonnet/", "--set", "a=/high"}, + {"--set", "a=gpt/"}, + {"--set", "a=sonnet/", "||", "exit", "2"}, + {"--set", "a=sonnet/", "||", "true"}, + } { + if got, err := ParseArgs(args); err == nil { + t.Errorf("ParseArgs(%q) = %v, want an error", args, got) + } + } +} + +func TestCommand_SortedAndRoundTrips(t *testing.T) { + sets := map[string]Value{"zeta": {"", "high"}, "alpha": {"opus", ""}} + cmd := Command(sets) + if want := "agentsync hook claude-agent --set alpha=opus/ --set zeta=/high || exit 1"; cmd != want { + t.Fatalf("Command = %q, want %q", cmd, want) + } + back, err := ParseArgs(strings.Fields(cmd)[3:]) + if err != nil || !reflect.DeepEqual(back, sets) { + t.Fatalf("round trip = %v, %v; want %v", back, err, sets) + } + if Command(nil) != "" { + t.Error("Command(nil) should be empty") + } + if got := (Value{"sonnet", ""}).String(); got != "sonnet / -" { + t.Errorf("String = %q", got) + } +} + +func TestRewrite(t *testing.T) { + sets := map[string]Value{"probe": {"sonnet", "high"}, "half": {"", "low"}} + payload := `{"hook_event_name":"PreToolUse","tool_name":"Agent","tool_input":{` + + `"description":"d","prompt":"a & c","subagent_type":"probe","model":"haiku",` + + `"run_in_background":false,"n":1.50,"nested":{"k":[1,2]}}}` + + out, err := Rewrite([]byte(payload), sets) + if err != nil { + t.Fatal(err) + } + var got struct { + HookSpecificOutput map[string]json.RawMessage `json:"hookSpecificOutput"` + } + if err := json.Unmarshal(out, &got); err != nil { + t.Fatalf("%v in %s", err, out) + } + if _, ok := got.HookSpecificOutput["permissionDecision"]; ok { + t.Error("output must not carry a permissionDecision") + } + var input map[string]json.RawMessage + if err := json.Unmarshal(got.HookSpecificOutput["updatedInput"], &input); err != nil { + t.Fatal(err) + } + want := map[string]string{ + "description": `"d"`, "prompt": `"a & c"`, "subagent_type": `"probe"`, + "model": `"sonnet"`, "effort": `"high"`, "run_in_background": "false", + "n": "1.50", "nested": `{"k":[1,2]}`, + } + if len(input) != len(want) { + t.Errorf("updatedInput has %d fields, want %d: %s", len(input), len(want), out) + } + for k, v := range want { + if string(input[k]) != v { + t.Errorf("updatedInput[%s] = %s, want %s", k, input[k], v) + } + } + if !strings.Contains(string(out), `"hookEventName":"PreToolUse"`) { + t.Errorf("no hookEventName in %s", out) + } + + // One part set: the other is left as the parent passed it. + out, err = Rewrite([]byte(`{"tool_input":{"subagent_type":"half","model":"opus"}}`), sets) + if err != nil || !strings.Contains(string(out), `"model":"opus"`) || !strings.Contains(string(out), `"effort":"low"`) { + t.Errorf("half override = %s, %v", out, err) + } + + for _, in := range []string{ + `{"tool_input":{"subagent_type":"other"}}`, + `{"tool_input":{"prompt":"no agent named"}}`, + } { + if out, err := Rewrite([]byte(in), sets); out != nil || err != nil { + t.Errorf("Rewrite(%s) = %s, %v; want nothing", in, out, err) + } + } + + for _, in := range []string{ + ``, `not json`, `[]`, `{}`, `{"tool_input":null}`, `{"tool_input":"x"}`, + `{"tool_input":{"subagent_type":7}}`, `{"tool_input":{}} trailing`, + } { + if _, err := Rewrite([]byte(in), sets); err == nil { + t.Errorf("Rewrite(%q) = nil error, want one", in) + } + } +} + +func settings(t *testing.T, content string) string { + t.Helper() + path := filepath.Join(t.TempDir(), ".claude", "settings.local.json") + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + return path +} + +func mustLoad(t *testing.T, path string) *File { + t.Helper() + f, err := Load(path) + if err != nil { + t.Fatal(err) + } + return f +} + +func readJSON(t *testing.T, path string) map[string]any { + t.Helper() + b, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var m map[string]any + if err := json.Unmarshal(b, &m); err != nil { + t.Fatalf("%v in %s", err, b) + } + return m +} + +func TestLoad_Refusals(t *testing.T) { + ours := `{"type":"command","command":"agentsync hook claude-agent --set a=sonnet/"}` + tests := map[string]string{ + "not json": `{`, + "not an object": `[]`, + "null": `null`, + "trailing data": `{} {}`, + "hooks not an object": `{"hooks":[]}`, + "PreToolUse not list": `{"hooks":{"PreToolUse":{}}}`, + "two of ours": `{"hooks":{"PreToolUse":[{"matcher":"Agent","hooks":[` + ours + `]},` + + `{"matcher":"Agent","hooks":[` + ours + `]}]}}`, + "ours shares an entry": `{"hooks":{"PreToolUse":[{"matcher":"Agent","hooks":[` + ours + `,{"type":"command","command":"echo"}]}]}}`, + "ours has a bad value": `{"hooks":{"PreToolUse":[{"matcher":"Agent","hooks":[{"type":"command","command":"agentsync hook claude-agent --set a=gpt/"}]}]}}`, + } + for name, content := range tests { + t.Run(name, func(t *testing.T) { + if f, err := Load(settings(t, content)); err == nil { + t.Fatalf("Load = %+v, want an error", f) + } + }) + } + t.Run("unreadable path", func(t *testing.T) { + if _, err := Load(t.TempDir()); err == nil { + t.Fatal("Load of a directory should fail") + } + }) +} + +func TestLoad_FindsOursAmongOthers(t *testing.T) { + path := settings(t, `{"hooks":{"PreToolUse":["odd",{"matcher":"Bash","hooks":"odd"},`+ + `{"matcher":"Bash","hooks":[{"type":"command","command":"agentsync hook claude-agentx"}]},`+ + `{"matcher":"Agent","hooks":[{"type":"command","command":"agentsync hook claude-agent --set a=sonnet/high"}]}]}}`) + f := mustLoad(t, path) + if f.index != 3 || !reflect.DeepEqual(f.Sets, map[string]Value{"a": {"sonnet", "high"}}) { + t.Fatalf("Load = index %d, sets %v", f.index, f.Sets) + } +} + +func TestSave_NewFile(t *testing.T) { + path := filepath.Join(t.TempDir(), ".claude", "settings.local.json") + f := mustLoad(t, path) + if f.Command != "" || len(f.Sets) != 0 { + t.Fatalf("missing file loaded as %+v", f) + } + if err := f.Save(map[string]Value{"b": {"", "high"}, "a": {"sonnet", "medium"}}); err != nil { + t.Fatal(err) + } + b, _ := os.ReadFile(path) + want := `{ + "hooks": { + "PreToolUse": [ + { + "hooks": [ + { + "command": "agentsync hook claude-agent --set a=sonnet/medium --set b=/high || exit 1", + "type": "command" + } + ], + "matcher": "Agent" + } + ] + } +} +` + if string(b) != want { + t.Errorf("file =\n%s\nwant\n%s", b, want) + } + fi, _ := os.Stat(path) + if fi.Mode().Perm() != 0o600 { + t.Errorf("mode = %v, want 0600", fi.Mode().Perm()) + } + + // Clearing the only override of a file holding nothing else removes it. + if err := mustLoad(t, path).Save(nil); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(path); !errors.Is(err, fs.ErrNotExist) { + t.Errorf("file should be gone, stat = %v", err) + } + // A file that never existed and would hold nothing stays absent. + if err := mustLoad(t, path).Save(nil); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(path); !errors.Is(err, fs.ErrNotExist) { + t.Errorf("file should still be absent, stat = %v", err) + } +} + +func TestSave_KeepsEverythingElse(t *testing.T) { + path := settings(t, `{"permissions":{"allow":["Bash(ls)"]},"n":1.0,"big":12345678901234567890,`+ + `"hooks":{"Stop":[{"hooks":[]}],"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"echo a"}]}]}}`) + if err := mustLoad(t, path).Save(map[string]Value{"a": {"opus", ""}}); err != nil { + t.Fatal(err) + } + b, _ := os.ReadFile(path) + for _, s := range []string{`"n": 1.0`, `"big": 12345678901234567890`, `"Bash(ls)"`, `"Stop"`, `"echo a"`} { + if !strings.Contains(string(b), s) { + t.Errorf("lost %s:\n%s", s, b) + } + } + if fi, _ := os.Stat(path); fi.Mode().Perm() != 0o644 { + t.Errorf("mode = %v, want the existing 0644", fi.Mode().Perm()) + } + pre := readJSON(t, path)["hooks"].(map[string]any)["PreToolUse"].([]any) + if len(pre) != 2 || pre[0].(map[string]any)["matcher"] != "Bash" { + t.Fatalf("PreToolUse = %v; the user's hook must stay first", pre) + } + + // Replaced in place, then removed, leaving the user's entries untouched. + if err := mustLoad(t, path).Save(map[string]Value{"a": {"", "low"}}); err != nil { + t.Fatal(err) + } + if f := mustLoad(t, path); f.index != 1 || f.Command != "agentsync hook claude-agent --set a=/low || exit 1" { + t.Fatalf("after replace: index %d, command %q", f.index, f.Command) + } + if err := mustLoad(t, path).Save(nil); err != nil { + t.Fatal(err) + } + pre = readJSON(t, path)["hooks"].(map[string]any)["PreToolUse"].([]any) + if len(pre) != 1 || strings.Contains(readFile(t, path), Prefix) { + t.Fatalf("after clear: %s", readFile(t, path)) + } +} + +func TestSave_PrunesContainersItEmptied(t *testing.T) { + path := settings(t, `{"model":"opus"}`) + if err := mustLoad(t, path).Save(map[string]Value{"a": {"opus", ""}}); err != nil { + t.Fatal(err) + } + if err := mustLoad(t, path).Save(nil); err != nil { + t.Fatal(err) + } + if got := readJSON(t, path); !reflect.DeepEqual(got, map[string]any{"model": "opus"}) { + t.Fatalf("file = %v, want only the user's key", got) + } + + path = settings(t, `{"hooks":{"Stop":[]}}`) + if err := mustLoad(t, path).Save(map[string]Value{"a": {"opus", ""}}); err != nil { + t.Fatal(err) + } + if err := mustLoad(t, path).Save(nil); err != nil { + t.Fatal(err) + } + if got := readJSON(t, path); !reflect.DeepEqual(got, map[string]any{"hooks": map[string]any{"Stop": []any{}}}) { + t.Fatalf("file = %v, want the user's Stop hooks kept", got) + } +} + +func TestSave_Errors(t *testing.T) { + // A dangling .claude symlink reads as "no file" but cannot be created. + dir := t.TempDir() + if err := os.Symlink(filepath.Join(dir, "gone"), filepath.Join(dir, ".claude")); err != nil { + t.Fatal(err) + } + f := mustLoad(t, filepath.Join(dir, ".claude", "settings.local.json")) + if err := f.Save(map[string]Value{"a": {"opus", ""}}); err == nil { + t.Error("Save under a dangling .claude should fail") + } + + // The file turned into a non-empty directory after Load: neither rename + // nor remove can replace it. + path := settings(t, `{"hooks":{"PreToolUse":[{"matcher":"Agent","hooks":[{"type":"command","command":"agentsync hook claude-agent --set a=opus/"}]}]}}`) + f = mustLoad(t, path) + g := mustLoad(t, path) + if err := os.Remove(path); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(path, "x"), 0o755); err != nil { + t.Fatal(err) + } + if err := f.Save(map[string]Value{"a": {"sonnet", ""}}); err == nil { + t.Error("Save over a directory should fail") + } + if err := g.Save(nil); err == nil { + t.Error("removing a non-empty directory should fail") + } +} + +func readFile(t *testing.T, path string) string { + t.Helper() + b, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + return string(b) +} + +func TestSymlink_ReadButNeverWritten(t *testing.T) { + content := `{"hooks":{"PreToolUse":[{"matcher":"Agent","hooks":[{"type":"command","command":"agentsync hook claude-agent --set a=opus/ || exit 1"}]}]}}` + real := settings(t, content) + dir := t.TempDir() + link := filepath.Join(dir, "settings.local.json") + if err := os.Symlink(real, link); err != nil { + t.Fatal(err) + } + f := mustLoad(t, link) + if !reflect.DeepEqual(f.Sets, map[string]Value{"a": {"opus", ""}}) { + t.Fatalf("Load through the link = %v", f.Sets) + } + for _, sets := range []map[string]Value{{"a": {"sonnet", ""}}, nil} { + if err := f.Save(sets); err == nil || !strings.Contains(err.Error(), "symlink") { + t.Fatalf("Save(%v) = %v, want a symlink refusal", sets, err) + } + } + if readFile(t, real) != content { + t.Error("the link's target was changed") + } + + dangling := filepath.Join(dir, "dangling.json") + if err := os.Symlink(filepath.Join(dir, "gone"), dangling); err != nil { + t.Fatal(err) + } + if err := mustLoad(t, dangling).Save(map[string]Value{"a": {"opus", ""}}); err == nil { + t.Error("Save through a dangling link should be refused") + } + if _, err := os.Lstat(filepath.Join(dir, "gone")); !errors.Is(err, fs.ErrNotExist) { + t.Error("a file was created through the dangling link") + } +} + +func TestSave_KeepsFieldsOnItsHook(t *testing.T) { + path := settings(t, `{"hooks":{"PreToolUse":[{"matcher":"Agent|Task","hooks":[{"type":"command","timeout":30,`+ + `"command":"agentsync hook claude-agent --set a=opus/ || exit 1"}]}]}}`) + if err := mustLoad(t, path).Save(map[string]Value{"a": {"sonnet", ""}}); err != nil { + t.Fatal(err) + } + b := readFile(t, path) + for _, w := range []string{`"timeout": 30`, `"matcher": "Agent|Task"`, `--set a=sonnet/ || exit 1`} { + if !strings.Contains(b, w) { + t.Errorf("missing %s:\n%s", w, b) + } + } +}