From 722dd04459bed6c2257a52fbc645ed5db69cbd1c Mon Sep 17 00:00:00 2001 From: lyrics <3531587877@qq.com> Date: Mon, 29 Jun 2026 00:12:25 +0800 Subject: [PATCH] feat(cli): add --headless flag for non-interactive run-and-exit mode Add --headless option that allows deepcode to run a prompt and exit automatically without entering the interactive TUI. This enables: - CI/CD pipeline integration - Shell scripting / automation - Batch processing Changes: - cli-args.ts: add --headless boolean flag and validation (requires --prompt) - cli.tsx: add runHeadless() function that creates SessionManager, submits prompt, streams output to stdout, and exits cleanly. In headless mode, permissions are forced to allowAll since there is no user to ask for approval. Closes #139 --- docs/quickstart.md | 13 ++++++ docs/quickstart_en.md | 13 ++++++ packages/cli/src/cli-args.ts | 13 +++++- packages/cli/src/cli.tsx | 76 +++++++++++++++++++++++++++++++++++- 4 files changed, 113 insertions(+), 2 deletions(-) diff --git a/docs/quickstart.md b/docs/quickstart.md index 8de1f3ce..bf571ad0 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -74,6 +74,19 @@ Deep Code 会在当前目录中启动交互式界面。你可以直接输入任 deepcode -p "总结这个项目" ``` +### 非交互模式(Headless) + +如果你需要在脚本、CI/CD 流水线或 Docker 容器中自动执行任务,可以使用 `--headless` 参数。该模式下 deepcode 不会启动交互界面,执行完 prompt 后自动退出并将结果输出到 stdout: + +```bash +deepcode --headless -p "总结这个项目" + +# 可与管道组合 +deepcode --headless -p "列出所有 API 接口" | grep "auth" +``` + +`--headless` 必须与 `-p` 搭配使用,且在该模式下所有权限操作均自动批准。 + ## 第一次可以这样问 可以先从只读任务开始: diff --git a/docs/quickstart_en.md b/docs/quickstart_en.md index 44f62692..d923504a 100644 --- a/docs/quickstart_en.md +++ b/docs/quickstart_en.md @@ -74,6 +74,19 @@ To start with an initial prompt: deepcode -p "Summarize this project" ``` +### Non-Interactive Mode (Headless) + +For scripts, CI/CD pipelines, or Docker containers where you need automatic execution, use the `--headless` flag. In this mode, deepcode skips the interactive TUI, runs the prompt, prints the result to stdout, and exits: + +```bash +deepcode --headless -p "Summarize this project" + +# Pipe with other tools +deepcode --headless -p "List all API endpoints" | grep "auth" +``` + +`--headless` requires `-p`, and all permission operations are auto-approved in this mode. + ## Try These First Start with a read-only task: diff --git a/packages/cli/src/cli-args.ts b/packages/cli/src/cli-args.ts index b86eda41..2977be91 100644 --- a/packages/cli/src/cli-args.ts +++ b/packages/cli/src/cli-args.ts @@ -33,6 +33,8 @@ export interface ParsedCliArgs { version: boolean; /** True when --help / -h was passed */ help: boolean; + /** True when --headless was passed (non-interactive, run-and-exit mode) */ + headless: boolean; } const EPILOG = [ @@ -73,7 +75,7 @@ async function configureYargs(argv?: string[]) { .locale("en") .scriptName("deepcode") .usage( - "Usage: $0 [options] [command]\n\nDeep Code - Launch an interactive CLI, use -p/--prompt for non-interactive mode" + "Usage: $0 [options] [command]\n\nDeep Code - Launch an interactive CLI, use -p/--prompt with --headless for non-interactive mode" ) .command("$0 [query..]", "Launch Deep Code CLI", (yargsInstance: Argv) => yargsInstance @@ -82,6 +84,10 @@ async function configureYargs(argv?: string[]) { type: "string", describe: "Submit a prompt on launch", }) + .option("headless", { + type: "boolean", + describe: "Run in non-interactive (headless) mode. Use with --prompt to execute and exit automatically.", + }) .option("resume", { alias: "r", type: "string", @@ -106,6 +112,10 @@ async function configureYargs(argv?: string[]) { if (argv["prompt"] === "") { return "--prompt / -p requires a non-empty value."; } + // headless mode requires --prompt + if (argv["headless"] && !argv["prompt"]) { + return "--headless mode requires --prompt / -p with a non-empty value."; + } return true; }) ) @@ -156,5 +166,6 @@ export async function parseArguments(argv?: string[]): Promise { resume, version: parsed.version === true, help: parsed.help === true, + headless: parsed.headless === true, }; } diff --git a/packages/cli/src/cli.tsx b/packages/cli/src/cli.tsx index 80b11f08..362472a0 100644 --- a/packages/cli/src/cli.tsx +++ b/packages/cli/src/cli.tsx @@ -3,7 +3,14 @@ import { render } from "ink"; import { readFileSync } from "node:fs"; import { join } from "node:path"; import { homedir } from "node:os"; -import { setShellIfWindows, getProjectCode } from "@vegamo/deepcode-core"; +import { + setShellIfWindows, + getProjectCode, + SessionManager, + createOpenAIClient, + resolveCurrentSettings, +} from "@vegamo/deepcode-core"; +import type { SessionMessage, LlmStreamProgress } from "@vegamo/deepcode-core"; import { checkForNpmUpdate, promptForPendingUpdate } from "./common/update-check"; import { AppContainer } from "./ui"; import { parseArguments } from "./cli-args"; @@ -32,6 +39,11 @@ async function main(): Promise { let resumeSessionId = parsed.resume; const projectRoot = process.cwd(); + if (parsed.headless && parsed.prompt) { + await runHeadless(parsed.prompt, projectRoot); + return; + } + if (!process.stdin.isTTY) { writeStderrLine("deepcode requires an interactive terminal (TTY). Re-run from a real terminal session.\n"); process.exit(1); @@ -115,3 +127,65 @@ function configureWindowsShell(): void { process.exit(1); } } + +/** + * Run in headless (non-interactive) mode. + * Creates a SessionManager, submits the prompt, outputs the final response, and exits. + */ +async function runHeadless(promptText: string, projectRoot: string): Promise { + process.stderr.write("[headless] Starting non-interactive mode...\n"); + + const assistantMessages: SessionMessage[] = []; + + const sessionManager = new SessionManager({ + projectRoot, + createOpenAIClient: () => createOpenAIClient(projectRoot), + getResolvedSettings: () => { + const settings = resolveCurrentSettings(projectRoot); + // Headless = no user to ask → auto-approve all permissions + return { + ...settings, + permissions: { + allow: [], + deny: [], + ask: [], + defaultMode: "allowAll" as const, + }, + }; + }, + renderMarkdown: (text: string) => text, + onAssistantMessage: (message: SessionMessage) => { + assistantMessages.push(message); + // Stream content to stdout as it arrives + if (message.content) { + process.stdout.write(message.content as string); + } + }, + onLlmStreamProgress: (progress: LlmStreamProgress) => { + if (progress.phase === "start") { + process.stderr.write("[headless] Model is thinking...\n"); + } else if (progress.phase === "end") { + process.stderr.write("[headless] Response complete.\n"); + } + }, + }); + + try { + // Initialize MCP servers from settings + const settings = resolveCurrentSettings(projectRoot); + await sessionManager.initMcpServers(settings.mcpServers); + + // Submit the prompt + await sessionManager.handleUserPrompt({ text: promptText }); + + process.stdout.write("\n"); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + process.stderr.write(`[headless] Error: ${message}\n`); + process.exit(1); + } finally { + sessionManager.dispose(); + } + + process.exit(0); +}