fix: route gsd auto to headless runner to prevent hang on piped stdin/stdout (#3057)
`gsd auto` was not handled as a subcommand — it fell through to the interactive TUI, which hangs indefinitely when stdin/stdout are piped (non-TTY). Add `auto` as a recognized subcommand that rewrites argv and delegates to `runHeadless(parseHeadlessArgs(...))`, matching the existing `gsd headless auto` behavior. Also adds `gsd auto` to TTY error hints and help text. Closes #2732 Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
df9e06cfa5
commit
05b7cb95cb
3 changed files with 126 additions and 0 deletions
19
src/cli.ts
19
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')
|
||||
|
|
|
|||
|
|
@ -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 <cmd> 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 <subcommand> --help for subcommand-specific help.\n')
|
||||
}
|
||||
|
|
|
|||
106
src/tests/auto-mode-piped.test.ts
Normal file
106
src/tests/auto-mode-piped.test.ts
Normal file
|
|
@ -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')
|
||||
})
|
||||
Loading…
Add table
Reference in a new issue