diff --git a/src/cli.ts b/src/cli.ts index d09152543..9df7baf4c 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -171,6 +171,7 @@ const hasSubcommand = cliFlags.messages.length > 0 if (!process.stdin.isTTY && !isPrintMode && !hasSubcommand && !cliFlags.listModels && !cliFlags.web) { process.stderr.write('[gsd] Error: Interactive mode requires a terminal (TTY).\n') process.stderr.write('[gsd] Non-interactive alternatives:\n') + process.stderr.write('[gsd] gsd auto Auto-mode (pipeable, no TUI)\n') process.stderr.write('[gsd] gsd --print "your message" Single-shot prompt\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') @@ -301,6 +302,23 @@ if (cliFlags.messages[0] === 'headless') { process.exit(0) } +// `gsd auto [args...]` — shorthand for `gsd headless auto [args...]` (#2732) +// Without this, `gsd auto` falls through to the interactive TUI which hangs +// when stdin/stdout are piped (non-TTY environments). +if (cliFlags.messages[0] === 'auto') { + await ensureRtkBootstrap() + const { runHeadless, parseHeadlessArgs } = await import('./headless.js') + // Rewrite argv so parseHeadlessArgs sees: [node, gsd, headless, auto, ...rest] + const rewrittenArgv = [ + process.argv[0], + process.argv[1], + 'headless', + ...cliFlags.messages, // ['auto', ...extra args] + ] + await runHeadless(parseHeadlessArgs(rewrittenArgv)) + process.exit(0) +} + // Pi's tool bootstrap can mis-detect already-installed fd/rg on some systems // because spawnSync(..., ["--version"]) returns EPERM despite a zero exit code. // Provision local managed binaries first so Pi sees them without probing PATH. @@ -659,6 +677,7 @@ if (!process.stdin.isTTY || !process.stdout.isTTY) { : '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 auto Auto-mode (pipeable, no TUI)\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') diff --git a/src/help-text.ts b/src/help-text.ts index 4976c0591..82f262268 100644 --- a/src/help-text.ts +++ b/src/help-text.ts @@ -169,6 +169,7 @@ export function printHelp(version: string): void { process.stdout.write(' update Update GSD to the latest version\n') process.stdout.write(' sessions List and resume a past session\n') process.stdout.write(' worktree Manage worktrees (list, merge, clean, remove)\n') + process.stdout.write(' auto [args] Run auto-mode without TUI (pipeable)\n') process.stdout.write(' headless [cmd] [args] Run /gsd commands without TUI (default: auto)\n') process.stdout.write('\nRun gsd --help for subcommand-specific help.\n') } diff --git a/src/tests/auto-mode-piped.test.ts b/src/tests/auto-mode-piped.test.ts new file mode 100644 index 000000000..005dddadd --- /dev/null +++ b/src/tests/auto-mode-piped.test.ts @@ -0,0 +1,106 @@ +/** + * Tests for `gsd auto` routing — verifies that `auto` is recognized as a + * subcommand alias for `headless auto` so it doesn't fall through to the + * interactive TUI, which hangs when stdin/stdout are piped. + * + * Regression test for #2732. + */ + +import test from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' + +const projectRoot = join(fileURLToPath(import.meta.url), '..', '..', '..') + +// --------------------------------------------------------------------------- +// Source-level verification: cli.ts must handle 'auto' before TUI +// --------------------------------------------------------------------------- + +/** + * Read cli.ts and verify the 'auto' subcommand is routed before the + * interactive TUI code path. This is the definitive test — if cli.ts doesn't + * handle 'auto', piped invocations will hang (#2732). + */ +function cliSourceHandlesAutoBeforeTUI(): boolean { + const cliSource = readFileSync(join(projectRoot, 'src', 'cli.ts'), 'utf-8') + + // Find the position of the 'auto' subcommand handler + // It should appear as: messages[0] === 'auto' + const autoHandlerMatch = cliSource.match( + /messages\[0\]\s*===\s*['"]auto['"]/, + ) + if (!autoHandlerMatch) return false + + // Find the position of the InteractiveMode TUI entry + const tuiMatch = cliSource.match(/new\s+InteractiveMode\s*\(/) + if (!tuiMatch) return false + + // The auto handler must appear BEFORE the TUI in the source + const autoPos = cliSource.indexOf(autoHandlerMatch[0]) + const tuiPos = cliSource.indexOf(tuiMatch[0]) + + return autoPos < tuiPos +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Core regression test: `gsd auto` must be handled before TUI (#2732) +// ═══════════════════════════════════════════════════════════════════════════ + +test('cli.ts handles `auto` subcommand before interactive TUI (#2732)', () => { + assert.ok( + cliSourceHandlesAutoBeforeTUI(), + 'cli.ts must route messages[0] === "auto" to a handler BEFORE ' + + 'reaching `new InteractiveMode()`. Without this, `gsd auto` with ' + + 'piped stdin/stdout falls through to the TUI and hangs.', + ) +}) + +// ═══════════════════════════════════════════════════════════════════════════ +// Verify the auto handler routes to headless (not a stub/no-op) +// ═══════════════════════════════════════════════════════════════════════════ + +test('cli.ts routes `auto` to headless runner', () => { + const cliSource = readFileSync(join(projectRoot, 'src', 'cli.ts'), 'utf-8') + + // The auto handler block should import or reference headless + // Look for the auto block and check it contains runHeadless or headless + const autoBlockRegex = /messages\[0\]\s*===\s*['"]auto['"][\s\S]*?runHeadless/ + assert.ok( + autoBlockRegex.test(cliSource), + '`auto` subcommand handler must invoke runHeadless to delegate to headless mode', + ) +}) + +// ═══════════════════════════════════════════════════════════════════════════ +// Verify piped-mode hint in error message when auto mode is not available +// ═══════════════════════════════════════════════════════════════════════════ + +test('TTY error message mentions `gsd auto` as a non-interactive alternative', () => { + const cliSource = readFileSync(join(projectRoot, 'src', 'cli.ts'), 'utf-8') + + // The TTY error message should mention auto as an alternative + assert.ok( + cliSource.includes('gsd auto') || cliSource.includes('gsd headless'), + 'TTY error hints should mention headless/auto mode as alternatives', + ) +}) + +// ═══════════════════════════════════════════════════════════════════════════ +// `gsd headless` still works (no regression) +// ═══════════════════════════════════════════════════════════════════════════ + +test('cli.ts handles `headless` subcommand before interactive TUI', () => { + const cliSource = readFileSync(join(projectRoot, 'src', 'cli.ts'), 'utf-8') + + const headlessMatch = cliSource.match(/messages\[0\]\s*===\s*['"]headless['"]/) + const tuiMatch = cliSource.match(/new\s+InteractiveMode\s*\(/) + + assert.ok(headlessMatch, 'headless subcommand handler exists') + assert.ok(tuiMatch, 'InteractiveMode TUI exists') + + const headlessPos = cliSource.indexOf(headlessMatch![0]) + const tuiPos = cliSource.indexOf(tuiMatch![0]) + assert.ok(headlessPos < tuiPos, 'headless handler is before TUI') +})