Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion internal/cli/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 <version>" 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
Expand Down Expand Up @@ -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
Expand Down
29 changes: 29 additions & 0 deletions internal/cli/app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <version>" record, no wordmark banner, no ANSI.
if got := stdout.String(); got != "zero dev\n" {
t.Fatalf("expected version output, got %q", got)
}
Expand All @@ -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"},
Expand Down
13 changes: 13 additions & 0 deletions internal/tui/startup.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
19 changes: 19 additions & 0 deletions internal/tui/startup_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading