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 <noreply@anthropic.com>
This commit is contained in:
parent
81e303a483
commit
3ec96fd992
2 changed files with 194 additions and 2 deletions
24
src/cli.ts
24
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)
|
||||
}
|
||||
|
||||
|
|
|
|||
172
src/tests/auto-piped-io.test.ts
Normal file
172
src/tests/auto-piped-io.test.ts
Normal file
|
|
@ -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));
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue