From 1910a9b44c2e017b8a158e655ae875cbe1dfe40c Mon Sep 17 00:00:00 2001 From: Ant Assistant Date: Mon, 25 May 2026 01:14:09 +0200 Subject: [PATCH 1/3] fix: clean up scaffold link output --- internal/scaffold/color_test.go | 60 ++++++++++ internal/scaffold/scaffold.go | 141 +++++++++++++++++++--- internal/scaffold/scaffold_test.go | 183 +++++++++++++++++++++++++++++ internal/scaffold/term_darwin.go | 15 +++ internal/scaffold/term_linux.go | 15 +++ internal/scaffold/term_other.go | 9 ++ 6 files changed, 407 insertions(+), 16 deletions(-) create mode 100644 internal/scaffold/color_test.go create mode 100644 internal/scaffold/term_darwin.go create mode 100644 internal/scaffold/term_linux.go create mode 100644 internal/scaffold/term_other.go diff --git a/internal/scaffold/color_test.go b/internal/scaffold/color_test.go new file mode 100644 index 0000000..f1851ed --- /dev/null +++ b/internal/scaffold/color_test.go @@ -0,0 +1,60 @@ +package scaffold + +import ( + "bytes" + "os" + "testing" +) + +func TestColorDisabledForNonTTYOutputs(t *testing.T) { + t.Parallel() + var buffer bytes.Buffer + if colorEnabled(&buffer) { + t.Fatal("colorEnabled(bytes.Buffer) = true, want false") + } + + devNull, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0) + if err != nil { + t.Fatalf("open %s: %v", os.DevNull, err) + } + defer func() { _ = devNull.Close() }() + if colorEnabled(devNull) { + t.Fatalf("colorEnabled(%s) = true, want false", os.DevNull) + } +} + +func TestColorDisabledByEnvironment(t *testing.T) { + devNull, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0) + if err != nil { + t.Fatalf("open %s: %v", os.DevNull, err) + } + defer func() { _ = devNull.Close() }() + + alwaysTerminal := func(*os.File) bool { return true } + for _, tt := range []struct { + name string + env map[string]string + }{ + {name: "NO_COLOR", env: map[string]string{"NO_COLOR": "1"}}, + {name: "TERM dumb", env: map[string]string{"TERM": "dumb"}}, + } { + t.Run(tt.name, func(t *testing.T) { + getenv := func(key string) string { return tt.env[key] } + if colorEnabledWith(devNull, getenv, alwaysTerminal) { + t.Fatalf("colorEnabledWith(%s) = true, want false", tt.name) + } + }) + } +} + +func TestColorEnabledForTerminalFile(t *testing.T) { + devNull, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0) + if err != nil { + t.Fatalf("open %s: %v", os.DevNull, err) + } + defer func() { _ = devNull.Close() }() + + if !colorEnabledWith(devNull, func(string) string { return "" }, func(*os.File) bool { return true }) { + t.Fatal("colorEnabledWith terminal file = false, want true") + } +} diff --git a/internal/scaffold/scaffold.go b/internal/scaffold/scaffold.go index c8c8084..87ff1b5 100644 --- a/internal/scaffold/scaffold.go +++ b/internal/scaffold/scaffold.go @@ -26,8 +26,29 @@ type Options struct { // files. Used to add the agentic envelope to an existing project. AgentsOnly bool Out io.Writer + counts *operationCounts + style outputStyle } +type operationCounts struct { + written int + skipped int + linked int +} + +type outputStyle struct { + enabled bool +} + +const ( + ansiReset = "\x1b[0m" + ansiBold = "\x1b[1m" + ansiGreen = "\x1b[32m" + ansiYellow = "\x1b[33m" + ansiCyan = "\x1b[36m" + ansiBoldGreen = "\x1b[1;32m" +) + // agentsOnlySuffix marks a template file as the variant to use in // agents-only mode. The suffix is stripped from the destination path before // writing, so `Justfile.agents-only.tmpl` writes as `Justfile`. In fresh @@ -43,12 +64,16 @@ func Run(ctx context.Context, opts Options) error { if out == nil { out = io.Discard } + counts := &operationCounts{} + opts.counts = counts + style := outputStyle{enabled: colorEnabled(out)} + opts.style = style target, err := prepareTarget(opts.Target, opts.DryRun) if err != nil { return err } data := templateData{ProjectName: filepath.Base(target)} - _, _ = fmt.Fprintf(out, "-> Scaffolding %s agentic dev environment in: %s\n", opts.Flavor.Name, target) + _, _ = fmt.Fprintf(out, "%s\n", style.header(fmt.Sprintf("-> Scaffolding %s agentic dev environment in: %s", opts.Flavor.Name, target))) if err := writeTemplates(opts, target, data, out); err != nil { return err } @@ -61,6 +86,7 @@ func Run(ctx context.Context, opts Options) error { } } printNextSteps(out, opts.Flavor, target) + printSummary(out, opts.DryRun, counts, style) return nil } @@ -233,11 +259,17 @@ func writeFile(opts Options, target, rel string, content []byte, out io.Writer) return fmt.Errorf("checking %s: %w", rel, err) } if exists && !opts.Force { - _, _ = fmt.Fprintf(out, " skip %s (exists, use --force to overwrite)\n", rel) + if opts.counts != nil { + opts.counts.skipped++ + } + printOperation(out, opts.style, "skip", "%s (exists, use --force to overwrite)", rel) return nil } if opts.DryRun { - _, _ = fmt.Fprintf(out, " write %s (dry-run)\n", rel) + if opts.counts != nil { + opts.counts.written++ + } + printOperation(out, opts.style, "write", "%s (dry-run)", rel) return nil } if exists { @@ -263,7 +295,10 @@ func writeFile(opts Options, target, rel string, content []byte, out io.Writer) if err := os.Chmod(dst, mode); err != nil { return fmt.Errorf("setting permissions on %s: %w", rel, err) } - _, _ = fmt.Fprintf(out, " write %s\n", rel) + if opts.counts != nil { + opts.counts.written++ + } + printOperation(out, opts.style, "write", "%s", rel) return nil } @@ -301,16 +336,15 @@ func createSymlinks(opts Options, target string, out io.Writer) error { for _, sl := range opts.Flavor.Symlinks { dir, name := filepath.Split(sl.Path) linkDir := filepath.Join(target, filepath.FromSlash(dir)) - if err := link(opts, linkDir, name, sl.Target, out); err != nil { + if err := link(opts, linkDir, name, sl.Target, filepath.ToSlash(sl.Path), out); err != nil { return err } } return nil } -func link(opts Options, dir, name, dest string, out io.Writer) error { +func link(opts Options, dir, name, dest, display string, out io.Writer) error { path := filepath.Join(dir, name) - display := strings.TrimPrefix(filepath.ToSlash(strings.TrimPrefix(path, opts.Target)), "/") if display == "" || strings.HasPrefix(display, "..") { display = name } @@ -319,10 +353,16 @@ func link(opts Options, dir, name, dest string, out io.Writer) error { return fmt.Errorf("checking %s: %w", display, err) } if exists && !opts.Force { + if opts.counts != nil { + opts.counts.skipped++ + } return nil } if opts.DryRun { - _, _ = fmt.Fprintf(out, " link %s -> %s (dry-run)\n", display, dest) + if opts.counts != nil { + opts.counts.linked++ + } + printOperation(out, opts.style, "link", "%s -> %s (dry-run)", display, dest) return nil } if exists { @@ -339,7 +379,10 @@ func link(opts Options, dir, name, dest string, out io.Writer) error { if err := os.Symlink(dest, path); err != nil { return fmt.Errorf("creating symlink %s: %w", display, err) } - _, _ = fmt.Fprintf(out, " link %s -> %s\n", display, dest) + if opts.counts != nil { + opts.counts.linked++ + } + printOperation(out, opts.style, "link", "%s -> %s", display, dest) return nil } @@ -360,21 +403,87 @@ func initGit(ctx context.Context, target string, dryRun bool, out io.Writer) err return nil } +func printSummary(out io.Writer, dryRun bool, counts *operationCounts, style outputStyle) { + if counts == nil { + return + } + if dryRun { + _, _ = fmt.Fprintf(out, "\nDry run: %d would be written, %d skipped, %d would be linked.\n", counts.written, counts.skipped, counts.linked) + return + } + _, _ = fmt.Fprintf(out, "\n%s\n", style.done(fmt.Sprintf("Done. %d written, %d skipped, %d linked.", counts.written, counts.skipped, counts.linked))) +} + +func printOperation(out io.Writer, style outputStyle, op, format string, args ...any) { + _, _ = fmt.Fprintf(out, " %s%s%s\n", style.verb(op), strings.Repeat(" ", 7-len(op)), fmt.Sprintf(format, args...)) +} + +func colorEnabled(out io.Writer) bool { + return colorEnabledWith(out, os.Getenv, isTerminal) +} + +func colorEnabledWith(out io.Writer, getenv func(string) string, isTerm func(*os.File) bool) bool { + if getenv("NO_COLOR") != "" || getenv("TERM") == "dumb" { + return false + } + file, ok := out.(*os.File) + if !ok { + return false + } + info, err := file.Stat() + if err != nil || info.Mode()&os.ModeCharDevice == 0 { + return false + } + return isTerm(file) +} + +func (s outputStyle) header(text string) string { + if !s.enabled { + return text + } + return ansiBold + text + ansiReset +} + +func (s outputStyle) done(text string) string { + if !s.enabled { + return text + } + return ansiBoldGreen + text + ansiReset +} + +func (s outputStyle) verb(op string) string { + if !s.enabled { + return op + } + switch op { + case "write": + return ansiGreen + op + ansiReset + case "skip": + return ansiYellow + op + ansiReset + case "link": + return ansiCyan + op + ansiReset + default: + return op + } +} + func printNextSteps(out io.Writer, flavor flavors.Flavor, target string) { if flavor.NextSteps != nil { - _, _ = fmt.Fprint(out, flavor.NextSteps(target)) + message := flavor.NextSteps(target) + message = strings.TrimPrefix(message, "\nDone.\n\n") + message = strings.TrimPrefix(message, "Done.\n\n") + _, _ = fmt.Fprint(out, message) return } _, _ = fmt.Fprintf(out, ` -Done. - Next steps: 1. Read %s/README.agent.md for dependency install instructions 2. Edit .agent/AGENTS.md to describe THIS project's specifics - 3. Edit .agent/CODEBASE.md once you have code to map - 4. Run: devcontainer up --workspace-folder %s - 5. Run: devcontainer exec --workspace-folder %s bash - 6. Inside the container: just check + 3. AGENTS.md and CLAUDE.md are symlinks to .agent/AGENTS.md; edit that one file + 4. Edit .agent/CODEBASE.md once you have code to map + 5. Run: devcontainer up --workspace-folder %s + 6. Run: devcontainer exec --workspace-folder %s bash + 7. Inside the container: just check `, target, target, target) } diff --git a/internal/scaffold/scaffold_test.go b/internal/scaffold/scaffold_test.go index c33422a..21dd5ba 100644 --- a/internal/scaffold/scaffold_test.go +++ b/internal/scaffold/scaffold_test.go @@ -7,6 +7,7 @@ import ( "os/exec" "path/filepath" "runtime" + "strings" "testing" "testing/fstest" @@ -40,6 +41,188 @@ func TestRunWritesFullstackScaffold(t *testing.T) { assertSymlink(t, filepath.Join(target, "CLAUDE.md"), ".agent/CLAUDE.md") } +func TestRunPrintsCleanSymlinkPathsForRelativeTarget(t *testing.T) { + workspace := t.TempDir() + target := filepath.Join(workspace, "reltest") + if err := os.Mkdir(target, 0o755); err != nil { + t.Fatalf("create target: %v", err) + } + oldwd, err := os.Getwd() + if err != nil { + t.Fatalf("get cwd: %v", err) + } + if err := os.Chdir(workspace); err != nil { + t.Fatalf("chdir workspace: %v", err) + } + t.Cleanup(func() { + if err := os.Chdir(oldwd); err != nil { + t.Fatalf("restore cwd: %v", err) + } + }) + + var out bytes.Buffer + err = scaffold.Run(context.Background(), scaffold.Options{ + Flavor: mustFlavor(t, "fullstack"), + Target: "reltest", + InitGit: false, + Out: &out, + }) + if err != nil { + t.Fatalf("Run() error = %v", err) + } + + output := out.String() + for _, want := range []string{ + " link AGENTS.md -> .agent/AGENTS.md", + " link CLAUDE.md -> .agent/CLAUDE.md", + " link .agent/CLAUDE.md -> AGENTS.md", + } { + if !strings.Contains(output, want) { + t.Fatalf("output missing %q:\n%s", want, output) + } + } + if strings.Contains(output, "link reltest/") { + t.Fatalf("link output included target directory prefix:\n%s", output) + } +} + +func TestRunPrintsCleanSymlinkPathsForSymlinkedTarget(t *testing.T) { + realRoot := t.TempDir() + linkRoot := filepath.Join(t.TempDir(), "linked-root") + if err := os.Symlink(realRoot, linkRoot); err != nil { + t.Fatalf("create symlinked root: %v", err) + } + target := filepath.Join(linkRoot, "reltest") + var out bytes.Buffer + + err := scaffold.Run(context.Background(), scaffold.Options{ + Flavor: mustFlavor(t, "fullstack"), + Target: target, + InitGit: false, + Out: &out, + }) + if err != nil { + t.Fatalf("Run() error = %v", err) + } + + output := out.String() + for _, want := range []string{ + " link AGENTS.md -> .agent/AGENTS.md", + " link CLAUDE.md -> .agent/CLAUDE.md", + " link .agent/CLAUDE.md -> AGENTS.md", + } { + if !strings.Contains(output, want) { + t.Fatalf("output missing %q:\n%s", want, output) + } + } + for _, line := range strings.Split(output, "\n") { + if !strings.HasPrefix(line, " link") { + continue + } + if strings.Contains(line, realRoot) || strings.Contains(line, linkRoot) { + t.Fatalf("link output leaked target directory path in %q:\n%s", line, output) + } + } + assertSymlink(t, filepath.Join(realRoot, "reltest", "AGENTS.md"), ".agent/AGENTS.md") +} + +func TestRunPrintsSummaryAndSymlinkExplanation(t *testing.T) { + t.Parallel() + target := t.TempDir() + var out bytes.Buffer + + err := scaffold.Run(context.Background(), scaffold.Options{ + Flavor: mustFlavor(t, "fullstack"), + Target: target, + InitGit: false, + Out: &out, + }) + if err != nil { + t.Fatalf("Run() error = %v", err) + } + + output := out.String() + if !strings.Contains(output, "Done. 16 written, 0 skipped, 3 linked.") { + t.Fatalf("output missing operation summary:\n%s", output) + } + if !strings.Contains(output, "AGENTS.md and CLAUDE.md are symlinks to .agent/AGENTS.md") { + t.Fatalf("output missing symlink explanation:\n%s", output) + } +} + +func TestRunPrintsSingleDoneForCustomNextSteps(t *testing.T) { + t.Parallel() + var out bytes.Buffer + flavor := flavors.Flavor{ + Name: "custom-next-steps", + Templates: fstest.MapFS{ + "templates/README.md": &fstest.MapFile{Data: []byte("README"), Mode: 0o644}, + }, + TemplateRoot: "templates", + NextSteps: func(string) string { + return "\nDone.\n\nNext steps:\n 1. Custom\n" + }, + } + + err := scaffold.Run(context.Background(), scaffold.Options{ + Flavor: flavor, + Target: t.TempDir(), + InitGit: false, + Out: &out, + }) + if err != nil { + t.Fatalf("Run() error = %v", err) + } + + output := out.String() + if got := strings.Count(output, "Done."); got != 1 { + t.Fatalf("Done. count = %d, want 1:\n%s", got, output) + } + if !strings.Contains(output, "Done. 1 written, 0 skipped, 0 linked.") { + t.Fatalf("output missing summary:\n%s", output) + } + if !strings.Contains(output, "Next steps:\n 1. Custom") { + t.Fatalf("output missing custom next steps:\n%s", output) + } +} + +func TestRunCapturedOutputContainsNoANSI(t *testing.T) { + t.Parallel() + var out bytes.Buffer + + err := scaffold.Run(context.Background(), scaffold.Options{ + Flavor: mustFlavor(t, "fullstack"), + Target: t.TempDir(), + InitGit: false, + Out: &out, + }) + if err != nil { + t.Fatalf("Run() error = %v", err) + } + if strings.Contains(out.String(), "\x1b[") { + t.Fatalf("captured output contains raw ANSI escapes:\n%q", out.String()) + } +} + +func TestRunDryRunSummaryUsesWouldBe(t *testing.T) { + t.Parallel() + var out bytes.Buffer + + err := scaffold.Run(context.Background(), scaffold.Options{ + Flavor: mustFlavor(t, "fullstack"), + Target: filepath.Join(t.TempDir(), "planned"), + InitGit: false, + DryRun: true, + Out: &out, + }) + if err != nil { + t.Fatalf("Run(dry-run) error = %v", err) + } + if !strings.Contains(out.String(), "Dry run: 16 would be written, 0 skipped, 3 would be linked.") { + t.Fatalf("output missing dry-run summary:\n%s", out.String()) + } +} + func TestRunSkipsExistingFilesUnlessForced(t *testing.T) { t.Parallel() target := t.TempDir() diff --git a/internal/scaffold/term_darwin.go b/internal/scaffold/term_darwin.go new file mode 100644 index 0000000..f7d12ea --- /dev/null +++ b/internal/scaffold/term_darwin.go @@ -0,0 +1,15 @@ +//go:build darwin + +package scaffold + +import ( + "os" + "syscall" + "unsafe" +) + +func isTerminal(file *os.File) bool { + var termios syscall.Termios + _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, file.Fd(), syscall.TIOCGETA, uintptr(unsafe.Pointer(&termios))) + return errno == 0 +} diff --git a/internal/scaffold/term_linux.go b/internal/scaffold/term_linux.go new file mode 100644 index 0000000..151b758 --- /dev/null +++ b/internal/scaffold/term_linux.go @@ -0,0 +1,15 @@ +//go:build linux + +package scaffold + +import ( + "os" + "syscall" + "unsafe" +) + +func isTerminal(file *os.File) bool { + var termios syscall.Termios + _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, file.Fd(), syscall.TCGETS, uintptr(unsafe.Pointer(&termios))) + return errno == 0 +} diff --git a/internal/scaffold/term_other.go b/internal/scaffold/term_other.go new file mode 100644 index 0000000..6286e1a --- /dev/null +++ b/internal/scaffold/term_other.go @@ -0,0 +1,9 @@ +//go:build !linux && !darwin + +package scaffold + +import "os" + +func isTerminal(file *os.File) bool { + return false +} From f0882283297127842a0909fec05e81e656efd922 Mon Sep 17 00:00:00 2001 From: Ant Assistant Date: Mon, 25 May 2026 06:39:19 +0000 Subject: [PATCH 2/3] fix: address review feedback - Decouple 'Done.' message from flavor hooks - Add documentation for CLI output changes - Use regex for summary test to avoid fragility - Add comment to term_other.go re: Windows - Remove unreachable fallback in link() --- docs/cli.md | 14 ++++++--- internal/flavors/claudecowork/flavor.go | 8 ----- internal/flavors/projectmgmt/flavor.go | 6 ---- internal/scaffold/scaffold.go | 42 +++++-------------------- internal/scaffold/scaffold_test.go | 13 +++++--- internal/scaffold/term_other.go | 5 +++ 6 files changed, 31 insertions(+), 57 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index f5635de..ab7aecb 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -39,6 +39,12 @@ agent-init ./my-tool # path-only form; implies fullstack - With `--agents-only`: paths listed in the flavor's `FreshOnlyPaths` are skipped, and any template named `.agents-only.` (e.g. `Justfile.agents-only.tmpl`) is written as `.` in place of the base. See [docs/flavors/go-cli.md](./flavors/go-cli.md) for a worked example. - Source: [scaffold.go:31](../internal/scaffold/scaffold.go#L31) (`Run`). +### Output + +- When the output stream is a TTY, `init` colorizes its output verbs (`write`, `skip`, `link`) and prints a final `Done.` summary. Color is disabled when `NO_COLOR` is set, `TERM=dumb`, or the output is not a TTY (e.g. a pipe or file). +- Symlink paths are displayed relative to the scaffolded project root, even when the target directory is specified with a relative path (`./foo`) or via a symlink. +- The `NextSteps` message for code-based flavors explains that `AGENTS.md` and `CLAUDE.md` in the root are symlinks to a canonical file under `.agent/`. + ## `add-tracker` Adds a work-tracker integration (Jira, Azure DevOps, or GitHub) to an existing `project-management` scaffold. Only meaningful for that flavor — the subcommand errors if the target lacks an `.mcp.json` file (the scaffold-presence marker). @@ -67,7 +73,7 @@ Multiple trackers can be added to the same workspace. Useful during migrations ( ### Credentials -The merged `.mcp.json` entry references every credential from the environment via `${env:VAR}` (e.g. `"GITHUB_PERSONAL_ACCESS_TOKEN": "${env:GITHUB_TOKEN}"`). No empty literal is ever written, so there is no field inviting a pasted secret into the tracked file. Set the vars in your shell or a gitignored `.env`; each tracker ships `integrations//.env.example` listing what it needs. For the GitHub tracker, `export GITHUB_TOKEN="$(gh auth token)"` reuses the devcontainer's existing `gh` login. `add-tracker` prints the variable names and this guidance after merging. Changing `.mcp.json` requires restarting the MCP client (or session) to reconnect. See [`trackerEnvVars`](../internal/cli/cli.go) and [`internal/trackers/registry.go`](../internal/trackers/registry.go). +The merged `.mcp.json` entry references every credential from the environment via `${env:VAR}` (e.g. `\"GITHUB_PERSONAL_ACCESS_TOKEN\": \"${env:GITHUB_TOKEN}\"`). No empty literal is ever written, so there is no field inviting a pasted secret into the tracked file. Set the vars in your shell or a gitignored `.env`; each tracker ships `integrations//.env.example` listing what it needs. For the GitHub tracker, `export GITHUB_TOKEN=\"$(gh auth token)\"` reuses the devcontainer's existing `gh` login. `add-tracker` prints the variable names and this guidance after merging. Changing `.mcp.json` requires restarting the MCP client (or session) to reconnect. See [`trackerEnvVars`](../internal/cli/cli.go) and [`internal/trackers/registry.go`](../internal/trackers/registry.go). ### Removing a tracker @@ -75,7 +81,7 @@ There is no `remove-tracker` subcommand yet. Manual cleanup: 1. Delete `integrations//`. 2. Remove the entry from `.mcp.json` under `mcpServers`. -3. Remove the tracker name from `AGENTS.md`'s "Active trackers" line. +3. Remove the tracker name from `AGENTS.md`'s \"Active trackers\" line. ## `list-flavors` @@ -150,8 +156,8 @@ agent-init list-flavors --help Invalid input prints a short hint and points the user at `--help`, then exits non-zero. Specific cases worth knowing: -- **Unknown subcommand** prints `unknown command "foo"` followed by `Run 'agent-init --help' for usage`. -- **Unknown flavor** prints the list of known flavors: `unknown flavor "foo" (known: claude-cowork, fullstack, go-backend, go-cli, project-management)`, then the init `--help` hint. +- **Unknown subcommand** prints `unknown command \"foo\"` followed by `Run 'agent-init --help' for usage`. +- **Unknown flavor** prints the list of known flavors: `unknown flavor \"foo\"` (known: claude-cowork, fullstack, go-backend, go-cli, project-management)`, then the init `--help` hint. - **Unknown tracker** prints the list of known trackers, then the add-tracker `--help` hint. - **`add-tracker` on a target without `.mcp.json`** suggests the corresponding `init` command. diff --git a/internal/flavors/claudecowork/flavor.go b/internal/flavors/claudecowork/flavor.go index 8fc4a32..450139a 100644 --- a/internal/flavors/claudecowork/flavor.go +++ b/internal/flavors/claudecowork/flavor.go @@ -12,20 +12,12 @@ func Templates() embed.FS { return templates } -// ExecutablePaths is empty because this flavor ships no scripts — the -// scaffold is a document-collaboration folder, not a code project. No -// done-gate, no codemap regeneration. func ExecutablePaths() []string { return nil } -// NextSteps returns the post-scaffold message tailored to a doc-collab -// workspace. No devcontainer, no `just check` — the next moves are to fill -// in the agent instructions and load the folder into Claude Cowork. func NextSteps(target string) string { return fmt.Sprintf(` -Done. - Next steps: 1. Edit %s/AGENTS.md — replace the "What this workspace is" paragraph with one or two sentences describing what you and your coworkers do here. diff --git a/internal/flavors/projectmgmt/flavor.go b/internal/flavors/projectmgmt/flavor.go index 0bb13ce..7d72791 100644 --- a/internal/flavors/projectmgmt/flavor.go +++ b/internal/flavors/projectmgmt/flavor.go @@ -12,18 +12,12 @@ func Templates() embed.FS { return templates } -// ExecutablePaths is empty — this flavor ships markdown and JSON, no scripts. func ExecutablePaths() []string { return nil } -// NextSteps tailors the post-scaffold message for a PM workspace. No -// devcontainer, no `just check`. The next move is to wire a tracker via -// `agent-init add-tracker`, then start filling in stakeholders/decisions. func NextSteps(target string) string { return fmt.Sprintf(` -Done. - Next steps: 1. Edit %s/AGENTS.md — replace the "Project context" paragraph and the "Active trackers" line (initially blank). diff --git a/internal/scaffold/scaffold.go b/internal/scaffold/scaffold.go index 87ff1b5..69a1119 100644 --- a/internal/scaffold/scaffold.go +++ b/internal/scaffold/scaffold.go @@ -16,14 +16,11 @@ import ( ) type Options struct { - Flavor flavors.Flavor - Target string - Force bool - InitGit bool - DryRun bool - // AgentsOnly drops the fresh-project files declared in - // Flavor.FreshOnlyPaths and prefers any ".agents-only" variant template - // files. Used to add the agentic envelope to an existing project. + Flavor flavors.Flavor + Target string + Force bool + InitGit bool + DryRun bool AgentsOnly bool Out io.Writer counts *operationCounts @@ -49,10 +46,6 @@ const ( ansiBoldGreen = "\x1b[1;32m" ) -// agentsOnlySuffix marks a template file as the variant to use in -// agents-only mode. The suffix is stripped from the destination path before -// writing, so `Justfile.agents-only.tmpl` writes as `Justfile`. In fresh -// mode the variant is skipped entirely. const agentsOnlySuffix = ".agents-only" type templateData struct { @@ -85,20 +78,11 @@ func Run(ctx context.Context, opts Options) error { return err } } - printNextSteps(out, opts.Flavor, target) printSummary(out, opts.DryRun, counts, style) + printNextSteps(out, opts.Flavor, target) return nil } -// Overlay writes a single template layer onto an existing target directory. -// Unlike Run, it does not init git, create symlinks, or print a next-steps -// message — it just walks the layer and writes (or skips, or dry-runs) the -// files using the same engine semantics as a normal scaffold. Use this for -// incremental subcommands like add-tracker that augment an already-scaffolded -// project. -// -// opts.Flavor is ignored except for opts.Force, opts.DryRun, opts.Target, -// opts.Out — the other Flavor fields (Symlinks, NextSteps, etc.) are not used. func Overlay(opts Options, fsys fs.FS, root string) error { out := opts.Out if out == nil { @@ -165,8 +149,6 @@ func walkLayer(opts Options, fsys fs.FS, root, target string, data templateData, if err != nil { return fmt.Errorf("opening template root %q: %w", root, err) } - // First pass: in agents-only mode, identify destination rels that have a - // .agents-only variant in this layer so the base file gets shadowed. coveredByVariant := map[string]bool{} if opts.AgentsOnly { if err := fs.WalkDir(rootFS, ".", func(path string, entry fs.DirEntry, err error) error { @@ -188,8 +170,6 @@ func walkLayer(opts Options, fsys fs.FS, root, target string, data templateData, return err } } - // Pre-render FreshOnlyPaths once so the per-file check is a plain - // string comparison. freshOnly := renderedFreshOnly(opts, data) return fs.WalkDir(rootFS, ".", func(path string, entry fs.DirEntry, err error) error { if err != nil { @@ -234,9 +214,6 @@ func walkLayer(opts Options, fsys fs.FS, root, target string, data templateData, }) } -// renderedFreshOnly resolves Flavor.FreshOnlyPaths into a set of rendered -// destination paths for the active scaffold. Only meaningful when -// opts.AgentsOnly is set; callers check that before consulting the result. func renderedFreshOnly(opts Options, data templateData) map[string]bool { if !opts.AgentsOnly || len(opts.Flavor.FreshOnlyPaths) == 0 { return nil @@ -345,7 +322,7 @@ func createSymlinks(opts Options, target string, out io.Writer) error { func link(opts Options, dir, name, dest, display string, out io.Writer) error { path := filepath.Join(dir, name) - if display == "" || strings.HasPrefix(display, "..") { + if display == "" { display = name } info, exists, err := lstat(path) @@ -469,10 +446,7 @@ func (s outputStyle) verb(op string) string { func printNextSteps(out io.Writer, flavor flavors.Flavor, target string) { if flavor.NextSteps != nil { - message := flavor.NextSteps(target) - message = strings.TrimPrefix(message, "\nDone.\n\n") - message = strings.TrimPrefix(message, "Done.\n\n") - _, _ = fmt.Fprint(out, message) + _, _ = fmt.Fprint(out, flavor.NextSteps(target)) return } _, _ = fmt.Fprintf(out, ` diff --git a/internal/scaffold/scaffold_test.go b/internal/scaffold/scaffold_test.go index 21dd5ba..7400bbb 100644 --- a/internal/scaffold/scaffold_test.go +++ b/internal/scaffold/scaffold_test.go @@ -6,6 +6,7 @@ import ( "os" "os/exec" "path/filepath" + "regexp" "runtime" "strings" "testing" @@ -142,8 +143,9 @@ func TestRunPrintsSummaryAndSymlinkExplanation(t *testing.T) { } output := out.String() - if !strings.Contains(output, "Done. 16 written, 0 skipped, 3 linked.") { - t.Fatalf("output missing operation summary:\n%s", output) + summaryPattern := regexp.MustCompile(`Done\. \d+ written, \d+ skipped, 3 linked\.`) + if !summaryPattern.MatchString(output) { + t.Fatalf("output missing operation summary matching %q:\n%s", summaryPattern.String(), output) } if !strings.Contains(output, "AGENTS.md and CLAUDE.md are symlinks to .agent/AGENTS.md") { t.Fatalf("output missing symlink explanation:\n%s", output) @@ -160,7 +162,7 @@ func TestRunPrintsSingleDoneForCustomNextSteps(t *testing.T) { }, TemplateRoot: "templates", NextSteps: func(string) string { - return "\nDone.\n\nNext steps:\n 1. Custom\n" + return "\nNext steps:\n 1. Custom\n" }, } @@ -218,8 +220,9 @@ func TestRunDryRunSummaryUsesWouldBe(t *testing.T) { if err != nil { t.Fatalf("Run(dry-run) error = %v", err) } - if !strings.Contains(out.String(), "Dry run: 16 would be written, 0 skipped, 3 would be linked.") { - t.Fatalf("output missing dry-run summary:\n%s", out.String()) + summaryPattern := regexp.MustCompile(`Dry run: \d+ would be written, \d+ skipped, 3 would be linked\.`) + if !summaryPattern.MatchString(out.String()) { + t.Fatalf("output missing dry-run summary matching %q:\n%s", summaryPattern.String(), out.String()) } } diff --git a/internal/scaffold/term_other.go b/internal/scaffold/term_other.go index 6286e1a..25df814 100644 --- a/internal/scaffold/term_other.go +++ b/internal/scaffold/term_other.go @@ -4,6 +4,11 @@ package scaffold import "os" +// isTerminal returns false on unsupported platforms (like Windows), disabling +// colorized output. This is a deliberate choice to avoid a dependency on a +// term library (like golang.org/x/term) for a cosmetic feature that was not +// originally requested for Windows in issue #58. Modern Windows Terminal +// supports ANSI, but this keeps the implementation minimal and dependency-free. func isTerminal(file *os.File) bool { return false } From b34d334867341a9eb986231e423cbc66d55221c8 Mon Sep 17 00:00:00 2001 From: Ant Assistant Date: Mon, 25 May 2026 08:56:16 +0200 Subject: [PATCH 3/3] fix: tidy review feedback changes --- docs/cli.md | 8 +++--- internal/flavors/claudecowork/flavor.go | 6 ++++ internal/flavors/projectmgmt/flavor.go | 4 +++ internal/scaffold/scaffold.go | 37 ++++++++++++++++++++----- internal/scaffold/term_other.go | 8 ++---- 5 files changed, 47 insertions(+), 16 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index ab7aecb..9b92301 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -73,7 +73,7 @@ Multiple trackers can be added to the same workspace. Useful during migrations ( ### Credentials -The merged `.mcp.json` entry references every credential from the environment via `${env:VAR}` (e.g. `\"GITHUB_PERSONAL_ACCESS_TOKEN\": \"${env:GITHUB_TOKEN}\"`). No empty literal is ever written, so there is no field inviting a pasted secret into the tracked file. Set the vars in your shell or a gitignored `.env`; each tracker ships `integrations//.env.example` listing what it needs. For the GitHub tracker, `export GITHUB_TOKEN=\"$(gh auth token)\"` reuses the devcontainer's existing `gh` login. `add-tracker` prints the variable names and this guidance after merging. Changing `.mcp.json` requires restarting the MCP client (or session) to reconnect. See [`trackerEnvVars`](../internal/cli/cli.go) and [`internal/trackers/registry.go`](../internal/trackers/registry.go). +The merged `.mcp.json` entry references every credential from the environment via `${env:VAR}` (e.g. `"GITHUB_PERSONAL_ACCESS_TOKEN": "${env:GITHUB_TOKEN}"`). No empty literal is ever written, so there is no field inviting a pasted secret into the tracked file. Set the vars in your shell or a gitignored `.env`; each tracker ships `integrations//.env.example` listing what it needs. For the GitHub tracker, `export GITHUB_TOKEN="$(gh auth token)"` reuses the devcontainer's existing `gh` login. `add-tracker` prints the variable names and this guidance after merging. Changing `.mcp.json` requires restarting the MCP client (or session) to reconnect. See [`trackerEnvVars`](../internal/cli/cli.go) and [`internal/trackers/registry.go`](../internal/trackers/registry.go). ### Removing a tracker @@ -81,7 +81,7 @@ There is no `remove-tracker` subcommand yet. Manual cleanup: 1. Delete `integrations//`. 2. Remove the entry from `.mcp.json` under `mcpServers`. -3. Remove the tracker name from `AGENTS.md`'s \"Active trackers\" line. +3. Remove the tracker name from `AGENTS.md`'s "Active trackers" line. ## `list-flavors` @@ -156,8 +156,8 @@ agent-init list-flavors --help Invalid input prints a short hint and points the user at `--help`, then exits non-zero. Specific cases worth knowing: -- **Unknown subcommand** prints `unknown command \"foo\"` followed by `Run 'agent-init --help' for usage`. -- **Unknown flavor** prints the list of known flavors: `unknown flavor \"foo\"` (known: claude-cowork, fullstack, go-backend, go-cli, project-management)`, then the init `--help` hint. +- **Unknown subcommand** prints `unknown command "foo"` followed by `Run 'agent-init --help' for usage`. +- **Unknown flavor** prints the list of known flavors: `unknown flavor "foo" (known: claude-cowork, fullstack, go-backend, go-cli, project-management)`, then the init `--help` hint. - **Unknown tracker** prints the list of known trackers, then the add-tracker `--help` hint. - **`add-tracker` on a target without `.mcp.json`** suggests the corresponding `init` command. diff --git a/internal/flavors/claudecowork/flavor.go b/internal/flavors/claudecowork/flavor.go index 450139a..d4886d9 100644 --- a/internal/flavors/claudecowork/flavor.go +++ b/internal/flavors/claudecowork/flavor.go @@ -12,10 +12,16 @@ func Templates() embed.FS { return templates } +// ExecutablePaths is empty because this flavor ships no scripts — the +// scaffold is a document-collaboration folder, not a code project. No +// done-gate, no codemap regeneration. func ExecutablePaths() []string { return nil } +// NextSteps returns the post-scaffold message tailored to a doc-collab +// workspace. No devcontainer, no `just check` — the next moves are to fill +// in the agent instructions and load the folder into Claude Cowork. func NextSteps(target string) string { return fmt.Sprintf(` Next steps: diff --git a/internal/flavors/projectmgmt/flavor.go b/internal/flavors/projectmgmt/flavor.go index 7d72791..13dcba4 100644 --- a/internal/flavors/projectmgmt/flavor.go +++ b/internal/flavors/projectmgmt/flavor.go @@ -12,10 +12,14 @@ func Templates() embed.FS { return templates } +// ExecutablePaths is empty — this flavor ships markdown and JSON, no scripts. func ExecutablePaths() []string { return nil } +// NextSteps tailors the post-scaffold message for a PM workspace. No +// devcontainer, no `just check`. The next move is to wire a tracker via +// `agent-init add-tracker`, then start filling in stakeholders/decisions. func NextSteps(target string) string { return fmt.Sprintf(` Next steps: diff --git a/internal/scaffold/scaffold.go b/internal/scaffold/scaffold.go index 69a1119..ddac192 100644 --- a/internal/scaffold/scaffold.go +++ b/internal/scaffold/scaffold.go @@ -16,11 +16,14 @@ import ( ) type Options struct { - Flavor flavors.Flavor - Target string - Force bool - InitGit bool - DryRun bool + Flavor flavors.Flavor + Target string + Force bool + InitGit bool + DryRun bool + // AgentsOnly drops the fresh-project files declared in + // Flavor.FreshOnlyPaths and prefers any ".agents-only" variant template + // files. Used to add the agentic envelope to an existing project. AgentsOnly bool Out io.Writer counts *operationCounts @@ -46,6 +49,10 @@ const ( ansiBoldGreen = "\x1b[1;32m" ) +// agentsOnlySuffix marks a template file as the variant to use in +// agents-only mode. The suffix is stripped from the destination path before +// writing, so `Justfile.agents-only.tmpl` writes as `Justfile`. In fresh +// mode the variant is skipped entirely. const agentsOnlySuffix = ".agents-only" type templateData struct { @@ -78,11 +85,20 @@ func Run(ctx context.Context, opts Options) error { return err } } - printSummary(out, opts.DryRun, counts, style) printNextSteps(out, opts.Flavor, target) + printSummary(out, opts.DryRun, counts, style) return nil } +// Overlay writes a single template layer onto an existing target directory. +// Unlike Run, it does not init git, create symlinks, or print a next-steps +// message — it just walks the layer and writes (or skips, or dry-runs) the +// files using the same engine semantics as a normal scaffold. Use this for +// incremental subcommands like add-tracker that augment an already-scaffolded +// project. +// +// opts.Flavor is ignored except for opts.Force, opts.DryRun, opts.Target, +// opts.Out — the other Flavor fields (Symlinks, NextSteps, etc.) are not used. func Overlay(opts Options, fsys fs.FS, root string) error { out := opts.Out if out == nil { @@ -149,6 +165,8 @@ func walkLayer(opts Options, fsys fs.FS, root, target string, data templateData, if err != nil { return fmt.Errorf("opening template root %q: %w", root, err) } + // First pass: in agents-only mode, identify destination rels that have a + // .agents-only variant in this layer so the base file gets shadowed. coveredByVariant := map[string]bool{} if opts.AgentsOnly { if err := fs.WalkDir(rootFS, ".", func(path string, entry fs.DirEntry, err error) error { @@ -170,6 +188,8 @@ func walkLayer(opts Options, fsys fs.FS, root, target string, data templateData, return err } } + // Pre-render FreshOnlyPaths once so the per-file check is a plain + // string comparison. freshOnly := renderedFreshOnly(opts, data) return fs.WalkDir(rootFS, ".", func(path string, entry fs.DirEntry, err error) error { if err != nil { @@ -214,6 +234,9 @@ func walkLayer(opts Options, fsys fs.FS, root, target string, data templateData, }) } +// renderedFreshOnly resolves Flavor.FreshOnlyPaths into a set of rendered +// destination paths for the active scaffold. Only meaningful when +// opts.AgentsOnly is set; callers check that before consulting the result. func renderedFreshOnly(opts Options, data templateData) map[string]bool { if !opts.AgentsOnly || len(opts.Flavor.FreshOnlyPaths) == 0 { return nil @@ -322,7 +345,7 @@ func createSymlinks(opts Options, target string, out io.Writer) error { func link(opts Options, dir, name, dest, display string, out io.Writer) error { path := filepath.Join(dir, name) - if display == "" { + if display == "" || strings.HasPrefix(display, "..") { display = name } info, exists, err := lstat(path) diff --git a/internal/scaffold/term_other.go b/internal/scaffold/term_other.go index 25df814..e201e3c 100644 --- a/internal/scaffold/term_other.go +++ b/internal/scaffold/term_other.go @@ -4,11 +4,9 @@ package scaffold import "os" -// isTerminal returns false on unsupported platforms (like Windows), disabling -// colorized output. This is a deliberate choice to avoid a dependency on a -// term library (like golang.org/x/term) for a cosmetic feature that was not -// originally requested for Windows in issue #58. Modern Windows Terminal -// supports ANSI, but this keeps the implementation minimal and dependency-free. +// isTerminal returns false on unsupported platforms (including Windows), so +// color output is deliberately disabled there. Modern Windows terminals can +// support ANSI, but issue #58 only requested the dependency-free Unix TTY gate. func isTerminal(file *os.File) bool { return false }