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 <roomote@roocode.com>
This commit is contained in:
Chris Estreich 2026-02-14 23:36:20 -08:00 committed by GitHub
parent 04ffb64bb7
commit 27a78833c8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 102 additions and 7 deletions

View file

@ -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` |

View file

@ -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
}
}

View file

@ -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)

View file

@ -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)

View file

@ -22,6 +22,7 @@ export type FlagOptions = {
promptFile?: string
workspace?: string
print: boolean
stdinPromptStream: boolean
extension?: string
debug: boolean
requireApproval: boolean