diff --git a/README.md b/README.md index df577f9..af3a179 100644 --- a/README.md +++ b/README.md @@ -43,8 +43,10 @@ repo = "~/src/my-agents" # your canonical agents repo root = "~/src" # the tree holding your projects ``` -`--repo` and `--root`, or `$AGENTSYNC_REPO` and `$AGENTSYNC_ROOT`, work too and -take precedence; see [configuration](docs/configuration.md) and [init](docs/init.md). +`$AGENTSYNC_REPO` and `$AGENTSYNC_ROOT` work too and take precedence; see +[configuration](docs/configuration.md) and [init](docs/init.md). Every command +but `init` and `inventory` works on the project you run it in, so `cd` into a +project first. ```bash cd ~/src/api diff --git a/cmd/agentsync/config_test.go b/cmd/agentsync/config_test.go index a564470..dccbd62 100644 --- a/cmd/agentsync/config_test.go +++ b/cmd/agentsync/config_test.go @@ -9,9 +9,9 @@ import ( "testing" ) -// TestMain points HOME and XDG_CONFIG_HOME at empty temp dirs, so no test can -// read the developer's real config file and pass or fail because of it. It -// also clears the OpenRouter key, so no test can spend money, and the Claude +// TestMain points HOME and XDG_CONFIG_HOME at empty temp dirs and clears both +// AGENTSYNC_* variables, so no test can reach the developer's real repo or tree. +// It also clears the OpenRouter key, so no test can spend money, and the Claude // overrides optimize reports. func TestMain(m *testing.M) { dir, err := os.MkdirTemp("", "agentsync-test-") @@ -25,6 +25,8 @@ func TestMain(m *testing.M) { os.Setenv("HOME", home), os.Setenv("XDG_CONFIG_HOME", filepath.Join(dir, "xdg")), os.Unsetenv(envOpenRouterKey), + os.Unsetenv(envRepo), + os.Unsetenv(envRoot), os.Unsetenv("CLAUDE_CODE_SUBAGENT_MODEL"), os.Unsetenv("CLAUDE_CODE_EFFORT_LEVEL"), } { @@ -56,17 +58,14 @@ func useConfig(t *testing.T, content string) string { } func TestOpenRepo_Precedence(t *testing.T) { - flagDir, envDir, fileDir := t.TempDir(), t.TempDir(), t.TempDir() + envDir, fileDir := t.TempDir(), t.TempDir() tests := []struct { - name, flag, env, file, want, wantFrom string + name, env, file, want, wantFrom string }{ - {"file alone", "", "", fileDir, fileDir, "config file"}, - {"env alone", "", envDir, "", envDir, "$" + envRepo}, - {"flag alone", flagDir, "", "", flagDir, "--repo"}, - {"env beats file", "", envDir, fileDir, envDir, "$" + envRepo}, - {"flag beats env and file", flagDir, envDir, fileDir, flagDir, "--repo"}, - {"blank flag counts as unset", " ", envDir, "", envDir, "$" + envRepo}, - {"blank env counts as unset", "", " ", fileDir, fileDir, "config file"}, + {"file alone", "", fileDir, fileDir, "config file"}, + {"env alone", envDir, "", envDir, "$" + envRepo}, + {"env beats file", envDir, fileDir, envDir, "$" + envRepo}, + {"blank env counts as unset", " ", fileDir, fileDir, "config file"}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { @@ -77,7 +76,7 @@ func TestOpenRepo_Precedence(t *testing.T) { path := useConfig(t, content) t.Setenv(envRepo, tc.env) - repo, from, err := openRepo(tc.flag) + repo, from, err := openRepo() if err != nil { t.Fatalf("openRepo: %v", err) } @@ -95,6 +94,17 @@ func TestOpenRepo_Precedence(t *testing.T) { } } +// TestOpenRepo_RelativeEnvRefuses: a relative $AGENTSYNC_REPO would name a +// different repo from every directory, so it refuses before the file is read. +func TestOpenRepo_RelativeEnvRefuses(t *testing.T) { + useConfig(t, "this is = = not toml\n") + t.Setenv(envRepo, "agents-repo") + _, _, err := openRepo() + if want := `$AGENTSYNC_REPO must be an absolute path, got "agents-repo"`; err == nil || err.Error() != want { + t.Errorf("err = %v, want %q", err, want) + } +} + // configFixture is projectScopeFixture with the default tree's resolved path // returned and both env vars left for each test to set. func configFixture(t *testing.T) (repoRoot, wide string) { @@ -212,10 +222,17 @@ func TestRun_BrokenConfigRefuses(t *testing.T) { if tc.setup != nil { tc.setup(t, path) } - for _, args := range [][]string{ - {"scan", "--root", wide}, // repo from the file - {"inventory", "--repo", repoRoot}, // root from the file + for _, c := range []struct { + args []string + env, val string + }{ + {[]string{"scan", "--all"}, envRoot, wide}, // repo from the file + {[]string{"inventory"}, envRepo, repoRoot}, // root from the file } { + args := c.args + t.Setenv(envRepo, "") + t.Setenv(envRoot, "") + t.Setenv(c.env, c.val) code, stdout, stderr := runCLI(t, args...) if code != exitFailure { t.Errorf("%v: exit = %d, want %d", args, code, exitFailure) @@ -239,19 +256,15 @@ func TestRun_BrokenConfigUnreadWhenSettled(t *testing.T) { repoRoot, wide := configFixture(t) for _, tc := range []struct { name string - env bool args []string }{ - {"both flags", false, []string{"scan", "--repo", repoRoot, "--root", wide}}, - {"inventory, both flags", false, []string{"inventory", "--stdout", "--repo", repoRoot, "--root", wide}}, - {"both env vars", true, []string{"scan", "--all"}}, + {"scan", []string{"scan", "--all"}}, + {"inventory", []string{"inventory", "--stdout"}}, } { t.Run(tc.name, func(t *testing.T) { useConfig(t, "this is = = not toml\n") - if tc.env { - t.Setenv(envRepo, repoRoot) - t.Setenv(envRoot, wide) - } + t.Setenv(envRepo, repoRoot) + t.Setenv(envRoot, wide) if code, _, stderr := runCLI(t, tc.args...); code != exitOK { t.Errorf("exit = %d, want %d: the broken file was read\n%s", code, exitOK, stderr) } @@ -260,30 +273,32 @@ func TestRun_BrokenConfigUnreadWhenSettled(t *testing.T) { } func TestRun_NothingConfiguredRefuses(t *testing.T) { - repoRoot, wide := configFixture(t) + repoRoot, _ := configFixture(t) path := useConfig(t, "") tests := []struct { args []string + repo string want string }{ - {[]string{"scan", "--root", wide}, + {[]string{"scan"}, "", "agentsync: no canonical agent repo configured.\n" + " Create a config file: agentsync init\n" + - " Pass it each run: --repo PATH (or set $AGENTSYNC_REPO)\n" + + " Or set $AGENTSYNC_REPO\n" + " Or set repo in " + path + "\n"}, - {[]string{"inventory", "--repo", repoRoot}, + {[]string{"inventory"}, repoRoot, "agentsync: no default tree of projects configured.\n" + " Create a config file: agentsync init\n" + - " Pass it each run: --root PATH (or set $AGENTSYNC_ROOT)\n" + + " Or set $AGENTSYNC_ROOT\n" + " Or set root in " + path + "\n"}, - {[]string{"scan", "--all", "--repo", repoRoot}, + {[]string{"scan", "--all"}, repoRoot, "agentsync: --all: no default tree of projects configured.\n" + " Create a config file: agentsync init\n" + - " Pass it each run: --root PATH (or set $AGENTSYNC_ROOT)\n" + + " Or set $AGENTSYNC_ROOT\n" + " Or set root in " + path + "\n"}, } for _, tc := range tests { + t.Setenv(envRepo, tc.repo) code, stdout, stderr := runCLI(t, tc.args...) if code != exitFailure { t.Errorf("%v: exit = %d, want %d", tc.args, code, exitFailure) @@ -302,7 +317,7 @@ func TestRun_NothingConfiguredRefuses(t *testing.T) { func TestOpenRepo_BadPathNamesItsSource(t *testing.T) { missing := filepath.Join(t.TempDir(), "nope") path := useConfig(t, fmt.Sprintf("repo = %q\n", missing)) - _, _, err := openRepo("") + _, _, err := openRepo() if want := "repo from config file " + path + ": "; err == nil || !strings.HasPrefix(err.Error(), want) { t.Errorf("err = %v, want it to start %q", err, want) } @@ -314,7 +329,7 @@ func TestRun_UnlocatableConfigRefuses(t *testing.T) { useConfig(t, "") t.Setenv("XDG_CONFIG_HOME", "") t.Setenv("HOME", "") - code, _, stderr := runCLI(t, "scan", "--root", t.TempDir()) + code, _, stderr := runCLI(t, "scan") if code != exitFailure || !strings.Contains(stderr, "cannot locate the config file") { t.Errorf("exit = %d, stderr = %q; want %d naming the config file", code, stderr, exitFailure) } diff --git a/cmd/agentsync/doctor.go b/cmd/agentsync/doctor.go index 0f806ea..8e69805 100644 --- a/cmd/agentsync/doctor.go +++ b/cmd/agentsync/doctor.go @@ -28,8 +28,7 @@ func runDoctor(args []string, stdout, stderr io.Writer) int { fs.SetOutput(stderr) fs.Usage = func() { fmt.Fprint(stderr, usage) } - rootF := registerRoot(fs) - repoPath := fs.String("repo", "", "canonical agent repo") + all := registerAll(fs) asJSON := fs.Bool("json", false, "emit JSON") allowOutsideHome := fs.Bool(scan.AllowOutsideHomeFlag, false, "projects legitimately live outside $HOME; suppress the home warning") @@ -42,14 +41,18 @@ func runDoctor(args []string, stdout, stderr io.Writer) int { // block to stderr. return exitFailure } + if fs.NArg() > 0 { + fmt.Fprintf(stderr, "agentsync: %s\n", strayArg("doctor", fs.Arg(0), nil)) + return exitFailure + } - repo, repoFrom, err := openRepo(*repoPath) + repo, repoFrom, err := openRepo() if err != nil { fmt.Fprintf(stderr, "agentsync: %v\n", err) return exitFailure } - root, err := rootF.resolve(repo.Home) + root, err := resolveRoot(repo.Home, *all) if err != nil { fmt.Fprintf(stderr, "agentsync: %v\n", err) return exitFailure diff --git a/cmd/agentsync/doctor_test.go b/cmd/agentsync/doctor_test.go index 410e4ac..ed29ca0 100644 --- a/cmd/agentsync/doctor_test.go +++ b/cmd/agentsync/doctor_test.go @@ -31,8 +31,10 @@ func doctorFixture(t *testing.T) (base, repoRoot string) { return base, repoRoot } -func doctorArgs(repoRoot, root string, extra ...string) []string { - return append([]string{"doctor", "--repo", repoRoot, "--root", root}, extra...) +func doctorArgs(t *testing.T, repoRoot, root string, extra ...string) []string { + t.Helper() + inTree(t, repoRoot, root) + return append([]string{"doctor", "--" + allFlag}, extra...) } // capturePath is where an import would have written a project's copy. @@ -67,7 +69,7 @@ func TestRunDoctor_BrokenLinkExitsOneAndIsLeftInPlace(t *testing.T) { missing := filepath.Join(repoRoot, "agents", "renamed-away.md") link := linkAt(t, projectAgentPath(base, "proj", "renamed-away"), missing) - code, stdout, stderr := runCLI(t, doctorArgs(repoRoot, base)...) + code, stdout, stderr := runCLI(t, doctorArgs(t, repoRoot, base)...) if code != exitAtRisk { t.Errorf("exit = %d, want %d\nstdout:\n%s\nstderr:\n%s", code, exitAtRisk, stdout, stderr) } @@ -98,7 +100,7 @@ func TestRunDoctor_ForeignLinkAloneStillExitsZero(t *testing.T) { mustWriteT(t, elsewhere, "not ours\n") linkAt(t, projectAgentPath(base, "proj", "theirs"), elsewhere) - code, stdout, stderr := runCLI(t, doctorArgs(repoRoot, base)...) + code, stdout, stderr := runCLI(t, doctorArgs(t, repoRoot, base)...) if code != exitOK { t.Errorf("exit = %d, want %d: a foreign link is not a fault\nstdout:\n%s\nstderr:\n%s", code, exitOK, stdout, stderr) @@ -120,7 +122,7 @@ func TestRunDoctor_CleanSummaryDoesNotClaimLinkWouldRunClean(t *testing.T) { target := globalAgent(t, repoRoot, "shared", "canonical\n") linkAt(t, projectAgentPath(base, "proj", "shared"), target) - code, stdout, _ := runCLI(t, doctorArgs(repoRoot, base)...) + code, stdout, _ := runCLI(t, doctorArgs(t, repoRoot, base)...) if code != exitOK { t.Fatalf("exit = %d, want %d\n%s", code, exitOK, stdout) } @@ -142,7 +144,7 @@ func TestRunDoctor_CleanTreeExitsZeroWithAnEmptyReport(t *testing.T) { target := globalAgent(t, repoRoot, "shared", "canonical\n") linkAt(t, projectAgentPath(base, "proj", "shared"), target) - code, stdout, stderr := runCLI(t, doctorArgs(repoRoot, base)...) + code, stdout, stderr := runCLI(t, doctorArgs(t, repoRoot, base)...) if code != exitOK { t.Errorf("exit = %d, want %d\nstdout:\n%s\nstderr:\n%s", code, exitOK, stdout, stderr) } @@ -150,7 +152,7 @@ func TestRunDoctor_CleanTreeExitsZeroWithAnEmptyReport(t *testing.T) { t.Errorf("the report does not say the tree is clean:\n%s", stdout) } - _, jsonOut, _ := runCLI(t, doctorArgs(repoRoot, base, "--json")...) + _, jsonOut, _ := runCLI(t, doctorArgs(t, repoRoot, base, "--json")...) rep := doctorJSON(t, jsonOut) if len(rep.Findings) != 0 { t.Errorf("Findings = %v, want []", rep.Findings) @@ -170,7 +172,7 @@ func TestRunDoctor_StaleCaptureExitsOneAndTheCaptureSurvives(t *testing.T) { kept := capturePath(repoRoot, "Web/example", "golang-developer") mustWriteT(t, kept, "a 44-line predecessor\n") - code, stdout, stderr := runCLI(t, doctorArgs(repoRoot, base)...) + code, stdout, stderr := runCLI(t, doctorArgs(t, repoRoot, base)...) if code != exitAtRisk { t.Errorf("exit = %d, want %d\nstdout:\n%s\nstderr:\n%s", code, exitAtRisk, stdout, stderr) } @@ -207,7 +209,7 @@ func TestRunDoctor_OrphanedCaptureDirIsReportedAsOneDirectory(t *testing.T) { mustWriteT(t, projectAgentPath(base, "Web/living", "kept"), alive) mustWriteT(t, capturePath(repoRoot, "Web/living", "kept"), alive) - code, stdout, stderr := runCLI(t, doctorArgs(repoRoot, base)...) + code, stdout, stderr := runCLI(t, doctorArgs(t, repoRoot, base)...) if code != exitAtRisk { t.Errorf("exit = %d, want %d\nstdout:\n%s\nstderr:\n%s", code, exitAtRisk, stdout, stderr) } @@ -221,7 +223,7 @@ func TestRunDoctor_OrphanedCaptureDirIsReportedAsOneDirectory(t *testing.T) { t.Errorf("one gone project among live ones must not be blamed on $HOME:\n%s", stderr) } - _, jsonOut, _ := runCLI(t, doctorArgs(repoRoot, base, "--json")...) + _, jsonOut, _ := runCLI(t, doctorArgs(t, repoRoot, base, "--json")...) rep := doctorJSON(t, jsonOut) if len(rep.Findings) != 1 { t.Fatalf("Findings = %v, want exactly one", rep.Findings) @@ -254,7 +256,7 @@ func TestRunDoctor_MismatchCarriesTheRemedyCommand(t *testing.T) { mustWriteT(t, want, "this project's customized one\n") linkAt(t, projectAgentPath(base, "proj", "web-researcher"), global) - code, stdout, stderr := runCLI(t, doctorArgs(repoRoot, base)...) + code, stdout, stderr := runCLI(t, doctorArgs(t, repoRoot, base)...) if code != exitAtRisk { t.Errorf("exit = %d, want %d\nstdout:\n%s\nstderr:\n%s", code, exitAtRisk, stdout, stderr) } @@ -274,7 +276,7 @@ func TestRunDoctor_ReadmeIsNeverACapture(t *testing.T) { base, repoRoot := doctorFixture(t) mustWriteT(t, filepath.Join(repoRoot, "project-agents", "README.md"), "# project-agents\n") - code, stdout, _ := runCLI(t, doctorArgs(repoRoot, base, "--json")...) + code, stdout, _ := runCLI(t, doctorArgs(t, repoRoot, base, "--json")...) if code != exitOK { t.Errorf("exit = %d, want %d\n%s", code, exitOK, stdout) } @@ -304,7 +306,7 @@ func TestRunDoctor_UnreadableSubtreeIsNotACleanRun(t *testing.T) { unreadable, inside := testfs.UnreadableSubtree(t, locked) testfs.PlantAgentsDir(t, inside, "proj") - code, stdout, stderr := runCLI(t, doctorArgs(repoRoot, base)...) + code, stdout, stderr := runCLI(t, doctorArgs(t, repoRoot, base)...) if code != exitAtRisk { t.Errorf("exit = %d, want %d\nstdout:\n%s\nstderr:\n%s", code, exitAtRisk, stdout, stderr) } @@ -339,7 +341,7 @@ func TestRunDoctor_ImplausibleHomeWarnsRatherThanFails(t *testing.T) { // live under — the shape `sudo -i` produces. t.Setenv("HOME", t.TempDir()) - code, stdout, stderr := runCLI(t, doctorArgs(repoRoot, base)...) + code, stdout, stderr := runCLI(t, doctorArgs(t, repoRoot, base)...) if code == exitFailure { t.Errorf("exit = %d: a read-only command must not refuse over $HOME\nstdout:\n%s\nstderr:\n%s", code, stdout, stderr) @@ -351,7 +353,7 @@ func TestRunDoctor_ImplausibleHomeWarnsRatherThanFails(t *testing.T) { t.Errorf("the warning does not name the opt-out flag:\n%s", stderr) } - _, _, quiet := runCLI(t, doctorArgs(repoRoot, base, "--allow-outside-home")...) + _, _, quiet := runCLI(t, doctorArgs(t, repoRoot, base, "--allow-outside-home")...) if strings.Contains(quiet, "warning:") { t.Errorf("--allow-outside-home did not suppress the warning:\n%s", quiet) } @@ -377,7 +379,7 @@ func TestRunDoctor_CapturesWarnAboutHomeEvenWhenNoProjectWasScanned(t *testing.T t.Setenv("HOME", t.TempDir()) emptyRoot := t.TempDir() - code, stdout, stderr := runCLI(t, doctorArgs(repoRoot, emptyRoot)...) + code, stdout, stderr := runCLI(t, doctorArgs(t, repoRoot, emptyRoot)...) if code != exitAtRisk { t.Errorf("exit = %d, want %d\nstdout:\n%s", code, exitAtRisk, stdout) @@ -404,7 +406,7 @@ func TestRunDoctor_CapturesWarnAboutHomeEvenWhenNoProjectWasScanned(t *testing.T // --allow-outside-home must not silence it: that flag claims the projects // live outside $HOME, and such projects key under _outside-home/ // and resolve to real directories, so it says nothing about this evidence. - _, _, stillLoud := runCLI(t, doctorArgs(repoRoot, emptyRoot, "--allow-outside-home")...) + _, _, stillLoud := runCLI(t, doctorArgs(t, repoRoot, emptyRoot, "--allow-outside-home")...) if !strings.Contains(stillLoud, "warning:") { t.Errorf("--allow-outside-home silenced a warning it makes no claim about:\n%s", stillLoud) } @@ -424,7 +426,7 @@ func TestRunDoctor_OutsideHomeOrphanDoesNotBlameTheHome(t *testing.T) { const key = "_outside-home/nonexistent-volume-for-tests/gone" mustWriteT(t, capturePath(repoRoot, key, "agent"), "the only copy\n") - code, stdout, stderr := runCLI(t, doctorArgs(repoRoot, base)...) + code, stdout, stderr := runCLI(t, doctorArgs(t, repoRoot, base)...) // The orphan itself is real and still reported. if code != exitAtRisk { @@ -442,7 +444,7 @@ func TestRunDoctor_OutsideHomeOrphanDoesNotBlameTheHome(t *testing.T) { t.Errorf("the summary caveats a finding the home took no part in:\n%s", stdout) } - _, jsonOut, _ := runCLI(t, doctorArgs(repoRoot, base, "--json")...) + _, jsonOut, _ := runCLI(t, doctorArgs(t, repoRoot, base, "--json")...) rep := doctorJSON(t, jsonOut) if rep.Home.Suspect { t.Errorf("home.suspect = true for a key the home never resolved:\n%s", jsonOut) @@ -464,7 +466,7 @@ func TestRunDoctor_JSONCarriesTheHomeSignal(t *testing.T) { mustWriteT(t, capturePath(repoRoot, "Web/one", "agent"), "the only copy\n") t.Setenv("HOME", t.TempDir()) - _, stdout, _ := runCLI(t, doctorArgs(repoRoot, t.TempDir(), "--json")...) + _, stdout, _ := runCLI(t, doctorArgs(t, repoRoot, t.TempDir(), "--json")...) rep := doctorJSON(t, stdout) if !rep.Home.Suspect { @@ -481,7 +483,7 @@ func TestRunDoctor_JSONCarriesTheHomeSignal(t *testing.T) { // rather than decoration. t.Setenv("HOME", base) mustMkdirT(t, filepath.Join(base, "Web", "one")) - _, clean, _ := runCLI(t, doctorArgs(repoRoot, base, "--json")...) + _, clean, _ := runCLI(t, doctorArgs(t, repoRoot, base, "--json")...) if got := doctorJSON(t, clean); got.Home.Suspect || got.Home.Warning != "" { t.Errorf("home signal fired on a run with a correct home: %+v", got.Home) } @@ -513,7 +515,7 @@ func TestRunDoctor_WritesNothingAnywhere(t *testing.T) { // base holds the repo too, so one snapshot covers both sides. before := treeSnapshot(t, base) - code, stdout, stderr := runCLI(t, doctorArgs(repoRoot, base)...) + code, stdout, stderr := runCLI(t, doctorArgs(t, repoRoot, base)...) after := treeSnapshot(t, base) if code != exitAtRisk { @@ -524,7 +526,7 @@ func TestRunDoctor_WritesNothingAnywhere(t *testing.T) { // And again in JSON, which walks the same code with a different emitter. before = treeSnapshot(t, base) - runCLI(t, doctorArgs(repoRoot, base, "--json")...) + runCLI(t, doctorArgs(t, repoRoot, base, "--json")...) assertUnchanged(t, before, treeSnapshot(t, base)) } @@ -599,7 +601,7 @@ func TestRunDoctor_JSONCarriesFindingsAndExamined(t *testing.T) { mustMkdirT(t, filepath.Join(base, "kept")) mustWriteT(t, capturePath(repoRoot, "kept", "stale-one"), "x\n") - code, stdout, stderr := runCLI(t, doctorArgs(repoRoot, base, "--json")...) + code, stdout, stderr := runCLI(t, doctorArgs(t, repoRoot, base, "--json")...) if code != exitAtRisk { t.Errorf("exit = %d, want %d\nstderr:\n%s", code, exitAtRisk, stderr) } diff --git a/cmd/agentsync/home_test.go b/cmd/agentsync/home_test.go index 7d12b92..73187a2 100644 --- a/cmd/agentsync/home_test.go +++ b/cmd/agentsync/home_test.go @@ -28,8 +28,10 @@ func homeScanFixture(t *testing.T) (home, repoRoot, root string) { return home, repoRoot, root } -func scanArgs(repoRoot, root string, extra ...string) []string { - return append([]string{"scan", "--repo", repoRoot, "--root", root}, extra...) +func scanArgs(t *testing.T, repoRoot, root string, extra ...string) []string { + t.Helper() + inTree(t, repoRoot, root) + return append([]string{"scan", "--" + allFlag}, extra...) } // TestRunScan_ValidButWrongHomeWarns is the issue itself. $HOME is a real, @@ -48,7 +50,7 @@ func TestRunScan_ValidButWrongHomeWarns(t *testing.T) { wrongHome := t.TempDir() t.Setenv("HOME", wrongHome) - code, stdout, stderr := runCLI(t, scanArgs(repoRoot, root, "--json")...) + code, stdout, stderr := runCLI(t, scanArgs(t, repoRoot, root, "--json")...) if code != exitOK { t.Errorf("exit = %d, want %d: a suspect $HOME must not change the exit code", code, exitOK) } @@ -80,7 +82,7 @@ func TestRunScan_ValidButWrongHomeWarns(t *testing.T) { // The scan still ran to completion and still found the file, and // --fail-on-risk still means what it meant. - codeFail, _, _ := runCLI(t, scanArgs(repoRoot, root, "--fail-on-risk")...) + codeFail, _, _ := runCLI(t, scanArgs(t, repoRoot, root, "--fail-on-risk")...) if codeFail != exitAtRisk { t.Errorf("--fail-on-risk exit = %d, want %d: the unprotected file is still found", codeFail, exitAtRisk) @@ -90,10 +92,10 @@ func TestRunScan_ValidButWrongHomeWarns(t *testing.T) { // TestRunScan_WrongHomeAboveTheRootStillWarns is the end-to-end guard for the // shape that a root-based check gets wrong. // -// `sudo -i agentsync scan --root /` (or --root /Users) puts $HOME=/var/root -// UNDER the root being scanned. Anything that asks "is the root related to -// $HOME?" answers yes and stays silent — while every project key is still -// re-based under _outside-home/. The projects, not the root, decide. +// `sudo -i agentsync scan --all` with a default tree of / (or /Users) puts +// $HOME=/var/root UNDER the root being scanned. Anything that asks "is the +// root related to $HOME?" answers yes and stays silent — while every project +// key is still re-based under _outside-home/. The projects, not the root, decide. func TestRunScan_WrongHomeAboveTheRootStillWarns(t *testing.T) { base := t.TempDir() @@ -107,7 +109,7 @@ func TestRunScan_WrongHomeAboveTheRootStillWarns(t *testing.T) { mustMkdirT(t, filepath.Join(repoRoot, "agents")) mustWriteT(t, filepath.Join(base, "home", "Web", "proj", ".claude", "agents", "orphan.md"), "only here\n") - code, stdout, stderr := runCLI(t, scanArgs(repoRoot, base, "--json")...) + code, stdout, stderr := runCLI(t, scanArgs(t, repoRoot, base, "--json")...) if code != exitOK { t.Fatalf("exit = %d, want %d", code, exitOK) } @@ -140,7 +142,7 @@ func TestRunScan_MixedProjectsDoNotWarn(t *testing.T) { mustWriteT(t, filepath.Join(home, "Web", "proj", ".claude", "agents", "a.md"), "a\n") mustWriteT(t, filepath.Join(base, "opt", "work", ".claude", "agents", "b.md"), "b\n") - code, stdout, stderr := runCLI(t, scanArgs(repoRoot, base)...) + code, stdout, stderr := runCLI(t, scanArgs(t, repoRoot, base)...) if code != exitOK { t.Fatalf("exit = %d, want %d", code, exitOK) } @@ -161,7 +163,7 @@ func TestRunScan_EmptyHomeWarnsAndCompletes(t *testing.T) { _, repoRoot, root := homeScanFixture(t) t.Setenv("HOME", "") - code, stdout, stderr := runCLI(t, scanArgs(repoRoot, root)...) + code, stdout, stderr := runCLI(t, scanArgs(t, repoRoot, root)...) if code != exitOK { t.Errorf("exit = %d, want %d", code, exitOK) } @@ -180,7 +182,7 @@ func TestRunScan_UnresolvableHomeWarns(t *testing.T) { gone := filepath.Join(t.TempDir(), "no-such-home") t.Setenv("HOME", gone) - code, _, stderr := runCLI(t, scanArgs(repoRoot, root)...) + code, _, stderr := runCLI(t, scanArgs(t, repoRoot, root)...) if code != exitOK { t.Errorf("exit = %d, want %d", code, exitOK) } @@ -204,7 +206,7 @@ func TestRunScan_OutsideHomeOptOutIsSilent(t *testing.T) { mustWriteT(t, filepath.Join(outside, "work", ".claude", "agents", "orphan.md"), "only here\n") code, stdout, stderr := runCLI(t, - scanArgs(repoRoot, outside, "--"+scan.AllowOutsideHomeFlag)...) + scanArgs(t, repoRoot, outside, "--"+scan.AllowOutsideHomeFlag)...) if code != exitOK { t.Errorf("exit = %d, want %d", code, exitOK) } @@ -217,7 +219,7 @@ func TestRunScan_OutsideHomeOptOutIsSilent(t *testing.T) { // Without the flag the same run does warn — otherwise this test would // pass even if the check had been removed entirely. - if _, _, stderr := runCLI(t, scanArgs(repoRoot, outside)...); stderr == "" { + if _, _, stderr := runCLI(t, scanArgs(t, repoRoot, outside)...); stderr == "" { t.Error("without the opt-out the same run must warn") } } @@ -244,7 +246,7 @@ func TestRunScan_CorrectHomeIsSilent(t *testing.T) { t.Setenv("HOME", home) for _, extra := range [][]string{nil, {"--json"}, {"--at-risk"}, {"--fail-on-risk"}} { - _, _, stderr := runCLI(t, scanArgs(repoRoot, root, extra...)...) + _, _, stderr := runCLI(t, scanArgs(t, repoRoot, root, extra...)...) if got := withoutChoiceLines(stderr); got != "" { t.Errorf("correct run with %v wrote to stderr:\n%s", extra, got) } @@ -259,7 +261,7 @@ func TestRunScan_HomeWarningStaysOffStdout(t *testing.T) { wrongHome := t.TempDir() t.Setenv("HOME", wrongHome) - code, stdout, stderr := runCLI(t, scanArgs(repoRoot, root, "--json")...) + code, stdout, stderr := runCLI(t, scanArgs(t, repoRoot, root, "--json")...) if code != exitOK { t.Fatalf("exit = %d, want %d", code, exitOK) } diff --git a/cmd/agentsync/import.go b/cmd/agentsync/import.go index 647fda3..f74122c 100644 --- a/cmd/agentsync/import.go +++ b/cmd/agentsync/import.go @@ -28,8 +28,7 @@ func runImport(args []string, stdout, stderr io.Writer) int { fs.SetOutput(stderr) fs.Usage = func() { fmt.Fprint(stderr, usage) } - rootF := registerRoot(fs) - repoPath := fs.String("repo", "", "canonical agent repo") + all := registerAll(fs) apply := fs.Bool("apply", false, "write the captures; without it this is a dry run") var update updateFlag fs.Var(&update, "update", @@ -63,18 +62,17 @@ func runImport(args []string, stdout, stderr io.Writer) int { // every differing capture in the run, which is the opposite of what they // typed. Refusing here turns that into a message naming the fix. if rest := fs.Args(); len(rest) > 0 { - fmt.Fprintf(stderr, "agentsync: unexpected argument %q; import takes flags only.%s\n", - rest[0], updateHint(rest[0])) + fmt.Fprintf(stderr, "agentsync: %s\n", strayArg("import", rest[0], updateHint)) return exitFailure } - repo, repoFrom, err := openRepo(*repoPath) + repo, repoFrom, err := openRepo() if err != nil { fmt.Fprintf(stderr, "agentsync: %v\n", err) return exitFailure } - root, err := rootF.resolve(repo.Home) + root, err := resolveRoot(repo.Home, *all) if err != nil { fmt.Fprintf(stderr, "agentsync: %v\n", err) return exitFailure diff --git a/cmd/agentsync/import_test.go b/cmd/agentsync/import_test.go index 88caf53..d343e70 100644 --- a/cmd/agentsync/import_test.go +++ b/cmd/agentsync/import_test.go @@ -32,8 +32,10 @@ func importFixture(t *testing.T) (base, repoRoot string) { return base, repoRoot } -func importArgs(repoRoot, root string, extra ...string) []string { - return append([]string{"import", "--repo", repoRoot, "--root", root}, extra...) +func importArgs(t *testing.T, repoRoot, root string, extra ...string) []string { + t.Helper() + inTree(t, repoRoot, root) + return append([]string{"import", "--" + allFlag}, extra...) } // commitRepo makes the fixture's agent repo a git repository and commits @@ -122,7 +124,7 @@ func TestRunImport_DryRunIsTheDefault(t *testing.T) { base, repoRoot := importFixture(t) projectAgent(t, base, "proj", "orphan", "only here\n") - code, stdout, stderr := runCLI(t, importArgs(repoRoot, base)...) + code, stdout, stderr := runCLI(t, importArgs(t, repoRoot, base)...) if code != exitOK { t.Errorf("exit = %d, want %d\nstdout:\n%s\nstderr:\n%s", code, exitOK, stdout, stderr) } @@ -151,7 +153,7 @@ func TestRunImport_ApplyCapturesAndIsIdempotent(t *testing.T) { projectAgent(t, base, "proj", "orphan", "only here\n") projectAgent(t, base, "nested/deeper", "other", "also only here\n") - code, stdout, stderr := runCLI(t, importArgs(repoRoot, base, "--apply")...) + code, stdout, stderr := runCLI(t, importArgs(t, repoRoot, base, "--apply")...) if code != exitOK { t.Fatalf("exit = %d, want %d\nstdout:\n%s\nstderr:\n%s", code, exitOK, stdout, stderr) } @@ -166,7 +168,7 @@ func TestRunImport_ApplyCapturesAndIsIdempotent(t *testing.T) { // Second run: the captures are now byte-identical counterparts, so the // scan calls them adoptable and there is nothing left to do. - code, stdout, stderr = runCLI(t, importArgs(repoRoot, base, "--apply")...) + code, stdout, stderr = runCLI(t, importArgs(t, repoRoot, base, "--apply")...) if code != exitOK { t.Fatalf("second run exit = %d, want %d\nstdout:\n%s\nstderr:\n%s", code, exitOK, stdout, stderr) } @@ -190,7 +192,7 @@ func TestRunImport_ConflictIsRefusedAndExitsNonZero(t *testing.T) { mustWriteT(t, dest, "an older capture nobody else has\n") for _, extra := range [][]string{{}, {"--apply"}} { - code, stdout, stderr := runCLI(t, importArgs(repoRoot, base, extra...)...) + code, stdout, stderr := runCLI(t, importArgs(t, repoRoot, base, extra...)...) if code != exitConflict { t.Errorf("%v: exit = %d, want %d\nstdout:\n%s\nstderr:\n%s", extra, code, exitConflict, stdout, stderr) @@ -209,7 +211,7 @@ func TestRunImport_ConflictIsRefusedAndExitsNonZero(t *testing.T) { // The opt-in, and only the opt-in, replaces it — and only once the // capture it is about to destroy exists somewhere other than that path. commitRepo(t, repoRoot) - code, stdout, stderr := runCLI(t, importArgs(repoRoot, base, "--apply", "--update")...) + code, stdout, stderr := runCLI(t, importArgs(t, repoRoot, base, "--apply", "--update")...) if code != exitOK { t.Fatalf("--update exit = %d, want %d\nstdout:\n%s\nstderr:\n%s", code, exitOK, stdout, stderr) } @@ -230,7 +232,7 @@ func TestRunImport_UpdateRefusesAnUncommittedCapture(t *testing.T) { dest := backupPath(t, repoRoot, "proj", "researcher") mustWriteT(t, dest, "an older capture nobody has committed\n") - code, stdout, stderr := runCLI(t, importArgs(repoRoot, base, "--apply", "--update")...) + code, stdout, stderr := runCLI(t, importArgs(t, repoRoot, base, "--apply", "--update")...) if code != exitConflict { t.Errorf("exit = %d, want %d\nstdout:\n%s\nstderr:\n%s", code, exitConflict, stdout, stderr) } @@ -247,7 +249,7 @@ func TestRunImport_UpdateRefusesAnUncommittedCapture(t *testing.T) { // The remedy works. commitRepo(t, repoRoot) - code, stdout, stderr = runCLI(t, importArgs(repoRoot, base, "--apply", "--update")...) + code, stdout, stderr = runCLI(t, importArgs(t, repoRoot, base, "--apply", "--update")...) if code != exitOK { t.Fatalf("after committing, exit = %d, want %d\nstdout:\n%s\nstderr:\n%s", code, exitOK, stdout, stderr) } @@ -270,7 +272,7 @@ func TestRunImport_SecondUpdateDoesNotDestroyTheFirst(t *testing.T) { mustWriteT(t, dest, "v1\n") commitRepo(t, repoRoot) - code, stdout, stderr := runCLI(t, importArgs(repoRoot, base, "--apply", "--update")...) + code, stdout, stderr := runCLI(t, importArgs(t, repoRoot, base, "--apply", "--update")...) if code != exitOK { t.Fatalf("first update exit = %d, want %d\nstdout:\n%s\nstderr:\n%s", code, exitOK, stdout, stderr) } @@ -281,7 +283,7 @@ func TestRunImport_SecondUpdateDoesNotDestroyTheFirst(t *testing.T) { // The project moves on; nobody committed the capture of v2. mustWriteT(t, src, "v3\n") - code, stdout, stderr = runCLI(t, importArgs(repoRoot, base, "--apply", "--update")...) + code, stdout, stderr = runCLI(t, importArgs(t, repoRoot, base, "--apply", "--update")...) if code != exitConflict { t.Errorf("second update exit = %d, want %d\nstdout:\n%s\nstderr:\n%s", code, exitConflict, stdout, stderr) } @@ -303,7 +305,7 @@ func TestRunImport_UpdateSelectsOneCapture(t *testing.T) { mustWriteT(t, other, "other old capture\n") commitRepo(t, repoRoot) - code, stdout, stderr := runCLI(t, importArgs(repoRoot, base, + code, stdout, stderr := runCLI(t, importArgs(t, repoRoot, base, "--apply", "--update=chosen/researcher")...) // The unselected one is still a conflict, so the run still exits 1. if code != exitConflict { @@ -328,7 +330,7 @@ func TestRunImport_UpdateSelectorSyntax(t *testing.T) { mustWriteT(t, dest, "old capture\n") commitRepo(t, repoRoot) - code, stdout, stderr := runCLI(t, importArgs(repoRoot, base, + code, stdout, stderr := runCLI(t, importArgs(t, repoRoot, base, "--apply", "--update", "proj/researcher")...) if code != exitFailure { t.Errorf("exit = %d, want %d\nstdout:\n%s\nstderr:\n%s", code, exitFailure, stdout, stderr) @@ -346,7 +348,7 @@ func TestRunImport_UpdateSelectorSyntax(t *testing.T) { projectAgent(t, base, "proj", "researcher", "current\n") for _, arg := range []string{"--update=researcher", "--update="} { - code, _, stderr := runCLI(t, importArgs(repoRoot, base, arg)...) + code, _, stderr := runCLI(t, importArgs(t, repoRoot, base, arg)...) if code != exitFailure { t.Errorf("%s: exit = %d, want %d\nstderr:\n%s", arg, code, exitFailure, stderr) } @@ -374,7 +376,7 @@ func TestRunImport_UpdateSelectorSyntax(t *testing.T) { projectAgent(t, base, "proj", "researcher", "current\n") for _, arg := range []string{"--", ""} { - code, _, stderr := runCLI(t, importArgs(repoRoot, base, "--", arg)...) + code, _, stderr := runCLI(t, importArgs(t, repoRoot, base, "--", arg)...) if code != exitFailure { t.Errorf("%q: exit = %d, want %d\nstderr:\n%s", arg, code, exitFailure, stderr) } @@ -395,7 +397,7 @@ func TestRunImport_UpdateSelectorSyntax(t *testing.T) { mustWriteT(t, dest, "old capture\n") commitRepo(t, repoRoot) - code, stdout, _ := runCLI(t, importArgs(repoRoot, base, + code, stdout, _ := runCLI(t, importArgs(t, repoRoot, base, "--apply", "--update", "--update=false")...) if code != exitConflict { t.Errorf("exit = %d, want %d (the capture is still a conflict)\n%s", code, exitConflict, stdout) @@ -410,7 +412,7 @@ func TestRunImport_UpdateSelectorSyntax(t *testing.T) { projectAgent(t, base, "proj", "researcher", "only here\n") commitRepo(t, repoRoot) - code, stdout, stderr := runCLI(t, importArgs(repoRoot, base, + code, stdout, stderr := runCLI(t, importArgs(t, repoRoot, base, "--apply", "--update=proj/typo")...) // Exit 0: the selector is a fact about the command line, and the // repo did end up holding everything unprotected. The exit codes @@ -437,7 +439,7 @@ func TestRunImport_JSONReportsTheScopeOfUpdate(t *testing.T) { read := func(t *testing.T, extra ...string) map[string]any { t.Helper() - _, stdout, stderr := runCLI(t, importArgs(repoRoot, base, append(extra, "--json")...)...) + _, stdout, stderr := runCLI(t, importArgs(t, repoRoot, base, append(extra, "--json")...)...) var rep map[string]any if err := json.Unmarshal([]byte(stdout), &rep); err != nil { t.Fatalf("unmarshal report: %v\nstdout:\n%s\nstderr:\n%s", err, stdout, stderr) @@ -477,11 +479,11 @@ func TestRunImport_FailOnPending(t *testing.T) { // The same run without the flag is the baseline: exit 0, which is // the gap the flag exists to close. - if code, stdout, _ := runCLI(t, importArgs(repoRoot, base)...); code != exitOK { + if code, stdout, _ := runCLI(t, importArgs(t, repoRoot, base)...); code != exitOK { t.Fatalf("without the flag, exit = %d, want %d\n%s", code, exitOK, stdout) } - code, stdout, stderr := runCLI(t, importArgs(repoRoot, base, "--fail-on-pending")...) + code, stdout, stderr := runCLI(t, importArgs(t, repoRoot, base, "--fail-on-pending")...) if code != exitAtRisk { t.Errorf("exit = %d, want %d\nstdout:\n%s\nstderr:\n%s", code, exitAtRisk, stdout, stderr) } @@ -496,11 +498,11 @@ func TestRunImport_FailOnPending(t *testing.T) { t.Run("a clean dry run exits 0", func(t *testing.T) { base, repoRoot := importFixture(t) projectAgent(t, base, "proj", "orphan", "only here\n") - if code, stdout, _ := runCLI(t, importArgs(repoRoot, base, "--apply")...); code != exitOK { + if code, stdout, _ := runCLI(t, importArgs(t, repoRoot, base, "--apply")...); code != exitOK { t.Fatalf("apply exit = %d\n%s", code, stdout) } - code, stdout, stderr := runCLI(t, importArgs(repoRoot, base, "--fail-on-pending")...) + code, stdout, stderr := runCLI(t, importArgs(t, repoRoot, base, "--fail-on-pending")...) if code != exitOK { t.Errorf("exit = %d, want %d\nstdout:\n%s\nstderr:\n%s", code, exitOK, stdout, stderr) } @@ -513,7 +515,7 @@ func TestRunImport_FailOnPending(t *testing.T) { base, repoRoot := importFixture(t) projectAgent(t, base, "proj", "orphan", "only here\n") - code, stdout, stderr := runCLI(t, importArgs(repoRoot, base, "--apply", "--fail-on-pending")...) + code, stdout, stderr := runCLI(t, importArgs(t, repoRoot, base, "--apply", "--fail-on-pending")...) if code != exitOK { t.Errorf("exit = %d, want %d\nstdout:\n%s\nstderr:\n%s", code, exitOK, stdout, stderr) } @@ -532,7 +534,7 @@ func TestRunImport_ConflictDoesNotBlockTheOtherProjects(t *testing.T) { mustWriteT(t, backupPath(t, repoRoot, "blocked", "researcher"), "older capture\n") projectAgent(t, base, "fine", "orphan", "only here\n") - code, stdout, _ := runCLI(t, importArgs(repoRoot, base, "--apply")...) + code, stdout, _ := runCLI(t, importArgs(t, repoRoot, base, "--apply")...) if code != exitConflict { t.Errorf("exit = %d, want %d\n%s", code, exitConflict, stdout) } @@ -576,7 +578,7 @@ func TestRunImport_UnreadableSubtreeIsNotACleanRun(t *testing.T) { unreadable, inside := testfs.UnreadableSubtree(t, locked) testfs.PlantAgent(t, inside, "proj", "hidden", "unreachable\n") - code, stdout, stderr := runCLI(t, importArgs(repoRoot, base, "--apply")...) + code, stdout, stderr := runCLI(t, importArgs(t, repoRoot, base, "--apply")...) if code != exitConflict { t.Errorf("exit = %d, want %d: an unreadable subtree cannot be a clean import\nstdout:\n%s\nstderr:\n%s", code, exitConflict, stdout, stderr) @@ -620,7 +622,7 @@ func TestRunImport_UnreadableSubtreeBlocksAnOtherwiseEmptyRun(t *testing.T) { _, inside := testfs.UnreadableSubtree(t, locked) testfs.PlantAgentsDir(t, inside, "proj") - code, stdout, _ := runCLI(t, importArgs(repoRoot, base, "--apply")...) + code, stdout, _ := runCLI(t, importArgs(t, repoRoot, base, "--apply")...) if code != exitConflict { t.Errorf("exit = %d, want %d\n%s", code, exitConflict, stdout) } @@ -648,7 +650,7 @@ func TestRunImport_WrongHomeWritesNothing(t *testing.T) { wrongHome := t.TempDir() t.Setenv("HOME", wrongHome) - code, stdout, stderr := runCLI(t, importArgs(repoRoot, base, "--apply")...) + code, stdout, stderr := runCLI(t, importArgs(t, repoRoot, base, "--apply")...) if code != exitFailure { t.Errorf("exit = %d, want %d: a wrong $HOME must stop a write path", code, exitFailure) } @@ -667,7 +669,7 @@ func TestRunImport_WrongHomeWritesNothing(t *testing.T) { // The legitimate case the guard must not break: projects that really do // live outside $HOME, opted in explicitly. - code, _, stderr = runCLI(t, importArgs(repoRoot, base, "--apply", "--allow-outside-home")...) + code, _, stderr = runCLI(t, importArgs(t, repoRoot, base, "--apply", "--allow-outside-home")...) if code != exitOK { t.Fatalf("opted-in exit = %d, want %d\nstderr:\n%s", code, exitOK, stderr) } @@ -686,7 +688,7 @@ func TestRunImport_NoHomeWritesNothingEvenWhenOptedIn(t *testing.T) { projectAgent(t, base, "proj", "orphan", "only here\n") t.Setenv("HOME", "") - code, _, stderr := runCLI(t, importArgs(repoRoot, base, "--apply", "--allow-outside-home")...) + code, _, stderr := runCLI(t, importArgs(t, repoRoot, base, "--apply", "--allow-outside-home")...) if code != exitFailure { t.Errorf("exit = %d, want %d", code, exitFailure) } @@ -710,7 +712,7 @@ func TestRunImport_TrackedInItsOwnRepoNeedsTheFlag(t *testing.T) { gitRun(t, tracked, "add", ".") gitRun(t, tracked, "commit", "-qm", "agents") - code, stdout, _ := runCLI(t, importArgs(repoRoot, base, "--apply")...) + code, stdout, _ := runCLI(t, importArgs(t, repoRoot, base, "--apply")...) if code != exitOK { t.Errorf("exit = %d, want %d\n%s", code, exitOK, stdout) } @@ -723,7 +725,7 @@ func TestRunImport_TrackedInItsOwnRepoNeedsTheFlag(t *testing.T) { t.Errorf("the report does not account for the skipped file:\n%s", stdout) } - code, stdout, _ = runCLI(t, importArgs(repoRoot, base, "--apply", "--include-tracked")...) + code, stdout, _ = runCLI(t, importArgs(t, repoRoot, base, "--apply", "--include-tracked")...) if code != exitOK { t.Errorf("exit = %d, want %d\n%s", code, exitOK, stdout) } @@ -740,7 +742,7 @@ func TestRunImport_JSONReportsTheWholePlan(t *testing.T) { projectAgent(t, base, "bbb", "researcher", "current\n") mustWriteT(t, backupPath(t, repoRoot, "bbb", "researcher"), "older capture\n") - code, stdout, _ := runCLI(t, importArgs(repoRoot, base, "--json")...) + code, stdout, _ := runCLI(t, importArgs(t, repoRoot, base, "--json")...) if code != exitConflict { t.Errorf("exit = %d, want %d", code, exitConflict) } @@ -788,7 +790,7 @@ func TestRunImport_DiagnosticsStayOffStdout(t *testing.T) { base, repoRoot := importFixture(t) projectAgent(t, base, "proj", "orphan", "only here\n") - _, stdout, _ := runCLI(t, importArgs(repoRoot, base, "--json", "--apply")...) + _, stdout, _ := runCLI(t, importArgs(t, repoRoot, base, "--json", "--apply")...) var decoded map[string]any if err := json.Unmarshal([]byte(stdout), &decoded); err != nil { t.Fatalf("stdout is not parseable JSON: %v\n%s", err, stdout) @@ -801,7 +803,7 @@ func TestRunImport_BadFlagReportsOnceAndWritesNothing(t *testing.T) { base, repoRoot := importFixture(t) projectAgent(t, base, "proj", "orphan", "only here\n") - code, _, stderr := runCLI(t, importArgs(repoRoot, base, "--nonexistent")...) + code, _, stderr := runCLI(t, importArgs(t, repoRoot, base, "--nonexistent")...) if code != exitFailure { t.Errorf("exit = %d, want %d", code, exitFailure) } @@ -815,7 +817,7 @@ func TestRunImport_BadFlagReportsOnceAndWritesNothing(t *testing.T) { func TestRunImport_MissingRepoFails(t *testing.T) { base, _ := importFixture(t) - code, _, stderr := runCLI(t, importArgs(filepath.Join(base, "no-such-repo"), base, "--apply")...) + code, _, stderr := runCLI(t, importArgs(t, filepath.Join(base, "no-such-repo"), base, "--apply")...) if code != exitFailure { t.Errorf("exit = %d, want %d", code, exitFailure) } diff --git a/cmd/agentsync/inventory.go b/cmd/agentsync/inventory.go index 75fc134..6f214ca 100644 --- a/cmd/agentsync/inventory.go +++ b/cmd/agentsync/inventory.go @@ -31,7 +31,7 @@ const inventoryFileName = "inventory.json" // The home check is a warning rather than a refusal, as in scan and doctor: a // read-only command with a wrong $HOME produces a visibly wrong report, which // fails safe. RequireHome exists to stop a write landing in the wrong -// namespace, and the only write here goes to a path the caller named. +// namespace, and the only write here is this command's own derived file. // // It is also the one command that does not derive its root from the working // directory. That document describes the whole machine by definition, so @@ -42,8 +42,7 @@ func runInventory(args []string, stdout, stderr io.Writer) int { fs.SetOutput(stderr) fs.Usage = func() { fmt.Fprint(stderr, usage) } - rootF := registerWideRoot(fs) - repoPath := fs.String("repo", "", "canonical agent repo") + all := registerWideAll(fs) out := fs.String("out", "", "write here instead of /"+inventoryFileName) toStdout := fs.Bool("stdout", false, "write to stdout and leave the file alone") check := fs.Bool("check", false, @@ -59,6 +58,10 @@ func runInventory(args []string, stdout, stderr io.Writer) int { // block to stderr. return exitFailure } + if fs.NArg() > 0 { + fmt.Fprintf(stderr, "agentsync: %s\n", strayArg("inventory", fs.Arg(0), nil)) + return exitFailure + } // Refused rather than resolved by precedence. --stdout says "write the // document here", --check says "write nothing and compare" — whichever @@ -71,13 +74,13 @@ func runInventory(args []string, stdout, stderr io.Writer) int { return exitFailure } - repo, repoFrom, err := openRepo(*repoPath) + repo, repoFrom, err := openRepo() if err != nil { fmt.Fprintf(stderr, "agentsync: %v\n", err) return exitFailure } - root, err := rootF.resolveWide(repo.Home) + root, err := resolveWide(*all) if err != nil { fmt.Fprintf(stderr, "agentsync: %v\n", err) return exitFailure diff --git a/cmd/agentsync/inventory_test.go b/cmd/agentsync/inventory_test.go index acd2daf..f8560e6 100644 --- a/cmd/agentsync/inventory_test.go +++ b/cmd/agentsync/inventory_test.go @@ -34,8 +34,10 @@ func inventoryFixture(t *testing.T) (base, repoRoot, root string) { return base, repoRoot, root } -func inventoryArgs(repoRoot, root string, extra ...string) []string { - return append([]string{"inventory", "--repo", repoRoot, "--root", root}, extra...) +func inventoryArgs(t *testing.T, repoRoot, root string, extra ...string) []string { + t.Helper() + inTree(t, repoRoot, root) + return append([]string{"inventory"}, extra...) } func inventoryPath(repoRoot string) string { @@ -44,11 +46,35 @@ func inventoryPath(repoRoot string) string { // --- writing ---------------------------------------------------------------- +// TestRunInventory_EmptyHomeWarnsAndCompletes: inventory needs no home to find +// its tree, so an empty $HOME is a warning, as in scan, on the default path and +// with --all. +func TestRunInventory_EmptyHomeWarnsAndCompletes(t *testing.T) { + base, repoRoot, root := inventoryFixture(t) + mustWriteT(t, projectAgentPath(base, "Web/one", "solo"), "only here\n") + + for _, extra := range [][]string{nil, {"--" + allFlag}} { + args := inventoryArgs(t, repoRoot, root, extra...) + t.Setenv("HOME", "") + mustRemoveAll(t, inventoryPath(repoRoot)) + code, stdout, stderr := runCLI(t, args...) + if code != exitOK { + t.Errorf("%v: exit = %d, want %d\n%s", extra, code, exitOK, stderr) + } + if !strings.Contains(stderr, "warning:") || !strings.Contains(stderr, "$HOME") { + t.Errorf("%v: an empty $HOME produced no warning naming it:\n%s", extra, stderr) + } + if !strings.Contains(stdout, "Root: ") || !strings.Contains(readFileT(t, inventoryPath(repoRoot)), "solo") { + t.Errorf("%v: the run did not complete:\n%s", extra, stdout) + } + } +} + func TestRunInventory_WritesTheFileAndSummarizes(t *testing.T) { base, repoRoot, root := inventoryFixture(t) mustWriteT(t, projectAgentPath(base, "Web/one", "solo"), "only here\n") - code, stdout, stderr := runCLI(t, inventoryArgs(repoRoot, root)...) + code, stdout, stderr := runCLI(t, inventoryArgs(t, repoRoot, root)...) if code != exitOK { t.Fatalf("exit = %d, want %d\nstdout:\n%s\nstderr:\n%s", code, exitOK, stdout, stderr) } @@ -74,12 +100,12 @@ func TestRunInventory_RegeneratingAnUnchangedTreeChangesNothing(t *testing.T) { mustWriteT(t, projectAgentPath(base, "Web/one", "solo"), "only here\n") mustWriteT(t, filepath.Join(base, ".claude", "agents", "global.md"), "global\n") - if code, _, stderr := runCLI(t, inventoryArgs(repoRoot, root)...); code != exitOK { + if code, _, stderr := runCLI(t, inventoryArgs(t, repoRoot, root)...); code != exitOK { t.Fatalf("first run exit = %d: %s", code, stderr) } first := readFileT(t, inventoryPath(repoRoot)) - if code, _, stderr := runCLI(t, inventoryArgs(repoRoot, root)...); code != exitOK { + if code, _, stderr := runCLI(t, inventoryArgs(t, repoRoot, root)...); code != exitOK { t.Fatalf("second run exit = %d: %s", code, stderr) } if second := readFileT(t, inventoryPath(repoRoot)); second != first { @@ -93,7 +119,7 @@ func TestRunInventory_RegeneratingAnUnchangedTreeChangesNothing(t *testing.T) { func TestRunInventory_StdoutLeavesTheFileAlone(t *testing.T) { _, repoRoot, root := inventoryFixture(t) - code, stdout, stderr := runCLI(t, inventoryArgs(repoRoot, root, "--stdout")...) + code, stdout, stderr := runCLI(t, inventoryArgs(t, repoRoot, root, "--stdout")...) if code != exitOK { t.Fatalf("exit = %d, want %d: %s", code, exitOK, stderr) } @@ -111,7 +137,7 @@ func TestRunInventory_OutRedirectsTheWrite(t *testing.T) { _, repoRoot, root := inventoryFixture(t) dest := filepath.Join(t.TempDir(), "nested", "elsewhere.json") - code, _, stderr := runCLI(t, inventoryArgs(repoRoot, root, "--out", dest)...) + code, _, stderr := runCLI(t, inventoryArgs(t, repoRoot, root, "--out", dest)...) if code != exitOK { t.Fatalf("exit = %d, want %d: %s", code, exitOK, stderr) } @@ -135,7 +161,7 @@ func TestRunInventory_NeverTouchesAProject(t *testing.T) { t.Fatalf("lstat: %v", err) } - if code, _, stderr := runCLI(t, inventoryArgs(repoRoot, root)...); code != exitOK { + if code, _, stderr := runCLI(t, inventoryArgs(t, repoRoot, root)...); code != exitOK { t.Fatalf("exit = %d: %s", code, stderr) } @@ -161,11 +187,11 @@ func TestRunInventory_NeverTouchesAProject(t *testing.T) { func TestRunInventory_Check(t *testing.T) { t.Run("a current file exits 0", func(t *testing.T) { _, repoRoot, root := inventoryFixture(t) - if code, _, stderr := runCLI(t, inventoryArgs(repoRoot, root)...); code != exitOK { + if code, _, stderr := runCLI(t, inventoryArgs(t, repoRoot, root)...); code != exitOK { t.Fatalf("write exit = %d: %s", code, stderr) } - code, stdout, stderr := runCLI(t, inventoryArgs(repoRoot, root, "--check")...) + code, stdout, stderr := runCLI(t, inventoryArgs(t, repoRoot, root, "--check")...) if code != exitOK { t.Fatalf("exit = %d, want %d\nstdout:\n%s\nstderr:\n%s", code, exitOK, stdout, stderr) @@ -177,7 +203,7 @@ func TestRunInventory_Check(t *testing.T) { t.Run("a stale file exits 1 and is left alone", func(t *testing.T) { base, repoRoot, root := inventoryFixture(t) - if code, _, stderr := runCLI(t, inventoryArgs(repoRoot, root)...); code != exitOK { + if code, _, stderr := runCLI(t, inventoryArgs(t, repoRoot, root)...); code != exitOK { t.Fatalf("write exit = %d: %s", code, stderr) } // A real change in the tree, not a hand-edit of the file: this is the @@ -186,7 +212,7 @@ func TestRunInventory_Check(t *testing.T) { mustWriteT(t, projectAgentPath(base, "Web/new", "appeared"), "new agent\n") before := readFileT(t, inventoryPath(repoRoot)) - code, _, stderr := runCLI(t, inventoryArgs(repoRoot, root, "--check")...) + code, _, stderr := runCLI(t, inventoryArgs(t, repoRoot, root, "--check")...) if code != exitAtRisk { t.Fatalf("exit = %d, want %d: %s", code, exitAtRisk, stderr) } @@ -203,7 +229,7 @@ func TestRunInventory_Check(t *testing.T) { t.Run("an absent file exits 1 and says how to create it", func(t *testing.T) { _, repoRoot, root := inventoryFixture(t) - code, _, stderr := runCLI(t, inventoryArgs(repoRoot, root, "--check")...) + code, _, stderr := runCLI(t, inventoryArgs(t, repoRoot, root, "--check")...) if code != exitAtRisk { t.Fatalf("exit = %d, want %d: %s", code, exitAtRisk, stderr) } @@ -220,7 +246,7 @@ func TestRunInventory_Check(t *testing.T) { dest := filepath.Join(t.TempDir(), "in-the-way") mustMkdirT(t, dest) - code, _, stderr := runCLI(t, inventoryArgs(repoRoot, root, "--check", "--out", dest)...) + code, _, stderr := runCLI(t, inventoryArgs(t, repoRoot, root, "--check", "--out", dest)...) // 2, not 1: the tool could not tell whether the file is current, which // is a different answer from knowing it is stale, and CI must be able // to distinguish "regenerate this" from "something is wrong here". @@ -235,7 +261,7 @@ func TestRunInventory_Check(t *testing.T) { // Refused rather than resolved by precedence: silently ignoring // --check would make `--check --stdout` exit 0 on every run and read // as a passing freshness gate while checking nothing. - code, stdout, stderr := runCLI(t, inventoryArgs(repoRoot, root, "--check", "--stdout")...) + code, stdout, stderr := runCLI(t, inventoryArgs(t, repoRoot, root, "--check", "--stdout")...) if code != exitFailure { t.Fatalf("exit = %d, want %d\nstdout:\n%s\nstderr:\n%s", code, exitFailure, stdout, stderr) @@ -266,14 +292,14 @@ func TestRunInventory_IsWiredIntoTheCLI(t *testing.T) { } _, repoRoot, root := inventoryFixture(t) - if code, _, stderr := runCLI(t, inventoryArgs(repoRoot, root, "--stdout")...); code != exitOK { + if code, _, stderr := runCLI(t, inventoryArgs(t, repoRoot, root, "--stdout")...); code != exitOK { t.Errorf("the subcommand is not reachable: exit %d: %s", code, stderr) } } func TestRunInventory_UnknownFlagExitsTwo(t *testing.T) { _, repoRoot, root := inventoryFixture(t) - code, _, _ := runCLI(t, inventoryArgs(repoRoot, root, "--nope")...) + code, _, _ := runCLI(t, inventoryArgs(t, repoRoot, root, "--nope")...) if code != exitFailure { t.Errorf("exit = %d, want %d", code, exitFailure) } @@ -291,7 +317,7 @@ func TestRunInventory_WarnsWhenTheInstallRegistryIsMissing(t *testing.T) { "market", "plug", "1.0.0", "agents", "a.md"), "plugin agent\n") for _, mode := range []string{"", "--stdout"} { - args := inventoryArgs(repoRoot, root) + args := inventoryArgs(t, repoRoot, root) if mode != "" { args = append(args, mode) } @@ -312,7 +338,7 @@ func TestRunInventory_WarnsWhenTheInstallRegistryIsMissing(t *testing.T) { func TestRunInventory_DoesNotWarnWithNoPluginTree(t *testing.T) { _, repoRoot, root := inventoryFixture(t) - _, _, stderr := runCLI(t, inventoryArgs(repoRoot, root)...) + _, _, stderr := runCLI(t, inventoryArgs(t, repoRoot, root)...) if strings.Contains(stderr, "no plugin install registry") { t.Errorf("warned with no plugin tree at all:\n%s", stderr) } diff --git a/cmd/agentsync/link.go b/cmd/agentsync/link.go index 4902743..b0cbc5b 100644 --- a/cmd/agentsync/link.go +++ b/cmd/agentsync/link.go @@ -135,8 +135,7 @@ func runLink(args []string, stdout, stderr io.Writer) int { fs.SetOutput(stderr) fs.Usage = func() { fmt.Fprint(stderr, usage) } - rootF := registerRoot(fs) - repoPath := fs.String("repo", "", "canonical agent repo") + all := registerAll(fs) apply := fs.Bool("apply", false, "create the links; without it this is a dry run") excludeLocal := fs.Bool(linker.ExcludeLocalFlag, false, "add the agents directory to the project's .git/info/exclude before linking") @@ -170,18 +169,17 @@ func runLink(args []string, stdout, stderr io.Writer) int { // Refused rather than ignored, as import does: an argument that lands here // is one the user believed was doing something. if rest := fs.Args(); len(rest) > 0 { - fmt.Fprintf(stderr, "agentsync: unexpected argument %q; link takes flags only.%s\n", - rest[0], undeclineHint(rest[0])) + fmt.Fprintf(stderr, "agentsync: %s\n", strayArg("link", rest[0], undeclineHint)) return exitFailure } - repo, repoFrom, err := openRepo(*repoPath) + repo, repoFrom, err := openRepo() if err != nil { fmt.Fprintf(stderr, "agentsync: %v\n", err) return exitFailure } - root, err := rootF.resolve(repo.Home) + root, err := resolveRoot(repo.Home, *all) if err != nil { fmt.Fprintf(stderr, "agentsync: %v\n", err) return exitFailure diff --git a/cmd/agentsync/link_test.go b/cmd/agentsync/link_test.go index a1636c2..50d612a 100644 --- a/cmd/agentsync/link_test.go +++ b/cmd/agentsync/link_test.go @@ -32,8 +32,10 @@ func linkFixture(t *testing.T) (base, repoRoot string) { return base, repoRoot } -func linkArgs(repoRoot, root string, extra ...string) []string { - return append([]string{"link", "--repo", repoRoot, "--root", root}, extra...) +func linkArgs(t *testing.T, repoRoot, root string, extra ...string) []string { + t.Helper() + inTree(t, repoRoot, root) + return append([]string{"link", "--" + allFlag}, extra...) } // globalAgent writes an agent into the repo's global pool and returns the path @@ -94,7 +96,7 @@ func TestRunLink_DryRunIsTheDefault(t *testing.T) { path := projectAgentPath(base, "proj", "shared") mustWriteT(t, path, body) - code, stdout, stderr := runCLI(t, linkArgs(repoRoot, base)...) + code, stdout, stderr := runCLI(t, linkArgs(t, repoRoot, base)...) if code != exitOK { t.Errorf("exit = %d, want %d\nstdout:\n%s\nstderr:\n%s", code, exitOK, stdout, stderr) } @@ -120,13 +122,13 @@ func TestRunLink_ApplyLinksAndIsIdempotent(t *testing.T) { path := projectAgentPath(base, "proj", "shared") mustWriteT(t, path, body) - code, stdout, stderr := runCLI(t, linkArgs(repoRoot, base, "--apply")...) + code, stdout, stderr := runCLI(t, linkArgs(t, repoRoot, base, "--apply")...) if code != exitOK { t.Fatalf("exit = %d, want %d\nstdout:\n%s\nstderr:\n%s", code, exitOK, stdout, stderr) } assertIsLink(t, path, target) - code, stdout, stderr = runCLI(t, linkArgs(repoRoot, base, "--apply")...) + code, stdout, stderr = runCLI(t, linkArgs(t, repoRoot, base, "--apply")...) if code != exitOK { t.Fatalf("second run exit = %d, want %d\nstdout:\n%s\nstderr:\n%s", code, exitOK, stdout, stderr) } @@ -153,7 +155,7 @@ func TestRunLink_OverrideLinksToItsOwnCapture(t *testing.T) { path := projectAgentPath(base, "Web/content-pipeline", "web-researcher") mustWriteT(t, path, custom) - code, stdout, stderr := runCLI(t, linkArgs(repoRoot, base, "--apply")...) + code, stdout, stderr := runCLI(t, linkArgs(t, repoRoot, base, "--apply")...) if code != exitOK { t.Fatalf("exit = %d, want %d\nstdout:\n%s\nstderr:\n%s", code, exitOK, stdout, stderr) } @@ -188,7 +190,7 @@ func TestRunLink_UncapturedFileBlocksItsProjectOnly(t *testing.T) { safe := projectAgentPath(base, "safe", "shared") mustWriteT(t, safe, body) - code, stdout, stderr := runCLI(t, linkArgs(repoRoot, base, "--apply")...) + code, stdout, stderr := runCLI(t, linkArgs(t, repoRoot, base, "--apply")...) if code != exitAtRisk { t.Fatalf("exit = %d, want %d\nstdout:\n%s\nstderr:\n%s", code, exitAtRisk, stdout, stderr) } @@ -212,7 +214,7 @@ func TestRunLink_BrokenLinkIsReportedAndKept(t *testing.T) { t.Fatalf("symlink: %v", err) } - code, stdout, stderr := runCLI(t, linkArgs(repoRoot, base, "--apply")...) + code, stdout, stderr := runCLI(t, linkArgs(t, repoRoot, base, "--apply")...) if code != exitAtRisk { t.Fatalf("exit = %d, want %d\nstdout:\n%s\nstderr:\n%s", code, exitAtRisk, stdout, stderr) } @@ -243,7 +245,7 @@ func TestRunLink_ForeignLinkIsUntouchedAndDoesNotFailTheRun(t *testing.T) { t.Fatalf("symlink: %v", err) } - code, stdout, stderr := runCLI(t, linkArgs(repoRoot, base, "--apply")...) + code, stdout, stderr := runCLI(t, linkArgs(t, repoRoot, base, "--apply")...) if code != exitOK { t.Fatalf("exit = %d, want %d: a foreign link must not fail the run\nstdout:\n%s\nstderr:\n%s", code, exitOK, stdout, stderr) @@ -283,7 +285,7 @@ func TestRunLink_TrackedAgentsDirectoryIsRefusedUntilExcluded(t *testing.T) { exclude := filepath.Join(projectDir, ".git", "info", "exclude") gitignore := filepath.Join(projectDir, ".gitignore") - code, stdout, stderr := runCLI(t, linkArgs(repoRoot, base, "--apply")...) + code, stdout, stderr := runCLI(t, linkArgs(t, repoRoot, base, "--apply")...) if code != exitAtRisk { t.Fatalf("exit = %d, want %d\nstdout:\n%s\nstderr:\n%s", code, exitAtRisk, stdout, stderr) } @@ -295,7 +297,7 @@ func TestRunLink_TrackedAgentsDirectoryIsRefusedUntilExcluded(t *testing.T) { // A dry run with the flag must still write nothing at all — including // nothing to the exclude file, which is an edit to somebody's repository. before := readFileT(t, exclude) - code, stdout, stderr = runCLI(t, linkArgs(repoRoot, base, "--exclude-local")...) + code, stdout, stderr = runCLI(t, linkArgs(t, repoRoot, base, "--exclude-local")...) if code != exitOK { t.Fatalf("dry run exit = %d, want %d\nstdout:\n%s\nstderr:\n%s", code, exitOK, stdout, stderr) } @@ -314,7 +316,7 @@ func TestRunLink_TrackedAgentsDirectoryIsRefusedUntilExcluded(t *testing.T) { t.Errorf("the dry run does not carry the exclusion caveat:\n%s", stdout) } - code, stdout, stderr = runCLI(t, linkArgs(repoRoot, base, "--exclude-local", "--apply")...) + code, stdout, stderr = runCLI(t, linkArgs(t, repoRoot, base, "--exclude-local", "--apply")...) if code != exitOK { t.Fatalf("exit = %d, want %d\nstdout:\n%s\nstderr:\n%s", code, exitOK, stdout, stderr) } @@ -345,7 +347,7 @@ func TestRunLink_IgnoredAgentsDirectoryIsReportedAsLocalOnly(t *testing.T) { path := projectAgentPath(base, "ignored", "shared") mustWriteT(t, path, body) - code, stdout, stderr := runCLI(t, linkArgs(repoRoot, base, "--apply")...) + code, stdout, stderr := runCLI(t, linkArgs(t, repoRoot, base, "--apply")...) if code != exitOK { t.Fatalf("exit = %d, want %d\nstdout:\n%s\nstderr:\n%s", code, exitOK, stdout, stderr) } @@ -365,7 +367,7 @@ func TestRunLink_AgentRequiresProject(t *testing.T) { globalAgent(t, repoRoot, "shared", "canonical shared agent\n") mustMkdirT(t, filepath.Join(base, "proj")) - code, stdout, stderr := runCLI(t, linkArgs(repoRoot, base, "--agent", "shared", "--apply")...) + code, stdout, stderr := runCLI(t, linkArgs(t, repoRoot, base, "--agent", "shared", "--apply")...) if code != exitFailure { t.Fatalf("exit = %d, want %d\nstdout:\n%s\nstderr:\n%s", code, exitFailure, stdout, stderr) } @@ -379,7 +381,7 @@ func TestRunLink_AgentRequiresProject(t *testing.T) { // The valid form provisions the project it names, and only that one. mustMkdirT(t, filepath.Join(base, "other")) code, stdout, stderr = runCLI(t, - linkArgs(repoRoot, base, "--project", "proj", "--agent", "shared", "--apply")...) + linkArgs(t, repoRoot, base, "--project", "proj", "--agent", "shared", "--apply")...) if code != exitOK { t.Fatalf("exit = %d, want %d\nstdout:\n%s\nstderr:\n%s", code, exitOK, stdout, stderr) } @@ -403,7 +405,7 @@ func TestRunLink_AgentNameCannotEscapeTheProject(t *testing.T) { globalAgent(t, repoRoot, "shared", "canonical shared agent\n") mustMkdirT(t, filepath.Join(base, "Proj", "trav")) - code, stdout, stderr := runCLI(t, linkArgs(repoRoot, base, + code, stdout, stderr := runCLI(t, linkArgs(t, repoRoot, base, "--project", "Proj/trav", "--agent", "../../../agents/shared", "--apply")...) if code != exitFailure { t.Fatalf("exit = %d, want %d\nstdout:\n%s\nstderr:\n%s", code, exitFailure, stdout, stderr) @@ -431,7 +433,7 @@ func TestRunLink_TrackedProjectWithAnUnaskableGitIsRefused(t *testing.T) { mustWriteT(t, path, body) mustWriteT(t, filepath.Join(base, "proj", ".git"), "gitdir: /nonexistent-agentsync-test\n") - code, stdout, stderr := runCLI(t, linkArgs(repoRoot, base, "--apply")...) + code, stdout, stderr := runCLI(t, linkArgs(t, repoRoot, base, "--apply")...) if code != exitAtRisk { t.Fatalf("exit = %d, want %d\nstdout:\n%s\nstderr:\n%s", code, exitAtRisk, stdout, stderr) } @@ -457,7 +459,7 @@ func TestRunLink_WrongHomeWritesNothing(t *testing.T) { wrongHome := t.TempDir() t.Setenv("HOME", wrongHome) - code, stdout, stderr := runCLI(t, linkArgs(repoRoot, base, "--apply")...) + code, stdout, stderr := runCLI(t, linkArgs(t, repoRoot, base, "--apply")...) if code != exitFailure { t.Errorf("exit = %d, want %d: a wrong $HOME must stop a write path", code, exitFailure) } @@ -474,7 +476,7 @@ func TestRunLink_WrongHomeWritesNothing(t *testing.T) { // The legitimate case the guard must not break: projects that really do // live outside $HOME, opted in explicitly. - code, _, stderr = runCLI(t, linkArgs(repoRoot, base, "--apply", "--allow-outside-home")...) + code, _, stderr = runCLI(t, linkArgs(t, repoRoot, base, "--apply", "--allow-outside-home")...) if code != exitOK { t.Fatalf("opted-in exit = %d, want %d\nstderr:\n%s", code, exitOK, stderr) } @@ -491,7 +493,7 @@ func TestRunLink_JSONReport(t *testing.T) { globalAgent(t, repoRoot, "shared", body) mustWriteT(t, projectAgentPath(base, "proj", "shared"), body) - code, stdout, stderr := runCLI(t, linkArgs(repoRoot, base, "--json")...) + code, stdout, stderr := runCLI(t, linkArgs(t, repoRoot, base, "--json")...) if code != exitOK { t.Fatalf("exit = %d, want %d\nstderr:\n%s", code, exitOK, stderr) } @@ -541,7 +543,7 @@ func TestRunLink_ApplyRecordsWhatItCreated(t *testing.T) { path := projectAgentPath(base, "proj", "shared") mustWriteT(t, path, body) - code, stdout, stderr := runCLI(t, linkArgs(repoRoot, base, "--apply")...) + code, stdout, stderr := runCLI(t, linkArgs(t, repoRoot, base, "--apply")...) if code != exitOK { t.Fatalf("exit = %d, want %d\nstdout:\n%s\nstderr:\n%s", code, exitOK, stdout, stderr) } @@ -576,7 +578,7 @@ func TestRunLink_DryRunRecordsNothing(t *testing.T) { globalAgent(t, repoRoot, "shared", body) mustWriteT(t, projectAgentPath(base, "proj", "shared"), body) - if code, stdout, stderr := runCLI(t, linkArgs(repoRoot, base)...); code != exitOK { + if code, stdout, stderr := runCLI(t, linkArgs(t, repoRoot, base)...); code != exitOK { t.Fatalf("exit = %d\nstdout:\n%s\nstderr:\n%s", code, stdout, stderr) } if _, err := os.Lstat(filepath.Join(repoRoot, ".agentsync-state.json")); err == nil { @@ -592,7 +594,7 @@ func TestRunLink_DeclinedAgentIsReportedAndNotRecreated(t *testing.T) { mustMkdirT(t, filepath.Join(base, "proj")) declineInState(t, repoRoot, "proj", "shared") - code, stdout, stderr := runCLI(t, linkArgs(repoRoot, base, + code, stdout, stderr := runCLI(t, linkArgs(t, repoRoot, base, "--apply", "--project", "proj", "--agent", "shared")...) if code != exitOK { t.Fatalf("exit = %d, want %d\nstdout:\n%s\nstderr:\n%s", code, exitOK, stdout, stderr) @@ -616,7 +618,7 @@ func TestRunLink_UndeclineClearsTheMarkerAndLinks(t *testing.T) { mustMkdirT(t, filepath.Join(base, "proj")) declineInState(t, repoRoot, "proj", "shared") - code, stdout, stderr := runCLI(t, linkArgs(repoRoot, base, + code, stdout, stderr := runCLI(t, linkArgs(t, repoRoot, base, "--apply", "--project", "proj", "--agent", "shared", "--undecline=proj/shared")...) if code != exitOK { t.Fatalf("exit = %d, want %d\nstdout:\n%s\nstderr:\n%s", code, exitOK, stdout, stderr) @@ -642,7 +644,7 @@ func TestRunLink_UndeclineNeedsApply(t *testing.T) { mustMkdirT(t, filepath.Join(base, "proj")) declineInState(t, repoRoot, "proj", "shared") - code, stdout, stderr := runCLI(t, linkArgs(repoRoot, base, + code, stdout, stderr := runCLI(t, linkArgs(t, repoRoot, base, "--project", "proj", "--agent", "shared", "--undecline=proj/shared")...) if code != exitOK { t.Fatalf("exit = %d\nstdout:\n%s\nstderr:\n%s", code, stdout, stderr) @@ -668,7 +670,7 @@ func TestRunLink_UndeclineThatNamesNoMarkerIsReportedNotFailed(t *testing.T) { globalAgent(t, repoRoot, "shared", "canonical shared agent\n") mustMkdirT(t, filepath.Join(base, "proj")) - code, stdout, stderr := runCLI(t, linkArgs(repoRoot, base, + code, stdout, stderr := runCLI(t, linkArgs(t, repoRoot, base, "--apply", "--project", "proj", "--agent", "shared", "--undecline=proj/typo")...) if code != exitOK { t.Fatalf("exit = %d, want %d\nstdout:\n%s\nstderr:\n%s", code, exitOK, stdout, stderr) @@ -685,7 +687,7 @@ func TestRunLink_UndeclineRefusesAValueThatIsNotAPair(t *testing.T) { base, repoRoot := linkFixture(t) globalAgent(t, repoRoot, "shared", "canonical shared agent\n") - code, _, stderr := runCLI(t, linkArgs(repoRoot, base, "--undecline", "--apply")...) + code, _, stderr := runCLI(t, linkArgs(t, repoRoot, base, "--undecline", "--apply")...) if code != exitFailure { t.Fatalf("exit = %d, want %d; --apply was swallowed as a selector", code, exitFailure) } @@ -700,7 +702,7 @@ func TestRunLink_RefusesAPositionalArgument(t *testing.T) { base, repoRoot := linkFixture(t) globalAgent(t, repoRoot, "shared", "canonical shared agent\n") - code, _, stderr := runCLI(t, linkArgs(repoRoot, base, "proj/shared")...) + code, _, stderr := runCLI(t, linkArgs(t, repoRoot, base, "proj/shared")...) if code != exitFailure { t.Fatalf("exit = %d, want %d", code, exitFailure) } @@ -719,7 +721,7 @@ func TestRunLink_AnUnreadableStateFileStopsTheRun(t *testing.T) { mustWriteT(t, path, body) mustWriteT(t, filepath.Join(repoRoot, ".agentsync-state.json"), "{\"schema\":99}\n") - code, _, stderr := runCLI(t, linkArgs(repoRoot, base, "--apply")...) + code, _, stderr := runCLI(t, linkArgs(t, repoRoot, base, "--apply")...) if code != exitFailure { t.Fatalf("exit = %d, want %d", code, exitFailure) } diff --git a/cmd/agentsync/main.go b/cmd/agentsync/main.go index e53ab0c..667e0b0 100644 --- a/cmd/agentsync/main.go +++ b/cmd/agentsync/main.go @@ -50,6 +50,7 @@ import ( "fmt" "io" "os" + "path/filepath" "sort" "strings" "unicode/utf8" @@ -74,26 +75,20 @@ Usage: agentsync override [flags] set this checkout's model and effort for a Claude agent agentsync version +Every command but init and inventory works on the project the working +directory is in: the first ancestor below $HOME holding .claude/agents/, else +.claude/, else .git/. For link that also selects the project as if --project +had named it. Nothing found before $HOME is refused rather than widened. To +work on a different project, run the command from inside it. Every run prints +the root it chose and how it decided. + +The canonical agent repo comes from $AGENTSYNC_REPO, else repo in the config +file; one of the two is required. + Common flags: - --root PATH directory tree to scan for projects. Without it the root is - derived from the working directory: the first ancestor - below $HOME holding .claude/agents/, else .claude/, else - .git/ — so running inside a project means that project, and - for link it also selects that project as if --project had - named it. Nothing found before $HOME is refused rather - than widened: pass --all or --root to scan from there. - Every run prints the root it chose and how it decided. --all scan the whole default tree ($AGENTSYNC_ROOT, else root in the config file) rather than the project the working directory - is in. Refused together with --root: the two say different - things and neither should quietly win. - -inventory is the one command that does NOT derive a root: it describes the whole -machine by definition, so --root still names a tree but the default is the whole -default tree and never the project you are standing in. --all is accepted there -and means what the default already means. - --repo PATH canonical agent repo (default $AGENTSYNC_REPO, else repo in the - config file; required from one of the three) + is in --json emit machine-readable JSON --allow-outside-home your projects legitimately live outside $HOME; do not warn @@ -101,10 +96,16 @@ and means what the default already means. link, reconcile --apply) that every project key falls under _outside-home/ +inventory is the one command that does NOT derive a root: it describes the whole +machine by definition, so it always scans the default tree and never the project +you are standing in. --all is accepted there and means what the default already +means. + Config file: $XDG_CONFIG_HOME/agentsync/config.toml, else -~/.config/agentsync/config.toml. It may set repo and root; a flag beats the -environment, which beats the file. A file that exists but is broken refuses. -agentsync init writes it. +~/.config/agentsync/config.toml. It may set repo and root; $AGENTSYNC_REPO and +$AGENTSYNC_ROOT beat the file, and both must be absolute paths. A file that +exists but is broken refuses. agentsync init writes it, and is the one command +that takes --repo and --root. init flags: --repo PATH the canonical agent repo to write (must hold agents/) @@ -246,14 +247,14 @@ unresolved or skipped, or a change was refused; exit 2 when a write failed. override flags: --agent NAME the agent to override, by its frontmatter name; it must be - in the root's .claude/agents/ (--all is refused) + in the project'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 +With no flags, override lists the project'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 @@ -350,8 +351,8 @@ const ( envRoot = "AGENTSYNC_ROOT" ) -// loadConfig reads the config file. Callers reach it only once flag and env -// have left a value unset, so a broken file never blocks a run that named both. +// loadConfig reads the config file. Callers reach it only once the environment +// has left a value unset, so a broken file never blocks a run that set both. func loadConfig() (config.Config, string, error) { path, err := config.Path() if err != nil { @@ -361,13 +362,14 @@ func loadConfig() (config.Config, string, error) { return c, path, err } -// openRepo resolves the repo by flag, then $AGENTSYNC_REPO, then the config -// file, and refuses rather than guessing: every link this tool writes points -// into it. The string returned names where the path came from. -func openRepo(flagValue string) (*scan.Repo, string, error) { - path, from := strings.TrimSpace(flagValue), "--repo" - if path == "" { - path, from = strings.TrimSpace(os.Getenv(envRepo)), "$"+envRepo +// openRepo resolves the repo from $AGENTSYNC_REPO, then the config file, and +// refuses rather than guessing: every link this tool writes points into it. The +// string returned names where the path came from. +func openRepo() (*scan.Repo, string, error) { + path, from := strings.TrimSpace(os.Getenv(envRepo)), "$"+envRepo + // A relative value would name a different repo from every directory. + if path != "" && !filepath.IsAbs(path) { + return nil, "", fmt.Errorf("$%s must be an absolute path, got %q", envRepo, path) } if path == "" { c, cfgPath, err := loadConfig() @@ -386,10 +388,32 @@ func openRepo(flagValue string) (*scan.Repo, string, error) { return repo, from, nil } -// notConfigured is the refusal for a setting no source supplied. key is both -// the flag name and the config key. +// notConfigured is the refusal for a setting neither the environment nor the +// config file supplied. func notConfigured(what, key, env, cfgPath string) error { - return fmt.Errorf("no %s configured.\n Create a config file: agentsync init\n Pass it each run: --%s PATH (or set $%s)\n Or set %s in %s", what, key, env, key, cfgPath) + return fmt.Errorf("no %s configured.\n Create a config file: agentsync init\n Or set $%s\n Or set %s in %s", what, env, key, cfgPath) +} + +// strayArg is the refusal for a positional argument. One shaped like a path is +// someone naming a project, so it is told to cd instead; anything else gets the +// command's own hint, if it has one. +func strayArg(cmd, arg string, hint func(string) string) string { + msg := fmt.Sprintf("unexpected argument %q; %s takes flags only.", arg, cmd) + if looksLikePath(arg) { + return msg + " Run it from the project directory instead." + } + if hint != nil { + msg += hint(arg) + } + return msg +} + +func looksLikePath(arg string) bool { + if strings.HasPrefix(arg, "/") || strings.HasPrefix(arg, "~") || strings.HasPrefix(arg, ".") { + return true + } + fi, err := os.Stat(arg) + return err == nil && fi.IsDir() } // emitRepoChoice prints which repo this run uses and where that came from, @@ -403,8 +427,7 @@ func runScan(args []string, stdout, stderr io.Writer) int { fs.SetOutput(stderr) fs.Usage = func() { fmt.Fprint(stderr, usage) } - rootF := registerRoot(fs) - repoPath := fs.String("repo", "", "canonical agent repo") + all := registerAll(fs) asJSON := fs.Bool("json", false, "emit JSON") atRiskOnly := fs.Bool("at-risk", false, "list only at-risk entries") failOnRisk := fs.Bool("fail-on-risk", false, "exit 1 if any at-risk entry is found") @@ -419,14 +442,18 @@ func runScan(args []string, stdout, stderr io.Writer) int { // block to stderr. Printing it again would double up. return exitFailure } + if fs.NArg() > 0 { + fmt.Fprintf(stderr, "agentsync: %s\n", strayArg("scan", fs.Arg(0), nil)) + return exitFailure + } - repo, repoFrom, err := openRepo(*repoPath) + repo, repoFrom, err := openRepo() if err != nil { fmt.Fprintf(stderr, "agentsync: %v\n", err) return exitFailure } - root, err := rootF.resolve(repo.Home) + root, err := resolveRoot(repo.Home, *all) if err != nil { fmt.Fprintf(stderr, "agentsync: %v\n", err) return exitFailure diff --git a/cmd/agentsync/main_test.go b/cmd/agentsync/main_test.go index 9f8a333..7d0e5e9 100644 --- a/cmd/agentsync/main_test.go +++ b/cmd/agentsync/main_test.go @@ -478,9 +478,7 @@ func TestRunScan_BadFlagReportsOnce(t *testing.T) { } func TestRunScan_MissingRepoFails(t *testing.T) { - code, _, stderr := runCLI(t, "scan", - "--repo", filepath.Join(t.TempDir(), "no-such-repo"), - "--root", t.TempDir()) + code, _, stderr := runCLI(t, scanArgs(t, filepath.Join(t.TempDir(), "no-such-repo"), t.TempDir())...) if code != exitFailure { t.Errorf("exit = %d, want %d", code, exitFailure) } @@ -513,7 +511,7 @@ func TestRunScan_EndToEnd_AtRiskConsistency(t *testing.T) { // loose: a file in no repository at all. mustWriteT(t, filepath.Join(base, "loose", ".claude", "agents", "orphan.md"), "nowhere else\n") - commonArgs := []string{"scan", "--repo", repoRoot, "--root", base} + commonArgs := scanArgs(t, repoRoot, base) codeText, stdoutText, _ := runCLI(t, append(append([]string{}, commonArgs...), "--at-risk")...) codeJSON, stdoutJSON, _ := runCLI(t, append(append([]string{}, commonArgs...), "--at-risk", "--json")...) @@ -580,7 +578,7 @@ func TestRunScan_EndToEnd_ProblemDoesNotHideRisk(t *testing.T) { // A later project holding the file we must not miss. mustWriteT(t, filepath.Join(base, "zzz", ".claude", "agents", "precious.md"), "unique\n") - code, stdout, stderr := runCLI(t, "scan", "--repo", repoRoot, "--root", base, "--fail-on-risk") + code, stdout, stderr := runCLI(t, scanArgs(t, repoRoot, base, "--fail-on-risk")...) if code != exitAtRisk { t.Errorf("exit = %d, want %d (an unprotected file exists)\nstdout:\n%s\nstderr:\n%s", code, exitAtRisk, stdout, stderr) @@ -593,6 +591,29 @@ func TestRunScan_EndToEnd_ProblemDoesNotHideRisk(t *testing.T) { } } +// useRepo names the repo the way a user's environment does, and points the +// config lookup at an empty directory so the author's real file is never read. +func useRepo(t *testing.T, repoRoot string) { + t.Helper() + t.Setenv(envRepo, repoRoot) + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) +} + +// inProject names the repo and makes dir the working directory, which is how +// a run names its project. +func inProject(t *testing.T, repoRoot, dir string) { + t.Helper() + useRepo(t, repoRoot) + t.Chdir(dir) +} + +// inTree makes root the default tree, for a run that passes --all. +func inTree(t *testing.T, repoRoot, root string) { + t.Helper() + useRepo(t, repoRoot) + t.Setenv(envRoot, root) +} + // --- local fs helpers (the scan package's own are unexported) -------------- func mustMkdirT(t *testing.T, dir string) { diff --git a/cmd/agentsync/optimize.go b/cmd/agentsync/optimize.go index e56b73f..708444b 100644 --- a/cmd/agentsync/optimize.go +++ b/cmd/agentsync/optimize.go @@ -41,8 +41,7 @@ func runOptimize(args []string, stdout, stderr io.Writer) int { fs.SetOutput(stderr) fs.Usage = func() { fmt.Fprint(stderr, usage) } - rootF := registerRoot(fs) - repoPath := fs.String("repo", "", "canonical agent repo") + all := registerAll(fs) asJSON := fs.Bool("json", false, "emit JSON") advisor := fs.String("advisor", defaultAdvisor, "OpenRouter decision model to ask") maxCost := fs.Float64("max-cost", 0.50, "most this run may spend, in USD") @@ -67,12 +66,12 @@ func runOptimize(args []string, stdout, stderr io.Writer) int { return exitFailure } - repo, repoFrom, err := openRepo(*repoPath) + repo, repoFrom, err := openRepo() if err != nil { fmt.Fprintf(stderr, "agentsync: %v\n", err) return exitFailure } - root, err := rootF.resolve(repo.Home) + root, err := resolveRoot(repo.Home, *all) if err != nil { fmt.Fprintf(stderr, "agentsync: %v\n", err) return exitFailure @@ -299,7 +298,7 @@ func resolved(p string) string { func optimizeFlagsErr(fs *flag.FlagSet, advisor string, maxCost, minConf float64) error { switch { case fs.NArg() > 0: - return fmt.Errorf("optimize takes no arguments, got %q", fs.Arg(0)) + return errors.New(strayArg("optimize", fs.Arg(0), nil)) case strings.TrimSpace(advisor) == "": return errors.New("--advisor is empty") case !(maxCost >= 0) || math.IsInf(maxCost, 1): diff --git a/cmd/agentsync/optimize_test.go b/cmd/agentsync/optimize_test.go index 6cf60c0..0d13351 100644 --- a/cmd/agentsync/optimize_test.go +++ b/cmd/agentsync/optimize_test.go @@ -10,6 +10,7 @@ import ( "os" "path/filepath" "reflect" + "runtime" "strings" "sync" "testing" @@ -17,11 +18,12 @@ import ( "github.com/agentic-tooling/agentsync/internal/optimize" ) -const orTestdata = "../../internal/openrouter/testdata/" +// orTestdata is made absolute before any test changes directory. +var orTestdata, _ = filepath.Abs("../../internal/openrouter/testdata") func orFixture(t *testing.T, name string) string { t.Helper() - b, err := os.ReadFile(orTestdata + name) + b, err := os.ReadFile(filepath.Join(orTestdata, name)) if err != nil { t.Fatal(err) } @@ -110,11 +112,12 @@ func optimizeProject(t *testing.T, files map[string]string) (project, repoRoot s for name, content := range files { mustWriteT(t, filepath.Join(project, ".claude", "agents", name), content) } + inProject(t, repoRoot, project) return project, repoRoot } func TestOptimize_Report(t *testing.T) { - project, repoRoot := optimizeProject(t, map[string]string{ + optimizeProject(t, map[string]string{ "go-merge-reviewer.md": agentFile("go-merge-reviewer", "opus", "xhigh"), "README.md": "not an agent\n", }) @@ -122,13 +125,13 @@ func TestOptimize_Report(t *testing.T) { f.start(t) t.Setenv("CLAUDE_CODE_EFFORT_LEVEL", "low") - code, stdout, stderr := runCLI(t, "optimize", "--root", project, "--repo", repoRoot) + code, stdout, stderr := runCLI(t, "optimize") if code != exitOK { t.Fatalf("exit = %d, want %d\n%s", code, exitOK, stderr) } for _, w := range []string{ - "Repo: ~/Web/agents-repo (from --repo).", - "Root: ~/Web/proj (from --root).", + "Repo: ~/Web/agents-repo (from $AGENTSYNC_REPO).", + "Root: ~/Web/proj (from the current directory, which is in a project: .claude/agents) — pass --all to widen.", "Note: $CLAUDE_CODE_EFFORT_LEVEL is set", "Asked ~typesafe/jev-latest about 1 agent (cap $0.50, min confidence 0.60).", "AGENT NOW SUGGESTED CONFIDENCE", @@ -149,12 +152,12 @@ func TestOptimize_Report(t *testing.T) { } func TestOptimize_JSON(t *testing.T) { - project, repoRoot := optimizeProject(t, map[string]string{"a.md": agentFile("a", "sonnet", "high")}) + optimizeProject(t, map[string]string{"a.md": agentFile("a", "sonnet", "high")}) f := &fakeOpenRouter{models: orFixture(t, "models.json"), decisions: []string{orFixture(t, "decision-clef.json")}} f.start(t) t.Setenv("CLAUDE_CODE_SUBAGENT_MODEL", "haiku") - code, stdout, stderr := runCLI(t, "optimize", "--json", "--advisor", "cloudflare/clef", "--root", project, "--repo", repoRoot) + code, stdout, stderr := runCLI(t, "optimize", "--json", "--advisor", "cloudflare/clef") if code != exitOK { t.Fatalf("exit = %d\n%s", code, stderr) } @@ -179,10 +182,10 @@ func TestOptimize_JSON(t *testing.T) { } func TestOptimize_JSONWriteFails(t *testing.T) { - project, repoRoot := optimizeProject(t, map[string]string{"a.md": agentFile("a", "opus", "high")}) + optimizeProject(t, map[string]string{"a.md": agentFile("a", "opus", "high")}) (&fakeOpenRouter{models: orFixture(t, "models.json"), decisions: []string{orFixture(t, "decision-clef.json")}}).start(t) var stderr bytes.Buffer - if code := runOptimize([]string{"--json", "--root", project, "--repo", repoRoot}, failWriter{}, &stderr); code != exitFailure { + if code := runOptimize([]string{"--json"}, failWriter{}, &stderr); code != exitFailure { t.Errorf("exit = %d, want %d\n%s", code, exitFailure, stderr.String()) } } @@ -192,7 +195,7 @@ type failWriter struct{} func (failWriter) Write([]byte) (int, error) { return 0, errors.New("closed") } func TestOptimize_UnresolvedAndSkippedExitOne(t *testing.T) { - project, repoRoot := optimizeProject(t, map[string]string{ + optimizeProject(t, map[string]string{ "a.md": agentFile("a", "opus", "high"), "b.md": agentFile("b", "opus", "high"), }) @@ -200,7 +203,7 @@ func TestOptimize_UnresolvedAndSkippedExitOne(t *testing.T) { f.start(t) t.Setenv("CLAUDE_CODE_SUBAGENT_MODEL", "haiku") - code, stdout, stderr := runCLI(t, "optimize", "--root", project, "--repo", repoRoot) + code, stdout, stderr := runCLI(t, "optimize") if code != exitAtRisk { t.Errorf("exit = %d, want %d", code, exitAtRisk) } @@ -236,9 +239,10 @@ func TestOptimize_AgentSelectionAndDedup(t *testing.T) { f := &fakeOpenRouter{models: orFixture(t, "models.json"), decisions: []string{clef, clef, clef, clef}} f.start(t) web := filepath.Join(home, "Web") + inTree(t, filepath.Join(web, "agents-repo"), web) // The shared file is asked about once, under the first project linking it. - code, stdout, stderr := runCLI(t, "optimize", "--root", web, "--repo", repoRoot) + code, stdout, stderr := runCLI(t, "optimize", "--all") if code != exitOK { t.Fatalf("exit = %d\n%s", code, stderr) } @@ -251,12 +255,12 @@ func TestOptimize_AgentSelectionAndDedup(t *testing.T) { t.Errorf("%d requests, want 3 with the shared file once:\n%s", f.requests(), stdout) } - code, stdout, stderr = runCLI(t, "optimize", "--agent", "shared", "--root", web, "--repo", repoRoot) + code, stdout, stderr = runCLI(t, "optimize", "--agent", "shared", "--all") if code != exitOK || f.requests() != 4 || strings.Contains(stdout, "own") { t.Errorf("--agent shared: exit %d, %d requests in all, want 4:\n%s%s", code, f.requests(), stdout, stderr) } - code, _, stderr = runCLI(t, "optimize", "--agent", "shared", "--agent", "nope", "--root", web, "--repo", repoRoot) + code, _, stderr = runCLI(t, "optimize", "--agent", "shared", "--agent", "nope", "--all") if code != exitFailure || !strings.Contains(stderr, "--agent nope: no Claude agent by that name") { t.Errorf("exit = %d, stderr:\n%s", code, stderr) } @@ -276,7 +280,7 @@ func TestOptimize_RefusesBeforeAnyRequest(t *testing.T) { }{ {name: "help", args: []string{"-h"}, code: exitOK}, {name: "unknown flag", args: []string{"--nope"}, code: exitFailure, stderr: "flag provided but not defined"}, - {name: "positional", args: []string{"extra"}, code: exitFailure, stderr: `optimize takes no arguments, got "extra"`}, + {name: "positional", args: []string{"extra"}, code: exitFailure, stderr: `unexpected argument "extra"; optimize takes flags only.`}, {name: "empty advisor", args: []string{"--advisor", " "}, code: exitFailure, stderr: "--advisor is empty"}, {name: "negative cap", args: []string{"--max-cost", "-1"}, code: exitFailure, stderr: "--max-cost must be"}, {name: "infinite cap", args: []string{"--max-cost", "Inf"}, code: exitFailure, stderr: "--max-cost must be"}, @@ -284,10 +288,11 @@ func TestOptimize_RefusesBeforeAnyRequest(t *testing.T) { {name: "no key", setup: func(t *testing.T, _ string) { t.Setenv(envOpenRouterKey, "") }, code: exitFailure, stderr: "$OPENROUTER_API_KEY"}, {name: "no repo", setup: func(t *testing.T, _ string) { t.Setenv(envRepo, "") }, - args: []string{"--repo", ""}, code: exitFailure, stderr: "no canonical agent repo configured"}, - {name: "root and all", args: []string{"--all"}, code: exitFailure, stderr: "mutually exclusive"}, + code: exitFailure, stderr: "no canonical agent repo configured"}, + {name: "all without a default tree", setup: func(t *testing.T, _ string) { t.Setenv(envRoot, "") }, + args: []string{"--all"}, code: exitFailure, stderr: "--all: no default tree of projects configured"}, {name: "root missing", setup: func(t *testing.T, project string) { mustRemoveAll(t, project) }, - code: exitFailure, stderr: "walking"}, + code: exitFailure, stderr: deletedCwdRefusal()}, {name: "scan problem", files: map[string]string{"dir.md/x": "x"}, code: exitFailure, stderr: "could not read every agent under the root"}, {name: "broken link", setup: func(t *testing.T, project string) { if err := os.Symlink(filepath.Join(project, "gone.md"), filepath.Join(project, ".claude", "agents", "b.md")); err != nil { @@ -306,7 +311,7 @@ func TestOptimize_RefusesBeforeAnyRequest(t *testing.T) { if files == nil { files = good } - project, repoRoot := optimizeProject(t, files) + project, _ := optimizeProject(t, files) f := &fakeOpenRouter{models: orFixture(t, "models.json")} if tc.fake != nil { tc.fake(f) @@ -315,7 +320,7 @@ func TestOptimize_RefusesBeforeAnyRequest(t *testing.T) { if tc.setup != nil { tc.setup(t, project) } - args := append([]string{"optimize", "--root", project, "--repo", repoRoot}, tc.args...) + args := append([]string{"optimize"}, tc.args...) code, stdout, stderr := runCLI(t, args...) if code != tc.code || !strings.Contains(stderr, tc.stderr) { t.Errorf("exit = %d, want %d; stderr missing %q:\n%s", code, tc.code, tc.stderr, stderr) @@ -339,10 +344,10 @@ func mustRemoveAll(t *testing.T, p string) { } func TestOptimize_DryRunWritesNothing(t *testing.T) { - project, repoRoot := optimizeProject(t, map[string]string{"a.md": agentFile("a", "sonnet", "high")}) + project, _ := optimizeProject(t, map[string]string{"a.md": agentFile("a", "sonnet", "high")}) (&fakeOpenRouter{models: orFixture(t, "models.json"), decisions: []string{orFixture(t, "decision-clef.json")}}).start(t) - code, stdout, stderr := runCLI(t, "optimize", "--root", project, "--repo", repoRoot) + code, stdout, stderr := runCLI(t, "optimize") if code != exitOK || !strings.Contains(stdout, "Dry run: nothing written. Run with --apply to write 1 change.\n") { t.Errorf("exit %d:\n%s%s", code, stdout, stderr) } @@ -357,6 +362,7 @@ func TestOptimize_ApplyWritesTheCanonicalTarget(t *testing.T) { home := mustResolveDir(t, t.TempDir()) t.Setenv("HOME", home) web := filepath.Join(home, "Web") + inTree(t, filepath.Join(web, "agents-repo"), web) repoRoot := filepath.Join(web, "agents-repo") mustMkdirT(t, filepath.Join(repoRoot, "project-agents")) target := filepath.Join(repoRoot, "agents", "shared.md") @@ -364,13 +370,13 @@ func TestOptimize_ApplyWritesTheCanonicalTarget(t *testing.T) { mustWriteT(t, target, content) for _, p := range []string{"one", "two"} { mustMkdirT(t, filepath.Join(web, p, ".claude")) - if code, out, errOut := runCLI(t, "link", "--repo", repoRoot, "--root", web, "--project", "Web/"+p, "--agent", "shared", "--apply"); code != exitOK { + if code, out, errOut := runCLI(t, "link", "--all", "--project", "Web/"+p, "--agent", "shared", "--apply"); code != exitOK { t.Fatalf("link %s: exit %d\n%s%s", p, code, out, errOut) } } before := map[string]string{} for _, cmd := range []string{"doctor", "reconcile"} { - code, out, errOut := runCLI(t, cmd, "--root", web, "--repo", repoRoot) + code, out, errOut := runCLI(t, cmd, "--all") if code != exitOK { t.Fatalf("%s before the apply: exit %d\n%s%s", cmd, code, out, errOut) } @@ -379,7 +385,7 @@ func TestOptimize_ApplyWritesTheCanonicalTarget(t *testing.T) { f := &fakeOpenRouter{models: orFixture(t, "models.json"), decisions: []string{orFixture(t, "decision-clef.json"), orFixture(t, "decision-jev.json"), orFixture(t, "decision-clef.json")}} f.start(t) - code, stdout, stderr := runCLI(t, "optimize", "--apply", "--root", web, "--repo", repoRoot) + code, stdout, stderr := runCLI(t, "optimize", "--apply", "--all") if code != exitOK { t.Fatalf("exit = %d\n%s%s", code, stdout, stderr) } @@ -400,14 +406,14 @@ func TestOptimize_ApplyWritesTheCanonicalTarget(t *testing.T) { } } for cmd, was := range before { - if code, out, _ := runCLI(t, cmd, "--root", web, "--repo", repoRoot); code != exitOK || out != was { + if code, out, _ := runCLI(t, cmd, "--all"); code != exitOK || out != was { t.Errorf("%s changed after the apply: exit %d\n%s\n->\n%s", cmd, code, was, out) } } // Without a state file there is no link count to give. mustRemoveAll(t, filepath.Join(repoRoot, ".agentsync-state.json")) - code, stdout, stderr = runCLI(t, "optimize", "--apply", "--root", web, "--repo", repoRoot) + code, stdout, stderr = runCLI(t, "optimize", "--apply", "--all") if code != exitOK || !strings.Contains(stdout, "wrote ~/Web/agents-repo/agents/shared.md (canonical file)\n") { t.Errorf("exit %d:\n%s%s", code, stdout, stderr) } @@ -417,7 +423,7 @@ func TestOptimize_ApplyWritesTheCanonicalTarget(t *testing.T) { // An unreadable state file is warned about, not trusted for a count. mustWriteT(t, filepath.Join(repoRoot, ".agentsync-state.json"), "{") - code, stdout, stderr = runCLI(t, "optimize", "--apply", "--min-confidence", "0.5", "--root", web, "--repo", repoRoot) + code, stdout, stderr = runCLI(t, "optimize", "--apply", "--min-confidence", "0.5", "--all") if code != exitOK || !strings.Contains(stdout, "(canonical file)\n") || !strings.Contains(stderr, "warning: link counts not shown") { t.Errorf("exit %d:\n%s%s", code, stdout, stderr) } @@ -426,7 +432,7 @@ func TestOptimize_ApplyWritesTheCanonicalTarget(t *testing.T) { // Only the confident differing part of a change row is written, in place; // keep and unresolved rows are left alone. func TestOptimize_ApplyEditsAProjectFileInPlace(t *testing.T) { - project, repoRoot := optimizeProject(t, map[string]string{ + project, _ := optimizeProject(t, map[string]string{ "b.md": agentFile("b", "opus", "high"), "c.md": agentFile("c", "opus", "low"), }) @@ -442,7 +448,7 @@ func TestOptimize_ApplyEditsAProjectFileInPlace(t *testing.T) { clef := orFixture(t, "decision-clef.json") (&fakeOpenRouter{models: orFixture(t, "models.json"), decisions: []string{clef, clef}}).start(t) - code, stdout, stderr := runCLI(t, "optimize", "--apply", "--root", project, "--repo", repoRoot) + code, stdout, stderr := runCLI(t, "optimize", "--apply") if code != exitAtRisk { t.Errorf("exit = %d, want %d (c is unresolved)\n%s", code, exitAtRisk, stderr) } @@ -469,7 +475,7 @@ func TestOptimize_ApplyEditsAProjectFileInPlace(t *testing.T) { // written; the other rows still are. func TestOptimize_ApplyRefusals(t *testing.T) { block := "---\nname: b\ndescription: d\nmodel: |\n sonnet\n---\n" - project, repoRoot := optimizeProject(t, map[string]string{ + project, _ := optimizeProject(t, map[string]string{ "a.md": agentFile("a", "sonnet", "high"), "b.md": block, "c.md": agentFile("c", "sonnet", "high"), @@ -485,7 +491,7 @@ func TestOptimize_ApplyRefusals(t *testing.T) { } f.start(t) - code, stdout, stderr := runCLI(t, "optimize", "--apply", "--json", "--root", project, "--repo", repoRoot) + code, stdout, stderr := runCLI(t, "optimize", "--apply", "--json") if code != exitAtRisk { t.Errorf("exit = %d, want %d\n%s", code, exitAtRisk, stderr) } @@ -510,7 +516,7 @@ func TestOptimize_ApplyRefusals(t *testing.T) { } func TestOptimize_ApplyWriteFailureExitsTwo(t *testing.T) { - project, repoRoot := optimizeProject(t, map[string]string{ + project, _ := optimizeProject(t, map[string]string{ "a.md": agentFile("a", "sonnet", "high"), "b.md": agentFile("b", "sonnet", "high"), }) @@ -524,7 +530,7 @@ func TestOptimize_ApplyWriteFailureExitsTwo(t *testing.T) { } f.start(t) - code, stdout, stderr := runCLI(t, "optimize", "--apply", "--root", project, "--repo", repoRoot) + code, stdout, stderr := runCLI(t, "optimize", "--apply") if code != exitFailure { t.Errorf("exit = %d, want %d\n%s", code, exitFailure, stderr) } @@ -537,12 +543,12 @@ func TestOptimize_ApplyWriteFailureExitsTwo(t *testing.T) { } func TestOptimize_ApplyNotesOverrideStillWins(t *testing.T) { - project, repoRoot, settings := overrideFixture(t) + _, _, settings := overrideFixture(t) mustWriteT(t, settings, oneOverride) clef := orFixture(t, "decision-clef.json") (&fakeOpenRouter{models: orFixture(t, "models.json"), decisions: []string{clef, clef, clef, clef}}).start(t) - code, stdout, stderr := runCLI(t, "optimize", "--apply", "--agent", "a", "--root", project, "--repo", repoRoot) + code, stdout, stderr := runCLI(t, "optimize", "--apply", "--agent", "a") if code != exitOK { t.Fatalf("exit %d\n%s", code, stderr) } @@ -554,13 +560,13 @@ func TestOptimize_ApplyNotesOverrideStillWins(t *testing.T) { } // b's model is written, but its override sets only effort, so nothing is masked. - code, stdout, stderr = runCLI(t, "optimize", "--apply", "--root", project, "--repo", repoRoot) + code, stdout, stderr = runCLI(t, "optimize", "--apply") if code != exitOK || !strings.Contains(stdout, "wrote ~/Web/proj/.claude/agents/b.md\n") || strings.Contains(stdout, "still wins") { t.Errorf("exit %d:\n%s%s", code, stdout, stderr) } // a's effort is written, but its override sets only model. - code, stdout, stderr = runCLI(t, "optimize", "--apply", "--min-confidence", "0.5", "--agent", "a", "--root", project, "--repo", repoRoot) + code, stdout, stderr = runCLI(t, "optimize", "--apply", "--min-confidence", "0.5", "--agent", "a") if code != exitOK || !strings.Contains(stdout, "wrote ~/Web/proj/.claude/agents/a.md\n") || strings.Contains(stdout, "still wins") { t.Errorf("exit %d:\n%s%s", code, stdout, stderr) } @@ -578,7 +584,7 @@ func TestOptimize_ApplyRefusesWhatAgentsyncLeavesAlone(t *testing.T) { // A sibling whose path merely starts with the repo's is still outside it. sibling := filepath.Join(repoRoot+"2", "agents", "e.md") mustWriteT(t, sibling, agentFile("e", "sonnet", "high")) - if code, out, errOut := runCLI(t, "link", "--repo", repoRoot, "--root", project, "--project", "Web/proj", "--agent", "s", "--apply"); code != exitOK { + if code, out, errOut := runCLI(t, "link", "--project", "Web/proj", "--agent", "s", "--apply"); code != exitOK { t.Fatalf("link: exit %d\n%s%s", code, out, errOut) } foreign := filepath.Join(home, "dotfiles", "d.md") @@ -597,7 +603,7 @@ func TestOptimize_ApplyRefusesWhatAgentsyncLeavesAlone(t *testing.T) { clef := orFixture(t, "decision-clef.json") (&fakeOpenRouter{models: orFixture(t, "models.json"), decisions: []string{clef, clef, clef, clef, clef, clef, clef, clef}}).start(t) - code, stdout, stderr := runCLI(t, "optimize", "--apply", "--root", project, "--repo", repoRoot) + code, stdout, stderr := runCLI(t, "optimize", "--apply") if code != exitAtRisk { t.Errorf("exit = %d, want %d\n%s", code, exitAtRisk, stderr) } @@ -619,8 +625,17 @@ func TestOptimize_ApplyRefusesWhatAgentsyncLeavesAlone(t *testing.T) { } mustWriteT(t, filepath.Join(repoRoot, ".agentsync-state.json"), "{") - code, stdout, stderr = runCLI(t, "optimize", "--apply", "--root", project, "--repo", repoRoot) + code, stdout, stderr = runCLI(t, "optimize", "--apply") if code != exitFailure || !strings.Contains(stdout, "s: not written: the state file could not be read") || strings.Contains(stderr, "warning") { t.Errorf("exit %d:\n%s%s", code, stdout, stderr) } } + +// deletedCwdRefusal is how a deleted working directory is refused: Linux getwd +// fails outright, while macOS returns the stale path, which then fails to resolve. +func deletedCwdRefusal() string { + if runtime.GOOS == "linux" { + return "could not determine the working directory" + } + return "could not resolve the working directory" +} diff --git a/cmd/agentsync/override.go b/cmd/agentsync/override.go index 82de48a..cd9a9ca 100644 --- a/cmd/agentsync/override.go +++ b/cmd/agentsync/override.go @@ -65,8 +65,7 @@ func runOverride(args []string, stdout, stderr io.Writer) int { fs.SetOutput(stderr) fs.Usage = func() { fmt.Fprint(stderr, usage) } - rootF := registerRoot(fs) - repoPath := fs.String("repo", "", "canonical agent repo") + all := registerAll(fs) 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") @@ -81,18 +80,18 @@ func runOverride(args []string, stdout, stderr io.Writer) int { } 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) + if err == nil && *all { + err = fmt.Errorf("override works on one project, so --%s is refused; run it inside the project", allFlag) } var repo *scan.Repo var repoFrom string var root rootChoice var f *override.File if err == nil { - repo, repoFrom, err = openRepo(*repoPath) + repo, repoFrom, err = openRepo() } if err == nil { - root, err = rootF.resolve(repo.Home) + root, err = resolveProject(repo.Home) } agentsDir := filepath.Join(root.Path, ".claude", "agents") if err == nil { @@ -144,7 +143,7 @@ func runOverride(args []string, stdout, stderr io.Writer) int { 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)) + return errors.New(strayArg("override", fs.Arg(0), nil)) case clear != "" && (agent != "" || set != override.Value{}): return errors.New("--clear takes no --agent, --model or --effort") case agent == "" && set != override.Value{}: diff --git a/cmd/agentsync/override_test.go b/cmd/agentsync/override_test.go index 5292b3d..d272dfe 100644 --- a/cmd/agentsync/override_test.go +++ b/cmd/agentsync/override_test.go @@ -4,6 +4,7 @@ import ( "bytes" "encoding/json" "errors" + "fmt" "io/fs" "os" "os/exec" @@ -11,6 +12,8 @@ import ( "strings" "testing" "testing/iotest" + + "github.com/agentic-tooling/agentsync/internal/testfs" ) func runHookWith(t *testing.T, stdin string, args ...string) (code int, stdout, stderr string) { @@ -91,7 +94,8 @@ func overrideFixture(t *testing.T) (project, repoRoot, settings string) { 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...)...) + inProject(t, repoRoot, project) + return runCLI(t, append([]string{"override"}, args...)...) } func assertAbsent(t *testing.T, path string) { @@ -238,7 +242,7 @@ func TestOverride_RefusesWithoutWriting(t *testing.T) { t.Run("no repo", func(t *testing.T) { useConfig(t, "") - if code, _, _ := runCLI(t, "override", "--root", project); code != exitFailure { + if code, _, _ := runCLI(t, "override"); code != exitFailure { t.Errorf("exit %d, want 2", code) } }) @@ -255,24 +259,24 @@ const oneOverride = `{"hooks":{"PreToolUse":[{"matcher":"Agent","hooks":[{"type" func TestScan_ShowsOverrides(t *testing.T) { project, repoRoot, settings := overrideFixture(t) mustWriteT(t, settings, oneOverride) - code, stdout, stderr := runCLI(t, scanArgs(repoRoot, project)...) + code, stdout, stderr := runCLI(t, scanArgs(t, 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)...) + code, stdout, stderr = runCLI(t, scanArgs(t, 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) + _, _, 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) + code, stdout, stderr := runCLI(t, "optimize", "--agent", "a") if code != exitOK { t.Fatalf("exit %d\n%s", code, stderr) } @@ -314,7 +318,7 @@ func TestDoctor_OverrideNeedsAgentsyncOnPath(t *testing.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") { + if code, stdout, stderr := runCLI(t, doctorArgs(t, 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) } @@ -324,7 +328,7 @@ func TestDoctor_OverrideNeedsAgentsyncOnPath(t *testing.T) { {"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)...) + code, stdout, stderr := runCLI(t, doctorArgs(t, repoRoot, project)...) if code != exitAtRisk { t.Fatalf("%s: exit %d, want 1\n%s%s", tc.name, code, stdout, stderr) } @@ -337,24 +341,43 @@ func TestDoctor_OverrideNeedsAgentsyncOnPath(t *testing.T) { pathWith(t, servesHook) mustWriteT(t, settings, "{") - code, stdout, _ := runCLI(t, doctorArgs(repoRoot, project)...) + code, stdout, _ := runCLI(t, doctorArgs(t, 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 { + if code, stdout, _ := runCLI(t, doctorArgs(t, repoRoot, project)...); code != exitOK { t.Fatalf("no overrides: exit %d, want 0\n%s", code, stdout) } } -func TestOverride_RootThatCannotBeScanned(t *testing.T) { +// TestRequireAgent_UnscannableRootRefuses: a root the scan cannot read leaves +// the agent name unchecked, so it refuses rather than reporting "no agent". The +// cwd walk cannot reach such a root as uid 0, so this calls requireAgent. +func TestRequireAgent_UnscannableRootRefuses(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) + root, _ := testfs.UnreadableSubtree(t, t.TempDir()) + + err := requireAgent(mustRepo(t, repoRoot), root, filepath.Join(root, ".claude", "agents"), "a") + if want := fmt.Sprintf("walking %q: ", root); err == nil || !strings.HasPrefix(err.Error(), want) { + t.Errorf("err = %v, want the scan error starting %s", err, want) + } +} + +// TestOverride_OutsideAProjectDoesNotSuggestAll: override refuses --all, so its +// refusal must not offer --all as the way out. +func TestOverride_OutsideAProjectDoesNotSuggestAll(t *testing.T) { + project, repoRoot, _ := overrideFixture(t) + inProject(t, repoRoot, filepath.Dir(project)) + + code, _, stderr := runCLI(t, "override") + if code != exitFailure || !strings.Contains(stderr, "no project found") { + t.Errorf("exit %d, stderr %q; want 2 and the no-project refusal", code, stderr) + } + if strings.Contains(stderr, "--"+allFlag) { + t.Errorf("the refusal suggests --%s, which override refuses:\n%s", allFlag, stderr) } - assertAbsent(t, missing) } func TestOverride_MatchesFrontmatterName(t *testing.T) { @@ -372,7 +395,7 @@ func TestOverride_MatchesFrontmatterName(t *testing.T) { 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 / -") { + if code, stdout, _ := runCLI(t, scanArgs(t, repoRoot, project)...); code != exitOK || !strings.Contains(stdout, "reviewer UNPROTECTED override: opus / -") { t.Fatalf("scan: exit %d\n%s", code, stdout) } } @@ -405,7 +428,7 @@ 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)...) + code, stdout, _ := runCLI(t, scanArgs(t, 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) } @@ -419,10 +442,10 @@ func TestSymlinkedSettings_ReadOnlyCommandsFollowIt(t *testing.T) { t.Fatal(err) } pathWith(t, "") - if code, stdout, stderr := runCLI(t, doctorArgs(repoRoot, project)...); code != exitOK || strings.Contains(stderr, "settings") { + if code, stdout, stderr := runCLI(t, doctorArgs(t, 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") { + if code, stdout, stderr := runCLI(t, scanArgs(t, repoRoot, project)...); code != exitOK || strings.Contains(stderr, "warning: overrides") { t.Fatalf("scan: exit %d\n%s%s", code, stdout, stderr) } diff --git a/cmd/agentsync/reconcile.go b/cmd/agentsync/reconcile.go index 97bd6fe..3912390 100644 --- a/cmd/agentsync/reconcile.go +++ b/cmd/agentsync/reconcile.go @@ -108,8 +108,7 @@ func runReconcile(args []string, stdout, stderr io.Writer) int { fs.SetOutput(stderr) fs.Usage = func() { fmt.Fprint(stderr, usage) } - rootF := registerRoot(fs) - repoPath := fs.String("repo", "", "canonical agent repo") + all := registerAll(fs) apply := fs.Bool("apply", false, "make the changes; without it this is a dry run") asJSON := fs.Bool("json", false, "emit JSON") allowOutsideHome := fs.Bool(scan.AllowOutsideHomeFlag, false, @@ -148,8 +147,7 @@ func runReconcile(args []string, stdout, stderr io.Writer) int { return exitFailure } if rest := fs.Args(); len(rest) > 0 { - fmt.Fprintf(stderr, "agentsync: unexpected argument %q; reconcile takes flags only.%s\n", - rest[0], dropCaptureHint(rest[0])) + fmt.Fprintf(stderr, "agentsync: %s\n", strayArg("reconcile", rest[0], dropCaptureHint)) return exitFailure } @@ -165,7 +163,7 @@ func runReconcile(args []string, stdout, stderr io.Writer) int { return exitFailure } - repo, repoFrom, err := openRepo(*repoPath) + repo, repoFrom, err := openRepo() if err != nil { fmt.Fprintf(stderr, "agentsync: %v\n", err) return exitFailure @@ -179,7 +177,7 @@ func runReconcile(args []string, stdout, stderr io.Writer) int { return exitFailure } - root, err := rootF.resolve(repo.Home) + root, err := resolveRoot(repo.Home, *all) if err != nil { fmt.Fprintf(stderr, "agentsync: %v\n", err) return exitFailure @@ -205,9 +203,9 @@ func runReconcile(args []string, stdout, stderr io.Writer) int { // // plan.Dirs is what the scan walked, which is NOT everything the plan is // about: judgeRecords works from absolute paths in the state document, and - // those need not fall under --root at all. So the guard covers the passes - // keyed by $HOME — adopt and re-key — and does not cover a decline or a - // prune for a project the scan never saw. That is the right split anyway: + // those need not fall under the scan root at all. So the guard covers the + // passes keyed by $HOME — adopt and re-key — and does not cover a decline or + // a prune for a project the scan never saw. That is the right split anyway: // a record's path was resolved when it was written and does not depend on // today's home, so a wrong home cannot misdirect it. if *apply { diff --git a/cmd/agentsync/reconcile_test.go b/cmd/agentsync/reconcile_test.go index 28d4a17..462f46c 100644 --- a/cmd/agentsync/reconcile_test.go +++ b/cmd/agentsync/reconcile_test.go @@ -38,8 +38,10 @@ func reconcileFixture(t *testing.T) (base, repoRoot string) { return base, repoRoot } -func reconcileArgs(repoRoot, root string, extra ...string) []string { - return append([]string{"reconcile", "--repo", repoRoot, "--root", root}, extra...) +func reconcileArgs(t *testing.T, repoRoot, root string, extra ...string) []string { + t.Helper() + inTree(t, repoRoot, root) + return append([]string{"reconcile", "--" + allFlag}, extra...) } // recordInState plants the record link --apply would have written, so a test @@ -100,7 +102,7 @@ func TestRunReconcile_DryRunIsTheDefault(t *testing.T) { base, repoRoot := reconcileFixture(t) path, _ := brokenRecordedLink(t, base, repoRoot, "Web/proj", "a") - code, stdout, _ := runCLI(t, reconcileArgs(repoRoot, base, "--"+reconcile.PruneFlag)...) + code, stdout, _ := runCLI(t, reconcileArgs(t, repoRoot, base, "--"+reconcile.PruneFlag)...) if code != exitOK { t.Errorf("exit = %d, want %d\n%s", code, exitOK, stdout) } @@ -119,7 +121,7 @@ func TestRunReconcile_PruneAppliesAndIsIdempotent(t *testing.T) { base, repoRoot := reconcileFixture(t) path, _ := brokenRecordedLink(t, base, repoRoot, "Web/proj", "a") - code, stdout, stderr := runCLI(t, reconcileArgs(repoRoot, base, "--"+reconcile.PruneFlag, "--apply")...) + code, stdout, stderr := runCLI(t, reconcileArgs(t, repoRoot, base, "--"+reconcile.PruneFlag, "--apply")...) if code != exitOK { t.Fatalf("exit = %d, want %d\n%s\n%s", code, exitOK, stdout, stderr) } @@ -134,7 +136,7 @@ func TestRunReconcile_PruneAppliesAndIsIdempotent(t *testing.T) { t.Errorf("pruning wrote a declined marker: %v", st.AllDeclined()) } - code, stdout, _ = runCLI(t, reconcileArgs(repoRoot, base, "--"+reconcile.PruneFlag, "--apply")...) + code, stdout, _ = runCLI(t, reconcileArgs(t, repoRoot, base, "--"+reconcile.PruneFlag, "--apply")...) if code != exitOK { t.Errorf("second run exit = %d, want %d\n%s", code, exitOK, stdout) } @@ -217,7 +219,7 @@ func TestRunReconcile_ExitCodes(t *testing.T) { t.Run(tc.name, func(t *testing.T) { base, repoRoot := reconcileFixture(t) extra := tc.setup(t, base, repoRoot) - code, stdout, stderr := runCLI(t, reconcileArgs(repoRoot, base, extra...)...) + code, stdout, stderr := runCLI(t, reconcileArgs(t, repoRoot, base, extra...)...) if code != tc.want { t.Errorf("exit = %d, want %d\n%s\n%s", code, tc.want, stdout, stderr) } @@ -238,7 +240,7 @@ func TestRunReconcile_DeclineIsHonouredByLinkAndUndoneByIt(t *testing.T) { mustMkdirT(t, filepath.Dir(path)) recordInState(t, repoRoot, project, "a", path, target) - code, stdout, stderr := runCLI(t, reconcileArgs(repoRoot, base, "--apply")...) + code, stdout, stderr := runCLI(t, reconcileArgs(t, repoRoot, base, "--apply")...) if code != exitOK { t.Fatalf("reconcile exit = %d\n%s\n%s", code, stdout, stderr) } @@ -247,7 +249,7 @@ func TestRunReconcile_DeclineIsHonouredByLinkAndUndoneByIt(t *testing.T) { } // link must now refuse to recreate it, and say so rather than skipping. - code, stdout, _ = runCLI(t, linkArgs(repoRoot, base, + code, stdout, _ = runCLI(t, linkArgs(t, repoRoot, base, "--project", project, "--agent", "a", "--apply")...) if code != exitOK { t.Errorf("link exit = %d, want %d\n%s", code, exitOK, stdout) @@ -260,7 +262,7 @@ func TestRunReconcile_DeclineIsHonouredByLinkAndUndoneByIt(t *testing.T) { } // And --undecline is the override, which is a write like any other. - code, stdout, _ = runCLI(t, linkArgs(repoRoot, base, + code, stdout, _ = runCLI(t, linkArgs(t, repoRoot, base, "--project", project, "--agent", "a", "--undecline="+project+"/a", "--apply")...) if code != exitOK { t.Fatalf("link --undecline exit = %d\n%s", code, stdout) @@ -299,7 +301,7 @@ func TestRunReconcile_SelectorsRefuseAValueThatIsNotAPair(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { base, repoRoot := reconcileFixture(t) - code, _, stderr := runCLI(t, reconcileArgs(repoRoot, base, tc.args...)...) + code, _, stderr := runCLI(t, reconcileArgs(t, repoRoot, base, tc.args...)...) if code != exitFailure { t.Errorf("exit = %d, want %d\n%s", code, exitFailure, stderr) } @@ -324,7 +326,7 @@ func TestRunReconcile_RefusesAPositionalArgument(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { base, repoRoot := reconcileFixture(t) - code, _, stderr := runCLI(t, reconcileArgs(repoRoot, base, "--", tc.arg)...) + code, _, stderr := runCLI(t, reconcileArgs(t, repoRoot, base, "--", tc.arg)...) if code != exitFailure { t.Fatalf("exit = %d, want %d\n%s", code, exitFailure, stderr) } @@ -348,7 +350,7 @@ func TestRunReconcile_RefusesAPositionalArgument(t *testing.T) { func TestRunReconcile_RepeatedSelectorsAreDeduplicated(t *testing.T) { base, repoRoot := reconcileFixture(t) - _, stdout, _ := runCLI(t, reconcileArgs(repoRoot, base, + _, stdout, _ := runCLI(t, reconcileArgs(t, repoRoot, base, "--"+reconcile.DropCaptureFlag+"=Web/proj/nope", "--"+reconcile.DropCaptureFlag+"=Web/proj/nope", "--"+reconcile.RekeyFlag+"=Web/a=Web/b", @@ -393,7 +395,7 @@ func TestRunReconcile_RekeyMovesCapturesAndRepointsLinks(t *testing.T) { recordInState(t, repoRoot, newKey, "a", path, oldCapture) move := "--" + reconcile.RekeyFlag + "=" + oldKey + "=" + newKey - code, stdout, stderr := runCLI(t, reconcileArgs(repoRoot, base, move)...) + code, stdout, stderr := runCLI(t, reconcileArgs(t, repoRoot, base, move)...) if code != exitOK { t.Fatalf("dry run exit = %d\n%s\n%s", code, stdout, stderr) } @@ -402,7 +404,7 @@ func TestRunReconcile_RekeyMovesCapturesAndRepointsLinks(t *testing.T) { } assertIsRegular(t, oldCapture, "a dry run moves nothing") - code, stdout, stderr = runCLI(t, reconcileArgs(repoRoot, base, move, "--apply")...) + code, stdout, stderr = runCLI(t, reconcileArgs(t, repoRoot, base, move, "--apply")...) if code != exitOK { t.Fatalf("apply exit = %d\n%s\n%s", code, stdout, stderr) } @@ -434,7 +436,7 @@ func TestRunReconcile_RekeyIsProposedNotApplied(t *testing.T) { mustWriteT(t, oldCapture, "captured\n") mustWriteT(t, projectAgentPath(base, "Web/proj", "a"), "captured\n") - code, stdout, _ := runCLI(t, reconcileArgs(repoRoot, base)...) + code, stdout, _ := runCLI(t, reconcileArgs(t, repoRoot, base)...) if code != exitOK { t.Errorf("exit = %d, want %d\n%s", code, exitOK, stdout) } @@ -461,7 +463,7 @@ func TestRunReconcile_AdoptRecordsUnownedLinksAndSaysWhatItCannotKnow(t *testing t.Fatalf("symlink: %v", err) } - code, stdout, _ := runCLI(t, reconcileArgs(repoRoot, base)...) + code, stdout, _ := runCLI(t, reconcileArgs(t, repoRoot, base)...) if code != exitOK { t.Fatalf("exit = %d\n%s", code, stdout) } @@ -469,7 +471,7 @@ func TestRunReconcile_AdoptRecordsUnownedLinksAndSaysWhatItCannotKnow(t *testing t.Errorf("the report does not say how to record an unowned link:\n%s", stdout) } - code, stdout, stderr := runCLI(t, reconcileArgs(repoRoot, base, "--"+reconcile.AdoptFlag, "--apply")...) + code, stdout, stderr := runCLI(t, reconcileArgs(t, repoRoot, base, "--"+reconcile.AdoptFlag, "--apply")...) if code != exitOK { t.Fatalf("exit = %d\n%s\n%s", code, stdout, stderr) } @@ -497,7 +499,7 @@ func TestRunReconcile_AnUnjudgeablePathIsExitTwo(t *testing.T) { recordInState(t, repoRoot, project, "a", path, target) mustMkdirT(t, path) - code, stdout, _ := runCLI(t, reconcileArgs(repoRoot, base, "--"+reconcile.PruneFlag, "--apply")...) + code, stdout, _ := runCLI(t, reconcileArgs(t, repoRoot, base, "--"+reconcile.PruneFlag, "--apply")...) if code != exitFailure { t.Errorf("exit = %d, want %d\n%s", code, exitFailure, stdout) } @@ -520,7 +522,7 @@ func TestRunReconcile_DropCaptureNeedsTheBytesInHEAD(t *testing.T) { capture := filepath.Join(repoRoot, "project-agents", "Web", "proj", "cap.md") mustWriteT(t, capture, "never committed\n") - code, stdout, _ := runCLI(t, reconcileArgs(repoRoot, base, + code, stdout, _ := runCLI(t, reconcileArgs(t, repoRoot, base, "--"+reconcile.DropCaptureFlag+"="+project+"/cap", "--apply")...) if code != exitAtRisk { t.Errorf("exit = %d, want %d\n%s", code, exitAtRisk, stdout) @@ -536,7 +538,7 @@ func TestRunReconcile_DropCaptureNeedsTheBytesInHEAD(t *testing.T) { testfs.GitRepo(t, repoRoot, "project-agents/Web/proj/cap.md", "captured\n") capture := filepath.Join(repoRoot, "project-agents", "Web", "proj", "cap.md") - code, stdout, stderr := runCLI(t, reconcileArgs(repoRoot, base, + code, stdout, stderr := runCLI(t, reconcileArgs(t, repoRoot, base, "--"+reconcile.DropCaptureFlag+"="+project+"/cap", "--apply")...) if code != exitOK { t.Fatalf("exit = %d, want %d\n%s\n%s", code, exitOK, stdout, stderr) @@ -563,13 +565,13 @@ func TestRunReconcile_WrongHomeWritesNothing(t *testing.T) { mustMkdirT(t, elsewhere) t.Setenv("HOME", elsewhere) - code, _, stderr := runCLI(t, reconcileArgs(repoRoot, base, "--"+reconcile.PruneFlag, "--apply")...) + code, _, stderr := runCLI(t, reconcileArgs(t, repoRoot, base, "--"+reconcile.PruneFlag, "--apply")...) if code != exitFailure { t.Errorf("exit = %d, want %d\n%s", code, exitFailure, stderr) } assertIsLinkPath(t, path, "a run refused by the home guard writes nothing") - code, stdout, stderr := runCLI(t, reconcileArgs(repoRoot, base, "--"+reconcile.PruneFlag)...) + code, stdout, stderr := runCLI(t, reconcileArgs(t, repoRoot, base, "--"+reconcile.PruneFlag)...) if code == exitFailure { t.Errorf("the dry run failed rather than warning\n%s\n%s", stdout, stderr) } @@ -586,7 +588,7 @@ func TestRunReconcile_JSONReport(t *testing.T) { base, repoRoot := reconcileFixture(t) path, _ := brokenRecordedLink(t, base, repoRoot, "Web/proj", "a") - code, stdout, _ := runCLI(t, reconcileArgs(repoRoot, base, + code, stdout, _ := runCLI(t, reconcileArgs(t, repoRoot, base, "--"+reconcile.PruneFlag, "--apply", "--json")...) if code != exitOK { t.Fatalf("exit = %d\n%s", code, stdout) @@ -655,7 +657,7 @@ func TestRunReconcile_ReportPointsAtDoctorForWhatItCannotSettle(t *testing.T) { mustMkdirT(t, projectAgentPathDir(base, "Web/proj")) mustWriteT(t, filepath.Join(repoRoot, "project-agents", "Web", "proj", "gone.md"), "captured\n") - code, stdout, _ := runCLI(t, reconcileArgs(repoRoot, base)...) + code, stdout, _ := runCLI(t, reconcileArgs(t, repoRoot, base)...) if code != exitOK { t.Errorf("exit = %d, want %d — a stale capture is doctor's to fail on\n%s", code, exitOK, stdout) } @@ -672,7 +674,7 @@ func TestRunReconcile_AnUnreadableStateFileStopsTheRun(t *testing.T) { path, _ := brokenRecordedLink(t, base, repoRoot, "Web/proj", "a") mustWriteT(t, filepath.Join(repoRoot, state.FileName), `{"schema": 99, "links": []}`) - code, _, stderr := runCLI(t, reconcileArgs(repoRoot, base, "--"+reconcile.PruneFlag, "--apply")...) + code, _, stderr := runCLI(t, reconcileArgs(t, repoRoot, base, "--"+reconcile.PruneFlag, "--apply")...) if code != exitFailure { t.Errorf("exit = %d, want %d", code, exitFailure) } @@ -723,7 +725,7 @@ func TestRunReconcile_AMovedProjectWritesNoMarkers(t *testing.T) { t.Fatalf("moving the project: %v", err) } - code, stdout, stderr := runCLI(t, reconcileArgs(repoRoot, base, "--apply")...) + code, stdout, stderr := runCLI(t, reconcileArgs(t, repoRoot, base, "--apply")...) if code != exitOK { t.Fatalf("exit = %d, want %d\n%s\n%s", code, exitOK, stdout, stderr) } diff --git a/cmd/agentsync/root.go b/cmd/agentsync/root.go index 7b1cfe9..49c2ed1 100644 --- a/cmd/agentsync/root.go +++ b/cmd/agentsync/root.go @@ -13,8 +13,7 @@ import ( "github.com/agentic-tooling/agentsync/internal/scan" ) -// allFlag widens a run back to the whole default tree, so the machine-wide view -// is discoverable from --help rather than folklore about --root. +// allFlag widens a run from the project you are in to the whole default tree. const allFlag = "all" // The entries that identify a project directory, in the order the walk tests @@ -34,7 +33,6 @@ const ( // project, so nothing acts on it. rootOriginInvalid rootOrigin = iota - rootFromFlag // an explicit --root rootFromAll // an explicit --all rootFromProject // the project the cwd is in; the one origin implying a project rootFromWide // the default of a command that never derives one; see resolveWide @@ -56,31 +54,14 @@ type rootChoice struct { from string } -// rootFlags are the two ways a run may name its root, registered together -// because they are refused together. -type rootFlags struct { - root *string - all *bool +func registerAll(fs *flag.FlagSet) *bool { + return fs.Bool(allFlag, false, "scan the whole default tree ($"+envRoot+" or root in the config file) instead of the project you are in") } -func registerRoot(fs *flag.FlagSet) rootFlags { - return rootFlags{ - root: fs.String("root", "", - "directory tree to scan (default: the project the working directory is in)"), - all: fs.Bool(allFlag, false, "scan the whole default tree ($"+envRoot+" or root in the config file) instead of the project you are in"), - } -} - -// registerWideRoot registers the same pair for a command that never narrows. -// --all is accepted, meaning what the default already means, so the flag reads -// the same on every command rather than being missing from one. It differs from -// registerRoot in help text only: resolveWide enforces the exemption, and this -// is not a safety boundary. -func registerWideRoot(fs *flag.FlagSet) rootFlags { - return rootFlags{ - root: fs.String("root", "", "directory tree to scan (default: the whole tree, $"+envRoot+" or root in the config file)"), - all: fs.Bool(allFlag, false, "scan the whole default tree ($"+envRoot+" or root in the config file), which is already the default here"), - } +// registerWideAll registers --all for inventory, where it means the default, +// so the flag reads the same on every command. +func registerWideAll(fs *flag.FlagSet) *bool { + return fs.Bool(allFlag, false, "scan the whole default tree ($"+envRoot+" or root in the config file), which is already the default here") } // wideRoot is the whole default tree, from $AGENTSYNC_ROOT or else the config @@ -104,45 +85,22 @@ func wideRoot() (path, from string, err error) { return c.Root, "config file " + cfgPath, nil } -// settled returns the choice the flags decide with no derivation of any kind. -// Origin is rootOriginInvalid when neither settled it, leaving the meaning of -// their absence to the caller — all the two resolvers below disagree about. -func (f rootFlags) settled(home string) (rootChoice, error) { - // Trimmed to empty counts as absent: filepath.Abs("") is the working - // directory, so honouring it would scan somewhere nobody named. - explicit := strings.TrimSpace(*f.root) - if explicit != "" && *f.all { - return rootChoice{}, fmt.Errorf("--root and --%s are mutually exclusive: --%s is the whole default tree, so naming a root as well says two different things", - allFlag, allFlag) - } - if explicit != "" { - return rootChoice{Path: explicit, Origin: rootFromFlag}, nil - } - if home == "" { - return rootChoice{}, fmt.Errorf("no home directory, so project keys and the walk up from the working directory cannot be worked out; pass --root PATH") - } - if *f.all { - p, from, err := wideRoot() - if err != nil { - return rootChoice{}, fmt.Errorf("--%s: %w", allFlag, err) - } - return rootChoice{Path: p, Origin: rootFromAll, from: from}, nil +// resolveAll is the whole default tree, as --all names it. +func resolveAll() (rootChoice, error) { + p, from, err := wideRoot() + if err != nil { + return rootChoice{}, fmt.Errorf("--%s: %w", allFlag, err) } - return rootChoice{}, nil + return rootChoice{Path: p, Origin: rootFromAll, from: from}, nil } -// resolveWide decides the root for a command that must never narrow: --root if -// given, the machine-wide tree otherwise, with no walk up from the cwd. -// -// inventory is the only caller, and the exemption is deliberate. Its document -// is every agent on this machine by definition, so a project-scoped one is not -// a narrower view of it but a different document under the same name, and -// regenerating that from inside a project overwrites the real file. --check -// failing safe does not save it: its message names the overwriting command. -func (f rootFlags) resolveWide(home string) (rootChoice, error) { - c, err := f.settled(home) - if err != nil || c.Origin != rootOriginInvalid { - return c, err +// resolveWide decides the root for inventory, which never narrows. Its document +// is every agent on this machine, so a project-scoped one would be a different +// document under the same name, and regenerating it inside a project would +// overwrite the real file. +func resolveWide(all bool) (rootChoice, error) { + if all { + return resolveAll() } p, from, err := wideRoot() if err != nil { @@ -151,17 +109,30 @@ func (f rootFlags) resolveWide(home string) (rootChoice, error) { return rootChoice{Path: p, Origin: rootFromWide, from: from}, nil } -// resolve decides the root, given the resolved home directory project keys are -// expressed against. Absent both flags, it derives one from the cwd. -func (f rootFlags) resolve(home string) (rootChoice, error) { - c, err := f.settled(home) - if err != nil || c.Origin != rootOriginInvalid { - return c, err +// resolveRoot decides the root: --all, else the project the cwd is in. Only +// the walk needs home; a wide run with none is left to the home warning and the +// write guards. +func resolveRoot(home string, all bool) (rootChoice, error) { + if all { + return resolveAll() + } + c, err := resolveProject(home) + if err != nil { + return rootChoice{}, fmt.Errorf("%w; or pass --%s to scan the whole default tree ($%s or root in the config file)", err, allFlag, envRoot) + } + return c, nil +} + +// resolveProject is the project the cwd is in, found by a walk that stops at +// home. Its refusals name no flag, because override takes no --all. +func resolveProject(home string) (rootChoice, error) { + if home == "" { + return rootChoice{}, errors.New("no home directory, so the walk up from the working directory has nowhere to stop; set $HOME") } cwd, err := os.Getwd() if err != nil { - return rootChoice{}, fmt.Errorf("could not determine the working directory, so the root cannot be derived from it (%w); pass --root PATH or --%s", err, allFlag) + return rootChoice{}, fmt.Errorf("could not determine the working directory, so the project cannot be derived from it (%w)", err) } dir, marker, err := detectProject(home, cwd) if err != nil { @@ -170,7 +141,7 @@ func (f rootFlags) resolve(home string) (rootChoice, error) { // A new project has no marker yet, and widening to the whole tree from there // would scan every project on the machine; --all is the explicit way to ask. if dir == "" { - return rootChoice{}, fmt.Errorf("no project found above the working directory, so nothing was scanned; run inside a project or create its .claude/agents/, or pass --%s to scan the whole tree ($%s or root in the config file) or --root PATH", allFlag, envRoot) + return rootChoice{}, errors.New("no project found above the working directory, so nothing was scanned; run inside a project or create its .claude/agents/") } return rootChoice{ Path: dir, @@ -195,12 +166,12 @@ func detectProject(home, cwd string) (dir, marker string, err error) { // a walk from it finds nothing at every level and calls that a clean result. cur, err := resolveDir(cwd) if err != nil { - return "", "", fmt.Errorf("could not resolve the working directory %q (%w); pass --root PATH or --%s", cwd, err, allFlag) + return "", "", fmt.Errorf("could not resolve the working directory %q (%w)", cwd, err) } for cur != home && filepath.Dir(cur) != cur { m, err := markerAt(cur) if err != nil { - return "", "", fmt.Errorf("could not read %q while deriving the root from the working directory (%w); pass --root PATH or --%s", cur, err, allFlag) + return "", "", fmt.Errorf("could not read %q while deriving the root from the working directory (%w)", cur, err) } if m != "" { return cur, m, nil @@ -282,10 +253,8 @@ func emitRootChoice(w io.Writer, home string, c rootChoice) { switch c.Origin { case rootOriginInvalid: // Nothing was resolved, so there is nothing to name. - case rootFromFlag: - fmt.Fprintf(w, "Root: %s (from --root).\n", shown) case rootFromProject: - fmt.Fprintf(w, "Root: %s (from the current directory, which is in a project: %s) — pass --root PATH or --%s to widen.\n", + fmt.Fprintf(w, "Root: %s (from the current directory, which is in a project: %s) — pass --%s to widen.\n", shown, c.marker, allFlag) case rootFromAll: fmt.Fprintf(w, "Root: %s (from --%s: the whole default tree, %s).\n", shown, allFlag, c.from) diff --git a/cmd/agentsync/root_test.go b/cmd/agentsync/root_test.go index 80646f6..4c07257 100644 --- a/cmd/agentsync/root_test.go +++ b/cmd/agentsync/root_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "flag" + "fmt" "io/fs" "os" "path/filepath" @@ -121,8 +122,8 @@ func TestDetectProject_UnreadableDirectoryRefuses(t *testing.T) { if err == nil { t.Fatal("a directory that cannot be read was treated as a clean negative") } - if !strings.Contains(err.Error(), "--root") || !strings.Contains(err.Error(), "--"+allFlag) { - t.Errorf("the refusal does not name a way out:\n%v", err) + if !strings.Contains(err.Error(), "could not") { + t.Errorf("the refusal does not say the walk failed:\n%v", err) } } @@ -146,8 +147,8 @@ func TestDetectProject_UnresolvableCwdRefuses(t *testing.T) { if err == nil { t.Fatal("a working directory that no longer exists was walked as if it did") } - if !strings.Contains(err.Error(), "--root") || !strings.Contains(err.Error(), "--"+allFlag) { - t.Errorf("the refusal does not name a way out:\n%v", err) + if !strings.Contains(err.Error(), "could not") { + t.Errorf("the refusal does not say the walk failed:\n%v", err) } } @@ -163,45 +164,21 @@ func TestResolveDir(t *testing.T) { } } -// --- resolving the flags ---------------------------------------------------- +// --- resolving the root ---------------------------------------------------- -func resolveWith(t *testing.T, home, root string, all bool) (rootChoice, error) { - t.Helper() - return rootFlags{root: &root, all: &all}.resolve(home) -} - -func TestRootFlags_Resolve(t *testing.T) { +func TestResolveRoot(t *testing.T) { home := t.TempDir() wide := filepath.Join(home, "Web") mustMkdirT(t, wide) t.Chdir(wide) t.Setenv(envRoot, wide) - t.Run("explicit root wins and is reported as such", func(t *testing.T) { - got, err := resolveWith(t, home, "/somewhere", false) - if err != nil { - t.Fatalf("resolve: %v", err) - } - if got.Path != "/somewhere" || got.Origin != rootFromFlag || got.Project != "" { - t.Errorf("resolve = %+v, want /somewhere from the flag and no implied project", got) - } - }) - - t.Run("an empty root counts as absent", func(t *testing.T) { - // filepath.Abs("") is the working directory, so honouring an empty - // value would scan somewhere the user never named. - got, err := resolveWith(t, home, " ", false) - if err == nil || !strings.Contains(err.Error(), "no project found") { - t.Errorf("resolve = %+v, %v; want the no-project refusal", got, err) - } - }) - t.Run("no project marker refuses rather than widening", func(t *testing.T) { - got, err := resolveWith(t, home, "", false) + got, err := resolveRoot(home, false) if err == nil { t.Fatalf("resolve = %+v, want a refusal: a markerless directory widened to the whole tree", got) } - for _, w := range []string{"no project found", "--" + allFlag, "--root"} { + for _, w := range []string{"no project found", "--" + allFlag} { if !strings.Contains(err.Error(), w) { t.Errorf("refusal does not name %q:\n%v", w, err) } @@ -209,7 +186,7 @@ func TestRootFlags_Resolve(t *testing.T) { }) t.Run("--all is the whole default tree", func(t *testing.T) { - got, err := resolveWith(t, home, "", true) + got, err := resolveRoot(home, true) if err != nil { t.Fatalf("resolve: %v", err) } @@ -218,41 +195,28 @@ func TestRootFlags_Resolve(t *testing.T) { } }) - t.Run("--root with --all is refused, not resolved by precedence", func(t *testing.T) { - _, err := resolveWith(t, home, "/somewhere", true) - if err == nil { - t.Fatal("--root and --all together were accepted") + t.Run("no home refuses the walk but not --all", func(t *testing.T) { + _, err := resolveRoot("", false) + if err == nil || !strings.Contains(err.Error(), "no home directory") || !strings.Contains(err.Error(), "--"+allFlag) { + t.Errorf("err = %v, want the no-home refusal naming --%s", err, allFlag) } - if !strings.Contains(err.Error(), "mutually exclusive") { - t.Errorf("refusal does not say why:\n%v", err) - } - }) - - t.Run("no home refuses rather than guessing", func(t *testing.T) { - _, err := resolveWith(t, "", "", false) - if err == nil { - t.Fatal("a run with no home directory derived a root anyway") + if got, err := resolveRoot("", true); err != nil || got.Origin != rootFromAll { + t.Errorf("resolve = %+v, %v; want --all to need no home", got, err) } }) t.Run("no default tree refuses --all", func(t *testing.T) { t.Setenv(envRoot, "") - _, err := resolveWith(t, home, "", true) - if err == nil || !strings.Contains(err.Error(), "$"+envRoot) || !strings.Contains(err.Error(), "--root") { - t.Errorf("err = %v, want a refusal naming --root and $%s", err, envRoot) + _, err := resolveRoot(home, true) + if err == nil || !strings.Contains(err.Error(), "$"+envRoot) || !strings.Contains(err.Error(), "--"+allFlag) { + t.Errorf("err = %v, want a refusal naming --%s and $%s", err, allFlag, envRoot) } }) } -func resolveWideWith(t *testing.T, home, root string, all bool) (rootChoice, error) { - t.Helper() - return rootFlags{root: &root, all: &all}.resolveWide(home) -} - -// TestRootFlags_ResolveWideNeverDerives: the resolver inventory uses shares -// every flag rule with the other one and differs in exactly one place — what -// the absence of both flags means. -func TestRootFlags_ResolveWide(t *testing.T) { +// TestResolveWide: inventory's resolver differs from resolveRoot in exactly one +// place, what the absence of --all means. +func TestResolveWide(t *testing.T) { home := t.TempDir() wide := filepath.Join(home, "Web") project := filepath.Join(home, "Web", "toolbox") @@ -263,7 +227,7 @@ func TestRootFlags_ResolveWide(t *testing.T) { t.Setenv(envRoot, wide) t.Run("no flags is the whole tree, not the project underfoot", func(t *testing.T) { - got, err := resolveWideWith(t, home, "", false) + got, err := resolveWide(false) if err != nil { t.Fatalf("resolveWide: %v", err) } @@ -273,9 +237,8 @@ func TestRootFlags_ResolveWide(t *testing.T) { if got.Project != "" { t.Errorf("implied project = %q, want none: this command selects no project", got.Project) } - // The other resolver, same directory, to prove the fixture really is - // one that narrows — otherwise this test would pass on a broken walk. - derived, err := resolveWith(t, mustResolveDir(t, home), "", false) + // The other resolver, same directory, proves the fixture really narrows. + derived, err := resolveRoot(mustResolveDir(t, home), false) if err != nil { t.Fatalf("resolve: %v", err) } @@ -285,7 +248,7 @@ func TestRootFlags_ResolveWide(t *testing.T) { }) t.Run("--all is accepted and means the default", func(t *testing.T) { - got, err := resolveWideWith(t, home, "", true) + got, err := resolveWide(true) if err != nil { t.Fatalf("resolveWide: %v", err) } @@ -294,53 +257,31 @@ func TestRootFlags_ResolveWide(t *testing.T) { } }) - t.Run("an explicit root still wins", func(t *testing.T) { - got, err := resolveWideWith(t, home, "/somewhere", false) - if err != nil { - t.Fatalf("resolveWide: %v", err) - } - if got.Path != "/somewhere" || got.Origin != rootFromFlag { - t.Errorf("resolveWide = %+v, want /somewhere from the flag", got) - } - }) - - t.Run("--root with --all is still refused", func(t *testing.T) { - if _, err := resolveWideWith(t, home, "/somewhere", true); err == nil { - t.Fatal("--root and --all together were accepted") - } - }) - - t.Run("no home still refuses", func(t *testing.T) { - if _, err := resolveWideWith(t, "", "", false); err == nil { - t.Fatal("a run with no home directory produced a root anyway") - } - }) - t.Run("no default tree refuses", func(t *testing.T) { t.Setenv(envRoot, "") - if _, err := resolveWideWith(t, home, "", false); err == nil { + if _, err := resolveWide(false); err == nil { t.Fatal("an unset default tree produced a root anyway") } }) t.Run("relative default tree refuses", func(t *testing.T) { t.Setenv(envRoot, ".") - if _, err := resolveWideWith(t, home, "", false); err == nil || !strings.Contains(err.Error(), "absolute") { + if _, err := resolveWide(false); err == nil || !strings.Contains(err.Error(), "absolute") { t.Fatalf("err = %v, want a refusal naming an absolute path", err) } }) } -// TestRootFlags_ResolveDerivesAndImpliesTheProject: the derived root carries -// the project key with it, which is what lets link fill --project in rather -// than relax the rule that requires it. -func TestRootFlags_ResolveDerivesAndImpliesTheProject(t *testing.T) { +// TestResolveRoot_DerivesAndImpliesTheProject: the derived root carries the +// project key with it, which is what lets link fill --project in rather than +// relax the rule that requires it. +func TestResolveRoot_DerivesAndImpliesTheProject(t *testing.T) { home := t.TempDir() project := filepath.Join(home, "Web", "toolbox") mustMkdirT(t, filepath.Join(project, ".claude")) t.Chdir(project) - got, err := resolveWith(t, mustResolveDir(t, home), "", false) + got, err := resolveRoot(mustResolveDir(t, home), false) if err != nil { t.Fatalf("resolve: %v", err) } @@ -355,15 +296,17 @@ func TestRootFlags_ResolveDerivesAndImpliesTheProject(t *testing.T) { } } -func TestRegisterRoot(t *testing.T) { - fs := flag.NewFlagSet("t", flag.ContinueOnError) - fs.SetOutput(&bytes.Buffer{}) - f := registerRoot(fs) - if err := fs.Parse([]string{"--root", "/x", "--" + allFlag}); err != nil { - t.Fatalf("parse: %v", err) - } - if *f.root != "/x" || !*f.all { - t.Errorf("registerRoot did not bind both flags: root=%q all=%v", *f.root, *f.all) +func TestRegisterAll(t *testing.T) { + for name, register := range map[string]func(*flag.FlagSet) *bool{"registerAll": registerAll, "registerWideAll": registerWideAll} { + fs := flag.NewFlagSet("t", flag.ContinueOnError) + fs.SetOutput(&bytes.Buffer{}) + all := register(fs) + if err := fs.Parse([]string{"--" + allFlag}); err != nil { + t.Fatalf("%s: parse: %v", name, err) + } + if !*all { + t.Errorf("%s did not bind --%s", name, allFlag) + } } } @@ -378,19 +321,15 @@ func TestEmitRootChoice(t *testing.T) { notWant []string }{ { - name: "flag", - choice: rootChoice{Path: "/somewhere", Origin: rootFromFlag}, - want: []string{"Root: /somewhere", "--root"}, - }, - { - name: "derived names the marker and how to widen", - choice: rootChoice{Path: "/home/u/Web/toolbox", Origin: rootFromProject, marker: markerClaude}, - want: []string{"~/Web/toolbox", "current directory", markerClaude, "--all"}, + name: "derived names the marker and how to widen", + choice: rootChoice{Path: "/home/u/Web/toolbox", Origin: rootFromProject, marker: markerClaude}, + want: []string{"Root: ~/Web/toolbox (from the current directory, which is in a project: .claude) — pass --all to widen."}, + notWant: []string{"--root"}, }, { name: "all", - choice: rootChoice{Path: "/home/u/Web", Origin: rootFromAll}, - want: []string{"~/Web", "--all"}, + choice: rootChoice{Path: "/home/u/Web", Origin: rootFromAll, from: "$AGENTSYNC_ROOT"}, + want: []string{"Root: ~/Web (from --all: the whole default tree, $AGENTSYNC_ROOT)."}, }, { name: "all names the config file it came from", @@ -491,18 +430,19 @@ func projectScopeFixture(t *testing.T) (home, repoRoot string) { mustWriteT(t, filepath.Join(home, "Web", "beta", ".claude", "agents", "b.md"), "b\n") // Resolved, so the root displays under ~ the way the resolved home does. t.Setenv(envRoot, filepath.Join(mustResolveDir(t, home), "Web")) + t.Setenv(envRepo, repoRoot) return home, repoRoot } func TestRun_RepoIsRequired(t *testing.T) { t.Setenv(envRepo, "") for _, cmd := range []string{"scan", "import", "link", "doctor", "reconcile", "inventory"} { - code, stdout, stderr := runCLI(t, cmd, "--root", t.TempDir()) + code, stdout, stderr := runCLI(t, cmd, "--"+allFlag) if code != exitFailure { t.Errorf("%s: exit = %d, want %d with no repo named", cmd, code, exitFailure) } - if !strings.Contains(stderr, "--repo") || !strings.Contains(stderr, "$"+envRepo) || !strings.Contains(stderr, "agentsync init") { - t.Errorf("%s: the refusal does not name --repo, $%s and agentsync init:\n%s", cmd, envRepo, stderr) + if !strings.Contains(stderr, "$"+envRepo) || !strings.Contains(stderr, "agentsync init") { + t.Errorf("%s: the refusal does not name $%s and agentsync init:\n%s", cmd, envRepo, stderr) } if stdout != "" { t.Errorf("%s: a refused run must not emit a report:\n%s", cmd, stdout) @@ -531,11 +471,11 @@ func scanProjects(t *testing.T, stdout string) []string { // TestRunScan_RootFollowsTheWorkingDirectory is the complaint itself: standing // in one project and getting a machine-wide report. func TestRunScan_RootFollowsTheWorkingDirectory(t *testing.T) { - home, repoRoot := projectScopeFixture(t) + home, _ := projectScopeFixture(t) t.Run("inside a project reports that project alone", func(t *testing.T) { t.Chdir(filepath.Join(home, "Web", "alpha")) - code, stdout, stderr := runCLI(t, "scan", "--repo", repoRoot, "--json") + code, stdout, stderr := runCLI(t, "scan", "--json") if code != exitOK { t.Fatalf("exit = %d, want %d\n%s", code, exitOK, stderr) } @@ -554,7 +494,7 @@ func TestRunScan_RootFollowsTheWorkingDirectory(t *testing.T) { for _, args := range [][]string{ {"scan"}, {"import"}, {"link", "--agent", "shared"}, {"doctor"}, {"reconcile"}, } { - code, stdout, stderr := runCLI(t, append(args, "--repo", repoRoot)...) + code, stdout, stderr := runCLI(t, args...) if code != exitFailure { t.Errorf("%s: exit = %d, want %d\n%s\n%s", args[0], code, exitFailure, stdout, stderr) } @@ -569,7 +509,7 @@ func TestRunScan_RootFollowsTheWorkingDirectory(t *testing.T) { t.Run("--all from outside any project scans everything", func(t *testing.T) { t.Chdir(filepath.Join(home, "Web")) - code, stdout, stderr := runCLI(t, "scan", "--repo", repoRoot, "--json", "--"+allFlag) + code, stdout, stderr := runCLI(t, "scan", "--json", "--"+allFlag) if code != exitOK { t.Fatalf("exit = %d, want %d\n%s", code, exitOK, stderr) } @@ -580,7 +520,7 @@ func TestRunScan_RootFollowsTheWorkingDirectory(t *testing.T) { t.Run("--all widens from inside a project", func(t *testing.T) { t.Chdir(filepath.Join(home, "Web", "alpha")) - code, stdout, stderr := runCLI(t, "scan", "--repo", repoRoot, "--json", "--"+allFlag) + code, stdout, stderr := runCLI(t, "scan", "--json", "--"+allFlag) if code != exitOK { t.Fatalf("exit = %d, want %d\n%s", code, exitOK, stderr) } @@ -596,27 +536,20 @@ func TestRunScan_RootFollowsTheWorkingDirectory(t *testing.T) { // or a capture would land under a different key depending on where the user was // standing when they ran the command. func TestRunScan_ProjectKeysDoNotDependOnTheRoot(t *testing.T) { - home, repoRoot := projectScopeFixture(t) + home, _ := projectScopeFixture(t) project := filepath.Join(home, "Web", "alpha") t.Chdir(project) - _, narrow, _ := runCLI(t, "scan", "--repo", repoRoot, "--json") + _, narrow, _ := runCLI(t, "scan", "--json") narrowKeys := scanProjects(t, narrow) t.Chdir(home) - _, wide, _ := runCLI(t, "scan", "--repo", repoRoot, "--json", "--"+allFlag) + _, wide, _ := runCLI(t, "scan", "--json", "--"+allFlag) wideKeys := scanProjects(t, wide) - // And a third route to the same project: named explicitly by path. - _, explicit, _ := runCLI(t, "scan", "--repo", repoRoot, "--root", project, "--json") - explicitKeys := scanProjects(t, explicit) - if len(narrowKeys) != 1 || narrowKeys[0] != "Web/alpha" { t.Fatalf("narrow run keys = %v, want [Web/alpha]", narrowKeys) } - if len(explicitKeys) != 1 || explicitKeys[0] != narrowKeys[0] { - t.Errorf("explicit --root keys = %v, want the same key the derived run produced (%v)", explicitKeys, narrowKeys) - } found := false for _, k := range wideKeys { if k == narrowKeys[0] { @@ -629,24 +562,113 @@ func TestRunScan_ProjectKeysDoNotDependOnTheRoot(t *testing.T) { } } -// TestRun_RootAndAllRefusedByEveryCommand: the refusal is a property of the -// flag pair, so it must hold wherever the pair is registered. -func TestRun_RootAndAllRefusedByEveryCommand(t *testing.T) { +// TestRun_RepoAndRootFlagsAreGone: the project is where you stand and the repo +// is configured, so naming either per run is an unknown flag. +func TestRun_RepoAndRootFlagsAreGone(t *testing.T) { _, repoRoot := projectScopeFixture(t) - for _, cmd := range []string{"scan", "import", "link", "doctor", "reconcile", "inventory"} { - t.Run(cmd, func(t *testing.T) { - code, stdout, stderr := runCLI(t, cmd, "--repo", repoRoot, "--root", repoRoot, "--"+allFlag) + for _, cmd := range []string{"scan", "import", "link", "doctor", "reconcile", "inventory", "optimize", "override"} { + for _, f := range []string{"--repo", "--root"} { + code, stdout, stderr := runCLI(t, cmd, f, repoRoot) if code != exitFailure { - t.Errorf("exit = %d, want %d\n%s", code, exitFailure, stderr) + t.Errorf("%s %s: exit = %d, want %d\n%s", cmd, f, code, exitFailure, stderr) } - if !strings.Contains(stderr, "mutually exclusive") { - t.Errorf("refusal does not say why:\n%s", stderr) + if !strings.Contains(stderr, "flag provided but not defined: "+strings.TrimPrefix(f, "-")) { + t.Errorf("%s %s: refusal does not name the flag:\n%s", cmd, f, stderr) } if stdout != "" { - t.Errorf("a refused run must not emit a report:\n%s", stdout) + t.Errorf("%s %s: a refused run must not emit a report:\n%s", cmd, f, stdout) } - }) + } + } +} + +// TestRun_StrayArgumentRefused: every command but init takes flags only, and a +// path-shaped argument is someone trying to name a project, so it is told to cd. +func TestRun_StrayArgumentRefused(t *testing.T) { + home, _ := projectScopeFixture(t) + t.Chdir(filepath.Join(home, "Web")) + const cdHint = "Run it from the project directory instead." + + for _, cmd := range []string{"scan", "import", "link", "doctor", "reconcile", "inventory", "optimize", "override"} { + for _, tc := range []struct { + arg string + wantCD bool + }{ + {"someagent", false}, + {filepath.Join(home, "Web", "alpha"), true}, + {"./alpha", true}, + {"~/Web/alpha", true}, + {"..", true}, + {"alpha", true}, // an existing directory, relative to the cwd + } { + code, stdout, stderr := runCLI(t, cmd, tc.arg) + if code != exitFailure { + t.Errorf("%s %s: exit = %d, want %d", cmd, tc.arg, code, exitFailure) + } + if want := fmt.Sprintf("agentsync: unexpected argument %q; %s takes flags only.", tc.arg, cmd); !strings.HasPrefix(stderr, want) { + t.Errorf("%s %s: stderr = %q, want it to start %q", cmd, tc.arg, stderr, want) + } + if got := strings.Contains(stderr, cdHint); got != tc.wantCD { + t.Errorf("%s %s: cd hint = %v, want %v:\n%s", cmd, tc.arg, got, tc.wantCD, stderr) + } + if stdout != "" { + t.Errorf("%s %s: a refused run must not emit a report:\n%s", cmd, tc.arg, stdout) + } + } + } +} + +// TestRun_StrayArgumentHintsAreExclusive: the selector hints fire for a +// / pair and never for a path, which gets the cd hint alone. +func TestRun_StrayArgumentHintsAreExclusive(t *testing.T) { + projectScopeFixture(t) + for _, tc := range []struct{ cmd, flag string }{ + {"link", "--" + linker.UndeclineFlag + "="}, + {"import", "--update="}, + {"reconcile", "--" + reconcile.DropCaptureFlag + "="}, + } { + _, _, stderr := runCLI(t, tc.cmd, "Web/proj/a") + if !strings.Contains(stderr, tc.flag+"Web/proj/a") || strings.Contains(stderr, "project directory") { + t.Errorf("%s with a selector: want the %s hint alone:\n%s", tc.cmd, tc.flag, stderr) + } + _, _, stderr = runCLI(t, tc.cmd, "/abs/path") + if strings.Contains(stderr, tc.flag) || !strings.Contains(stderr, "project directory") { + t.Errorf("%s with a path: want the cd hint alone:\n%s", tc.cmd, stderr) + } + } +} + +// TestRunScan_PathArgumentDoesNotScanTheCwd: scan once ignored a stray argument, +// so naming another project from inside one silently scanned the wrong one. +func TestRunScan_PathArgumentDoesNotScanTheCwd(t *testing.T) { + home, _ := projectScopeFixture(t) + t.Chdir(filepath.Join(home, "Web", "alpha")) + + code, stdout, stderr := runCLI(t, "scan", "--fail-on-risk", filepath.Join(home, "Web", "beta")) + if code != exitFailure { + t.Errorf("exit = %d, want %d", code, exitFailure) + } + if stdout != "" || !strings.Contains(stderr, "Run it from the project directory instead.") { + t.Errorf("want a refusal and no report:\nstdout: %s\nstderr: %s", stdout, stderr) + } +} + +// TestRun_RelativeRepoEnvRefused: a relative $AGENTSYNC_REPO names a different +// repo from every directory, so every command refuses it rather than guess. +func TestRun_RelativeRepoEnvRefused(t *testing.T) { + home, _ := projectScopeFixture(t) + t.Chdir(filepath.Join(home, "Web", "alpha")) + t.Setenv(envRepo, "Web/agents-repo") + + for _, cmd := range []string{"scan", "import", "link", "doctor", "reconcile", "inventory", "override"} { + code, stdout, stderr := runCLI(t, cmd) + if code != exitFailure || !strings.Contains(stderr, "$"+envRepo+" must be an absolute path") { + t.Errorf("%s: exit = %d, want %d naming $%s:\n%s", cmd, code, exitFailure, envRepo, stderr) + } + if stdout != "" { + t.Errorf("%s: a refused run must not emit a report:\n%s", cmd, stdout) + } } } @@ -669,7 +691,7 @@ func TestRunLink_DerivedRootImpliesProject(t *testing.T) { t.Chdir(project) t.Run("the dry run creates nothing", func(t *testing.T) { - code, stdout, stderr := runCLI(t, "link", "--repo", repoRoot, "--agent", "shared") + code, stdout, stderr := runCLI(t, "link", "--agent", "shared") if code != exitOK { t.Fatalf("exit = %d, want %d\n%s\n%s", code, exitOK, stdout, stderr) } @@ -685,7 +707,7 @@ func TestRunLink_DerivedRootImpliesProject(t *testing.T) { }) t.Run("apply creates the missing agents directory and the link", func(t *testing.T) { - code, stdout, stderr := runCLI(t, "link", "--repo", repoRoot, "--agent", "shared", "--apply") + code, stdout, stderr := runCLI(t, "link", "--agent", "shared", "--apply") if code != exitOK { t.Fatalf("exit = %d, want %d\n%s\n%s", code, exitOK, stdout, stderr) } @@ -708,7 +730,7 @@ func TestRunLink_DerivedRootImpliesProject(t *testing.T) { }) t.Run("a second apply changes nothing and says nothing about restarting", func(t *testing.T) { - code, stdout, stderr := runCLI(t, "link", "--repo", repoRoot, "--agent", "shared", "--apply") + code, stdout, stderr := runCLI(t, "link", "--agent", "shared", "--apply") if code != exitOK { t.Fatalf("exit = %d, want %d\n%s\n%s", code, exitOK, stdout, stderr) } @@ -726,7 +748,7 @@ func TestRunLink_WideRootStillRefusesAgentWithoutProject(t *testing.T) { globalAgent(t, repoRoot, "shared", "canonical\n") t.Chdir(filepath.Join(home, "Web")) - code, stdout, stderr := runCLI(t, "link", "--repo", repoRoot, "--agent", "shared", "--apply", "--"+allFlag) + code, stdout, stderr := runCLI(t, "link", "--agent", "shared", "--apply", "--"+allFlag) if code != exitFailure { t.Errorf("exit = %d, want %d: --agent with no project selected must be refused", code, exitFailure) } @@ -747,7 +769,7 @@ func TestRunReconcile_RestartNoticeOnlyForLinkChanges(t *testing.T) { base, repoRoot := reconcileFixture(t) brokenRecordedLink(t, base, repoRoot, "Web/proj", "a") - code, stdout, stderr := runCLI(t, reconcileArgs(repoRoot, base, "--"+reconcile.PruneFlag, "--apply")...) + code, stdout, stderr := runCLI(t, reconcileArgs(t, repoRoot, base, "--"+reconcile.PruneFlag, "--apply")...) if code != exitOK { t.Fatalf("exit = %d, want %d\n%s\n%s", code, exitOK, stdout, stderr) } @@ -761,7 +783,7 @@ func TestRunReconcile_RestartNoticeOnlyForLinkChanges(t *testing.T) { base, repoRoot := reconcileFixture(t) brokenRecordedLink(t, base, repoRoot, "Web/proj", "a") - _, stdout, _ := runCLI(t, reconcileArgs(repoRoot, base, "--"+reconcile.PruneFlag)...) + _, stdout, _ := runCLI(t, reconcileArgs(t, repoRoot, base, "--"+reconcile.PruneFlag)...) if strings.Contains(stdout, restartNotice) { t.Errorf("a dry run told the user to restart their session:\n%s", stdout) } @@ -776,7 +798,7 @@ func TestRunReconcile_RestartNoticeOnlyForLinkChanges(t *testing.T) { t.Fatalf("removing the link: %v", err) } - code, stdout, stderr := runCLI(t, reconcileArgs(repoRoot, base, "--apply")...) + code, stdout, stderr := runCLI(t, reconcileArgs(t, repoRoot, base, "--apply")...) if code != exitOK { t.Fatalf("exit = %d, want %d\n%s\n%s", code, exitOK, stdout, stderr) } @@ -802,7 +824,7 @@ func TestRunInventory_NeverNarrowsToTheProjectYouAreIn(t *testing.T) { mustMkdirT(t, filepath.Join(repoRoot, ".claude", "agents")) t.Chdir(home) - code, stdout, stderr := runCLI(t, "inventory", "--repo", repoRoot) + code, stdout, stderr := runCLI(t, "inventory") if code != exitOK { t.Fatalf("exit = %d, want %d\n%s\n%s", code, exitOK, stdout, stderr) } @@ -821,12 +843,12 @@ func TestRunInventory_NeverNarrowsToTheProjectYouAreIn(t *testing.T) { // narrow, so the assertion below is about the exemption and not // about a walk that failed to find anything. Read off stderr, // which is where --json puts the root line. - _, _, scanned := runCLI(t, "scan", "--repo", repoRoot, "--json") + _, _, scanned := runCLI(t, "scan", "--json") if !strings.Contains(scanned, "current directory") { t.Fatalf("scan did not narrow here, so the fixture proves nothing:\n%s", scanned) } - code, stdout, stderr := runCLI(t, "inventory", "--repo", repoRoot, "--check") + code, stdout, stderr := runCLI(t, "inventory", "--check") if code != exitOK { t.Errorf("exit = %d, want %d: --check narrowed and called the machine-wide file stale\n%s\n%s", code, exitOK, stdout, stderr) @@ -861,7 +883,7 @@ func TestRunReconcile_RekeyDoesNotAskForARestart(t *testing.T) { recordInState(t, repoRoot, "Web/new-name", "a", path, oldCapture) move := "--" + reconcile.RekeyFlag + "=Web/old-name=Web/new-name" - code, stdout, stderr := runCLI(t, reconcileArgs(repoRoot, base, move, "--apply")...) + code, stdout, stderr := runCLI(t, reconcileArgs(t, repoRoot, base, move, "--apply")...) if code != exitOK { t.Fatalf("exit = %d, want %d\n%s\n%s", code, exitOK, stdout, stderr) } @@ -881,7 +903,7 @@ func TestRun_RootDerivationRefusalsReachTheExitCode(t *testing.T) { // not fs.ErrNotExist, on every platform and as root, which chmod 000 is not. // The enclosing .git makes a swallowed error climb to a project and exit 0. t.Run("a marker that cannot be looked at", func(t *testing.T) { - home, repoRoot := projectScopeFixture(t) + home, _ := projectScopeFixture(t) enclosing := filepath.Join(home, "Web", "outer") mustMkdirT(t, filepath.Join(enclosing, ".git")) dir := filepath.Join(enclosing, "loop") @@ -898,7 +920,7 @@ func TestRun_RootDerivationRefusalsReachTheExitCode(t *testing.T) { } t.Chdir(dir) - code, stdout, stderr := runCLI(t, "scan", "--repo", repoRoot) + code, stdout, stderr := runCLI(t, "scan") if code != exitFailure { t.Errorf("exit = %d, want %d: an unreadable marker must refuse, not climb to the enclosing project\n%s\n%s", code, exitFailure, stdout, stderr) @@ -912,7 +934,7 @@ func TestRun_RootDerivationRefusalsReachTheExitCode(t *testing.T) { // cd into a directory, delete it, and os.Getwd may still hand back the path. // Getwd failing and resolveDir failing on the stale path both say "could not". t.Run("a working directory that was deleted underneath us", func(t *testing.T) { - home, repoRoot := projectScopeFixture(t) + home, _ := projectScopeFixture(t) gone := filepath.Join(home, "Web", "gone", "sub") mustMkdirT(t, gone) @@ -921,7 +943,7 @@ func TestRun_RootDerivationRefusalsReachTheExitCode(t *testing.T) { t.Fatalf("removing the working directory: %v", err) } - code, stdout, stderr := runCLI(t, "scan", "--repo", repoRoot) + code, stdout, stderr := runCLI(t, "scan") if code != exitFailure { t.Errorf("exit = %d, want %d: a deleted working directory must refuse\n%s\n%s", code, exitFailure, stdout, stderr) @@ -942,7 +964,7 @@ func TestRunLink_AgentRefusalNamesTheRoot(t *testing.T) { globalAgent(t, repoRoot, "shared", "canonical\n") t.Chdir(filepath.Join(home, "Web")) - code, _, stderr := runCLI(t, "link", "--repo", repoRoot, "--agent", "shared", "--apply", "--"+allFlag) + code, _, stderr := runCLI(t, "link", "--agent", "shared", "--apply", "--"+allFlag) if code != exitFailure { t.Fatalf("exit = %d, want %d", code, exitFailure) } @@ -954,7 +976,7 @@ func TestRunLink_AgentRefusalNamesTheRoot(t *testing.T) { // And it stays quiet when the root is not the reason: a bad agent name is // refused for its own reason and needs no advice about where to stand. - _, _, stderr = runCLI(t, "link", "--repo", repoRoot, "--project", "Web/alpha", "--agent", "../escape") + _, _, stderr = runCLI(t, "link", "--project", "Web/alpha", "--agent", "../escape") if strings.Contains(stderr, "selects no project") { t.Errorf("the root hint fired on an unrelated refusal:\n%s", stderr) } diff --git a/docs/configuration.md b/docs/configuration.md index 320562b..c25eb9b 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -21,35 +21,36 @@ absolute path. That holds on macOS too. with `~/`; `~` expands against `$HOME`. A missing file is not an error, but a symlink there whose target is missing is a broken file. -## Flags and environment +## Environment -| Setting | Flag | Environment | Config key | Used for | -|---|---|---|---|---| -| Canonical agents repo | `--repo PATH` | `$AGENTSYNC_REPO` | `repo` | every command | -| Default tree of projects | `--root PATH` | `$AGENTSYNC_ROOT` | `root` | `--all` and `inventory` | +| Setting | Environment | Config key | Used for | +|---|---|---|---| +| Canonical agents repo | `$AGENTSYNC_REPO` | `repo` | every command | +| Default tree of projects | `$AGENTSYNC_ROOT` | `root` | `--all` and `inventory` | ```bash export AGENTSYNC_REPO="$HOME/src/my-agents" export AGENTSYNC_ROOT="$HOME/src" ``` -`$AGENTSYNC_ROOT` must be an absolute path. +Both variables must be absolute paths; a relative one refuses with exit 2. +There is no flag for either: the project is the directory you run in, and only +[`init`](init.md) takes `--repo` and `--root`, as the values it writes. ## Precedence Each setting is resolved on its own, so the repo can come from the environment -and the root from the file. An empty or blank flag or variable counts as unset. +and the root from the file. An empty or blank variable counts as unset. | Order | Source | |---|---| -| 1 | flag | -| 2 | environment variable | -| 3 | config file | -| 4 | refuse with exit 2 | +| 1 | environment variable | +| 2 | config file | +| 3 | refuse with exit 2 | -The file is read only when a setting is still unset after the flag and the -environment. A broken file therefore never blocks a run that passes both, which -is how you work around it while fixing it. +The file is read only when a setting is still unset after the environment. A +broken file therefore never blocks a run that sets both variables, which is how +you work around it while fixing it. Every run that reports prints a `Repo:` line and a `Root:` line naming where each value came from, including the config file's path. @@ -80,8 +81,9 @@ path>`. ## Which tree gets scanned -Without `--root`, every command except `inventory` derives its root from the -working directory. It walks up, stopping before `$HOME`, and takes the first +Every command except `inventory` and `init` works on the project the working +directory is in; to work on a different project, run the command from inside +it. It walks up, stopping before `$HOME`, and takes the first directory holding `.claude/agents/`, else `.claude/`, else `.git/`. All three are tried at each level before ascending, so a `.claude/` inside a git repository resolves to that subdirectory, not the repository root. A project @@ -91,14 +93,17 @@ provision its first agent. If nothing is found before `$HOME`, the run is refused with exit 2 and nothing is scanned: a new project with no markers yet would otherwise widen to every project on the machine. Run inside a project, create its `.claude/agents/`, or -pass `--all` or `--root PATH`. A working directory that cannot be determined or +pass `--all`. A working directory that cannot be determined or resolved, or an ancestor that cannot be read, also refuses with exit 2 rather than widening to the whole tree. -- `--root PATH` names a tree explicitly and switches the derivation off. -- `--all` scans the default tree (`$AGENTSYNC_ROOT` or `root` in the config - file), the machine-wide view. It is refused together with `--root`, not - resolved by precedence. +`--all` scans the default tree (`$AGENTSYNC_ROOT` or `root` in the config file) +instead, the machine-wide view. + +`scan`, `import`, `link`, `doctor`, `reconcile`, `inventory`, `optimize` and +`override` refuse a positional argument with exit 2. One that looks like a path +(it starts with `/`, `~` or `.`, or names an existing directory) adds "Run it +from the project directory instead." A derived root also selects that project for `link`, as `--project ` would. That is what makes `agentsync link --agent NAME --apply` a complete @@ -114,7 +119,7 @@ stays machine-readable. A run refused before it reports prints nothing to stdout `inventory` never derives a root from the working directory. It describes every agent on the machine, so a project-scoped run would overwrite the machine-wide -document under the same name. It uses `--root`, else the default tree; `--all` is +document under the same name. It always uses the default tree; `--all` is accepted and means what the default already means. See [inventory](inventory.md). ## The home guard @@ -136,8 +141,6 @@ If your projects genuinely live outside `$HOME`, pass `--allow-outside-home`. | Flag | Meaning | |---|---| -| `--repo PATH` | canonical agents repo | -| `--root PATH` | tree to scan | | `--all` | scan the default tree | | `--json` | machine-readable output | | `--allow-outside-home` | see above | diff --git a/docs/doctor.md b/docs/doctor.md index 0c35f84..4b4e26b 100644 --- a/docs/doctor.md +++ b/docs/doctor.md @@ -2,7 +2,8 @@ `agentsync doctor` reports drift the other commands do not. It writes nothing: no `--apply`, no state file, no exclude line. It takes only the common flags -(see [configuration](configuration.md)). +(see [configuration](configuration.md)). Run it from the project directory; +`--all` checks the whole default tree instead. ```bash agentsync doctor diff --git a/docs/exit-codes.md b/docs/exit-codes.md index 9c2e3ac..6987622 100644 --- a/docs/exit-codes.md +++ b/docs/exit-codes.md @@ -4,7 +4,7 @@ |---|---| | `0` | clean | | `1` | the run completed and found something a human must settle | -| `2` | agentsync could not do its job: bad flags, missing configuration, an unreadable path, a refused write | +| `2` | agentsync could not do its job: an unknown flag or a stray argument, missing configuration, an unreadable path, a refused write | What counts as exit 1, by command: diff --git a/docs/import.md b/docs/import.md index 3ff4e87..d3cf7d6 100644 --- a/docs/import.md +++ b/docs/import.md @@ -3,7 +3,8 @@ `agentsync import` copies project-only agents into the canonical repo under `project-agents//`, so nothing is lost when `link` later replaces the file with a symlink. It writes only into the canonical repo, never into a -project's `.claude/agents/`, and only with `--apply`. +project's `.claude/agents/`, and only with `--apply`. Run it from the project +directory; `--all` covers the whole default tree instead. ```bash agentsync import # dry run: what a capture would do diff --git a/docs/link.md b/docs/link.md index c2e66ac..5cac275 100644 --- a/docs/link.md +++ b/docs/link.md @@ -22,8 +22,8 @@ agentsync link --exclude-local --apply # project's own git tracks .claude/a | `--undecline=KEY` | clear the declined marker for one `/`; repeatable; needs `--apply` | `--agent` with no project selected is refused: it would provision every project -under the scan root. Inside a project the derived root selects it (see -[configuration](configuration.md)). `link` does not decide which agents a +under the scan root. Run it from the project directory, which selects that +project (see [configuration](configuration.md)). `link` does not decide which agents a project should have; it provisions what it is told to. ## Rules diff --git a/docs/optimize.md b/docs/optimize.md index 39d7429..43cd532 100644 --- a/docs/optimize.md +++ b/docs/optimize.md @@ -21,8 +21,9 @@ That limit is the backstop if anything here undercounts. ## What it reads and sends The agents are the ones [scan](scan.md) finds under the root (the project's -`.claude/agents/`, linked or local); root and repo resolve as for every other -command (see [configuration](configuration.md)). An agent file linked into +`.claude/agents/`, linked or local); run it from the project directory, or pass +`--all` for the whole default tree. The repo resolves as for every other command +(see [configuration](configuration.md)). An agent file linked into several projects is asked about once. A `.md` file without `name` and `description` frontmatter is not an agent and is skipped. diff --git a/docs/override.md b/docs/override.md index eaee735..4c7dfd5 100644 --- a/docs/override.md +++ b/docs/override.md @@ -12,15 +12,15 @@ 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 +Run it from the project directory; `--all` is refused. The repo resolves 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/` | +| `--agent NAME` | the agent to override, by the `name:` in its frontmatter; it must be in the project'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 | @@ -35,7 +35,7 @@ refused: an override belongs to one project. ## How it works The overrides live in one `PreToolUse` hook in -`/.claude/settings.local.json`: +`/.claude/settings.local.json`: ```json {"matcher": "Agent", "hooks": [{"type": "command", @@ -81,7 +81,7 @@ A running Claude Code session may need a restart to pick up a change. - `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. + accept, no repo configured, no project above the working directory, a + settings file that cannot be read or edited, or a failed write. See [exit codes](exit-codes.md). diff --git a/docs/reconcile.md b/docs/reconcile.md index fca4c5d..0ea8a18 100644 --- a/docs/reconcile.md +++ b/docs/reconcile.md @@ -3,7 +3,8 @@ `agentsync reconcile` acts on the drift [doctor](doctor.md) reports, and only where a fact recorded in the [state file](state-file.md) or provable from git makes the act safe. It is a dry run without `--apply`, and every operation that -removes something needs its own flag on top. +removes something needs its own flag on top. Run it from the project directory; +`--all` covers the whole default tree instead. ```bash agentsync reconcile # report: what it could do and why not diff --git a/docs/scan.md b/docs/scan.md index 067a8c5..b13fa13 100644 --- a/docs/scan.md +++ b/docs/scan.md @@ -30,4 +30,5 @@ An agent with an [override](override.md) in its project's 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). +Run it from the project directory; `--all` scans the whole default tree instead. +Root selection is covered in [configuration](configuration.md). diff --git a/internal/doctor/doctor.go b/internal/doctor/doctor.go index 09a2056..cd57e8a 100644 --- a/internal/doctor/doctor.go +++ b/internal/doctor/doctor.go @@ -329,7 +329,7 @@ type capture struct { // live counterpart, and a whole capture directory whose project is gone. // // Liveness is asked of the filesystem rather than of the scan's entries. The -// scan only saw what was under its --root, so a capture belonging to a project +// scan only saw what was under its root, so a capture belonging to a project // outside that root would look stale on every run — a false report about the // one thing doctor must never be casually wrong about, since the suggested // remedy a reader will imagine is deletion. diff --git a/internal/scan/home.go b/internal/scan/home.go index 2b1162f..06879d7 100644 --- a/internal/scan/home.go +++ b/internal/scan/home.go @@ -30,8 +30,8 @@ const AllowOutsideHomeFlag = "allow-outside-home" // statement about the projects, never about the root they were reached // through. Judging the root instead looks equivalent and is not — scanning / // or /Users puts home *under* the root, which says nothing about where the -// projects are, and is exactly the shape `sudo -i agentsync scan --root /` -// takes. +// projects are, and is exactly the shape `sudo -i agentsync scan --all` with +// a default tree of / takes. type HomeStatus int const ( diff --git a/internal/scan/state.go b/internal/scan/state.go index afcae97..362077f 100644 --- a/internal/scan/state.go +++ b/internal/scan/state.go @@ -592,7 +592,7 @@ var reservedKeyRoots = map[string]bool{ // - Unique. Two distinct directories differ in some path component, and the // key preserves every component, so their keys differ too. // - Independent of the scan root. It is computed from the project's absolute -// path and home, never from the --root the caller passed, so ~/Web/example +// path and home, never from the root the caller scanned, so ~/Web/example // keys as Web/example whether the scan started at ~/Web, at ~, or at the // project itself. A root-relative key would rename the project depending on // how it was found, and counterpart lookup would miss.