Skip to content
Open
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
25 changes: 25 additions & 0 deletions internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,24 @@ import (
// Version is set from main via ldflags at build time.
var Version = "dev"

// ErrNoTTYForTUI is returned when the TUI is requested from a non-interactive
// environment (scripts, CI, non-interactive SSH). Lists available non-TUI commands.
var ErrNoTTYForTUI = errors.New(
"gentle-ai: no TTY available — the interactive TUI cannot launch from scripts, CI, or non-interactive SSH.\n" +
"Available non-interactive commands:\n" +
" gentle-ai --version Print version\n" +
" gentle-ai --help Show all commands\n" +
" gentle-ai update Check for available updates\n" +
" gentle-ai install Configure AI coding agents\n" +
" gentle-ai sync Sync agent configs to current version\n" +
" gentle-ai doctor Run ecosystem health diagnostics")

// stdinIsTerminal reports whether stdin is connected to a terminal.
// Uses the existing isattyFn seam from selfupdate.go for testability.
var stdinIsTerminal = func() bool {
return isattyFn(os.Stdin.Fd())
}

var (
updateCheckAll = update.CheckAll
updateCheckFiltered = update.CheckFiltered
Expand Down Expand Up @@ -125,6 +143,13 @@ func RunArgs(args []string, stdout io.Writer) error {
}
}

// TTY guard: the interactive TUI requires a terminal. Non-interactive
// environments (scripts, CI, non-interactive SSH) get a clear error
// listing available non-TUI commands instead of a Bubbletea panic (#95).
if isTUIFlow && !stdinIsTerminal() {
return ErrNoTTYForTUI
}

if len(args) == 0 {
homeDir, err := os.UserHomeDir()
if err != nil {
Expand Down
84 changes: 84 additions & 0 deletions internal/app/app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1135,18 +1135,23 @@ func TestRunArgs_TUISkipsSelfUpdate(t *testing.T) {
origDetect := detectSystem
origEnsure := ensureCurrentOSSupported
origRunTUI := runTUI
origStdinIsTerminal := stdinIsTerminal
t.Cleanup(func() {
selfUpdateFn = origSelfUpdate
detectSystem = origDetect
ensureCurrentOSSupported = origEnsure
runTUI = origRunTUI
stdinIsTerminal = origStdinIsTerminal
})

ensureCurrentOSSupported = func() error { return nil }
detectSystem = func(context.Context) (system.DetectionResult, error) {
return system.DetectionResult{System: system.SystemInfo{Supported: true}}, nil
}

// Simulate a TTY so the TTY guard doesn't block the TUI flow.
stdinIsTerminal = func() bool { return true }

// Return the same model to avoid nil dereference if RunArgs inspects it.
tuiCalled := 0
runTUI = func(m tea.Model, _ ...tea.ProgramOption) (tea.Model, error) {
Expand All @@ -1173,6 +1178,64 @@ func TestRunArgs_TUISkipsSelfUpdate(t *testing.T) {
}
}

func TestRunArgs_NoTTY_ReturnsErrNoTTYForTUI(t *testing.T) {
// NOTE: modifies package-level vars; must not run in parallel.
origStdinIsTerminal := stdinIsTerminal
t.Cleanup(func() { stdinIsTerminal = origStdinIsTerminal })

// Simulate a non-interactive environment (no TTY).
stdinIsTerminal = func() bool { return false }

var buf bytes.Buffer
err := RunArgs([]string{}, &buf)
if err == nil {
t.Fatal("RunArgs(empty args) with no TTY: expected error, got nil")
}
if !strings.Contains(err.Error(), "no TTY available") {
t.Fatalf("RunArgs(empty args) with no TTY: error = %v; want error containing 'no TTY available'", err)
}
if !strings.Contains(err.Error(), "--version") {
t.Fatalf("RunArgs(empty args) with no TTY: error should list --version command; got %v", err)
}
if !strings.Contains(err.Error(), "--help") {
t.Fatalf("RunArgs(empty args) with no TTY: error should list --help command; got %v", err)
}
}

func TestRunArgs_NoTTY_StillAllowsVersionAndHelp(t *testing.T) {
// NOTE: modifies package-level vars; must not run in parallel.
origStdinIsTerminal := stdinIsTerminal
t.Cleanup(func() { stdinIsTerminal = origStdinIsTerminal })

// Simulate a non-interactive environment (no TTY).
stdinIsTerminal = func() bool { return false }

tests := []struct {
name string
args []string
}{
{name: "version", args: []string{"version"}},
{name: "--version", args: []string{"--version"}},
{name: "-v", args: []string{"-v"}},
{name: "help", args: []string{"help"}},
{name: "--help", args: []string{"--help"}},
{name: "-h", args: []string{"-h"}},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
var buf bytes.Buffer
err := RunArgs(tc.args, &buf)
if err != nil {
t.Fatalf("RunArgs(%v) with no TTY: unexpected error = %v", tc.args, err)
}
if buf.Len() == 0 {
t.Fatalf("RunArgs(%v) with no TTY: expected output, got empty buffer", tc.args)
}
})
}
}

func TestIsExplicitUpdateFlow(t *testing.T) {
tests := []struct {
name string
Expand Down Expand Up @@ -1485,10 +1548,12 @@ func TestRunArgs_TUIRestartsAfterGentleAIUpgradeResult(t *testing.T) {
origDetect := detectSystem
origEnsure := ensureCurrentOSSupported
origRunTUI := runTUI
origStdinIsTerminal := stdinIsTerminal
t.Cleanup(func() {
detectSystem = origDetect
ensureCurrentOSSupported = origEnsure
runTUI = origRunTUI
stdinIsTerminal = origStdinIsTerminal
unsetEnv(t, envSelfUpdateDone)
})
unsetEnv(t, envSelfUpdateDone)
Expand All @@ -1498,6 +1563,9 @@ func TestRunArgs_TUIRestartsAfterGentleAIUpgradeResult(t *testing.T) {
return system.DetectionResult{System: system.SystemInfo{Supported: true, Profile: system.PlatformProfile{OS: "darwin", PackageManager: "brew", Supported: true}}}, nil
}

// Simulate a TTY so the TTY guard doesn't block the TUI flow.
stdinIsTerminal = func() bool { return true }

report := upgrade.UpgradeReport{Results: []upgrade.ToolUpgradeResult{
{ToolName: "gentle-ai", Status: upgrade.UpgradeSucceeded, NewVersion: "v1.40.0"},
}}
Expand Down Expand Up @@ -1539,12 +1607,14 @@ func TestRunArgs_PendingSync_RunsSyncAndClearsFlag(t *testing.T) {
origDetect := detectSystem
origRunTUI := runTUI
origDeferredSync := deferredSyncFn
origStdinIsTerminal := stdinIsTerminal
t.Cleanup(func() {
selfUpdateFn = origSelf
ensureCurrentOSSupported = origEnsure
detectSystem = origDetect
runTUI = origRunTUI
deferredSyncFn = origDeferredSync
stdinIsTerminal = origStdinIsTerminal
})

selfUpdateFn = func(_ context.Context, _ string, _ system.PlatformProfile, _ io.Writer) error {
Expand All @@ -1554,6 +1624,8 @@ func TestRunArgs_PendingSync_RunsSyncAndClearsFlag(t *testing.T) {
detectSystem = func(context.Context) (system.DetectionResult, error) {
return system.DetectionResult{System: system.SystemInfo{Supported: true, Profile: system.PlatformProfile{OS: "darwin", PackageManager: "brew", Supported: true}}}, nil
}
// Simulate a TTY so the TTY guard doesn't block the TUI flow.
stdinIsTerminal = func() bool { return true }
runTUI = func(m tea.Model, _ ...tea.ProgramOption) (tea.Model, error) {
return m, nil
}
Expand Down Expand Up @@ -1601,12 +1673,14 @@ func TestRunArgs_PendingSync_LeavesSetOnFailure(t *testing.T) {
origDetect := detectSystem
origRunTUI := runTUI
origDeferredSync := deferredSyncFn
origStdinIsTerminal := stdinIsTerminal
t.Cleanup(func() {
selfUpdateFn = origSelf
ensureCurrentOSSupported = origEnsure
detectSystem = origDetect
runTUI = origRunTUI
deferredSyncFn = origDeferredSync
stdinIsTerminal = origStdinIsTerminal
})

selfUpdateFn = func(_ context.Context, _ string, _ system.PlatformProfile, _ io.Writer) error {
Expand All @@ -1616,6 +1690,8 @@ func TestRunArgs_PendingSync_LeavesSetOnFailure(t *testing.T) {
detectSystem = func(context.Context) (system.DetectionResult, error) {
return system.DetectionResult{System: system.SystemInfo{Supported: true, Profile: system.PlatformProfile{OS: "darwin", PackageManager: "brew", Supported: true}}}, nil
}
// Simulate a TTY so the TTY guard doesn't block the TUI flow.
stdinIsTerminal = func() bool { return true }
runTUI = func(m tea.Model, _ ...tea.ProgramOption) (tea.Model, error) {
return m, nil
}
Expand Down Expand Up @@ -1677,12 +1753,14 @@ func TestRunArgs_PendingSync_ClearWriteFailureIsLogged(t *testing.T) {
origDetect := detectSystem
origRunTUI := runTUI
origDeferredSync := deferredSyncFn
origStdinIsTerminal := stdinIsTerminal
t.Cleanup(func() {
selfUpdateFn = origSelf
ensureCurrentOSSupported = origEnsure
detectSystem = origDetect
runTUI = origRunTUI
deferredSyncFn = origDeferredSync
stdinIsTerminal = origStdinIsTerminal
})

selfUpdateFn = func(_ context.Context, _ string, _ system.PlatformProfile, _ io.Writer) error {
Expand All @@ -1692,6 +1770,8 @@ func TestRunArgs_PendingSync_ClearWriteFailureIsLogged(t *testing.T) {
detectSystem = func(context.Context) (system.DetectionResult, error) {
return system.DetectionResult{System: system.SystemInfo{Supported: true, Profile: system.PlatformProfile{OS: "darwin", PackageManager: "brew", Supported: true}}}, nil
}
// Simulate a TTY so the TTY guard doesn't block the TUI flow.
stdinIsTerminal = func() bool { return true }
runTUI = func(m tea.Model, _ ...tea.ProgramOption) (tea.Model, error) {
return m, nil
}
Expand Down Expand Up @@ -1730,12 +1810,14 @@ func TestRunArgs_NoPendingSync_NoSyncCall(t *testing.T) {
origDetect := detectSystem
origRunTUI := runTUI
origDeferredSync := deferredSyncFn
origStdinIsTerminal := stdinIsTerminal
t.Cleanup(func() {
selfUpdateFn = origSelf
ensureCurrentOSSupported = origEnsure
detectSystem = origDetect
runTUI = origRunTUI
deferredSyncFn = origDeferredSync
stdinIsTerminal = origStdinIsTerminal
})

selfUpdateFn = func(_ context.Context, _ string, _ system.PlatformProfile, _ io.Writer) error {
Expand All @@ -1745,6 +1827,8 @@ func TestRunArgs_NoPendingSync_NoSyncCall(t *testing.T) {
detectSystem = func(context.Context) (system.DetectionResult, error) {
return system.DetectionResult{System: system.SystemInfo{Supported: true, Profile: system.PlatformProfile{OS: "darwin", PackageManager: "brew", Supported: true}}}, nil
}
// Simulate a TTY so the TTY guard doesn't block the TUI flow.
stdinIsTerminal = func() bool { return true }
runTUI = func(m tea.Model, _ ...tea.ProgramOption) (tea.Model, error) {
return m, nil
}
Expand Down
10 changes: 9 additions & 1 deletion internal/app/parity_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,15 @@ func TestGuardFlowLinuxDryRunPropagatesDecision(t *testing.T) {

func TestRunArgsNoCommandLaunchesTUI(t *testing.T) {
origRunTUI := runTUI
t.Cleanup(func() { runTUI = origRunTUI })
origStdinIsTerminal := stdinIsTerminal
t.Cleanup(func() {
runTUI = origRunTUI
stdinIsTerminal = origStdinIsTerminal
})

// Simulate a TTY so the TTY guard doesn't block the TUI flow.
stdinIsTerminal = func() bool { return true }

runTUI = func(m tea.Model, opts ...tea.ProgramOption) (tea.Model, error) {
return nil, errors.New("mock TUI error: no TTY")
}
Expand Down