Skip to content

Commit c30ed54

Browse files
fix: add TTY detection before TUI fallbacks to prevent agent/CI hangs (#949)
* 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 * 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.
1 parent 3d2d671 commit c30ed54

19 files changed

Lines changed: 52 additions & 5 deletions

src/cli/cli.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import { LayoutProvider } from './tui/context';
2626
import { COMMAND_DESCRIPTIONS } from './tui/copy';
2727
import { clearExitAction, getExitAction } from './tui/exit-action';
2828
import { clearExitMessage, getExitMessage } from './tui/exit-message';
29+
import { requireTTY } from './tui/guards';
2930
import { CommandListScreen } from './tui/screens/home';
3031
import { getCommandsForUI } from './tui/utils';
3132
import { type UpdateCheckResult, checkForUpdate, printUpdateNotification } from './update-notifier';
@@ -212,6 +213,7 @@ export const main = async (argv: string[]) => {
212213

213214
// Show TUI for no arguments, commander handles --help via configureHelp()
214215
if (args.length === 0) {
216+
requireTTY();
215217
renderTUI(updateCheck, isFirstRun);
216218
return;
217219
}

src/cli/commands/add/command.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { COMMAND_DESCRIPTIONS } from '../../tui/copy';
2-
import { requireProject } from '../../tui/guards';
2+
import { requireProject, requireTTY } from '../../tui/guards';
33
import { AddFlow } from '../../tui/screens/add/AddFlow';
44
import type { Command } from '@commander-js/extra-typings';
55
import { render } from 'ink';
@@ -21,6 +21,7 @@ export function registerAdd(program: Command): Command {
2121
}
2222

2323
requireProject();
24+
requireTTY();
2425

2526
const { clear, unmount } = render(
2627
<AddFlow

src/cli/commands/create/command.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import type {
1010
import { LIFECYCLE_TIMEOUT_MAX, LIFECYCLE_TIMEOUT_MIN } from '../../../schema';
1111
import { getErrorMessage } from '../../errors';
1212
import { COMMAND_DESCRIPTIONS } from '../../tui/copy';
13+
import { requireTTY } from '../../tui/guards';
1314
import { CreateScreen } from '../../tui/screens/create';
1415
import { parseCommaSeparatedList } from '../shared/vpc-utils';
1516
import { type ProgressCallback, createProject, createProjectWithAgent, getDryRunInfo } from './action';
@@ -245,6 +246,7 @@ export const registerCreate = (program: Command) => {
245246
options.language = options.language ?? 'Python';
246247
await handleCreateCLI(options as CreateOptions);
247248
} else {
249+
requireTTY();
248250
handleCreateTUI();
249251
}
250252
} catch (error) {

src/cli/commands/deploy/command.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { getErrorMessage } from '../../errors';
22
import { COMMAND_DESCRIPTIONS } from '../../tui/copy';
3-
import { requireProject } from '../../tui/guards';
3+
import { requireProject, requireTTY } from '../../tui/guards';
44
import { DeployScreen } from '../../tui/screens/deploy/DeployScreen';
55
import { handleDeploy } from './actions';
66
import type { DeployOptions } from './types';
@@ -160,8 +160,10 @@ export const registerDeploy = (program: Command) => {
160160
await handleDeployCLI(options as DeployOptions);
161161
} else if (cliOptions.diff) {
162162
// Diff-only: use TUI with diff mode
163+
requireTTY();
163164
handleDeployTUI({ diffMode: true });
164165
} else {
166+
requireTTY();
165167
handleDeployTUI();
166168
}
167169
} catch (error) {

src/cli/commands/dev/command.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import { OtelCollector, startOtelCollector } from '../../operations/dev/otel';
2121
import { FatalError } from '../../tui/components';
2222
import { LayoutProvider } from '../../tui/context';
2323
import { COMMAND_DESCRIPTIONS } from '../../tui/copy';
24-
import { requireProject } from '../../tui/guards';
24+
import { requireProject, requireTTY } from '../../tui/guards';
2525
import { parseHeaderFlags } from '../shared/header-utils';
2626
import { runBrowserMode } from './browser-mode';
2727
import type { Command } from '@commander-js/extra-typings';
@@ -383,6 +383,7 @@ export const registerDev = (program: Command) => {
383383

384384
// If --no-browser provided, launch terminal TUI mode
385385
if (!opts.browser) {
386+
requireTTY();
386387
// Enter alternate screen buffer for fullscreen mode
387388
process.stdout.write(ENTER_ALT_SCREEN);
388389

src/cli/commands/invoke/command.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { getErrorMessage } from '../../errors';
22
import { COMMAND_DESCRIPTIONS } from '../../tui/copy';
3-
import { requireProject } from '../../tui/guards';
3+
import { requireProject, requireTTY } from '../../tui/guards';
44
import { InvokeScreen } from '../../tui/screens/invoke';
55
import { parseHeaderFlags } from '../shared/header-utils';
66
import { handleInvoke, loadInvokeConfig } from './action';
@@ -168,6 +168,7 @@ export const registerInvoke = (program: Command) => {
168168
});
169169
} else {
170170
// No CLI options - interactive TUI mode (headers still passed if provided)
171+
requireTTY();
171172
const { waitUntilExit, unmount } = render(
172173
<InvokeScreen
173174
isInteractive={true}

src/cli/commands/remove/command.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { ConfigIO } from '../../../lib';
22
import { getErrorMessage } from '../../errors';
33
import { COMMAND_DESCRIPTIONS } from '../../tui/copy';
4-
import { requireProject } from '../../tui/guards';
4+
import { requireProject, requireTTY } from '../../tui/guards';
55
import { RemoveAllScreen, RemoveFlow } from '../../tui/screens/remove';
66
import type { RemoveAllOptions, RemoveResult } from './types';
77
import { validateRemoveAllOptions } from './validate';
@@ -76,6 +76,7 @@ export const registerRemove = (program: Command): Command => {
7676
json: cliOptions.json,
7777
});
7878
} else {
79+
requireTTY();
7980
const { unmount } = render(
8081
<RemoveAllScreen
8182
isInteractive={false}
@@ -112,6 +113,7 @@ export const registerRemove = (program: Command): Command => {
112113
}
113114

114115
requireProject();
116+
requireTTY();
115117

116118
const { clear, unmount } = render(
117119
<RemoveFlow

src/cli/primitives/AgentPrimitive.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import { executeImportAgent } from '../operations/agent/import';
3535
import { setupPythonProject } from '../operations/python';
3636
import type { RemovalPreview, RemovalResult, SchemaChange } from '../operations/remove/types';
3737
import { createRenderer } from '../templates';
38+
import { requireTTY } from '../tui/guards/tty';
3839
import type { GenerateConfig, MemoryOption } from '../tui/screens/generate/types';
3940
import { BasePrimitive } from './BasePrimitive';
4041
import { CredentialPrimitive } from './CredentialPrimitive';
@@ -332,6 +333,7 @@ export class AgentPrimitive extends BasePrimitive<AddAgentOptions, RemovableReso
332333
process.exit(result.success ? 0 : 1);
333334
} else {
334335
// TUI fallback — dynamic imports to avoid pulling ink (async) into registry
336+
requireTTY();
335337
const [{ render }, { default: React }, { AddFlow }] = await Promise.all([
336338
import('ink'),
337339
import('react'),

src/cli/primitives/BasePrimitive.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { ConfigIO, findConfigRoot } from '../../lib';
22
import type { AgentCoreProjectSpec } from '../../schema';
33
import type { ResourceType } from '../commands/remove/types';
44
import { getErrorMessage } from '../errors';
5+
import { requireTTY } from '../tui/guards/tty';
56
import { SOURCE_CODE_NOTE } from './constants';
67
import type { AddResult, AddScreenComponent, RemovableResource, RemovalPreview, RemovalResult } from './types';
78
import type { Command } from '@commander-js/extra-typings';
@@ -133,6 +134,7 @@ export abstract class BasePrimitive<
133134
process.exit(result.success ? 0 : 1);
134135
} else {
135136
// TUI fallback — dynamic imports to avoid pulling ink (async) into registry
137+
requireTTY();
136138
const [{ render }, { default: React }, { RemoveFlow }] = await Promise.all([
137139
import('ink'),
138140
import('react'),

src/cli/primitives/CredentialPrimitive.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { CredentialSchema } from '../../schema';
44
import { validateAddCredentialOptions } from '../commands/add/validate';
55
import { getErrorMessage } from '../errors';
66
import type { RemovalPreview, RemovalResult, SchemaChange } from '../operations/remove/types';
7+
import { requireTTY } from '../tui/guards/tty';
78
import { BasePrimitive } from './BasePrimitive';
89
import { computeDefaultCredentialEnvVarName } from './credential-utils';
910
import type { AddResult, AddScreenComponent, RemovableResource } from './types';
@@ -339,6 +340,7 @@ export class CredentialPrimitive extends BasePrimitive<AddCredentialOptions, Rem
339340
process.exit(result.success ? 0 : 1);
340341
} else {
341342
// TUI fallback — dynamic imports to avoid pulling ink (async) into registry
343+
requireTTY();
342344
const [{ render }, { default: React }, { AddFlow }] = await Promise.all([
343345
import('ink'),
344346
import('react'),

0 commit comments

Comments
 (0)