diff --git a/apps/cli/README.md b/apps/cli/README.md index 0e49140b91d..62b03e5cd88 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -100,6 +100,24 @@ In approval-required mode: - Tool, command, browser, and MCP actions prompt for yes/no approval - Followup questions wait for manual input (no auto-timeout) +### Print Mode (`--print`) + +Use `--print` for non-interactive execution and machine-readable output: + +```bash +# Prompt is required +roo --print "Summarize this repository" +``` + +### Stdin Stream Mode (`--stdin-prompt-stream`) + +For programmatic control (one process, multiple prompts), use `--stdin-prompt-stream` with `--print`. +Send one prompt per line via stdin: + +```bash +printf '1+1=?\n10!=?\n' | roo --print --stdin-prompt-stream --output-format stream-json +``` + ### Roo Code Cloud Authentication To use Roo Code Cloud features (like the provider proxy), you need to authenticate: @@ -152,6 +170,7 @@ Tokens are valid for 90 days. The CLI will prompt you to re-authenticate when yo | `--prompt-file ` | Read prompt from a file instead of command line argument | None | | `-w, --workspace ` | Workspace path to operate in | Current directory | | `-p, --print` | Print response and exit (non-interactive mode) | `false` | +| `--stdin-prompt-stream` | Read prompts from stdin (one prompt per line, requires `--print`) | `false` | | `-e, --extension ` | Path to the extension bundle directory | Auto-detected | | `-d, --debug` | Enable debug output (includes detailed debug information, prompts, paths, etc) | `false` | | `-a, --require-approval` | Require manual approval before actions execute | `false` | diff --git a/apps/cli/src/agent/json-event-emitter.ts b/apps/cli/src/agent/json-event-emitter.ts index a1a404e5556..4f305aa6bdf 100644 --- a/apps/cli/src/agent/json-event-emitter.ts +++ b/apps/cli/src/agent/json-event-emitter.ts @@ -93,6 +93,8 @@ export class JsonEventEmitter { private previousContent = new Map() // Track the completion result content private completionResultContent: string | undefined + // The first non-partial "say:text" per task is the echoed user prompt. + private expectPromptEchoAsUser = true constructor(options: JsonEventEmitterOptions) { this.mode = options.mode @@ -227,7 +229,14 @@ export class JsonEventEmitter { private handleSayMessage(msg: ClineMessage, contentToSend: string | null, isDone: boolean): void { switch (msg.say) { case "text": - this.emitEvent(this.buildTextEvent("assistant", msg.ts, contentToSend, isDone)) + if (this.expectPromptEchoAsUser) { + this.emitEvent(this.buildTextEvent("user", msg.ts, contentToSend, isDone)) + if (isDone) { + this.expectPromptEchoAsUser = false + } + } else { + this.emitEvent(this.buildTextEvent("assistant", msg.ts, contentToSend, isDone)) + } break case "reasoning": @@ -396,6 +405,9 @@ export class JsonEventEmitter { if (this.mode === "json") { this.outputFinalResult(event.success, resultContent) } + + // Next task in the same process starts with a new echoed prompt. + this.expectPromptEchoAsUser = true } /** @@ -460,5 +472,6 @@ export class JsonEventEmitter { this.seenMessageIds.clear() this.previousContent.clear() this.completionResultContent = undefined + this.expectPromptEchoAsUser = true } } diff --git a/apps/cli/src/commands/cli/run.ts b/apps/cli/src/commands/cli/run.ts index 1ce2f4a1f11..c7a01450a48 100644 --- a/apps/cli/src/commands/cli/run.ts +++ b/apps/cli/src/commands/cli/run.ts @@ -1,5 +1,6 @@ import fs from "fs" import path from "path" +import { createInterface } from "readline" import { fileURLToPath } from "url" import { createElement } from "react" @@ -30,6 +31,24 @@ import { ExtensionHost, ExtensionHostOptions } from "@/agent/index.js" const __dirname = path.dirname(fileURLToPath(import.meta.url)) +async function* readPromptsFromStdinLines(): AsyncGenerator { + const lineReader = createInterface({ + input: process.stdin, + crlfDelay: Infinity, + terminal: false, + }) + + try { + for await (const line of lineReader) { + if (line.trim()) { + yield line + } + } + } finally { + lineReader.close() + } +} + export async function run(promptArg: string | undefined, flagOptions: FlagOptions) { setLogger({ info: () => {}, @@ -185,15 +204,42 @@ export async function run(promptArg: string | undefined, flagOptions: FlagOption // Output format only works with --print mode if (outputFormat !== "text" && !flagOptions.print && isTuiSupported) { console.error("[CLI] Error: --output-format requires --print mode") - console.error("[CLI] Usage: roo --print --output-format json") + console.error("[CLI] Usage: roo --print --output-format json") process.exit(1) } + if (flagOptions.stdinPromptStream && !flagOptions.print) { + console.error("[CLI] Error: --stdin-prompt-stream requires --print mode") + console.error("[CLI] Usage: roo --print --stdin-prompt-stream [options]") + process.exit(1) + } + + if (flagOptions.stdinPromptStream && process.stdin.isTTY) { + console.error("[CLI] Error: --stdin-prompt-stream requires piped stdin") + console.error("[CLI] Example: printf '1+1=?\\n10!=?\\n' | roo --print --stdin-prompt-stream [options]") + process.exit(1) + } + + if (flagOptions.stdinPromptStream && prompt) { + console.error("[CLI] Error: cannot use positional prompt or --prompt-file with --stdin-prompt-stream") + console.error("[CLI] Usage: roo --print --stdin-prompt-stream [options]") + process.exit(1) + } + + const useStdinPromptStream = flagOptions.stdinPromptStream + if (!isTuiEnabled) { - if (!prompt) { - console.error("[CLI] Error: prompt is required in print mode") - console.error("[CLI] Usage: roo --print [options]") - console.error("[CLI] Run without -p for interactive mode") + if (!prompt && !useStdinPromptStream) { + if (flagOptions.print) { + console.error("[CLI] Error: no prompt provided") + console.error("[CLI] Usage: roo --print [options] ") + console.error("[CLI] For stdin control mode: roo --print --stdin-prompt-stream [options]") + } else { + console.error("[CLI] Error: prompt is required in non-interactive mode") + console.error("[CLI] Usage: roo [options]") + console.error("[CLI] Run without -p for interactive mode") + } + process.exit(1) } @@ -258,7 +304,22 @@ export async function run(promptArg: string | undefined, flagOptions: FlagOption jsonEmitter.attachToClient(host.client) } - await host.runTask(prompt!) + if (useStdinPromptStream) { + let hasReceivedStdinPrompt = false + + for await (const stdinPrompt of readPromptsFromStdinLines()) { + hasReceivedStdinPrompt = true + await host.runTask(stdinPrompt) + jsonEmitter?.clear() + } + + if (!hasReceivedStdinPrompt) { + throw new Error("no prompt provided via stdin") + } + } else { + await host.runTask(prompt!) + } + jsonEmitter?.detach() await host.dispose() process.exit(0) diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index a1fd1be89e1..6eaab059879 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -16,6 +16,7 @@ program .option("--prompt-file ", "Read prompt from a file instead of command line argument") .option("-w, --workspace ", "Workspace directory path (defaults to current working directory)") .option("-p, --print", "Print response and exit (non-interactive mode)", false) + .option("--stdin-prompt-stream", "Read prompts from stdin (one prompt per line, requires --print)", false) .option("-e, --extension ", "Path to the extension bundle directory") .option("-d, --debug", "Enable debug output (includes detailed debug information)", false) .option("-a, --require-approval", "Require manual approval for actions", false) diff --git a/apps/cli/src/types/types.ts b/apps/cli/src/types/types.ts index 162f7bac7b7..fbd132bfdce 100644 --- a/apps/cli/src/types/types.ts +++ b/apps/cli/src/types/types.ts @@ -22,6 +22,7 @@ export type FlagOptions = { promptFile?: string workspace?: string print: boolean + stdinPromptStream: boolean extension?: string debug: boolean requireApproval: boolean