diff --git a/.continue/prompts/sub-agent-background.md b/.continue/prompts/sub-agent-background.md new file mode 100644 index 00000000000..031d8490d68 --- /dev/null +++ b/.continue/prompts/sub-agent-background.md @@ -0,0 +1,11 @@ +--- +name: Sub Agent Background Prompt +description: Start a subagent using the continue cli in the background +invokable: true +--- + +# Continue Sub Agent Background Prompt + +Take the prompt provided by the user and using the terminal tool run the following command in the background: + +cn -p "{{prompt}}" diff --git a/.continue/prompts/sub-agent-foreground.md b/.continue/prompts/sub-agent-foreground.md new file mode 100644 index 00000000000..aa8c854b4da --- /dev/null +++ b/.continue/prompts/sub-agent-foreground.md @@ -0,0 +1,11 @@ +--- +name: Sub Agent Foreground Prompt +description: Start a subagent using the continue cli in the foreground +invokable: true +--- + +# Continue Sub Agent Foreground Prompt + +Take the prompt provided by the user and using the terminal tool run the following command in the foreground: + +cn -p "{{prompt}}" diff --git a/extensions/cli/README.md b/extensions/cli/README.md index 324d3d7be40..c700f294a32 100644 --- a/extensions/cli/README.md +++ b/extensions/cli/README.md @@ -18,10 +18,30 @@ cn ### Headless Mode +Headless mode (`-p` flag) runs without an interactive terminal UI, making it perfect for: + +- Scripts and automation +- CI/CD pipelines +- Docker containers +- VSCode/IntelliJ extension integration +- Environments without a TTY + ```bash +# Basic usage cn -p "Generate a conventional commit name for the current git changes." + +# With piped input +echo "Review this code" | cn -p + +# JSON output for scripting +cn -p "Analyze the code" --format json + +# Silent mode (strips thinking tags) +cn -p "Write a README" --silent ``` +**TTY-less Environments**: Headless mode is designed to work in environments without a terminal (TTY), such as when called from VSCode/IntelliJ extensions using terminal commands. The CLI will not attempt to read stdin or initialize the interactive UI when running in headless mode with a supplied prompt. + ### Session Management The CLI automatically saves your chat history for each terminal session. You can resume where you left off: @@ -47,6 +67,7 @@ cn ls --json ## Environment Variables - `CONTINUE_CLI_DISABLE_COMMIT_SIGNATURE`: Disable adding the Continue commit signature to generated commit messages +- `FORCE_NO_TTY`: Force TTY-less mode, prevents stdin reading (useful for testing and automation) ## Commands @@ -62,3 +83,26 @@ cn ls --json Shows recent sessions, limited by screen height to ensure it fits on your terminal. - `--json`: Output in JSON format for scripting (always shows 10 sessions) + +## TTY-less Support + +The CLI fully supports running in environments without a TTY (terminal): + +```bash +# From Docker without TTY allocation +docker run --rm my-image cn -p "Generate docs" + +# From CI/CD pipeline +cn -p "Review changes" --format json + +# From VSCode/IntelliJ extension terminal tool +cn -p "Analyze code" --silent +``` + +The CLI automatically detects TTY-less environments and adjusts its behavior: + +- Skips stdin reading when a prompt is supplied +- Disables interactive UI components +- Ensures clean stdout/stderr output + +For more details, see [`spec/tty-less-support.md`](./spec/tty-less-support.md). diff --git a/extensions/cli/spec/tty-less-support.md b/extensions/cli/spec/tty-less-support.md new file mode 100644 index 00000000000..312afdfe253 --- /dev/null +++ b/extensions/cli/spec/tty-less-support.md @@ -0,0 +1,239 @@ +# TTY-less Environment Support + +## Overview + +The Continue CLI supports running in TTY-less environments (environments without a terminal/TTY), which is essential for: + +- VSCode and IntelliJ extensions using the `run_terminal_command` tool +- Docker containers without TTY allocation +- CI/CD pipelines +- Automated scripts and tools +- Background processes + +## Architecture + +### Mode Separation + +The CLI has two distinct execution modes with complete separation: + +1. **Interactive Mode (TUI)**: Requires a TTY, uses Ink for rendering +2. **Headless Mode**: Works in TTY-less environments, outputs to stdout/stderr + +``` +┌─────────────────────────────────────────────────────────────┐ +│ CLI Entry Point │ +│ (src/index.ts) │ +└────────────────────────┬────────────────────────────────────┘ + │ + ┌────────────┴────────────┐ + │ │ + ┌───────▼────────┐ ┌───────▼─────────┐ + │ Interactive │ │ Headless │ + │ Mode (TUI) │ │ Mode (-p) │ + │ │ │ │ + │ • Requires TTY │ │ • No TTY needed │ + │ • Uses Ink │ │ • Stdin/stdout │ + │ • Keyboard UI │ │ • One-shot exec │ + └────────────────┘ └─────────────────┘ +``` + +### Safeguards Implemented + +#### 1. **TTY Detection Utilities** (`src/util/cli.ts`) + +```typescript +// Check if running in TTY-less environment +export function isTTYless(): boolean; + +// Check if environment supports interactive features +export function supportsInteractive(): boolean; + +// Check if prompt was supplied via CLI arguments +export function hasSuppliedPrompt(): boolean; +``` + +#### 2. **Stdin Reading Protection** (`src/util/stdin.ts`) + +Prevents stdin reading when: + +- In headless mode with supplied prompt +- `FORCE_NO_TTY` environment variable is set +- In test environments + +This avoids blocking/hanging in TTY-less environments where stdin is not available or not readable. + +#### 3. **TUI Initialization Guards** (`src/ui/index.ts`) + +The `startTUIChat()` function now includes multiple safeguards: + +- **Headless mode check**: Throws error if called in headless mode +- **TTY-less check**: Throws error if no TTY is available +- **Raw mode test**: Validates stdin supports raw mode (required by Ink) +- **Explicit stdin/stdout**: Passes streams explicitly to Ink + +```typescript +// Critical safeguard: Prevent TUI in headless mode +if (isHeadlessMode()) { + throw new Error("Cannot start TUI in headless mode"); +} + +// Critical safeguard: Prevent TUI in TTY-less environment +if (isTTYless() && !customStdin) { + throw new Error("Cannot start TUI in TTY-less environment"); +} +``` + +#### 4. **Headless Mode Validation** (`src/commands/chat.ts`) + +Ensures headless mode has all required inputs: + +```typescript +if (!prompt) { + throw new Error("Headless mode requires a prompt"); +} +``` + +#### 5. **Logger Configuration** (`src/util/logger.ts`) + +Configures output handling for TTY-less environments: + +- Sets UTF-8 encoding +- Leaves stdout/stderr buffering unchanged in headless mode. +- Disables progress indicators + +## Usage Examples + +### From VSCode/IntelliJ Extension + +```typescript +// Using the run_terminal_command tool +const command = 'cn -p "Analyze the current git diff"'; +const result = await runTerminalCommand(command); +``` + +### From Docker Container + +```bash +# Without TTY allocation (-t flag) +docker run --rm my-image cn -p "Generate a README" +``` + +### From CI/CD Pipeline + +```yaml +- name: Run Continue CLI + run: | + cn -p "Review code changes" --format json +``` + +### From Automated Script + +```bash +#!/bin/bash +# Non-interactive script +cn -p "Generate commit message for current changes" --silent +``` + +## Environment Variables + +- `FORCE_NO_TTY`: Forces TTY-less mode, prevents stdin reading +- `CONTINUE_CLI_TEST`: Marks test environment, prevents stdin reading + +## Testing + +### TTY-less Test + +```typescript +const result = await runCLI(context, { + args: ["-p", "Hello, world!"], + env: { + FORCE_NO_TTY: "true", + }, +}); +``` + +### Expected Behavior + +- ✅ Should not hang on stdin +- ✅ Should not attempt to initialize Ink +- ✅ Should output results to stdout +- ✅ Should exit cleanly + +## Error Messages + +### Attempting TUI in TTY-less Environment + +``` +Error: Cannot start TUI in TTY-less environment. No TTY available for interactive mode. +For non-interactive use, run with -p flag: + cn -p "your prompt here" +``` + +### Missing Prompt in Headless Mode + +``` +Error: A prompt is required when using the -p/--print flag, unless --prompt or --agent is provided. + +Usage examples: + cn -p "please review my current git diff" + echo "hello" | cn -p + cn -p "analyze the code in src/" + cn -p --agent my-org/my-agent +``` + +## Troubleshooting + +### CLI Hangs in Docker/CI + +**Cause**: CLI attempting to read stdin in TTY-less environment + +**Solution**: Ensure using `-p` flag with a prompt: + +```bash +cn -p "your prompt" --config config.yaml +``` + +### "Cannot start TUI" Error + +**Cause**: Attempting interactive mode in TTY-less environment + +**Solution**: Use headless mode: + +```bash +cn -p "your prompt" +``` + +### Raw Mode Error + +**Cause**: Terminal doesn't support raw mode (required by Ink) + +**Solution**: Use headless mode instead of interactive mode + +## Design Principles + +1. **Fail Fast**: Detect environment early and fail with clear messages +2. **Explicit Separation**: No code path should allow Ink to load in headless mode +3. **No Blocking**: Never block on stdin in TTY-less environments +4. **Clear Errors**: Provide actionable error messages with examples +5. **Testing**: Comprehensive tests for TTY-less scenarios + +## Implementation Checklist + +- [x] Add TTY detection utilities +- [x] Protect stdin reading in headless mode +- [x] Guard TUI initialization +- [x] Validate headless mode inputs +- [x] Configure logger for TTY-less output +- [x] Update test helpers +- [x] Add TTY-less tests +- [x] Document TTY-less support + +## Related Files + +- `src/util/cli.ts` - TTY detection utilities +- `src/util/stdin.ts` - Stdin reading protection +- `src/ui/index.ts` - TUI initialization guards +- `src/commands/chat.ts` - Mode routing and validation +- `src/util/logger.ts` - Output configuration +- `src/test-helpers/cli-helpers.ts` - Test support +- `src/e2e/headless-minimal.test.ts` - TTY-less tests diff --git a/extensions/cli/src/commands/chat.ts b/extensions/cli/src/commands/chat.ts index 4b7a998a941..71c1d53a446 100644 --- a/extensions/cli/src/commands/chat.ts +++ b/extensions/cli/src/commands/chat.ts @@ -492,6 +492,27 @@ async function runHeadlessMode( initialPrompt, ); + // Critical validation: Ensure we have actual prompt text in headless mode + // This prevents the CLI from hanging in TTY-less environments when question() is called + // We check AFTER processing all prompts (including agent files) to ensure we have real content + // EXCEPTION: Allow empty prompts when resuming/forking since they may just want to view history + if (!initialUserInput || !initialUserInput.trim()) { + // If resuming or forking, allow empty prompt - just exit successfully after showing history + if (options.resume || options.fork) { + // For resume/fork with no new input, we've already loaded the history above + // Just exit successfully (the history was already loaded into chatHistory) + await gracefulExit(0); + return; + } + + throw new Error( + 'Headless mode requires a prompt. Use: cn -p "your prompt"\n' + + 'Or pipe input: echo "prompt" | cn -p\n' + + "Or use agent files: cn -p --agent my-org/my-agent\n" + + "Note: Agent files must contain a prompt field.", + ); + } + let isFirstMessage = true; while (true) { // When in headless mode, don't ask for user input @@ -544,6 +565,15 @@ export async function chat(prompt?: string, options: ChatOptions = {}) { // Start active time tracking telemetryService.startActiveTime(); + // Critical routing: Explicit separation of headless and interactive modes + if (options.headless) { + // Headless path - no Ink, no TUI, works in TTY-less environments + logger.debug("Running in headless mode (TTY-less compatible)"); + await runHeadlessMode(prompt, options); + return; + } + + // Interactive path - requires TTY for Ink rendering // If not in headless mode, use unified initialization with TUI if (!options.headless) { // Process flags for TUI mode diff --git a/extensions/cli/src/e2e/headless-minimal.test.ts b/extensions/cli/src/e2e/headless-minimal.test.ts index e799c2ad0e0..c2e4727c6e5 100644 --- a/extensions/cli/src/e2e/headless-minimal.test.ts +++ b/extensions/cli/src/e2e/headless-minimal.test.ts @@ -39,4 +39,42 @@ provider: openai`, // The fact that it gets past config loading is what we're testing expect(result.exitCode).toBeDefined(); }); + + it("should run in TTY-less environment with supplied prompt", async () => { + // Create a minimal YAML config + const configPath = path.join(context.testDir, "test-config.yaml"); + await fs.writeFile( + configPath, + `name: Test Assistant +model: gpt-4 +provider: openai`, + ); + + const result = await runCLI(context, { + args: ["-p", "--config", configPath, "Hello, world!"], + env: { + OPENAI_API_KEY: "test-key", + // Simulate TTY-less environment (like Docker, CI, or VSCode terminal tool) + FORCE_NO_TTY: "true", + }, + expectError: true, // Will fail without proper LLM setup, but that's okay + }); + + // The key test is that it doesn't hang or crash due to TTY issues + expect(result.exitCode).toBeDefined(); + // Should not contain TTY-related error messages + expect(result.stderr).not.toMatch(/Cannot start TUI/); + expect(result.stderr).not.toMatch(/raw mode/); + }); + + it("should fail gracefully without a prompt in headless mode", async () => { + const result = await runCLI(context, { + args: ["-p"], + env: { FORCE_NO_TTY: "true" }, + expectError: true, + }); + + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toMatch(/prompt is required|Usage/); + }); }); diff --git a/extensions/cli/src/e2e/resume-flag.test.ts b/extensions/cli/src/e2e/resume-flag.test.ts index 910890b7d40..e18969c205a 100644 --- a/extensions/cli/src/e2e/resume-flag.test.ts +++ b/extensions/cli/src/e2e/resume-flag.test.ts @@ -7,8 +7,8 @@ import { runCLI, } from "../test-helpers/cli-helpers.js"; import { - setupMockLLMTest, cleanupMockLLMServer, + setupMockLLMTest, type MockLLMServer, } from "../test-helpers/mock-llm-server.js"; @@ -80,8 +80,9 @@ describe("E2E: Resume Flag", () => { expect(assistantMessage?.message?.content).toBe("Hello! Nice to meet you."); // Now run with --resume flag using the same session ID + // Use -p flag to run in headless mode (tests don't have TTY) const resumeResult = await runCLI(context, { - args: ["--resume", "--config", context.configPath], + args: ["-p", "--resume", "--config", context.configPath], env: { CONTINUE_CLI_TEST_SESSION_ID: "test-session-123", CONTINUE_GLOBAL_DIR: path.join(context.testDir, ".continue"), @@ -98,8 +99,9 @@ describe("E2E: Resume Flag", () => { it("should handle --resume when no previous session exists", async () => { // Try to resume without any previous session + // Use -p flag to run in headless mode (tests don't have TTY) const result = await runCLI(context, { - args: ["--resume", "--config", context.configPath], + args: ["-p", "--resume", "--config", context.configPath], env: { CONTINUE_CLI_TEST_SESSION_ID: "no-session-456", CONTINUE_GLOBAL_DIR: path.join(context.testDir, ".continue"), diff --git a/extensions/cli/src/index.ts b/extensions/cli/src/index.ts index b1aa39c2830..4735f416fab 100644 --- a/extensions/cli/src/index.ts +++ b/extensions/cli/src/index.ts @@ -216,11 +216,11 @@ addCommonOptions(program) } } - // In headless mode, ensure we have a prompt unless using --agent flag - // Agent files can provide their own prompts - if (options.print && !prompt && !options.agent) { + // In headless mode, ensure we have a prompt unless using --agent flag or --resume flag + // Agent files can provide their own prompts, and resume can work without new input + if (options.print && !prompt && !options.agent && !options.resume) { safeStderr( - "Error: A prompt is required when using the -p/--print flag, unless --prompt or --agent is provided.\n\n", + "Error: A prompt is required when using the -p/--print flag, unless --prompt, --agent, or --resume is provided.\n\n", ); safeStderr("Usage examples:\n"); safeStderr(' cn -p "please review my current git diff"\n'); @@ -228,6 +228,7 @@ addCommonOptions(program) safeStderr(' cn -p "analyze the code in src/"\n'); safeStderr(" cn -p --agent my-org/my-agent\n"); safeStderr(" cn -p --prompt my-org/my-prompt\n"); + safeStderr(" cn -p --resume\n"); await gracefulExit(1); } diff --git a/extensions/cli/src/test-helpers/cli-helpers.ts b/extensions/cli/src/test-helpers/cli-helpers.ts index 1814c671c4c..fdd410adc0a 100644 --- a/extensions/cli/src/test-helpers/cli-helpers.ts +++ b/extensions/cli/src/test-helpers/cli-helpers.ts @@ -77,6 +77,9 @@ export async function runCLI( expectError = false, } = options; + // Detect if this is a headless mode test (has -p flag) + const isHeadlessTest = args.includes("-p") || args.includes("--prompt"); + const execOptions = { cwd: context.testDir, env: { @@ -90,6 +93,8 @@ export async function runCLI( path.parse(context.testDir).root, context.testDir, ), + // Mark as TTY-less for headless tests to prevent stdin reading + ...(isHeadlessTest ? { FORCE_NO_TTY: "true" } : {}), ...env, }, timeout, diff --git a/extensions/cli/src/ui/index.ts b/extensions/cli/src/ui/index.ts index 9f5a3ec9ecc..45e81e7abb9 100644 --- a/extensions/cli/src/ui/index.ts +++ b/extensions/cli/src/ui/index.ts @@ -5,6 +5,8 @@ import { enableSigintHandler, setTUIUnmount } from "../index.js"; import { PermissionMode } from "../permissions/types.js"; import { initializeServices } from "../services/index.js"; import { ServiceContainerProvider } from "../services/ServiceContainerContext.js"; +import { isHeadlessMode, isTTYless } from "../util/cli.js"; +import { logger } from "../util/logger.js"; import { AppRoot } from "./AppRoot.js"; @@ -43,6 +45,23 @@ export async function startTUIChat( customStdin, } = options; + // Critical safeguard: Prevent TUI initialization in headless mode + if (isHeadlessMode()) { + throw new Error( + "Cannot start TUI in headless mode. This is a programming error - " + + "startTUIChat should not be called when -p/--print flag is used.", + ); + } + + // Critical safeguard: Prevent TUI initialization in TTY-less environment + if (isTTYless() && !customStdin) { + throw new Error( + "Cannot start TUI in TTY-less environment. No TTY available for interactive mode.\n" + + "For non-interactive use, run with -p flag:\n" + + ' cn -p "your prompt here"', + ); + } + // Initialize services only if not already done (skipOnboarding means already initialized) if (!skipOnboarding) { await initializeServices({ @@ -52,15 +71,36 @@ export async function startTUIChat( }); } - // Use static imports since we're always loading TUI when there's piped input + // Validate stdin is available and suitable for Ink + const stdinToUse = customStdin || process.stdin; + + // Test raw mode capability (required by Ink) + if ( + stdinToUse.isTTY && + typeof (stdinToUse as any).setRawMode === "function" + ) { + try { + // Test that we can enter raw mode (Ink requirement) + (stdinToUse as any).setRawMode(true); + (stdinToUse as any).setRawMode(false); + logger.debug("Raw mode test passed - TTY is suitable for Ink"); + } catch { + throw new Error( + "Terminal does not support raw mode required for interactive UI.\n" + + 'Use -p flag for headless mode: cn -p "your prompt"', + ); + } + } else if (!customStdin) { + logger.warn("stdin is not a TTY or does not support setRawMode"); + } // Start the TUI immediately - it will handle loading states const renderOptions: RenderOptions = { exitOnCtrlC: false, // Disable Ink's default Ctrl+C handling so we can implement two-stage exit + stdin: stdinToUse, + stdout: process.stdout, + stderr: process.stderr, }; - if (customStdin) { - renderOptions.stdin = customStdin; - } const { unmount } = render( React.createElement(ServiceContainerProvider, { @@ -89,6 +129,29 @@ export async function startRemoteTUIChat( remoteUrl: string, initialPrompt?: string, ) { + // Critical safeguard: Prevent TUI initialization in TTY-less environment + if (isTTYless()) { + throw new Error( + "Cannot start remote TUI in TTY-less environment. No TTY available for interactive mode.", + ); + } + + // Test raw mode capability for remote TUI + if ( + process.stdin.isTTY && + typeof (process.stdin as any).setRawMode === "function" + ) { + try { + (process.stdin as any).setRawMode(true); + (process.stdin as any).setRawMode(false); + logger.debug("Raw mode test passed for remote TUI"); + } catch { + throw new Error( + "Terminal does not support raw mode required for interactive UI.", + ); + } + } + // Start the TUI in remote mode - no services needed const { unmount } = render( React.createElement(ServiceContainerProvider, { @@ -97,6 +160,11 @@ export async function startRemoteTUIChat( initialPrompt, }), }), + { + stdin: process.stdin, + stdout: process.stdout, + stderr: process.stderr, + }, ); // Register unmount function with main process for two-stage Ctrl+C exit diff --git a/extensions/cli/src/util/cli.test.ts b/extensions/cli/src/util/cli.test.ts new file mode 100644 index 00000000000..eebef662f5c --- /dev/null +++ b/extensions/cli/src/util/cli.test.ts @@ -0,0 +1,130 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { hasSuppliedPrompt, isHeadlessMode, isServe } from "./cli.js"; + +describe("CLI utility functions", () => { + let originalArgv: string[]; + + beforeEach(() => { + originalArgv = process.argv; + }); + + afterEach(() => { + process.argv = originalArgv; + }); + + describe("isHeadlessMode", () => { + it("should return true when -p flag is present", () => { + process.argv = ["node", "script.js", "-p", "test prompt"]; + expect(isHeadlessMode()).toBe(true); + }); + + it("should return true when --print flag is present", () => { + process.argv = ["node", "script.js", "--print", "test prompt"]; + expect(isHeadlessMode()).toBe(true); + }); + + it("should return false when no print flag is present", () => { + process.argv = ["node", "script.js", "other", "args"]; + expect(isHeadlessMode()).toBe(false); + }); + }); + + describe("isServe", () => { + it("should return true when serve command is present", () => { + process.argv = ["node", "script.js", "serve"]; + expect(isServe()).toBe(true); + }); + + it("should return false when serve command is not present", () => { + process.argv = ["node", "script.js", "-p", "test"]; + expect(isServe()).toBe(false); + }); + }); + + describe("hasSuppliedPrompt", () => { + it("should return true when prompt immediately follows -p", () => { + process.argv = ["node", "script.js", "-p", "test prompt"]; + expect(hasSuppliedPrompt()).toBe(true); + }); + + it("should return true when prompt immediately follows --print", () => { + process.argv = ["node", "script.js", "--print", "test prompt"]; + expect(hasSuppliedPrompt()).toBe(true); + }); + + it("should return true when prompt follows other flags after -p", () => { + // This is the bug fix - handles cases like: cn -p --config my.yaml "Prompt" + process.argv = [ + "node", + "script.js", + "-p", + "--config", + "my.yaml", + "test prompt", + ]; + expect(hasSuppliedPrompt()).toBe(true); + }); + + it("should return true when prompt follows multiple flags after --print", () => { + process.argv = [ + "node", + "script.js", + "--print", + "--config", + "my.yaml", + "--model", + "gpt-4", + "test prompt", + ]; + expect(hasSuppliedPrompt()).toBe(true); + }); + + it("should return false when only flags and their values follow -p", () => { + process.argv = ["node", "script.js", "-p", "--config", "my.yaml"]; + expect(hasSuppliedPrompt()).toBe(false); + }); + + it("should return false when only unknown flags follow -p", () => { + process.argv = ["node", "script.js", "-p", "--some-flag"]; + expect(hasSuppliedPrompt()).toBe(false); + }); + + it("should return false when no -p or --print flag is present", () => { + process.argv = ["node", "script.js", "test prompt"]; + expect(hasSuppliedPrompt()).toBe(false); + }); + + it("should return true when --prompt flag is present", () => { + process.argv = ["node", "script.js", "-p", "--prompt"]; + expect(hasSuppliedPrompt()).toBe(true); + }); + + it("should return false when --agent flag is present (agent slug is not a prompt)", () => { + process.argv = ["node", "script.js", "-p", "--agent", "my-agent"]; + expect(hasSuppliedPrompt()).toBe(false); + }); + + it("should return false when -p is last argument with no prompt", () => { + process.argv = ["node", "script.js", "-p"]; + expect(hasSuppliedPrompt()).toBe(false); + }); + + it("should handle quoted prompts with flags in between", () => { + process.argv = [ + "node", + "script.js", + "-p", + "--config", + "my.yaml", + "Explain this code", + ]; + expect(hasSuppliedPrompt()).toBe(true); + }); + + it("should return false when prompt appears before -p flag", () => { + process.argv = ["node", "script.js", "test prompt", "-p"]; + expect(hasSuppliedPrompt()).toBe(false); + }); + }); +}); diff --git a/extensions/cli/src/util/cli.ts b/extensions/cli/src/util/cli.ts index bb23ea71f21..5132fd4c108 100644 --- a/extensions/cli/src/util/cli.ts +++ b/extensions/cli/src/util/cli.ts @@ -13,3 +13,80 @@ export function isHeadlessMode(): boolean { export function isServe(): boolean { return process.argv?.includes("serve") ?? false; } + +/** + * Check if running in a TTY-less environment + * Returns true if stdin, stdout, and stderr are all not TTYs + */ +export function isTTYless(): boolean { + return ( + process.stdin.isTTY !== true && + process.stdout.isTTY !== true && + process.stderr.isTTY !== true + ); +} + +/** + * Check if environment supports interactive features (TUI) + * Returns false if in headless mode or TTY-less environment + */ +export function supportsInteractive(): boolean { + return !isTTYless() && !isHeadlessMode(); +} + +/** + * Check if a prompt was supplied via CLI arguments + * Used to determine if stdin reading should be skipped + */ +export function hasSuppliedPrompt(): boolean { + const args = process.argv.slice(2); + const printIndex = args.findIndex((arg) => arg === "-p" || arg === "--print"); + + if (printIndex === -1) { + return false; + } + + // Check if --prompt flag is present (indicates a prompt is coming) + // Note: --agent doesn't supply a prompt, it just specifies which agent to use + // Piped stdin should still be read when --agent is present + const hasPromptFlag = args.includes("--prompt"); + if (hasPromptFlag) { + return true; + } + + // Check if there's a non-flag argument after -p/--print + // We need to skip flags that take values (like --config, --model, etc.) + // Known flags that take values + const flagsWithValues = new Set([ + "--config", + "--model", + "--output", + "--mode", + "--workflow", + "--agent", + "-m", + "-c", + "-o", + ]); + + const argsAfterPrint = args.slice(printIndex + 1); + for (let i = 0; i < argsAfterPrint.length; i++) { + const arg = argsAfterPrint[i]; + + // If this is a flag that takes a value, skip both the flag and its value + if (flagsWithValues.has(arg)) { + i++; // Skip the next argument (the value) + continue; + } + + // If this is any other flag (starts with -), skip it + if (arg.startsWith("-")) { + continue; + } + + // Found a non-flag argument - this is the prompt + return true; + } + + return false; +} diff --git a/extensions/cli/src/util/logger.ts b/extensions/cli/src/util/logger.ts index 9ff6964f8af..8f32e67fe63 100644 --- a/extensions/cli/src/util/logger.ts +++ b/extensions/cli/src/util/logger.ts @@ -84,6 +84,7 @@ const logFormat = printf( // Track headless mode let isHeadlessMode = false; +let isTTYlessEnvironment = false; // Create the winstonLogger instance const winstonLogger = winston.createLogger({ @@ -111,6 +112,23 @@ export function setLogLevel(level: string) { // Function to configure headless mode export function configureHeadlessMode(headless: boolean) { isHeadlessMode = headless; + + // Detect TTY-less environment + isTTYlessEnvironment = + process.stdin.isTTY !== true && + process.stdout.isTTY !== true && + process.stderr.isTTY !== true; + + // In TTY-less environments with headless mode, ensure output is line-buffered + if (headless && isTTYlessEnvironment) { + // Set encoding for consistent output + if (process.stdout.setDefaultEncoding) { + process.stdout.setDefaultEncoding("utf8"); + } + if (process.stderr.setDefaultEncoding) { + process.stderr.setDefaultEncoding("utf8"); + } + } } // Export winstonLogger methods diff --git a/extensions/cli/src/util/stdin.ts b/extensions/cli/src/util/stdin.ts index 9edc22f9578..5abd648bda1 100644 --- a/extensions/cli/src/util/stdin.ts +++ b/extensions/cli/src/util/stdin.ts @@ -1,5 +1,7 @@ import * as fs from "fs"; +import { hasSuppliedPrompt, isHeadlessMode } from "./cli.js"; + /** * Reads input from stdin if available (when data is piped in) * Returns null if stdin is a TTY (interactive terminal) or if no data available @@ -17,11 +19,24 @@ export function readStdinSync(): string | null { return null; } + // Skip stdin reading in headless mode when a prompt is supplied + // This prevents blocking on TTY-less environments (like VSCode/IntelliJ terminal tools) + if (isHeadlessMode() && hasSuppliedPrompt()) { + return null; + } + // Special handling for CI environments - allow reading if stdin is clearly not a TTY if (process.env.CI === "true" && process.stdin.isTTY === true) { return null; } + // In TTY-less environments (Docker, CI, VSCode/IntelliJ terminal tools), + // attempting to read stdin can hang or fail + // Only attempt to read if we're confident there's piped input + if (process.env.FORCE_NO_TTY === "true") { + return null; + } + // Check if stdin is a TTY (interactive terminal) if (process.stdin.isTTY === true) { // Definitely a TTY, don't read