diff --git a/internal/cli/app.go b/internal/cli/app.go index a138f014..b15e0fd2 100644 --- a/internal/cli/app.go +++ b/internal/cli/app.go @@ -42,6 +42,7 @@ import ( "github.com/Gitlawb/zero/internal/worktrees" "github.com/Gitlawb/zero/internal/zerogit" "github.com/Gitlawb/zero/internal/zeroruntime" + "github.com/charmbracelet/x/term" ) var version = "dev" @@ -339,7 +340,17 @@ func runWithDeps(args []string, stdout io.Writer, stderr io.Writer, deps appDeps return 0 } } - if _, err := fmt.Fprintf(stdout, "zero %s\n", version); err != nil { + // Pipes and redirects keep the machine-readable contract exactly as it + // was: a single "zero " line. Scripts, command substitutions, + // and the NPM wrapper smoke check all parse that record, so the banner + // is strictly a TTY affordance. + if !stdoutIsTerminal(stdout) { + if _, err := fmt.Fprintf(stdout, "zero %s\n", version); err != nil { + return 1 + } + return 0 + } + if _, err := fmt.Fprintf(stdout, "%s\n\nzero %s\n", tui.Wordmark(), version); err != nil { return 1 } return 0 @@ -1115,6 +1126,17 @@ func closeSpecialistRuntime(stderr io.Writer, runtime *agentToolRuntime) { } } +// stdoutIsTerminal reports whether w is an interactive terminal. Tests pass +// bytes.Buffer writers and pipes/redirects fail term.IsTerminal, so both take +// the machine-readable path. +func stdoutIsTerminal(w io.Writer) bool { + f, ok := w.(*os.File) + if !ok { + return false + } + return term.IsTerminal(f.Fd()) +} + func writeAppError(stderr io.Writer, message string, exitCode int) int { if _, err := fmt.Fprintf(stderr, "[zero] %s\n", message); err != nil { return 1 diff --git a/internal/cli/app_test.go b/internal/cli/app_test.go index d56bd906..bbcd20ad 100644 --- a/internal/cli/app_test.go +++ b/internal/cli/app_test.go @@ -41,6 +41,8 @@ func TestRunPrintsVersion(t *testing.T) { if exitCode != 0 { t.Fatalf("expected exit code 0, got %d", exitCode) } + // Non-terminal stdout keeps the machine-readable contract: exactly one + // "zero " record, no wordmark banner, no ANSI. if got := stdout.String(); got != "zero dev\n" { t.Fatalf("expected version output, got %q", got) } @@ -49,6 +51,33 @@ func TestRunPrintsVersion(t *testing.T) { } } +// TestRunVersionRedirectedFile covers the `zero --version > file` path: stdout +// is a real *os.File but not a terminal, so the single-line contract must hold. +func TestRunVersionRedirectedFile(t *testing.T) { + stdout, err := os.CreateTemp(t.TempDir(), "version-stdout") + if err != nil { + t.Fatalf("create temp stdout: %v", err) + } + defer stdout.Close() + var stderr bytes.Buffer + + exitCode := Run([]string{"--version"}, stdout, &stderr) + + if exitCode != 0 { + t.Fatalf("expected exit code 0, got %d", exitCode) + } + content, err := os.ReadFile(stdout.Name()) + if err != nil { + t.Fatalf("read temp stdout: %v", err) + } + if got := string(content); got != "zero dev\n" { + t.Fatalf("expected version output, got %q", got) + } + if stderr.Len() != 0 { + t.Fatalf("expected empty stderr, got %q", stderr.String()) + } +} + func TestRunPrintsHelp(t *testing.T) { for _, args := range [][]string{ {"--help"}, diff --git a/internal/tui/startup.go b/internal/tui/startup.go index 9e67e1b8..69c4c156 100644 --- a/internal/tui/startup.go +++ b/internal/tui/startup.go @@ -137,6 +137,19 @@ func zeroWordmarkLines() []string { return lines } +// Wordmark renders the brand ASCII art shown on the TUI's empty state, for +// reuse outside the TUI (e.g. `zero --version`). It is deliberately +// uncolored: only newModel resolves --theme/ZERO_THEME, so painting the +// global palette here would ignore a selected light theme and be unreadable +// on light terminals. The terminal's default foreground works everywhere. +func Wordmark() string { + lines := make([]string, 0, minInt(len(zeroWordmarkPrefixLines), len(zeroWordmarkOLines))) + for index := 0; index < len(zeroWordmarkPrefixLines) && index < len(zeroWordmarkOLines); index++ { + lines = append(lines, zeroWordmarkPrefixLines[index]+zeroWordmarkOLines[index]) + } + return strings.Join(lines, "\n") +} + func borderedBlock(width int, lines []string) string { return styledBlock(width, lines, zeroTheme.line) } diff --git a/internal/tui/startup_test.go b/internal/tui/startup_test.go index 13e67782..9408cbc7 100644 --- a/internal/tui/startup_test.go +++ b/internal/tui/startup_test.go @@ -21,6 +21,25 @@ func TestEmptyStateShowsBrandAndTaglineOnly(t *testing.T) { assertNotContains(t, view, "fix the failing test in internal/tools") } +// TestWordmarkIsPlain guards the --version banner: it must carry no ANSI +// escapes, because this renderer never resolves --theme/ZERO_THEME and any +// palette color could be unreadable on the user's background. +func TestWordmarkIsPlain(t *testing.T) { + wordmark := Wordmark() + if strings.Contains(wordmark, "\x1b") { + t.Fatalf("expected uncolored wordmark, got %q", wordmark) + } + lines := strings.Split(wordmark, "\n") + if len(lines) != len(zeroWordmarkPrefixLines) { + t.Fatalf("expected %d wordmark lines, got %d", len(zeroWordmarkPrefixLines), len(lines)) + } + for index, line := range lines { + if want := zeroWordmarkPrefixLines[index] + zeroWordmarkOLines[index]; line != want { + t.Fatalf("wordmark line %d: expected %q, got %q", index, want, line) + } + } +} + func TestEmptyStateShowsVersion(t *testing.T) { m := newModel(context.Background(), Options{Version: "0.2.0"}) m.width, m.height = 100, 30