diff --git a/docs/cli.md b/docs/cli.md index f5635de..9b92301 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). diff --git a/internal/flavors/claudecowork/flavor.go b/internal/flavors/claudecowork/flavor.go index 8fc4a32..d4886d9 100644 --- a/internal/flavors/claudecowork/flavor.go +++ b/internal/flavors/claudecowork/flavor.go @@ -24,8 +24,6 @@ func ExecutablePaths() []string { // 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..13dcba4 100644 --- a/internal/flavors/projectmgmt/flavor.go +++ b/internal/flavors/projectmgmt/flavor.go @@ -22,8 +22,6 @@ func ExecutablePaths() []string { // `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/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..ddac192 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,84 @@ 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)) 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..7400bbb 100644 --- a/internal/scaffold/scaffold_test.go +++ b/internal/scaffold/scaffold_test.go @@ -6,7 +6,9 @@ import ( "os" "os/exec" "path/filepath" + "regexp" "runtime" + "strings" "testing" "testing/fstest" @@ -40,6 +42,190 @@ 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() + 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) + } +} + +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 "\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) + } + 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()) + } +} + 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..e201e3c --- /dev/null +++ b/internal/scaffold/term_other.go @@ -0,0 +1,12 @@ +//go:build !linux && !darwin + +package scaffold + +import "os" + +// 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 +}