From 3ec96fd992645c48fd8be3ab9445598119c851fa Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Mon, 30 Mar 2026 15:48:07 -0400 Subject: [PATCH] fix: redirect auto-mode to headless when stdout is piped (#2732) (#3269) When `gsd auto` is run with piped stdout (e.g. `gsd auto | cat` or `gsd auto > file`), the TUI cannot render on a non-terminal output stream, causing the process to hang indefinitely. This fix: - Detects piped stdout before entering interactive mode and redirects `gsd auto` to headless mode automatically - Extends the interactive mode TTY gate to also check process.stdout.isTTY (previously only checked stdin), with a descriptive error message - Adds `gsd headless` to the non-interactive alternatives hint Co-authored-by: Claude Opus 4.6 --- src/cli.ts | 24 ++++- src/tests/auto-piped-io.test.ts | 172 ++++++++++++++++++++++++++++++++ 2 files changed, 194 insertions(+), 2 deletions(-) create mode 100644 src/tests/auto-piped-io.test.ts diff --git a/src/cli.ts b/src/cli.ts index a5b255fa9..255cbf3ed 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -565,6 +565,20 @@ if (!cliFlags.worktree && !isPrintMode) { } catch { /* non-fatal */ } } +// --------------------------------------------------------------------------- +// Auto-redirect: `gsd auto` with piped stdout → headless mode (#2732) +// When stdout is not a TTY (e.g. `gsd auto | cat`, `gsd auto > file`), +// the TUI cannot render and the process hangs. Redirect to headless mode +// which handles non-interactive output gracefully. +// --------------------------------------------------------------------------- +if (cliFlags.messages[0] === 'auto' && !process.stdout.isTTY) { + await ensureRtkBootstrap() + const { runHeadless, parseHeadlessArgs } = await import('./headless.js') + process.stderr.write('[gsd] stdout is not a terminal — running auto-mode in headless mode.\n') + await runHeadless(parseHeadlessArgs(['node', 'gsd', 'headless', ...cliFlags.messages.slice(1)])) + process.exit(0) +} + // --------------------------------------------------------------------------- // Interactive mode — normal TTY session // --------------------------------------------------------------------------- @@ -662,14 +676,20 @@ if (enabledModelPatterns && enabledModelPatterns.length > 0) { } } -if (!process.stdin.isTTY) { - process.stderr.write('[gsd] Error: Interactive mode requires a terminal (TTY).\n') +if (!process.stdin.isTTY || !process.stdout.isTTY) { + const missing = !process.stdin.isTTY && !process.stdout.isTTY + ? 'stdin and stdout are' + : !process.stdin.isTTY + ? 'stdin is' + : 'stdout is' + process.stderr.write(`[gsd] Error: Interactive mode requires a terminal (TTY) but ${missing} not a TTY.\n`) process.stderr.write('[gsd] Non-interactive alternatives:\n') process.stderr.write('[gsd] gsd --print "your message" Single-shot prompt\n') process.stderr.write('[gsd] gsd --web [path] Browser-only web mode\n') process.stderr.write('[gsd] gsd --mode rpc JSON-RPC over stdin/stdout\n') process.stderr.write('[gsd] gsd --mode mcp MCP server over stdin/stdout\n') process.stderr.write('[gsd] gsd --mode text "message" Text output mode\n') + process.stderr.write('[gsd] gsd headless Auto-mode without TUI\n') process.exit(1) } diff --git a/src/tests/auto-piped-io.test.ts b/src/tests/auto-piped-io.test.ts new file mode 100644 index 000000000..84bb5fbc1 --- /dev/null +++ b/src/tests/auto-piped-io.test.ts @@ -0,0 +1,172 @@ +/** + * Tests for auto-mode piped I/O detection (#2732). + * + * When `gsd auto` is run with piped stdout (e.g. `gsd auto | cat`), + * the CLI should detect the non-TTY stdout and redirect to headless + * mode instead of hanging in interactive mode trying to set up a TUI + * on a non-terminal output stream. + * + * Also verifies the stdout TTY gate at the interactive mode entry point: + * when stdout is piped, interactive mode must not be entered regardless + * of the subcommand. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; + +// ─── Extracted detection logic (mirrors cli.ts) ─────────────────────────── + +/** + * Subcommands that are explicitly handled before the interactive mode + * section in cli.ts and therefore never fall through to the TUI. + */ +const EXPLICIT_SUBCOMMANDS = new Set([ + "headless", + "update", + "config", + "worktree", + "wt", + "sessions", + "web", +]); + +/** + * Detect whether the current subcommand should be auto-redirected + * to headless mode when stdout is not a TTY. + * + * Returns true when: the subcommand is "auto" AND stdout is piped. + */ +function shouldRedirectAutoToHeadless( + subcommand: string | undefined, + stdoutIsTTY: boolean, +): boolean { + if (stdoutIsTTY) return false; + return subcommand === "auto"; +} + +/** + * Check whether interactive mode can be entered. + * Both stdin AND stdout must be TTY for the TUI to work. + */ +function canEnterInteractiveMode( + stdinIsTTY: boolean, + stdoutIsTTY: boolean, +): boolean { + return stdinIsTTY && stdoutIsTTY; +} + +/** + * Returns true if the subcommand is handled by an explicit branch + * in cli.ts and will never reach the interactive mode section. + */ +function isExplicitSubcommand(subcommand: string | undefined): boolean { + return subcommand !== undefined && EXPLICIT_SUBCOMMANDS.has(subcommand); +} + +// ─── shouldRedirectAutoToHeadless ───────────────────────────────────────── + +test("redirects 'auto' to headless when stdout is piped", () => { + assert.ok(shouldRedirectAutoToHeadless("auto", false)); +}); + +test("does NOT redirect 'auto' when stdout is a TTY", () => { + assert.ok(!shouldRedirectAutoToHeadless("auto", true)); +}); + +test("does NOT redirect non-auto subcommands when stdout is piped", () => { + assert.ok(!shouldRedirectAutoToHeadless("headless", false)); + assert.ok(!shouldRedirectAutoToHeadless("config", false)); + assert.ok(!shouldRedirectAutoToHeadless("update", false)); + assert.ok(!shouldRedirectAutoToHeadless(undefined, false)); +}); + +// ─── canEnterInteractiveMode ────────────────────────────────────────────── + +test("allows interactive mode when both stdin and stdout are TTY", () => { + assert.ok(canEnterInteractiveMode(true, true)); +}); + +test("blocks interactive mode when stdin is piped", () => { + assert.ok(!canEnterInteractiveMode(false, true)); +}); + +test("blocks interactive mode when stdout is piped", () => { + assert.ok(!canEnterInteractiveMode(true, false)); +}); + +test("blocks interactive mode when both stdin and stdout are piped", () => { + assert.ok(!canEnterInteractiveMode(false, false)); +}); + +// ─── isExplicitSubcommand ───────────────────────────────────────────────── + +test("identifies explicitly handled subcommands", () => { + assert.ok(isExplicitSubcommand("headless")); + assert.ok(isExplicitSubcommand("update")); + assert.ok(isExplicitSubcommand("config")); + assert.ok(isExplicitSubcommand("worktree")); + assert.ok(isExplicitSubcommand("wt")); + assert.ok(isExplicitSubcommand("sessions")); + assert.ok(isExplicitSubcommand("web")); +}); + +test("does NOT identify 'auto' as explicit subcommand", () => { + assert.ok(!isExplicitSubcommand("auto")); +}); + +test("does NOT identify undefined as explicit subcommand", () => { + assert.ok(!isExplicitSubcommand(undefined)); +}); + +// ─── End-to-end scenario: gsd auto | cat ────────────────────────────────── + +test("scenario: 'gsd auto 2>&1 | cat' — should redirect to headless", () => { + // Simulates: subcommand = "auto", stdin is TTY, stdout is piped + const subcommand = "auto"; + const stdinIsTTY = true; + const stdoutIsTTY = false; + + // Interactive mode should be blocked + assert.ok(!canEnterInteractiveMode(stdinIsTTY, stdoutIsTTY)); + + // Auto should be redirected to headless + assert.ok(shouldRedirectAutoToHeadless(subcommand, stdoutIsTTY)); +}); + +test("scenario: 'gsd auto > /tmp/output.txt' — should redirect to headless", () => { + const subcommand = "auto"; + const stdinIsTTY = true; + const stdoutIsTTY = false; + + assert.ok(!canEnterInteractiveMode(stdinIsTTY, stdoutIsTTY)); + assert.ok(shouldRedirectAutoToHeadless(subcommand, stdoutIsTTY)); +}); + +test("scenario: 'gsd auto' in terminal — normal interactive mode", () => { + const subcommand = "auto"; + const stdinIsTTY = true; + const stdoutIsTTY = true; + + assert.ok(canEnterInteractiveMode(stdinIsTTY, stdoutIsTTY)); + assert.ok(!shouldRedirectAutoToHeadless(subcommand, stdoutIsTTY)); +}); + +test("scenario: 'echo msg | gsd auto' — stdin piped, should redirect", () => { + const subcommand = "auto"; + const stdinIsTTY = false; + const stdoutIsTTY = true; // stdout is TTY even though stdin is piped + + // stdout is TTY, so auto redirect doesn't trigger... + assert.ok(!shouldRedirectAutoToHeadless(subcommand, stdoutIsTTY)); + // ...but interactive mode is blocked because stdin is piped + assert.ok(!canEnterInteractiveMode(stdinIsTTY, stdoutIsTTY)); +}); + +test("scenario: 'echo msg | gsd auto | cat' — both piped", () => { + const subcommand = "auto"; + const stdinIsTTY = false; + const stdoutIsTTY = false; + + assert.ok(!canEnterInteractiveMode(stdinIsTTY, stdoutIsTTY)); + assert.ok(shouldRedirectAutoToHeadless(subcommand, stdoutIsTTY)); +});