From af3a737b81e0aff06fbf0d3aad270362c319fb8d Mon Sep 17 00:00:00 2001 From: Jesse Turner Date: Fri, 24 Apr 2026 17:45:42 +0000 Subject: [PATCH 1/2] fix: add TTY detection before TUI fallbacks to prevent agent/CI hangs When commands are invoked without flags in non-interactive environments (CI, piped stdin, agent automation), the CLI falls through to Ink TUI rendering which hangs indefinitely. Add a requireTTY() guard at every TUI entry point that checks process.stdout.isTTY and exits with a helpful error message directing users to --help for non-interactive flags. Closes #685 --- src/cli/cli.ts | 2 ++ src/cli/commands/add/command.tsx | 3 ++- src/cli/commands/create/command.tsx | 2 ++ src/cli/commands/deploy/command.tsx | 4 +++- src/cli/commands/dev/command.tsx | 3 ++- src/cli/commands/invoke/command.tsx | 3 ++- src/cli/commands/remove/command.tsx | 4 +++- src/cli/primitives/AgentPrimitive.tsx | 2 ++ src/cli/primitives/BasePrimitive.ts | 2 ++ src/cli/primitives/CredentialPrimitive.tsx | 2 ++ src/cli/primitives/EvaluatorPrimitive.ts | 2 ++ src/cli/primitives/GatewayPrimitive.ts | 2 ++ src/cli/primitives/GatewayTargetPrimitive.ts | 2 ++ src/cli/primitives/MemoryPrimitive.tsx | 2 ++ src/cli/primitives/OnlineEvalConfigPrimitive.ts | 2 ++ src/cli/primitives/PolicyEnginePrimitive.ts | 3 +++ src/cli/primitives/PolicyPrimitive.ts | 3 +++ src/cli/tui/guards/index.ts | 1 + src/cli/tui/guards/tty.ts | 10 ++++++++++ 19 files changed, 49 insertions(+), 5 deletions(-) create mode 100644 src/cli/tui/guards/tty.ts diff --git a/src/cli/cli.ts b/src/cli/cli.ts index 7b9eef67..72cf5c50 100644 --- a/src/cli/cli.ts +++ b/src/cli/cli.ts @@ -26,6 +26,7 @@ import { LayoutProvider } from './tui/context'; import { COMMAND_DESCRIPTIONS } from './tui/copy'; import { clearExitAction, getExitAction } from './tui/exit-action'; import { clearExitMessage, getExitMessage } from './tui/exit-message'; +import { requireTTY } from './tui/guards'; import { CommandListScreen } from './tui/screens/home'; import { getCommandsForUI } from './tui/utils'; import { type UpdateCheckResult, checkForUpdate, printUpdateNotification } from './update-notifier'; @@ -212,6 +213,7 @@ export const main = async (argv: string[]) => { // Show TUI for no arguments, commander handles --help via configureHelp() if (args.length === 0) { + requireTTY(); renderTUI(updateCheck, isFirstRun); return; } diff --git a/src/cli/commands/add/command.tsx b/src/cli/commands/add/command.tsx index a94d8658..93490830 100644 --- a/src/cli/commands/add/command.tsx +++ b/src/cli/commands/add/command.tsx @@ -1,5 +1,5 @@ import { COMMAND_DESCRIPTIONS } from '../../tui/copy'; -import { requireProject } from '../../tui/guards'; +import { requireProject, requireTTY } from '../../tui/guards'; import { AddFlow } from '../../tui/screens/add/AddFlow'; import type { Command } from '@commander-js/extra-typings'; import { render } from 'ink'; @@ -21,6 +21,7 @@ export function registerAdd(program: Command): Command { } requireProject(); + requireTTY(); const { clear, unmount } = render( { options.language = options.language ?? 'Python'; await handleCreateCLI(options as CreateOptions); } else { + requireTTY(); handleCreateTUI(); } } catch (error) { diff --git a/src/cli/commands/deploy/command.tsx b/src/cli/commands/deploy/command.tsx index 58fe8b25..5870c7ff 100644 --- a/src/cli/commands/deploy/command.tsx +++ b/src/cli/commands/deploy/command.tsx @@ -1,6 +1,6 @@ import { getErrorMessage } from '../../errors'; import { COMMAND_DESCRIPTIONS } from '../../tui/copy'; -import { requireProject } from '../../tui/guards'; +import { requireProject, requireTTY } from '../../tui/guards'; import { DeployScreen } from '../../tui/screens/deploy/DeployScreen'; import { handleDeploy } from './actions'; import type { DeployOptions } from './types'; @@ -160,8 +160,10 @@ export const registerDeploy = (program: Command) => { await handleDeployCLI(options as DeployOptions); } else if (cliOptions.diff) { // Diff-only: use TUI with diff mode + requireTTY(); handleDeployTUI({ diffMode: true }); } else { + requireTTY(); handleDeployTUI(); } } catch (error) { diff --git a/src/cli/commands/dev/command.tsx b/src/cli/commands/dev/command.tsx index fadcfc54..eac485d8 100644 --- a/src/cli/commands/dev/command.tsx +++ b/src/cli/commands/dev/command.tsx @@ -21,7 +21,7 @@ import { OtelCollector, startOtelCollector } from '../../operations/dev/otel'; import { FatalError } from '../../tui/components'; import { LayoutProvider } from '../../tui/context'; import { COMMAND_DESCRIPTIONS } from '../../tui/copy'; -import { requireProject } from '../../tui/guards'; +import { requireProject, requireTTY } from '../../tui/guards'; import { parseHeaderFlags } from '../shared/header-utils'; import { runBrowserMode } from './browser-mode'; import type { Command } from '@commander-js/extra-typings'; @@ -383,6 +383,7 @@ export const registerDev = (program: Command) => { // If --no-browser provided, launch terminal TUI mode if (!opts.browser) { + requireTTY(); // Enter alternate screen buffer for fullscreen mode process.stdout.write(ENTER_ALT_SCREEN); diff --git a/src/cli/commands/invoke/command.tsx b/src/cli/commands/invoke/command.tsx index 6243d90f..dc94a491 100644 --- a/src/cli/commands/invoke/command.tsx +++ b/src/cli/commands/invoke/command.tsx @@ -1,6 +1,6 @@ import { getErrorMessage } from '../../errors'; import { COMMAND_DESCRIPTIONS } from '../../tui/copy'; -import { requireProject } from '../../tui/guards'; +import { requireProject, requireTTY } from '../../tui/guards'; import { InvokeScreen } from '../../tui/screens/invoke'; import { parseHeaderFlags } from '../shared/header-utils'; import { handleInvoke, loadInvokeConfig } from './action'; @@ -168,6 +168,7 @@ export const registerInvoke = (program: Command) => { }); } else { // No CLI options - interactive TUI mode (headers still passed if provided) + requireTTY(); const { waitUntilExit, unmount } = render( { json: cliOptions.json, }); } else { + requireTTY(); const { unmount } = render( { } requireProject(); + requireTTY(); const { clear, unmount } = render( Date: Fri, 24 Apr 2026 17:52:34 +0000 Subject: [PATCH 2/2] fix: check both stdin and stdout isTTY in requireTTY guard The hang from #685 is caused by stdin not being a TTY (Ink reads keyboard input from stdin), not stdout. Check both stdin and stdout so the guard fires for piped stdin, redirected stdout, and CI environments where both are non-TTY. --- src/cli/tui/guards/tty.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/cli/tui/guards/tty.ts b/src/cli/tui/guards/tty.ts index f14775cf..9f51d0c7 100644 --- a/src/cli/tui/guards/tty.ts +++ b/src/cli/tui/guards/tty.ts @@ -1,9 +1,12 @@ /** * Guard that checks for an interactive terminal and exits if not found. * Prevents TUI flows from hanging in CI, piped stdin, or agent automation. + * + * Checks both stdin (Ink reads keyboard input) and stdout (Ink renders TUI output). + * Either being non-TTY means the TUI cannot function. */ export function requireTTY(): void { - if (!process.stdout.isTTY) { + if (!process.stdin.isTTY || !process.stdout.isTTY) { console.error('Error: This command requires an interactive terminal. Use --help to see non-interactive flags.'); process.exit(1); }