Просмотр исходного кода

Add stdin stream mode for the cli (#11476)

* Add stdin stream mode for the cli

* fix: clear jsonEmitter state between tasks in stdin-prompt-stream mode

* fix: use consistent user role for prompt echo partials in stream-json mode

---------

Co-authored-by: Roo Code <[email protected]>
Chris Estreich 3 дней назад
Родитель
Сommit
27a78833c8

+ 19 - 0
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 <path>`            | Read prompt from a file instead of command line argument                                | None                                     |
 | `-w, --workspace <path>`          | 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>`          | 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`                                  |

+ 14 - 1
apps/cli/src/agent/json-event-emitter.ts

@@ -93,6 +93,8 @@ export class JsonEventEmitter {
 	private previousContent = new Map<number, string>()
 	// 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":
@@ -378,6 +387,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
 	}
 
 	/**
@@ -442,5 +454,6 @@ export class JsonEventEmitter {
 		this.seenMessageIds.clear()
 		this.previousContent.clear()
 		this.completionResultContent = undefined
+		this.expectPromptEchoAsUser = true
 	}
 }

+ 67 - 6
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<string> {
+	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 <prompt> --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 <prompt> --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] <prompt>")
+				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 <prompt> [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)

+ 1 - 0
apps/cli/src/index.ts

@@ -16,6 +16,7 @@ program
 	.option("--prompt-file <path>", "Read prompt from a file instead of command line argument")
 	.option("-w, --workspace <path>", "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>", "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)

+ 1 - 0
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