From b9b525836a11bef41cb62001566fb802fd6e334d Mon Sep 17 00:00:00 2001 From: Logan Lindquist Land Date: Sun, 18 Oct 2026 10:15:33 -0500 Subject: [PATCH] feat: agentsync init for first-run setup `agentsync init` finds agent repos under $HOME (or --search), and writes the config file. In a terminal it prompts and asks before writing; otherwise it prints the exact command, and writes only with --repo, --root and --apply. It never replaces an existing config without --force. Commands with nothing configured now point at `agentsync init`. --- README.md | 12 +- cmd/agentsync/config_test.go | 3 + cmd/agentsync/init.go | 317 ++++++++++++++++++ cmd/agentsync/init_test.go | 514 +++++++++++++++++++++++++++++ cmd/agentsync/main.go | 24 +- cmd/agentsync/root_test.go | 4 +- coverage-floors.txt | 3 +- docs/configuration.md | 9 +- docs/exit-codes.md | 1 + docs/init.md | 108 ++++++ internal/config/config.go | 99 +++++- internal/config/config_test.go | 180 ++++++++++ internal/discover/discover.go | 152 +++++++++ internal/discover/discover_test.go | 296 +++++++++++++++++ 14 files changed, 1704 insertions(+), 18 deletions(-) create mode 100644 cmd/agentsync/init.go create mode 100644 cmd/agentsync/init_test.go create mode 100644 docs/init.md create mode 100644 internal/discover/discover.go create mode 100644 internal/discover/discover_test.go diff --git a/README.md b/README.md index 5d98f08..9467aa6 100644 --- a/README.md +++ b/README.md @@ -32,15 +32,19 @@ just install ## Quick start -Tell agentsync where your trees are in `~/.config/agentsync/config.toml`: +```bash +agentsync init # find your agents repo, write the config file +``` + +`init` writes `~/.config/agentsync/config.toml`: ```toml repo = "~/src/my-agents" # your canonical agents repo root = "~/src" # the tree holding your projects ``` -`$AGENTSYNC_REPO` and `$AGENTSYNC_ROOT`, or `--repo` and `--root`, work too and -take precedence; see [configuration](docs/configuration.md). +`--repo` and `--root`, or `$AGENTSYNC_REPO` and `$AGENTSYNC_ROOT`, work too and +take precedence; see [configuration](docs/configuration.md) and [init](docs/init.md). ```bash cd ~/src/api @@ -56,6 +60,7 @@ Run without `--apply` first to see what a command would do. | Command | Does | |---|---| +| `init` | find your agents repo and write the config file | | `scan` | classify project agents against the canonical repo (read-only) | | `import` | capture project-only agents into `project-agents/` | | `link` | replace project agent files with symlinks into the canonical repo | @@ -68,6 +73,7 @@ Run without `--apply` first to see what a command would do. ## Documentation - [Configuration](docs/configuration.md): repo and root settings, which tree is scanned, the home guard +- [init](docs/init.md) - [scan](docs/scan.md) - [import](docs/import.md) - [link](docs/link.md) diff --git a/cmd/agentsync/config_test.go b/cmd/agentsync/config_test.go index 88c4c61..7ba6719 100644 --- a/cmd/agentsync/config_test.go +++ b/cmd/agentsync/config_test.go @@ -262,14 +262,17 @@ func TestRun_NothingConfiguredRefuses(t *testing.T) { }{ {[]string{"scan", "--root", wide}, "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 repo in " + path + "\n"}, {[]string{"inventory", "--repo", 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 root in " + path + "\n"}, {[]string{"scan", "--all", "--repo", 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 root in " + path + "\n"}, } diff --git a/cmd/agentsync/init.go b/cmd/agentsync/init.go new file mode 100644 index 0000000..62d8fd6 --- /dev/null +++ b/cmd/agentsync/init.go @@ -0,0 +1,317 @@ +package main + +import ( + "bufio" + "errors" + "flag" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "strconv" + "strings" + + "github.com/agentic-tooling/agentsync/internal/config" + "github.com/agentic-tooling/agentsync/internal/discover" +) + +// promptInput is where init reads answers, and whether it may prompt at all: +// only when stdin and stdout are both terminals. Tests replace it. +var promptInput = func() (io.Reader, bool) { + return os.Stdin, terminal(os.Stdin) && terminal(os.Stdout) +} + +// terminal reports whether f is a character device other than the null device, +// which is how `init 0 { + fmt.Fprintf(stderr, "agentsync: init takes no arguments, got %q\n", fs.Arg(0)) + return exitFailure + } + repoArg, rootArg := strings.TrimSpace(*repoF), strings.TrimSpace(*rootF) + // Nothing is written that the user did not name. + if *apply && (repoArg == "" || rootArg == "") { + fmt.Fprintln(stderr, "agentsync: init --apply needs both --repo and --root; run init without --apply to see candidates") + return exitFailure + } + + cfgPath, err := config.Path() + if err != nil { + fmt.Fprintf(stderr, "agentsync: %v\n", err) + return exitFailure + } + r := initRun{stdout: stdout, stderr: stderr, home: os.Getenv("HOME"), cfgPath: cfgPath, force: *force} + if code, done := r.refuseExisting(); done { + return code + } + + var repo, root string + if rootArg != "" { + if root, err = checkRoot(rootArg); err != nil { + fmt.Fprintf(stderr, "agentsync: %v\n", err) + return exitFailure + } + } + if repoArg != "" { + if repo, err = checkRepo(repoArg); err != nil { + fmt.Fprintf(stderr, "agentsync: %v\n", err) + return exitFailure + } + if root == "" { + root = filepath.Dir(repo) + } + return r.dryRunOrApply(repo, root, *apply) + } + + in, tty := promptInput() + interactive := tty && rootArg == "" + searchRoot, err := config.ParsePath("--search", *search) + if err != nil { + fmt.Fprintf(stderr, "agentsync: %v\n", err) + return exitFailure + } + found := r.listCandidates(searchRoot, interactive) + if interactive { + return r.interactive(bufio.NewScanner(in), found.Candidates) + } + if len(found.Candidates) == 0 { + fmt.Fprintln(stdout, "Name yours: agentsync init --repo PATH --root PATH --apply") + return exitAtRisk + } + best := found.Candidates[0].Path + if root == "" { + root = filepath.Dir(best) + } + fmt.Fprintf(stdout, "\nNothing written. To use the first, run:\n %s\n", r.command(best, root)) + return exitOK +} + +// refuseExisting stops the run when a config file is already there and --force +// was not given, showing what it holds so the user can decide. +func (r initRun) refuseExisting() (int, bool) { + _, err := os.Lstat(r.cfgPath) + if errors.Is(err, fs.ErrNotExist) || (err == nil && r.force) { + return 0, false + } + if err != nil { + fmt.Fprintf(r.stderr, "agentsync: config file %s: %v\n", r.cfgPath, err) + return exitFailure, true + } + fmt.Fprintf(r.stderr, "agentsync: config file %s already exists; pass --force to replace it.\n", r.show(r.cfgPath)) + if data, err := os.ReadFile(r.cfgPath); err != nil { + fmt.Fprintf(r.stderr, " (could not read it: %v)\n", err) + } else { + fmt.Fprint(r.stderr, indent(string(data))) + } + return exitAtRisk, true +} + +func (r initRun) listCandidates(searchRoot string, numbered bool) discover.Result { + fmt.Fprintf(r.stdout, "Looking for agent repos under %s (depth %d, at most %d directories)...\n", + r.show(searchRoot), discover.Depth, discover.MaxDirs) + found := discover.Find(searchRoot, discover.MaxDirs) + if len(found.Candidates) == 0 { + fmt.Fprintln(r.stdout, "No agent repos found.") + } + for i, c := range found.Candidates { + marker := "-" + if numbered { + marker = strconv.Itoa(i+1) + ")" + } + fmt.Fprintf(r.stdout, " %s %s %d agent%s\n", marker, r.show(c.Path), c.Agents, plural(c.Agents, "", "s")) + } + if len(found.Unreadable) > 0 { + fmt.Fprintln(r.stdout, "Could not read (anything under these was not searched):") + for _, p := range found.Unreadable { + fmt.Fprintf(r.stdout, " %s: %v\n", r.show(p.Path), p.Err) + } + } + if found.Truncated { + fmt.Fprintf(r.stdout, "Stopped after %d directories; pass --search PATH to look somewhere narrower.\n", discover.MaxDirs) + } + return found +} + +func (r initRun) interactive(sc *bufio.Scanner, cands []discover.Candidate) int { + // ask's false means stop: end of input, or q at any prompt. + ask := func(q string) (string, bool) { + fmt.Fprint(r.stdout, q) + if !sc.Scan() { + fmt.Fprintln(r.stdout) + return "", false + } + ans := strings.TrimSpace(sc.Text()) + return ans, ans != "q" + } + choices := "a path" + if len(cands) == 1 { + choices = "1, or a path" + } else if len(cands) > 1 { + choices = fmt.Sprintf("1-%d, or a path", len(cands)) + } + + var repo string + for repo == "" { + ans, ok := ask("Repo [" + choices + "; q to quit]: ") + if !ok { + return r.aborted() + } + if n, err := strconv.Atoi(ans); err == nil { + if n >= 1 && n <= len(cands) { + repo = cands[n-1].Path + } else { + fmt.Fprintf(r.stdout, "There is no candidate %d.\n", n) + } + } else if ans != "" { + if p, err := checkRepo(ans); err != nil { + fmt.Fprintf(r.stdout, "%v\n", err) + } else { + repo = p + } + } + } + + suggested := filepath.Dir(repo) + var root string + for root == "" { + ans, ok := ask("Default tree for --all and inventory [" + r.show(suggested) + "; q to quit]: ") + if !ok { + return r.aborted() + } + if ans == "" { + ans = suggested + } + if p, err := checkRoot(ans); err != nil { + fmt.Fprintf(r.stdout, "%v\n", err) + } else { + root = p + } + } + + r.preview("Will write", repo, root) + ans, ok := ask("Write it? [y/N]: ") + if ans = strings.ToLower(ans); !ok || (ans != "y" && ans != "yes") { + return r.aborted() + } + return r.write(repo, root) +} + +func (r initRun) dryRunOrApply(repo, root string, apply bool) int { + if apply { + return r.write(repo, root) + } + r.preview("Would write", repo, root) + fmt.Fprintf(r.stdout, "Dry run: nothing written. To write it:\n %s\n", r.command(repo, root)) + return exitOK +} + +// preview prints the exact file a write would produce. Render cannot refuse +// here: repo and root have already passed ParsePath, the only check it makes. +func (r initRun) preview(verb, repo, root string) { + data, _ := config.Render(config.Config{Repo: repo, Root: root}) + fmt.Fprintf(r.stdout, "%s %s:\n%s", verb, r.show(r.cfgPath), indent(string(data))) +} + +func (r initRun) write(repo, root string) int { + if err := config.Write(r.cfgPath, config.Config{Repo: repo, Root: root}, r.force); err != nil { + fmt.Fprintf(r.stderr, "agentsync: %v\n", err) + return exitFailure + } + fmt.Fprintf(r.stdout, "Wrote %s\nNext, inside a project:\n agentsync scan --at-risk\n agentsync link --agent --apply\n", r.show(r.cfgPath)) + return exitOK +} + +func (r initRun) aborted() int { + fmt.Fprintln(r.stderr, "agentsync: init aborted; nothing written.") + return exitAtRisk +} + +// command is the init invocation that writes repo and root, ready to paste. +func (r initRun) command(repo, root string) string { + cmd := "agentsync init --repo " + r.shellPath(repo) + " --root " + r.shellPath(root) + " --apply" + if r.force { + cmd += " --force" + } + return cmd +} + +// shellPath is show(p) quoted for a POSIX shell, keeping a leading ~/ outside +// the quotes so the shell still expands it. +func (r initRun) shellPath(p string) string { + s := r.show(p) + prefix, rest := "", s + if after, ok := strings.CutPrefix(s, "~/"); ok { + prefix, rest = "~/", after + } + if rest == "" || strings.Trim(rest, "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789/._-+~@") == "" { + return s + } + return prefix + "'" + strings.ReplaceAll(rest, "'", `'\''`) + "'" +} + +func (r initRun) show(p string) string { return displayPath(r.home, p) } + +// checkRepo returns the expanded repo path if it names a directory holding +// agents/, the layout every other command expects. +func checkRepo(raw string) (string, error) { + p, err := checkDir("repo", raw) + if err != nil { + return "", err + } + if fi, err := os.Stat(filepath.Join(p, "agents")); err != nil || !fi.IsDir() { + return "", fmt.Errorf("repo %s has no agents/ directory, so it is not an agent repo", p) + } + return p, nil +} + +func checkRoot(raw string) (string, error) { return checkDir("root", raw) } + +func checkDir(key, raw string) (string, error) { + p, err := config.ParsePath(key, raw) + if err != nil { + return "", err + } + fi, err := os.Stat(p) + if err != nil { + return "", fmt.Errorf("%s: %w", key, err) + } + if !fi.IsDir() { + return "", fmt.Errorf("%s %s is not a directory", key, p) + } + return p, nil +} + +func indent(s string) string { + return " " + strings.ReplaceAll(strings.TrimRight(s, "\n"), "\n", "\n ") + "\n" +} diff --git a/cmd/agentsync/init_test.go b/cmd/agentsync/init_test.go new file mode 100644 index 0000000..183cf2c --- /dev/null +++ b/cmd/agentsync/init_test.go @@ -0,0 +1,514 @@ +package main + +import ( + "io" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/agentic-tooling/agentsync/internal/config" + "github.com/agentic-tooling/agentsync/internal/testfs" +) + +// initEnv gives the test its own HOME with two agent repos in it, puts the +// config file under that HOME, and makes init non-interactive. +func initEnv(t *testing.T) (home, cfgPath string) { + t.Helper() + home = t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", "") + agentRepo(t, filepath.Join(home, "Web", "claude-agents"), 3) + agentRepo(t, filepath.Join(home, "old", "backup"), 1) + useInput(t, "", false) + return home, filepath.Join(home, ".config", "agentsync", "config.toml") +} + +func useInput(t *testing.T, input string, tty bool) { + t.Helper() + old := promptInput + promptInput = func() (io.Reader, bool) { return strings.NewReader(input), tty } + t.Cleanup(func() { promptInput = old }) +} + +// agentRepo makes dir a git repo whose agents/ holds n agent files. +func agentRepo(t *testing.T, dir string, n int) { + t.Helper() + mustMkdirT(t, filepath.Join(dir, ".git")) + mustMkdirT(t, filepath.Join(dir, "agents")) + for i := range n { + mustWriteT(t, filepath.Join(dir, "agents", string(rune('a'+i))+".md"), "---\nname: a\ndescription: d\n---\n") + } +} + +func assertNoConfig(t *testing.T, path string) { + t.Helper() + if _, err := os.Lstat(path); !os.IsNotExist(err) { + t.Errorf("config file %s exists (%v); want nothing written", path, err) + } +} + +func assertContains(t *testing.T, got string, want ...string) { + t.Helper() + for _, w := range want { + if !strings.Contains(got, w) { + t.Errorf("output is missing %q:\n%s", w, got) + } + } +} + +const wantFile = "repo = \"~/Web/claude-agents\"\nroot = \"~/Web\"\n" + +func TestRunInit_NonInteractiveListsCandidates(t *testing.T) { + _, cfgPath := initEnv(t) + + code, stdout, stderr := runCLI(t, "init") + if code != exitOK { + t.Fatalf("exit = %d, want %d\n%s", code, exitOK, stderr) + } + assertContains(t, stdout, + "Looking for agent repos under ~ (depth 4, at most 50000 directories)...\n"+ + " - ~/Web/claude-agents 3 agents\n"+ + " - ~/old/backup 1 agent\n", + "\nNothing written. To use the first, run:\n agentsync init --repo ~/Web/claude-agents --root ~/Web --apply\n") + assertNoConfig(t, cfgPath) + + t.Run("a given root and --force go into the command", func(t *testing.T) { + _, stdout, _ := runCLI(t, "init", "--root", "~/old", "--force") + assertContains(t, stdout, "agentsync init --repo ~/Web/claude-agents --root ~/old --apply --force\n") + }) +} + +func TestRunInit_NothingFound(t *testing.T) { + _, cfgPath := initEnv(t) + code, stdout, _ := runCLI(t, "init", "--search", t.TempDir()) + if code != exitAtRisk { + t.Errorf("exit = %d, want %d", code, exitAtRisk) + } + assertContains(t, stdout, "No agent repos found.\n", "agentsync init --repo PATH --root PATH --apply") + assertNoConfig(t, cfgPath) +} + +func TestRunInit_SearchReportsUnreadable(t *testing.T) { + initEnv(t) + // Resolved first: the search walks the resolved root, so the fixture's + // length must be measured against that path. + base, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + unreadable, _ := testfs.UnreadableSubtree(t, base) + search := filepath.Dir(unreadable) + agentRepo(t, filepath.Join(search, "r"), 1) + + code, stdout, stderr := runCLI(t, "init", "--search", search) + if code != exitOK { + t.Fatalf("exit = %d, want %d\n%s", code, exitOK, stderr) + } + assertContains(t, stdout, + " - "+filepath.Join(search, "r")+" 1 agent\n", + "Could not read (anything under these was not searched):\n "+unreadable+": ") +} + +func TestRunInit_DryRunWritesNothing(t *testing.T) { + home, cfgPath := initEnv(t) + want := "Would write ~/.config/agentsync/config.toml:\n" + + " repo = \"~/Web/claude-agents\"\n root = \"~/Web\"\n" + + "Dry run: nothing written. To write it:\n" + + " agentsync init --repo ~/Web/claude-agents --root ~/Web --apply\n" + for _, args := range [][]string{ + {"init", "--repo", filepath.Join(home, "Web", "claude-agents"), "--root", filepath.Join(home, "Web")}, + {"init", "--repo", "~/Web/claude-agents"}, // root defaults to the repo's parent + } { + code, stdout, stderr := runCLI(t, args...) + if code != exitOK || stdout != want { + t.Errorf("%v: exit = %d, stdout =\n%s\nwant %d and\n%s\nstderr: %s", args, code, stdout, exitOK, want, stderr) + } + assertNoConfig(t, cfgPath) + } +} + +func TestRunInit_ApplyWritesTheFile(t *testing.T) { + home, cfgPath := initEnv(t) + code, stdout, stderr := runCLI(t, "init", "--repo", "~/Web/claude-agents", "--root", "~/Web", "--apply") + if code != exitOK { + t.Fatalf("exit = %d, want %d\n%s", code, exitOK, stderr) + } + want := "Wrote ~/.config/agentsync/config.toml\nNext, inside a project:\n" + + " agentsync scan --at-risk\n agentsync link --agent --apply\n" + if stdout != want { + t.Errorf("stdout =\n%s\nwant\n%s", stdout, want) + } + data, err := os.ReadFile(cfgPath) + if err != nil || string(data) != wantFile { + t.Errorf("file = %q, %v; want %q", data, err, wantFile) + } + if fi, err := os.Stat(cfgPath); err != nil || fi.Mode().Perm() != 0o600 { + t.Errorf("mode = %v, %v; want 0600", fi.Mode(), err) + } + got, err := config.Load(cfgPath) + if c := (config.Config{Repo: filepath.Join(home, "Web", "claude-agents"), Root: filepath.Join(home, "Web")}); err != nil || got != c { + t.Errorf("Load = %+v, %v; want %+v", got, err, c) + } +} + +func TestRunInit_ApplyNeedsBothValues(t *testing.T) { + _, cfgPath := initEnv(t) + for _, args := range [][]string{ + {"init", "--apply"}, + {"init", "--apply", "--repo", "~/Web/claude-agents"}, + {"init", "--apply", "--root", "~/Web"}, + } { + code, _, stderr := runCLI(t, args...) + if code != exitFailure || !strings.Contains(stderr, "needs both --repo and --root") { + t.Errorf("%v: exit = %d, stderr = %q; want %d", args, code, stderr, exitFailure) + } + assertNoConfig(t, cfgPath) + } +} + +func TestRunInit_ExistingFile(t *testing.T) { + apply := []string{"init", "--repo", "~/Web/claude-agents", "--root", "~/Web", "--apply"} + + t.Run("refused with its contents shown, interactive too", func(t *testing.T) { + _, cfgPath := initEnv(t) + mustWriteT(t, cfgPath, "repo = \"/old\"\n") + for _, run := range []struct { + args []string + tty bool + }{{apply, false}, {apply[:len(apply)-1], false}, {[]string{"init"}, false}, {[]string{"init"}, true}} { + useInput(t, "1\n\ny\n", run.tty) + code, stdout, stderr := runCLI(t, run.args...) + if code != exitAtRisk { + t.Errorf("%v tty=%v: exit = %d, want %d", run.args, run.tty, code, exitAtRisk) + } + assertContains(t, stderr, "config file ~/.config/agentsync/config.toml already exists; pass --force to replace it.\n repo = \"/old\"\n") + if stdout != "" { + t.Errorf("%v tty=%v: printed before refusing:\n%s", run.args, run.tty, stdout) + } + } + if data, _ := os.ReadFile(cfgPath); string(data) != "repo = \"/old\"\n" { + t.Errorf("file = %q, want it untouched", data) + } + }) + + t.Run("replaced with --force", func(t *testing.T) { + _, cfgPath := initEnv(t) + mustWriteT(t, cfgPath, "repo = \"/old\"\n") + if code, _, stderr := runCLI(t, append(apply, "--force")...); code != exitOK { + t.Fatalf("exit = %d, want %d\n%s", code, exitOK, stderr) + } + if data, _ := os.ReadFile(cfgPath); string(data) != wantFile { + t.Errorf("file = %q, want %q", data, wantFile) + } + }) + + t.Run("a dangling symlink is still a file in the way", func(t *testing.T) { + _, cfgPath := initEnv(t) + mustMkdirT(t, filepath.Dir(cfgPath)) + if err := os.Symlink(cfgPath+".gone", cfgPath); err != nil { + t.Fatal(err) + } + code, _, stderr := runCLI(t, apply...) + if code != exitAtRisk || !strings.Contains(stderr, "(could not read it: ") { + t.Errorf("exit = %d, stderr = %q; want %d saying it could not be read", code, stderr, exitAtRisk) + } + }) + + t.Run("a directory in the way fails the forced write", func(t *testing.T) { + _, cfgPath := initEnv(t) + mustWriteT(t, filepath.Join(cfgPath, "keep"), "x") + code, _, stderr := runCLI(t, append(apply, "--force")...) + if code != exitFailure || !strings.Contains(stderr, "writing config file "+cfgPath) { + t.Errorf("exit = %d, stderr = %q; want %d", code, stderr, exitFailure) + } + }) + + t.Run("a config path that cannot be checked refuses", func(t *testing.T) { + initEnv(t) + xdg := filepath.Join(t.TempDir(), "file") + mustWriteT(t, xdg, "") + t.Setenv("XDG_CONFIG_HOME", xdg) + code, _, stderr := runCLI(t, apply...) + if code != exitFailure || !strings.Contains(stderr, "not a directory") { + t.Errorf("exit = %d, stderr = %q; want %d", code, stderr, exitFailure) + } + }) +} + +func TestRunInit_InvalidValuesRefuse(t *testing.T) { + home, cfgPath := initEnv(t) + file := filepath.Join(home, "file") + mustWriteT(t, file, "") + mustMkdirT(t, filepath.Join(home, "noagents")) + tests := []struct { + name string + args []string + want string + }{ + {"missing repo", []string{"--repo", "~/nope"}, "repo: stat "}, + {"repo is a file", []string{"--repo", file}, "is not a directory"}, + {"repo without agents/", []string{"--repo", "~/noagents"}, "has no agents/ directory"}, + {"relative repo", []string{"--repo", "Web/claude-agents"}, "repo must be an absolute path"}, + {"missing root", []string{"--repo", "~/Web/claude-agents", "--root", "~/nope"}, "root: stat "}, + {"root is a file", []string{"--repo", "~/Web/claude-agents", "--root", file}, "is not a directory"}, + {"relative root", []string{"--root", "Web"}, "root must be an absolute path"}, + {"relative search", []string{"--search", "Web"}, "--search must be an absolute path"}, + {"missing repo with --apply", []string{"--repo", "~/nope", "--root", "~/Web", "--apply"}, "repo: stat "}, + {"positional argument", []string{"extra"}, "init takes no arguments"}, + {"unknown flag", []string{"--nope"}, "flag provided but not defined"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + code, stdout, stderr := runCLI(t, append([]string{"init"}, tc.args...)...) + if code != exitFailure || !strings.Contains(stderr, tc.want) { + t.Errorf("exit = %d, stderr = %q; want %d saying %q", code, stderr, exitFailure, tc.want) + } + if strings.Contains(stdout, "Would write") { + t.Errorf("previewed a file it refused:\n%s", stdout) + } + assertNoConfig(t, cfgPath) + }) + } +} + +func TestRunInit_HelpAndUnlocatableConfig(t *testing.T) { + initEnv(t) + if code, _, _ := runCLI(t, "init", "-h"); code != exitOK { + t.Errorf("-h: exit = %d, want %d", code, exitOK) + } + t.Setenv("HOME", "") + if code, _, stderr := runCLI(t, "init"); code != exitFailure || !strings.Contains(stderr, "cannot locate the config file") { + t.Errorf("no HOME: exit = %d, stderr = %q", code, stderr) + } + // With the config file located through XDG, the default search of ~ is + // what cannot be resolved. + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + if code, _, stderr := runCLI(t, "init"); code != exitFailure || !strings.Contains(stderr, "--search: cannot expand ~") { + t.Errorf("no HOME, XDG set: exit = %d, stderr = %q", code, stderr) + } +} + +func TestRunInit_Interactive(t *testing.T) { + tests := []struct { + name, input string + wantFile string + wantOut []string + }{ + {"pick by number, accept the suggested root", "1\n\ny\n", wantFile, []string{ + " 1) ~/Web/claude-agents 3 agents\n 2) ~/old/backup 1 agent\n", + "Repo [1-2, or a path; q to quit]: Default tree for --all and inventory [~/Web; q to quit]: ", + "Will write ~/.config/agentsync/config.toml:\n repo = \"~/Web/claude-agents\"\n root = \"~/Web\"\nWrite it? [y/N]: Wrote ~/.config/agentsync/config.toml\n", + }}, + {"pick by path, type a root", "~/old/backup\n~/old\nYES\n", "repo = \"~/old/backup\"\nroot = \"~/old\"\n", []string{ + "[~/old; q to quit]: ", + }}, + {"bad answers re-ask", "9\n\nnope\n~/old\n2\nWeb\n~/nope\n\ny\n", "repo = \"~/old/backup\"\nroot = \"~/old\"\n", []string{ + "There is no candidate 9.\n", + "repo must be an absolute path", + "has no agents/ directory", + "root must be an absolute path", + "root: stat ", + }}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + _, cfgPath := initEnv(t) + useInput(t, tc.input, true) + code, stdout, stderr := runCLI(t, "init") + if code != exitOK { + t.Fatalf("exit = %d, want %d\n%s\n%s", code, exitOK, stdout, stderr) + } + assertContains(t, stdout, tc.wantOut...) + if data, err := os.ReadFile(cfgPath); err != nil || string(data) != tc.wantFile { + t.Errorf("file = %q, %v; want %q", data, err, tc.wantFile) + } + }) + } +} + +// boundedPrompts is init's stdout. It fails the test once init asks more +// questions than max, so a prompt loop that ignores end of input fails rather +// than hangs. +type boundedPrompts struct { + t *testing.T + max int + out strings.Builder +} + +func (w *boundedPrompts) Write(p []byte) (int, error) { + w.out.Write(p) + if n := strings.Count(w.out.String(), "]: "); n > w.max { + w.t.Fatalf("asked %d questions, want at most %d:\n%s", n, w.max, w.out.String()) + } + return len(p), nil +} + +func TestRunInit_InteractiveWritesNothingUnlessConfirmed(t *testing.T) { + tests := []struct { + name, input string + asks int + previewed bool + }{ + {"q at repo", "q\n", 1, false}, + {"EOF at repo", "", 1, false}, + {"q at root", "1\nq\n", 2, false}, + {"EOF at root", "1\n", 2, false}, + {"q at confirm", "1\n\nq\n", 3, true}, + {"EOF at confirm", "1\n\n", 3, true}, + {"empty confirm", "1\n\n\n", 3, true}, + {"n at confirm", "1\n\nN\n", 3, true}, + {"anything but yes", "1\n\nsure\n", 3, true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + _, cfgPath := initEnv(t) + useInput(t, tc.input, true) + stdout := &boundedPrompts{t: t, max: tc.asks} + var stderr strings.Builder + code := run([]string{"init"}, stdout, &stderr) + // A stop must end the run at once, not re-ask until input runs out. + if n := strings.Count(stdout.out.String(), "]: "); n != tc.asks { + t.Errorf("asked %d questions, want %d:\n%s", n, tc.asks, stdout.out.String()) + } + if code != exitAtRisk || !strings.Contains(stderr.String(), "init aborted; nothing written.") { + t.Errorf("exit = %d, stderr = %q; want %d and an abort", code, stderr.String(), exitAtRisk) + } + if got := strings.Contains(stdout.out.String(), "Will write"); got != tc.previewed { + t.Errorf("previewed = %v, want %v:\n%s", got, tc.previewed, stdout.out.String()) + } + assertNoConfig(t, cfgPath) + }) + } +} + +func TestRunInit_RootWithoutRepoNeverPrompts(t *testing.T) { + _, cfgPath := initEnv(t) + useInput(t, "", true) + code, stdout, stderr := runCLI(t, "init", "--root", "~/Web") + if code != exitOK || strings.Contains(stdout, "Repo [") { + t.Fatalf("exit = %d, want %d and no prompt\n%s\n%s", code, exitOK, stdout, stderr) + } + assertContains(t, stdout, " - ~/Web/claude-agents 3 agents\n", + "agentsync init --repo ~/Web/claude-agents --root ~/Web --apply\n") + assertNoConfig(t, cfgPath) +} + +func TestRunInit_InteractiveOneCandidate(t *testing.T) { + _, cfgPath := initEnv(t) + useInput(t, "1\n\ny\n", true) + code, stdout, stderr := runCLI(t, "init", "--search", "~/Web") + if code != exitOK { + t.Fatalf("exit = %d\n%s", code, stderr) + } + assertContains(t, stdout, " 1) ~/Web/claude-agents 3 agents\nRepo [1, or a path; q to quit]: ") + if data, _ := os.ReadFile(cfgPath); string(data) != wantFile { + t.Errorf("file = %q, want %q", data, wantFile) + } +} + +func TestRunInit_HomeThroughASymlink(t *testing.T) { + target := t.TempDir() + agentRepo(t, filepath.Join(target, "Web", "claude-agents"), 3) + home := filepath.Join(t.TempDir(), "home") + if err := os.Symlink(target, home); err != nil { + t.Fatal(err) + } + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", "") + useInput(t, "", false) + + code, stdout, stderr := runCLI(t, "init") + if code != exitOK { + t.Fatalf("exit = %d, want %d\n%s\n%s", code, exitOK, stdout, stderr) + } + assertContains(t, stdout, " - ~/Web/claude-agents 3 agents\n", + "agentsync init --repo ~/Web/claude-agents --root ~/Web --apply\n") +} + +func TestRunInit_SearchRootThatIsNotADirectory(t *testing.T) { + home, cfgPath := initEnv(t) + mustWriteT(t, filepath.Join(home, "file"), "") + if err := os.Symlink(filepath.Join(home, "gone"), filepath.Join(home, "dangling")); err != nil { + t.Fatal(err) + } + for search, want := range map[string]string{ + "~/file": " ~/file: not a directory\n", + "~/dangling": " ~/dangling: lstat ", + } { + code, stdout, _ := runCLI(t, "init", "--search", search) + if code != exitAtRisk { + t.Errorf("--search %s: exit = %d, want %d", search, code, exitAtRisk) + } + assertContains(t, stdout, "Could not read (anything under these was not searched):\n"+want) + } + assertNoConfig(t, cfgPath) +} + +func TestRunInit_InteractiveWithNothingFoundTakesAPath(t *testing.T) { + home, cfgPath := initEnv(t) + useInput(t, "~/Web/claude-agents\n\ny\n", true) + code, stdout, stderr := runCLI(t, "init", "--search", filepath.Join(home, "old", "backup", "agents")) + if code != exitOK { + t.Fatalf("exit = %d\n%s", code, stderr) + } + assertContains(t, stdout, "No agent repos found.\nRepo [a path; q to quit]: ") + if data, _ := os.ReadFile(cfgPath); string(data) != wantFile { + t.Errorf("file = %q, want %q", data, wantFile) + } +} + +func TestShellPath(t *testing.T) { + r := initRun{home: "/h"} + for in, want := range map[string]string{ + "/h/Web/agents": "~/Web/agents", + "/h": "~", + "/h/My Projects": "~/'My Projects'", + "/opt/it's here": `'/opt/it'\''s here'`, + "/opt/a-b_c.d+@~": "/opt/a-b_c.d+@~", + } { + if got := r.shellPath(in); got != want { + t.Errorf("shellPath(%q) = %s, want %s", in, got, want) + } + } +} + +// TestPromptInput_NeedsBothTerminals runs the real detection, with /dev/zero +// standing in for a terminal: a character device that is not the null device. +func TestPromptInput_NeedsBothTerminals(t *testing.T) { + open := func(name string) *os.File { + f, err := os.OpenFile(name, os.O_RDWR, 0) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = f.Close() }) + return f + } + null, zero := open(os.DevNull), open("/dev/zero") + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + defer func() { _ = r.Close(); _ = w.Close() }() + + oldIn, oldOut := os.Stdin, os.Stdout + defer func() { os.Stdin, os.Stdout = oldIn, oldOut }() + for _, tc := range []struct { + name string + in, out *os.File + want bool + }{ + {"both terminals", zero, zero, true}, + {"stdout a pipe", zero, w, false}, + {"stdin a pipe", r, zero, false}, + {"stdin the null device", null, zero, false}, + {"stdout the null device", zero, null, false}, + } { + os.Stdin, os.Stdout = tc.in, tc.out + in, tty := promptInput() + os.Stdin, os.Stdout = oldIn, oldOut + if tty != tc.want || in != tc.in { + t.Errorf("%s: prompting = %v, want %v (reader is stdin: %v)", tc.name, tty, tc.want, in == tc.in) + } + } +} diff --git a/cmd/agentsync/main.go b/cmd/agentsync/main.go index 220c488..eac66a0 100644 --- a/cmd/agentsync/main.go +++ b/cmd/agentsync/main.go @@ -32,6 +32,9 @@ // materialized view that is unsafe to re-run is not a materialized view. Its // output is derived and must never be used to decide a write; see the inventory // package doc. +// +// init writes only agentsync's own config file, from values the user named or +// confirmed, and refuses to replace an existing one without --force. package main import ( @@ -52,6 +55,7 @@ import ( const usage = `agentsync — inspect project-local Claude agents against the canonical repo Usage: + agentsync init [flags] find your agent repo and write the config file agentsync scan [flags] classify project agents against the repo (read-only) agentsync import [flags] capture project-only agents into project-agents/ agentsync link [flags] replace project agent files with symlinks into the repo @@ -90,6 +94,22 @@ 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. + +init flags: + --repo PATH the canonical agent repo to write (must hold agents/) + --root PATH the default tree to write (default shown: the repo's parent) + --apply write the file; without it init is a dry run + --force replace an existing config file, which is otherwise refused + with exit 1 and its contents shown + --search PATH where to look for agent repos when --repo is not given + (default ~, four levels deep) + +In a terminal with neither --repo nor --root, init lists the repos it found, +asks which to use and which default tree, shows the file, and writes it only +after you answer y. Anywhere else it never prompts: without --repo it lists +the candidates and prints the exact command to run, and it writes only with +--apply and both --repo and --root. scan flags: --at-risk list only entries whose content is protected nowhere @@ -250,6 +270,8 @@ func run(args []string, stdout, stderr io.Writer) int { } switch args[0] { + case "init": + return runInit(args[1:], stdout, stderr) case "scan": return runScan(args[1:], stdout, stderr) case "import": @@ -318,7 +340,7 @@ func openRepo(flagValue string) (*scan.Repo, string, error) { // notConfigured is the refusal for a setting no source supplied. key is both // the flag name and the config key. func notConfigured(what, key, env, cfgPath string) error { - return fmt.Errorf("no %s configured.\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 Pass it each run: --%s PATH (or set $%s)\n Or set %s in %s", what, key, env, key, cfgPath) } // emitRepoChoice prints which repo this run uses and where that came from, diff --git a/cmd/agentsync/root_test.go b/cmd/agentsync/root_test.go index 3aa316b..80646f6 100644 --- a/cmd/agentsync/root_test.go +++ b/cmd/agentsync/root_test.go @@ -501,8 +501,8 @@ func TestRun_RepoIsRequired(t *testing.T) { 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) { - t.Errorf("%s: the refusal does not name --repo and $%s:\n%s", cmd, envRepo, stderr) + 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 stdout != "" { t.Errorf("%s: a refused run must not emit a report:\n%s", cmd, stdout) diff --git a/coverage-floors.txt b/coverage-floors.txt index d68d51a..54e23b4 100644 --- a/coverage-floors.txt +++ b/coverage-floors.txt @@ -1,7 +1,8 @@ # Minimum per-package statement coverage, checked by `just check`. # Raise a floor after a coverage pass; never lower one. -cmd/agentsync 89.9 +cmd/agentsync 91.2 internal/config 100.0 +internal/discover 100.0 internal/doctor 88.1 internal/importer 88.8 internal/inventory 91.2 diff --git a/docs/configuration.md b/docs/configuration.md index 1938ff5..320562b 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1,11 +1,12 @@ # Configuration agentsync does not guess where your trees live. A run that needs a setting that -is unset refuses with exit 2. +is unset refuses with exit 2 and names `agentsync init`. ## The config file -The usual place to set them is `config.toml`: +The usual place to set them is `config.toml`, which +[`agentsync init`](init.md) writes for you: ```toml repo = "~/src/my-agents" @@ -16,8 +17,8 @@ It lives at `$XDG_CONFIG_HOME/agentsync/config.toml`, or at `~/.config/agentsync/config.toml` when `$XDG_CONFIG_HOME` is unset or not an absolute path. That holds on macOS too. -`repo` and `root` are the only keys. Each must be an absolute path or start -with `~/`, which expands against `$HOME`; `~` alone means `$HOME`. A missing file +`repo` and `root` are the only keys. Each must be an absolute path, `~`, or start +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 diff --git a/docs/exit-codes.md b/docs/exit-codes.md index 282c41f..f26a85b 100644 --- a/docs/exit-codes.md +++ b/docs/exit-codes.md @@ -10,6 +10,7 @@ What counts as exit 1, by command: | Command | Exit 1 when | |---|---| +| `init` | a config file already exists, no candidate repo was found, or the user stopped at a prompt | | `scan` | never by itself; with `--fail-on-risk`, any at-risk file | | `import` | a conflicting capture, an unreadable path, or (with `--fail-on-pending`) anything uncaptured | | `link` | a blocked project, a broken or mismatched link, or an unreadable path | diff --git a/docs/init.md b/docs/init.md new file mode 100644 index 0000000..9a91374 --- /dev/null +++ b/docs/init.md @@ -0,0 +1,108 @@ +# init + +`agentsync init` writes the [config file](configuration.md): it finds your +canonical agents repo, suggests a default tree of projects, and writes both to +`~/.config/agentsync/config.toml` (or under `$XDG_CONFIG_HOME`). It is the +command every other command names when nothing is configured. + +```bash +agentsync init # in a terminal: walks you through it +agentsync init # elsewhere: lists candidates and prints the command +agentsync init --repo ~/src/my-agents --root ~/src # dry run: shows the exact file +agentsync init --repo ~/src/my-agents --root ~/src --apply # writes it +``` + +| Flag | Meaning | +|---|---| +| `--repo PATH` | the canonical agents repo to write; must be a directory holding `agents/` | +| `--root PATH` | the default tree to write; must be a directory. Defaults to the repo's parent in a dry run | +| `--apply` | write the file; needs both `--repo` and `--root` | +| `--force` | replace an existing config file | +| `--search PATH` | where to look for agent repos when `--repo` is not given (default `~`) | + +Paths must be absolute, `~`, or start with `~/`. A value under `$HOME` is written as +`~/…`, so the file works on another machine with the same layout. + +## In a terminal + +When stdin and stdout are both terminals and neither `--repo` nor `--root` is +given, `init` asks (`/dev/null` does not count as a terminal): + +``` +$ agentsync init +Looking for agent repos under ~ (depth 4, at most 50000 directories)... + 1) ~/src/my-agents 33 agents + 2) ~/old/agents-backup 4 agents +Repo [1-2, or a path; q to quit]: 1 +Default tree for --all and inventory [~/src; q to quit]: +Will write ~/.config/agentsync/config.toml: + repo = "~/src/my-agents" + root = "~/src" +Write it? [y/N]: y +Wrote ~/.config/agentsync/config.toml +Next, inside a project: + agentsync scan --at-risk + agentsync link --agent --apply +``` + +Pick a repo by number or type a path. Press Enter at the second prompt to +accept the suggested tree, or type another. Nothing is written until you answer +`y`; any other answer, `q` at any prompt, or end of input stops with exit +1 and writes nothing. An invalid answer explains why and asks again. + +## Anywhere else + +Scripts, pipes and agents never get a prompt. Without `--repo`, `init` lists +what it found and prints the exact command for the best candidate: + +``` +$ agentsync init +Looking for agent repos under ~ (depth 4, at most 50000 directories)... + - ~/src/my-agents 33 agents + +Nothing written. To use the first, run: + agentsync init --repo ~/src/my-agents --root ~/src --apply +``` + +With `--repo` it shows the file it would write, and writes it only with +`--apply`. `--apply` without both `--repo` and `--root` is refused with exit 2, +so nothing reaches the file that you did not name. + +## What counts as an agent repo + +The search walks `--search` (default `$HOME`) four levels deep. A candidate is a +directory with a `.git/` directory and an `agents/` directory holding at least +one `*.md` file whose frontmatter has `name:` and `description:`. Candidates are +listed by agent count, most first. + +The search skips dot-directories, `node_modules`, `vendor` and `Library`, and +never follows a symlink below the search root. A search root that is itself a +symlink, such as a symlinked `$HOME`, is resolved once, and results are shown +under the path you gave. A directory it cannot read, including a search root +that is missing or not a directory, is listed under "Could not read" rather +than skipped silently. It stops after 50,000 directories and says so; pass +`--search` to look somewhere narrower. + +On macOS the home search may make the system ask whether your terminal can +read folders such as Documents or Desktop. A denied folder is listed under +"Could not read", and `--search PATH` narrows the walk to where your repos are. + +## Rules + +- **An existing config file is refused** with exit 1 and its contents shown, + before any prompt. `--force` replaces it. +- **Everything is checked before writing.** A repo that is missing, not a + directory, or has no `agents/`, or a root that is missing or not a directory, + exits 2 and writes nothing. +- **The write is atomic.** The file is created with mode `0600` (its directory + `0700` if missing) from a temporary file beside it, so a reader never sees a + half-written file, and a file that appears meanwhile is not overwritten + without `--force`. + +## Exit codes + +| Code | When | +|---|---| +| `0` | wrote the file, showed a dry run, or listed candidates | +| `1` | a config file already exists, no candidate was found, or you stopped at a prompt | +| `2` | bad flags, an invalid repo or root, `--apply` without both values, or a failed write | diff --git a/internal/config/config.go b/internal/config/config.go index a19e974..e188eeb 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -3,12 +3,14 @@ package config import ( + "bytes" "errors" "fmt" "io/fs" "os" "path/filepath" "strings" + "unicode/utf8" "github.com/BurntSushi/toml" ) @@ -65,20 +67,103 @@ func Load(path string) (Config, error) { if !md.IsDefined(f.key) { continue } - v, err := expand(strings.TrimSpace(*f.val)) + v, err := ParsePath(f.key, *f.val) if err != nil { - return Config{}, fmt.Errorf("config file %s: %s: %w", path, f.key, err) - } - // Unlike a flag or env var, a file has no working directory a relative - // path could sensibly mean. - if !filepath.IsAbs(v) { - return Config{}, fmt.Errorf("config file %s: %s must be an absolute path or start with ~/, got %q", path, f.key, *f.val) + return Config{}, fmt.Errorf("config file %s: %w", path, err) } *f.val = v } return c, nil } +// ParsePath checks one setting's value by the file's rules and returns it +// expanded: non-empty, and absolute once a leading ~ or ~/ is expanded against +// $HOME. key names the setting in the error. +func ParsePath(key, raw string) (string, error) { + v, err := expand(strings.TrimSpace(raw)) + if err != nil { + return "", fmt.Errorf("%s: %w", key, err) + } + // A written or file value has no working directory a relative path could mean. + if !filepath.IsAbs(v) { + return "", fmt.Errorf("%s must be an absolute path, ~, or start with ~/, got %q", key, raw) + } + // TOML holds only UTF-8, so Load would refuse a file carrying anything else. + if !utf8.ValidString(v) { + return "", fmt.Errorf("%s is not valid UTF-8, got %q", key, v) + } + return v, nil +} + +// Render is the exact file Write would put on disk for c. Both values are +// required, and one under $HOME is written as ~/… so the file stays portable. +func Render(c Config) ([]byte, error) { + var err error + if c.Repo, err = portable("repo", c.Repo); err != nil { + return nil, err + } + if c.Root, err = portable("root", c.Root); err != nil { + return nil, err + } + var buf bytes.Buffer + err = toml.NewEncoder(&buf).Encode(c) + return buf.Bytes(), err +} + +func portable(key, raw string) (string, error) { + v, err := ParsePath(key, raw) + if err != nil { + return "", err + } + h, err := home() + if err != nil { + return v, nil + } + rel, err := filepath.Rel(h, v) + if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return v, nil + } + if rel == "." { + return "~", nil + } + return "~/" + filepath.ToSlash(rel), nil +} + +// Write puts Render(c) at path with mode 0600, creating its directory 0700. The +// file is staged as a temp file beside it and then hard-linked into place, which +// fails if path already exists; replace renames over it instead. +func Write(path string, c Config, replace bool) error { + data, err := Render(c) + if err != nil { + return err + } + dir := filepath.Dir(path) + // Each step's error falls through to one exit, so every failure is reported + // the same way and none needs a branch of its own. + err = os.MkdirAll(dir, 0o700) + var f *os.File + if err == nil { + f, err = os.CreateTemp(dir, ".config-*.toml") + } + if err != nil { + return fmt.Errorf("writing config file %s: %w", path, err) + } + tmp := f.Name() + defer func() { _ = os.Remove(tmp) }() + _, err = f.Write(data) + if err = errors.Join(err, f.Close()); err == nil { + place := os.Link + if replace { + place = os.Rename + } + err = place(tmp, path) + } + if err != nil { + return fmt.Errorf("writing config file %s: %w", path, err) + } + return nil +} + func expand(v string) (string, error) { if v == "" { return "", errors.New("is empty; remove the key or give it a path") diff --git a/internal/config/config_test.go b/internal/config/config_test.go index b224a25..1d538f6 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -147,3 +147,183 @@ func TestLoad_TildeWithoutHomeRefuses(t *testing.T) { t.Errorf("err = %v, want a refusal naming $HOME", err) } } + +func TestParsePath_RefusalNamesBareTilde(t *testing.T) { + _, err := ParsePath("root", "Web") + if err == nil || !strings.Contains(err.Error(), `root must be an absolute path, ~, or start with ~/, got "Web"`) { + t.Errorf("err = %v", err) + } +} + +func TestParsePath_RefusesInvalidUTF8(t *testing.T) { + _, err := ParsePath("repo", "/opt/a\xffb") + if err == nil || !strings.Contains(err.Error(), "repo is not valid UTF-8") { + t.Errorf("err = %v, want a UTF-8 refusal", err) + } + if _, err := Render(Config{Repo: "/opt/a\xffb", Root: "/opt"}); err == nil { + t.Error("Render accepted a value Load would refuse") + } +} + +func TestRender(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + tests := []struct { + name string + in Config + want string + }{ + {"under HOME is written with ~", Config{Repo: filepath.Join(home, "Web", "agents"), Root: filepath.Join(home, "Web")}, + "repo = \"~/Web/agents\"\nroot = \"~/Web\"\n"}, + {"tilde input round-trips", Config{Repo: "~/agents", Root: "~"}, "repo = \"~/agents\"\nroot = \"~\"\n"}, + {"outside HOME stays absolute", Config{Repo: "/opt/agents", Root: "/srv"}, "repo = \"/opt/agents\"\nroot = \"/srv\"\n"}, + {"a sibling of HOME is not under it", Config{Repo: home + "x/agents", Root: "/srv"}, + "repo = \"" + home + "x/agents\"\nroot = \"/srv\"\n"}, + {"quotes are escaped", Config{Repo: `/opt/a "b"`, Root: "/srv"}, "repo = \"/opt/a \\\"b\\\"\"\nroot = \"/srv\"\n"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := Render(tc.in) + if err != nil || string(got) != tc.want { + t.Errorf("Render = %q, %v; want %q", got, err, tc.want) + } + }) + } +} + +func TestRender_NoUsableHomeWritesAbsolute(t *testing.T) { + t.Setenv("HOME", "") + got, err := Render(Config{Repo: "/opt/agents", Root: "/srv"}) + if want := "repo = \"/opt/agents\"\nroot = \"/srv\"\n"; err != nil || string(got) != want { + t.Errorf("Render = %q, %v; want %q", got, err, want) + } +} + +func TestRender_RefusesWhatLoadWould(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + tests := []struct { + name string + in Config + want string + }{ + {"empty repo", Config{Root: "/srv"}, "repo: is empty"}, + {"empty root", Config{Repo: "/opt/agents"}, "root: is empty"}, + {"relative root", Config{Repo: "/opt/agents", Root: "Web"}, "root must be an absolute path"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if _, err := Render(tc.in); err == nil || !strings.Contains(err.Error(), tc.want) { + t.Errorf("err = %v, want one saying %q", err, tc.want) + } + }) + } +} + +// configDir returns a path for config.toml in a directory that does not exist +// yet, so Write has to create it. +func configDir(t *testing.T) (dir, path string) { + t.Helper() + dir = filepath.Join(t.TempDir(), "agentsync") + return dir, filepath.Join(dir, "config.toml") +} + +// assertNoTemp fails if Write left its staging file behind in dir. +func assertNoTemp(t *testing.T, dir string) { + t.Helper() + tmps, err := filepath.Glob(filepath.Join(dir, ".config-*.toml")) + if err != nil || len(tmps) != 0 { + t.Errorf("temp files left behind: %v %v", tmps, err) + } +} + +func TestWrite_CreatesTheFileLoadReadsBack(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + dir, path := configDir(t) + want := Config{Repo: filepath.Join(home, "Web", "agents"), Root: filepath.Join(home, "Web")} + + if err := Write(path, want, false); err != nil { + t.Fatalf("Write: %v", err) + } + data, err := os.ReadFile(path) + if err != nil || string(data) != "repo = \"~/Web/agents\"\nroot = \"~/Web\"\n" { + t.Errorf("file = %q, %v", data, err) + } + for p, mode := range map[string]os.FileMode{path: 0o600, dir: 0o700 | os.ModeDir} { + if fi, err := os.Stat(p); err != nil || fi.Mode() != mode { + t.Errorf("%s: mode = %v, %v; want %v", p, fi.Mode(), err, mode) + } + } + if got, err := Load(path); err != nil || got != want { + t.Errorf("Load = %+v, %v; want %+v", got, err, want) + } + assertNoTemp(t, dir) +} + +func TestWrite_ExistingFile(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + c := Config{Repo: "/opt/agents", Root: "/srv"} + + t.Run("refused without replace and left alone", func(t *testing.T) { + dir, path := configDir(t) + mustWriteFile(t, path, "old\n") + err := Write(path, c, false) + assertNames(t, err, path, "file exists") + if data, _ := os.ReadFile(path); string(data) != "old\n" { + t.Errorf("file = %q, want it untouched", data) + } + assertNoTemp(t, dir) + }) + + t.Run("replaced with replace", func(t *testing.T) { + dir, path := configDir(t) + mustWriteFile(t, path, "old\n") + if err := Write(path, c, true); err != nil { + t.Fatalf("Write: %v", err) + } + if got, err := Load(path); err != nil || got != c { + t.Errorf("Load = %+v, %v; want %+v", got, err, c) + } + assertNoTemp(t, dir) + }) + + t.Run("a directory in the way is not replaced", func(t *testing.T) { + dir, path := configDir(t) + mustWriteFile(t, filepath.Join(path, "keep"), "x") + err := Write(path, c, true) + if err == nil || !strings.HasPrefix(err.Error(), "writing config file "+path) { + t.Errorf("err = %v, want a refusal naming %s", err, path) + } + assertNoTemp(t, dir) + }) +} + +func TestWrite_RefusesBeforeTouchingDisk(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + dir, path := configDir(t) + if err := Write(path, Config{Repo: "relative"}, false); err == nil || !strings.Contains(err.Error(), "repo must be an absolute path") { + t.Errorf("err = %v", err) + } + if _, err := os.Lstat(dir); !os.IsNotExist(err) { + t.Errorf("Write created %s before validating: %v", dir, err) + } +} + +func TestWrite_UncreatableDirRefuses(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + parent := filepath.Join(t.TempDir(), "file") + mustWriteFile(t, parent, "not a directory") + path := filepath.Join(parent, "agentsync", "config.toml") + err := Write(path, Config{Repo: "/opt/agents", Root: "/srv"}, false) + assertNames(t, err, path, "not a directory") +} + +func mustWriteFile(t *testing.T, path, content string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } +} diff --git a/internal/discover/discover.go b/internal/discover/discover.go new file mode 100644 index 0000000..124cf3f --- /dev/null +++ b/internal/discover/discover.go @@ -0,0 +1,152 @@ +// Package discover finds canonical agent repos on disk, so init can offer them +// instead of asking for a path from memory. +package discover + +import ( + "errors" + "io/fs" + "os" + "path/filepath" + "sort" + "strings" +) + +// Depth is how many levels below the search root are looked at, and MaxDirs +// how many directories a search visits before it stops. +const ( + Depth = 4 + MaxDirs = 50000 +) + +// Candidate is a git repo whose agents/ holds at least one agent file. +type Candidate struct { + Path string + Agents int +} + +// Problem is a path the search could not read. What lies beneath it is +// unknown, not empty. +type Problem struct { + Path string + Err error +} + +// Result is what one search found, best candidate first. +type Result struct { + Candidates []Candidate + Unreadable []Problem + // Truncated is true when the search stopped at its directory limit. + Truncated bool +} + +// skipped names directories never worth descending into: dependency trees and +// macOS's Library, which is large and holds no repos. +var skipped = map[string]bool{"node_modules": true, "vendor": true, "Library": true} + +var errNotDir = errors.New("not a directory") + +// Find searches root to Depth levels, visiting at most limit directories. +// Symlinks below root are never followed, and dot-directories are skipped. +// A root that is itself a symlink is resolved once, and paths are reported +// under root as given. +func Find(root string, limit int) Result { + var res Result + resolved, err := filepath.EvalSymlinks(root) + var fi fs.FileInfo + if err == nil { + fi, err = os.Stat(resolved) + } + if err == nil && !fi.IsDir() { + err = errNotDir + } + if err != nil { + res.Unreadable = []Problem{{root, err}} + return res + } + under := func(path string) string { return filepath.Join(root, path[len(resolved):]) } + visited := 0 + _ = filepath.WalkDir(resolved, func(path string, d fs.DirEntry, err error) error { + // A non-nil err is WalkDir failing to read path; it is reported like a + // repo agentCount could not check. + n := 0 + if err == nil { + // WalkDir reports a symlink as a non-directory, so it is never entered. + if !d.IsDir() { + return nil + } + if path != resolved && (strings.HasPrefix(d.Name(), ".") || skipped[d.Name()]) { + return fs.SkipDir + } + if visited++; visited > limit { + res.Truncated = true + return fs.SkipAll + } + n, err = agentCount(path) + } + if err != nil { + res.Unreadable = append(res.Unreadable, Problem{under(path), err}) + return fs.SkipDir + } + if n > 0 { + res.Candidates = append(res.Candidates, Candidate{under(path), n}) + } + if rel, _ := filepath.Rel(resolved, path); rel != "." && strings.Count(rel, string(filepath.Separator)) >= Depth-1 { + return fs.SkipDir + } + return nil + }) + sort.Slice(res.Candidates, func(i, j int) bool { + a, b := res.Candidates[i], res.Candidates[j] + if a.Agents != b.Agents { + return a.Agents > b.Agents + } + return a.Path < b.Path + }) + return res +} + +// agentCount returns how many agent files dir/agents holds when dir is a git +// repo, else 0. An error means dir could not be checked. +func agentCount(dir string) (int, error) { + for _, sub := range []string{".git", "agents"} { + fi, err := os.Lstat(filepath.Join(dir, sub)) + if errors.Is(err, fs.ErrNotExist) || (err == nil && !fi.IsDir()) { + return 0, nil + } + if err != nil { + return 0, err + } + } + // The first error stops the reading and is returned; callers discard n then. + entries, err := os.ReadDir(filepath.Join(dir, "agents")) + n := 0 + for _, e := range entries { + if err == nil && e.Type().IsRegular() && filepath.Ext(e.Name()) == ".md" { + var data []byte + data, err = os.ReadFile(filepath.Join(dir, "agents", e.Name())) + if err == nil && isAgent(string(data)) { + n++ + } + } + } + return n, err +} + +// isAgent reports whether s opens with YAML frontmatter carrying top-level +// name and description keys, which is what Claude Code requires of an agent. +func isAgent(s string) bool { + front, ok := strings.CutPrefix(strings.ReplaceAll(s, "\r\n", "\n"), "---\n") + if !ok { + return false + } + front, _, ok = strings.Cut(front, "\n---") + if !ok { + return false + } + var name, desc bool + for _, line := range strings.Split(front, "\n") { + name = name || strings.HasPrefix(line, "name:") + desc = desc || strings.HasPrefix(line, "description:") + } + return name && desc +} diff --git a/internal/discover/discover_test.go b/internal/discover/discover_test.go new file mode 100644 index 0000000..f601d7e --- /dev/null +++ b/internal/discover/discover_test.go @@ -0,0 +1,296 @@ +package discover + +import ( + "errors" + "os" + "path/filepath" + "reflect" + "strings" + "syscall" + "testing" + + "github.com/agentic-tooling/agentsync/internal/testfs" +) + +const agentMD = "---\nname: x\ndescription: does x\n---\nbody\n" + +func mustWrite(t *testing.T, path, content string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } +} + +// repo makes dir a git repo whose agents/ holds n agent files. +func repo(t *testing.T, dir string, n int) { + t.Helper() + if err := os.MkdirAll(filepath.Join(dir, ".git"), 0o755); err != nil { + t.Fatal(err) + } + for i := range n { + mustWrite(t, filepath.Join(dir, "agents", string(rune('a'+i))+".md"), agentMD) + } +} + +// mustSwap replaces link with a symlink to target. +func mustSwap(t *testing.T, target, link string) { + t.Helper() + if err := os.RemoveAll(link); err != nil { + t.Fatal(err) + } + if err := os.Symlink(target, link); err != nil { + t.Fatal(err) + } +} + +// resolvedTempDir is t.TempDir without symlinks in its path. Find walks the +// resolved root, so a PATH_MAX fixture must be measured against that path. +func resolvedTempDir(t *testing.T) string { + t.Helper() + dir, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + return dir +} + +func tooLong(path string) bool { + _, err := os.Lstat(path) + return errors.Is(err, syscall.ENAMETOOLONG) +} + +// unreadableAgentFile builds a repo under parent whose agents/ opens but whose +// one agent file's path is past PATH_MAX, so reading it fails even as root. +// Each level is made through an os.Root, the only way to create past the limit. +func unreadableAgentFile(t *testing.T, parent string) string { + t.Helper() + seg := strings.Repeat("d", 200) + name := strings.Repeat("a", 250) + ".md" + cur, err := os.OpenRoot(parent) + if err != nil { + t.Fatal(err) + } + defer func() { _ = cur.Close() }() + dir := parent + for depth := 0; !tooLong(filepath.Join(dir, seg, "agents")); depth++ { + if depth > 64 { + t.Fatalf("a %d-byte path is still under the path limit", len(dir)) + } + if err := cur.Mkdir(seg, 0o755); err != nil { + t.Fatal(err) + } + next, err := cur.OpenRoot(seg) + if err != nil { + t.Fatal(err) + } + _ = cur.Close() + cur, dir = next, filepath.Join(dir, seg) + } + for _, d := range []string{".git", "agents"} { + if err := cur.Mkdir(d, 0o755); err != nil { + t.Fatal(err) + } + } + if err := cur.WriteFile(filepath.Join("agents", name), []byte(agentMD), 0o644); err != nil { + t.Fatal(err) + } + if ents, err := os.ReadDir(filepath.Join(dir, "agents")); err != nil || len(ents) != 1 || !tooLong(filepath.Join(dir, "agents", name)) { + t.Fatalf("fixture: agents/ must list one entry (%d, %v) whose path is too long", len(ents), err) + } + return dir +} + +func paths(cs []Candidate) []string { + var out []string + for _, c := range cs { + out = append(out, c.Path) + } + return out +} + +func TestFind_WhatCountsAsACandidate(t *testing.T) { + root := t.TempDir() + repo(t, filepath.Join(root, "Web", "few"), 1) + repo(t, filepath.Join(root, "Web", "many"), 3) + repo(t, filepath.Join(root, "b-tie"), 1) + repo(t, filepath.Join(root, "a", "b", "c", "depth4"), 1) + repo(t, filepath.Join(root, "a", "b", "c", "d", "depth5"), 1) + repo(t, filepath.Join(root, ".hidden", "repo"), 1) + repo(t, filepath.Join(root, "node_modules", "repo"), 1) + repo(t, filepath.Join(root, "x", "vendor", "repo"), 1) + repo(t, filepath.Join(root, "Library", "repo"), 1) + repo(t, filepath.Join(root, "empty"), 0) + mustWrite(t, filepath.Join(root, "nogit", "agents", "a.md"), agentMD) + // A worktree's gitfile is not the .git/ directory a candidate needs. + mustWrite(t, filepath.Join(root, "gitfile", ".git"), "gitdir: /elsewhere\n") + mustWrite(t, filepath.Join(root, "gitfile", "agents", "a.md"), agentMD) + mustWrite(t, filepath.Join(root, "agentsfile", "agents"), "") + if err := os.MkdirAll(filepath.Join(root, "agentsfile", ".git"), 0o755); err != nil { + t.Fatal(err) + } + // A symlinked .git or agents/ is not the directory a candidate needs. + elsewhere := t.TempDir() + repo(t, elsewhere, 1) + repo(t, filepath.Join(root, "linkedgit"), 1) + mustSwap(t, filepath.Join(elsewhere, ".git"), filepath.Join(root, "linkedgit", ".git")) + repo(t, filepath.Join(root, "linkedagents"), 1) + mustSwap(t, filepath.Join(elsewhere, "agents"), filepath.Join(root, "linkedagents", "agents")) + + res := Find(root, MaxDirs) + want := []Candidate{ + {filepath.Join(root, "Web", "many"), 3}, + {filepath.Join(root, "Web", "few"), 1}, + {filepath.Join(root, "a", "b", "c", "depth4"), 1}, + {filepath.Join(root, "b-tie"), 1}, + } + // Sorted by count, then path: "Web" sorts before "a" in byte order. + if !reflect.DeepEqual(res.Candidates, want) { + t.Errorf("candidates = %v, want %v", res.Candidates, want) + } + if len(res.Unreadable) != 0 || res.Truncated { + t.Errorf("unreadable = %v, truncated = %v; want neither", res.Unreadable, res.Truncated) + } +} + +func TestFind_CountsOnlyAgentFiles(t *testing.T) { + root := t.TempDir() + dir := filepath.Join(root, "r") + repo(t, dir, 1) + agents := filepath.Join(dir, "agents") + mustWrite(t, filepath.Join(agents, "crlf.md"), "---\r\nname: y\r\ndescription: y\r\n---\r\n") + mustWrite(t, filepath.Join(agents, "noname.md"), "---\ndescription: y\n---\n") + mustWrite(t, filepath.Join(agents, "nodesc.md"), "---\nname: y\n---\n") + mustWrite(t, filepath.Join(agents, "nested.md"), "---\nmeta:\n name: y\n description: y\n---\n") + mustWrite(t, filepath.Join(agents, "nofront.md"), "name: y\ndescription: y\n") + mustWrite(t, filepath.Join(agents, "unclosed.md"), "---\nname: y\ndescription: y\n") + mustWrite(t, filepath.Join(agents, "notes.txt"), agentMD) + if err := os.Symlink(filepath.Join(agents, "a.md"), filepath.Join(agents, "link.md")); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(filepath.Join(agents, "sub.md"), 0o755); err != nil { + t.Fatal(err) + } + + res := Find(root, MaxDirs) + if want := []Candidate{{dir, 2}}; !reflect.DeepEqual(res.Candidates, want) { + t.Errorf("candidates = %v, want %v (a.md and crlf.md only)", res.Candidates, want) + } +} + +func TestFind_NeverFollowsSymlinks(t *testing.T) { + root := t.TempDir() + elsewhere := t.TempDir() + repo(t, filepath.Join(elsewhere, "real"), 1) + for link, target := range map[string]string{ + "loop": root, + "tolink": filepath.Join(elsewhere, "real"), + "towalk": elsewhere, + "dangles": filepath.Join(root, "gone"), + } { + if err := os.Symlink(target, filepath.Join(root, link)); err != nil { + t.Fatal(err) + } + } + res := Find(root, MaxDirs) + if len(res.Candidates) != 0 || len(res.Unreadable) != 0 { + t.Errorf("followed a symlink: candidates = %v, unreadable = %v", res.Candidates, res.Unreadable) + } +} + +func TestFind_ReportsWhatItCouldNotRead(t *testing.T) { + base := resolvedTempDir(t) + unreadable, inside := testfs.UnreadableSubtree(t, base) + // A repo the search would count if it could get there. + if err := inside.MkdirAll(filepath.Join("r", ".git"), 0o755); err != nil { + t.Fatal(err) + } + if err := inside.MkdirAll(filepath.Join("r", "agents"), 0o755); err != nil { + t.Fatal(err) + } + if err := inside.WriteFile(filepath.Join("r", "agents", "a.md"), []byte(agentMD), 0o644); err != nil { + t.Fatal(err) + } + root := filepath.Dir(unreadable) + repo(t, filepath.Join(root, "ok"), 1) + + res := Find(root, MaxDirs) + if got := paths(res.Candidates); !reflect.DeepEqual(got, []string{filepath.Join(root, "ok")}) { + t.Errorf("candidates = %v, want only the readable repo", got) + } + if len(res.Unreadable) != 1 || res.Unreadable[0].Path != unreadable || res.Unreadable[0].Err == nil { + t.Errorf("unreadable = %v, want exactly %s with its error", res.Unreadable, unreadable) + } +} + +func TestFind_ReportsAnAgentFileItCouldNotRead(t *testing.T) { + dir := unreadableAgentFile(t, resolvedTempDir(t)) + res := Find(dir, MaxDirs) + if len(res.Candidates) != 0 || len(res.Unreadable) != 1 || res.Unreadable[0].Path != dir || + !errors.Is(res.Unreadable[0].Err, syscall.ENAMETOOLONG) { + t.Errorf("%d candidates, %d unreadable; want the repo reported unreadable with ENAMETOOLONG", + len(res.Candidates), len(res.Unreadable)) + } +} + +func TestFind_RootThatIsNotADirectoryIsUnreadable(t *testing.T) { + base := t.TempDir() + file := filepath.Join(base, "file") + mustWrite(t, file, "") + dangling := filepath.Join(base, "dangling") + if err := os.Symlink(filepath.Join(base, "gone"), dangling); err != nil { + t.Fatal(err) + } + for _, root := range []string{filepath.Join(base, "gone"), dangling, file} { + res := Find(root, MaxDirs) + if len(res.Candidates) != 0 || len(res.Unreadable) != 1 || res.Unreadable[0].Path != root || res.Unreadable[0].Err == nil { + t.Errorf("Find(%s): candidates = %v, unreadable = %v; want only %[1]s with its error", root, res.Candidates, res.Unreadable) + } + } + if res := Find(file, MaxDirs); len(res.Unreadable) == 1 && !errors.Is(res.Unreadable[0].Err, errNotDir) { + t.Errorf("a file root: err = %v, want %v", res.Unreadable[0].Err, errNotDir) + } +} + +func TestFind_ResolvesASymlinkedRoot(t *testing.T) { + target := t.TempDir() + repo(t, filepath.Join(target, "Web", "agents-repo"), 1) + root := filepath.Join(t.TempDir(), "home") + if err := os.Symlink(target, root); err != nil { + t.Fatal(err) + } + res := Find(root, MaxDirs) + if want := []Candidate{{filepath.Join(root, "Web", "agents-repo"), 1}}; !reflect.DeepEqual(res.Candidates, want) || len(res.Unreadable) != 0 { + t.Errorf("candidates = %v, unreadable = %v; want %v, reported under the root as given", res.Candidates, res.Unreadable, want) + } +} + +func TestFind_StopsAtTheLimit(t *testing.T) { + root := t.TempDir() + for _, d := range []string{"a", "b", "c", "d"} { + repo(t, filepath.Join(root, d), 1) + } + res := Find(root, 3) + // The root, a and a/agents use up the limit, so b, c and d go unchecked. + if got := paths(res.Candidates); !res.Truncated || !reflect.DeepEqual(got, []string{filepath.Join(root, "a")}) { + t.Errorf("truncated = %v, candidates = %v; want true and only a", res.Truncated, got) + } + if res := Find(root, 9); res.Truncated || len(res.Candidates) != 4 { + t.Errorf("a limit the tree fits in: truncated = %v, %d candidates; want false, 4", res.Truncated, len(res.Candidates)) + } +} + +func TestIsAgent(t *testing.T) { + for _, s := range []string{agentMD, "---\ndescription: d\nname: n\n---"} { + if !isAgent(s) { + t.Errorf("isAgent(%q) = false", s) + } + } + for _, s := range []string{"", "---\n", "---\nname: n\n---\n", " ---\nname: n\ndescription: d\n---\n"} { + if isAgent(s) { + t.Errorf("isAgent(%q) = true", s) + } + } +}