mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
Support different cli output formats: text, json, streaming json (#10812)
Co-authored-by: Roo Code <roomote@roocode.com>
This commit is contained in:
parent
a148862a06
commit
6cc2a4c30e
8 changed files with 659 additions and 34 deletions
|
|
@ -153,7 +153,10 @@ export class ExtensionHost extends EventEmitter implements ExtensionHostInterfac
|
|||
super()
|
||||
|
||||
this.options = options
|
||||
this.options.integrationTest = true
|
||||
|
||||
// Set up quiet mode early, before any extension code runs.
|
||||
// This suppresses console output from the extension during load.
|
||||
this.setupQuietMode()
|
||||
|
||||
// Initialize client - single source of truth for agent state (including mode).
|
||||
this.client = new ExtensionClient({
|
||||
|
|
@ -162,9 +165,7 @@ export class ExtensionHost extends EventEmitter implements ExtensionHostInterfac
|
|||
})
|
||||
|
||||
// Initialize output manager.
|
||||
this.outputManager = new OutputManager({
|
||||
disabled: options.disableOutput,
|
||||
})
|
||||
this.outputManager = new OutputManager({ disabled: options.disableOutput })
|
||||
|
||||
// Initialize prompt manager with console mode callbacks.
|
||||
this.promptManager = new PromptManager({
|
||||
|
|
@ -222,8 +223,6 @@ export class ExtensionHost extends EventEmitter implements ExtensionHostInterfac
|
|||
this.initialSettings.reasoningEffort = this.options.reasoningEffort
|
||||
}
|
||||
}
|
||||
|
||||
this.setupQuietMode()
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
|
|
@ -267,7 +266,8 @@ export class ExtensionHost extends EventEmitter implements ExtensionHostInterfac
|
|||
// ==========================================================================
|
||||
|
||||
private setupQuietMode(): void {
|
||||
if (this.options.integrationTest) {
|
||||
// Skip if already set up or if integrationTest mode
|
||||
if (this.originalConsole || this.options.integrationTest) {
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -292,18 +292,16 @@ export class ExtensionHost extends EventEmitter implements ExtensionHostInterfac
|
|||
}
|
||||
|
||||
private restoreConsole(): void {
|
||||
if (this.options.integrationTest) {
|
||||
if (!this.originalConsole) {
|
||||
return
|
||||
}
|
||||
|
||||
if (this.originalConsole) {
|
||||
console.log = this.originalConsole.log
|
||||
console.warn = this.originalConsole.warn
|
||||
console.error = this.originalConsole.error
|
||||
console.debug = this.originalConsole.debug
|
||||
console.info = this.originalConsole.info
|
||||
this.originalConsole = null
|
||||
}
|
||||
console.log = this.originalConsole.log
|
||||
console.warn = this.originalConsole.warn
|
||||
console.error = this.originalConsole.error
|
||||
console.debug = this.originalConsole.debug
|
||||
console.info = this.originalConsole.info
|
||||
this.originalConsole = null
|
||||
|
||||
if (this.originalProcessEmitWarning) {
|
||||
process.emitWarning = this.originalProcessEmitWarning
|
||||
|
|
|
|||
|
|
@ -1 +1,2 @@
|
|||
export * from "./extension-host.js"
|
||||
export * from "./json-event-emitter.js"
|
||||
|
|
|
|||
464
apps/cli/src/agent/json-event-emitter.ts
Normal file
464
apps/cli/src/agent/json-event-emitter.ts
Normal file
|
|
@ -0,0 +1,464 @@
|
|||
/**
|
||||
* JsonEventEmitter - Handles structured JSON output for the CLI
|
||||
*
|
||||
* This class transforms internal CLI events (ClineMessage, state changes, etc.)
|
||||
* into structured JSON events and outputs them to stdout.
|
||||
*
|
||||
* Supports two output modes:
|
||||
* - "stream-json": NDJSON format (one JSON object per line) for real-time streaming
|
||||
* - "json": Single JSON object at the end with accumulated events
|
||||
*
|
||||
* Schema is optimized for efficiency with high message volume:
|
||||
* - Minimal fields per event
|
||||
* - No redundant wrappers
|
||||
* - `done` flag instead of partial:false
|
||||
*/
|
||||
|
||||
import type { ClineMessage } from "@roo-code/types"
|
||||
|
||||
import type { JsonEvent, JsonEventCost, JsonFinalOutput } from "@/types/json-events.js"
|
||||
|
||||
import type { ExtensionClient } from "./extension-client.js"
|
||||
import type { TaskCompletedEvent } from "./events.js"
|
||||
|
||||
/**
|
||||
* Options for JsonEventEmitter.
|
||||
*/
|
||||
export interface JsonEventEmitterOptions {
|
||||
/** Output mode: "json" or "stream-json" */
|
||||
mode: "json" | "stream-json"
|
||||
/** Output stream (defaults to process.stdout) */
|
||||
stdout?: NodeJS.WriteStream
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse tool information from a ClineMessage text field.
|
||||
* Tool messages are JSON with a `tool` field containing the tool name.
|
||||
*/
|
||||
function parseToolInfo(text: string | undefined): { name: string; input: Record<string, unknown> } | null {
|
||||
if (!text) return null
|
||||
try {
|
||||
const parsed = JSON.parse(text)
|
||||
return parsed.tool ? { name: parsed.tool, input: parsed } : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse API request cost information from api_req_started message text.
|
||||
*/
|
||||
function parseApiReqCost(text: string | undefined): JsonEventCost | undefined {
|
||||
if (!text) return undefined
|
||||
try {
|
||||
const parsed = JSON.parse(text)
|
||||
return parsed.cost !== undefined
|
||||
? {
|
||||
totalCost: parsed.cost,
|
||||
inputTokens: parsed.tokensIn,
|
||||
outputTokens: parsed.tokensOut,
|
||||
cacheWrites: parsed.cacheWrites,
|
||||
cacheReads: parsed.cacheReads,
|
||||
}
|
||||
: undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/** Internal events that should not be emitted */
|
||||
const SKIP_SAY_TYPES = new Set([
|
||||
"api_req_finished",
|
||||
"api_req_retried",
|
||||
"api_req_retry_delayed",
|
||||
"api_req_rate_limit_wait",
|
||||
"api_req_deleted",
|
||||
"checkpoint_saved",
|
||||
"condense_context",
|
||||
"condense_context_error",
|
||||
"sliding_window_truncation",
|
||||
])
|
||||
|
||||
/** Key offset for reasoning content to avoid collision with text content delta tracking */
|
||||
const REASONING_KEY_OFFSET = 1_000_000_000
|
||||
|
||||
export class JsonEventEmitter {
|
||||
private mode: "json" | "stream-json"
|
||||
private stdout: NodeJS.WriteStream
|
||||
private events: JsonEvent[] = []
|
||||
private unsubscribers: (() => void)[] = []
|
||||
private lastCost: JsonEventCost | undefined
|
||||
private seenMessageIds = new Set<number>()
|
||||
// Track previous content for delta computation
|
||||
private previousContent = new Map<number, string>()
|
||||
// Track the completion result content
|
||||
private completionResultContent: string | undefined
|
||||
|
||||
constructor(options: JsonEventEmitterOptions) {
|
||||
this.mode = options.mode
|
||||
this.stdout = options.stdout ?? process.stdout
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach to an ExtensionClient and subscribe to its events.
|
||||
*/
|
||||
attachToClient(client: ExtensionClient): void {
|
||||
// Subscribe to message events
|
||||
const unsubMessage = client.on("message", (msg) => this.handleMessage(msg, false))
|
||||
const unsubMessageUpdated = client.on("messageUpdated", (msg) => this.handleMessage(msg, true))
|
||||
const unsubTaskCompleted = client.on("taskCompleted", (event) => this.handleTaskCompleted(event))
|
||||
const unsubError = client.on("error", (error) => this.handleError(error))
|
||||
|
||||
this.unsubscribers.push(unsubMessage, unsubMessageUpdated, unsubTaskCompleted, unsubError)
|
||||
|
||||
// Emit init event
|
||||
this.emitEvent({
|
||||
type: "system",
|
||||
subtype: "init",
|
||||
content: "Task started",
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Detach from the client and clean up subscriptions.
|
||||
*/
|
||||
detach(): void {
|
||||
for (const unsub of this.unsubscribers) {
|
||||
unsub()
|
||||
}
|
||||
this.unsubscribers = []
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the delta (new content) for a streaming message.
|
||||
* Returns null if there's no new content.
|
||||
*/
|
||||
private computeDelta(msgId: number, fullContent: string | undefined): string | null {
|
||||
if (!fullContent) return null
|
||||
|
||||
const previous = this.previousContent.get(msgId) || ""
|
||||
if (fullContent === previous) return null
|
||||
|
||||
this.previousContent.set(msgId, fullContent)
|
||||
// If content is appended, return only the new part
|
||||
return fullContent.startsWith(previous) ? fullContent.slice(previous.length) : fullContent
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this is a streaming partial message with no new content.
|
||||
*/
|
||||
private isEmptyStreamingDelta(content: string | null): boolean {
|
||||
return this.mode === "stream-json" && content === null
|
||||
}
|
||||
|
||||
/**
|
||||
* Get content to send for a message (delta for streaming, full for json mode).
|
||||
*/
|
||||
private getContentToSend(msgId: number, text: string | undefined, isPartial: boolean): string | null {
|
||||
if (this.mode === "stream-json" && isPartial) {
|
||||
return this.computeDelta(msgId, text)
|
||||
}
|
||||
return text ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a base event with optional done flag.
|
||||
*/
|
||||
private buildTextEvent(
|
||||
type: "assistant" | "thinking" | "user",
|
||||
id: number,
|
||||
content: string | null,
|
||||
isDone: boolean,
|
||||
subtype?: string,
|
||||
): JsonEvent {
|
||||
const event: JsonEvent = { type, id }
|
||||
if (content !== null) {
|
||||
event.content = content
|
||||
}
|
||||
if (subtype) {
|
||||
event.subtype = subtype
|
||||
}
|
||||
if (isDone) {
|
||||
event.done = true
|
||||
}
|
||||
return event
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a ClineMessage and emit the appropriate JSON event.
|
||||
*/
|
||||
private handleMessage(msg: ClineMessage, _isUpdate: boolean): void {
|
||||
const isDone = !msg.partial
|
||||
|
||||
// In json mode, only emit complete (non-partial) messages
|
||||
if (this.mode === "json" && msg.partial) {
|
||||
return
|
||||
}
|
||||
|
||||
// Skip duplicate complete messages
|
||||
if (isDone && this.seenMessageIds.has(msg.ts)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (isDone) {
|
||||
this.seenMessageIds.add(msg.ts)
|
||||
this.previousContent.delete(msg.ts)
|
||||
}
|
||||
|
||||
const contentToSend = this.getContentToSend(msg.ts, msg.text, msg.partial ?? false)
|
||||
|
||||
// Skip if no new content for streaming partial messages
|
||||
if (msg.partial && this.isEmptyStreamingDelta(contentToSend)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (msg.type === "say" && msg.say) {
|
||||
this.handleSayMessage(msg, contentToSend, isDone)
|
||||
}
|
||||
|
||||
if (msg.type === "ask" && msg.ask) {
|
||||
this.handleAskMessage(msg, contentToSend, isDone)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle "say" type messages.
|
||||
*/
|
||||
private handleSayMessage(msg: ClineMessage, contentToSend: string | null, isDone: boolean): void {
|
||||
switch (msg.say) {
|
||||
case "text":
|
||||
this.emitEvent(this.buildTextEvent("assistant", msg.ts, contentToSend, isDone))
|
||||
break
|
||||
|
||||
case "reasoning":
|
||||
this.handleReasoningMessage(msg, isDone)
|
||||
break
|
||||
|
||||
case "error":
|
||||
this.emitEvent({ type: "error", id: msg.ts, content: contentToSend ?? undefined })
|
||||
break
|
||||
|
||||
case "command_output":
|
||||
this.emitEvent({
|
||||
type: "tool_result",
|
||||
tool_result: { name: "execute_command", output: msg.text },
|
||||
})
|
||||
break
|
||||
|
||||
case "user_feedback":
|
||||
case "user_feedback_diff":
|
||||
this.emitEvent(this.buildTextEvent("user", msg.ts, contentToSend, isDone))
|
||||
break
|
||||
|
||||
case "api_req_started": {
|
||||
const cost = parseApiReqCost(msg.text)
|
||||
if (cost) {
|
||||
this.lastCost = cost
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case "browser_action":
|
||||
case "browser_action_result":
|
||||
this.emitEvent({
|
||||
type: "tool_result",
|
||||
subtype: "browser",
|
||||
tool_result: { name: "browser_action", output: msg.text },
|
||||
})
|
||||
break
|
||||
|
||||
case "mcp_server_response":
|
||||
this.emitEvent({
|
||||
type: "tool_result",
|
||||
subtype: "mcp",
|
||||
tool_result: { name: "mcp_server", output: msg.text },
|
||||
})
|
||||
break
|
||||
|
||||
case "completion_result":
|
||||
if (msg.text && !msg.partial) {
|
||||
this.completionResultContent = msg.text
|
||||
}
|
||||
break
|
||||
|
||||
default:
|
||||
if (SKIP_SAY_TYPES.has(msg.say!)) {
|
||||
break
|
||||
}
|
||||
if (msg.text) {
|
||||
this.emitEvent(this.buildTextEvent("assistant", msg.ts, contentToSend, isDone, msg.say))
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle reasoning/thinking messages with separate delta tracking.
|
||||
*/
|
||||
private handleReasoningMessage(msg: ClineMessage, isDone: boolean): void {
|
||||
const reasoningContent = msg.reasoning || msg.text
|
||||
const reasoningKey = msg.ts + REASONING_KEY_OFFSET
|
||||
const reasoningDelta = this.getContentToSend(reasoningKey, reasoningContent, msg.partial ?? false)
|
||||
|
||||
if (msg.partial && this.isEmptyStreamingDelta(reasoningDelta)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!msg.partial) {
|
||||
this.previousContent.delete(reasoningKey)
|
||||
}
|
||||
|
||||
this.emitEvent(this.buildTextEvent("thinking", msg.ts, reasoningDelta, isDone))
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle "ask" type messages.
|
||||
*/
|
||||
private handleAskMessage(msg: ClineMessage, contentToSend: string | null, isDone: boolean): void {
|
||||
switch (msg.ask) {
|
||||
case "tool": {
|
||||
const toolInfo = parseToolInfo(msg.text)
|
||||
this.emitEvent({
|
||||
type: "tool_use",
|
||||
id: msg.ts,
|
||||
subtype: "tool",
|
||||
tool_use: toolInfo ?? { name: "unknown_tool", input: { raw: msg.text } },
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case "command":
|
||||
this.emitEvent({
|
||||
type: "tool_use",
|
||||
id: msg.ts,
|
||||
subtype: "command",
|
||||
tool_use: { name: "execute_command", input: { command: msg.text } },
|
||||
})
|
||||
break
|
||||
|
||||
case "browser_action_launch":
|
||||
this.emitEvent({
|
||||
type: "tool_use",
|
||||
id: msg.ts,
|
||||
subtype: "browser",
|
||||
tool_use: { name: "browser_action", input: { raw: msg.text } },
|
||||
})
|
||||
break
|
||||
|
||||
case "use_mcp_server":
|
||||
this.emitEvent({
|
||||
type: "tool_use",
|
||||
id: msg.ts,
|
||||
subtype: "mcp",
|
||||
tool_use: { name: "mcp_server", input: { raw: msg.text } },
|
||||
})
|
||||
break
|
||||
|
||||
case "followup":
|
||||
this.emitEvent(this.buildTextEvent("assistant", msg.ts, contentToSend, isDone, "followup"))
|
||||
break
|
||||
|
||||
case "command_output":
|
||||
// Handled in say type
|
||||
break
|
||||
|
||||
case "completion_result":
|
||||
if (msg.text && !msg.partial) {
|
||||
this.completionResultContent = msg.text
|
||||
}
|
||||
break
|
||||
|
||||
default:
|
||||
if (msg.text) {
|
||||
this.emitEvent(this.buildTextEvent("assistant", msg.ts, contentToSend, isDone, msg.ask))
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle task completion and emit result event.
|
||||
*/
|
||||
private handleTaskCompleted(event: TaskCompletedEvent): void {
|
||||
// Use tracked completion result content, falling back to event message
|
||||
const resultContent = this.completionResultContent || event.message?.text
|
||||
|
||||
this.emitEvent({
|
||||
type: "result",
|
||||
id: event.message?.ts ?? Date.now(),
|
||||
content: resultContent,
|
||||
done: true,
|
||||
success: event.success,
|
||||
cost: this.lastCost,
|
||||
})
|
||||
|
||||
// For "json" mode, output the final accumulated result
|
||||
if (this.mode === "json") {
|
||||
this.outputFinalResult(event.success, resultContent)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle errors and emit error event.
|
||||
*/
|
||||
private handleError(error: Error): void {
|
||||
this.emitEvent({
|
||||
type: "error",
|
||||
id: Date.now(),
|
||||
content: error.message,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a JSON event.
|
||||
* For stream-json mode: immediately output to stdout
|
||||
* For json mode: accumulate for final output
|
||||
*/
|
||||
private emitEvent(event: JsonEvent): void {
|
||||
this.events.push(event)
|
||||
|
||||
if (this.mode === "stream-json") {
|
||||
this.outputLine(event)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Output a single JSON line (NDJSON format).
|
||||
*/
|
||||
private outputLine(data: unknown): void {
|
||||
this.stdout.write(JSON.stringify(data) + "\n")
|
||||
}
|
||||
|
||||
/**
|
||||
* Output the final accumulated result (for "json" mode).
|
||||
*/
|
||||
private outputFinalResult(success: boolean, content?: string): void {
|
||||
const output: JsonFinalOutput = {
|
||||
type: "result",
|
||||
success,
|
||||
content,
|
||||
cost: this.lastCost,
|
||||
events: this.events.filter((e) => e.type !== "result"), // Exclude the result event itself
|
||||
}
|
||||
|
||||
this.stdout.write(JSON.stringify(output, null, 2) + "\n")
|
||||
}
|
||||
|
||||
/**
|
||||
* Get accumulated events (for testing or external use).
|
||||
*/
|
||||
getEvents(): JsonEvent[] {
|
||||
return [...this.events]
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear accumulated events and state.
|
||||
*/
|
||||
clear(): void {
|
||||
this.events = []
|
||||
this.lastCost = undefined
|
||||
this.seenMessageIds.clear()
|
||||
this.previousContent.clear()
|
||||
this.completionResultContent = undefined
|
||||
}
|
||||
}
|
||||
|
|
@ -11,11 +11,13 @@ import {
|
|||
isSupportedProvider,
|
||||
OnboardingProviderChoice,
|
||||
supportedProviders,
|
||||
ASCII_ROO,
|
||||
DEFAULT_FLAGS,
|
||||
REASONING_EFFORTS,
|
||||
SDK_BASE_URL,
|
||||
OutputFormat,
|
||||
} from "@/types/index.js"
|
||||
import { isValidOutputFormat } from "@/types/json-events.js"
|
||||
import { JsonEventEmitter } from "@/agent/json-event-emitter.js"
|
||||
|
||||
import { createClient } from "@/lib/sdk/index.js"
|
||||
import { loadToken, loadSettings } from "@/lib/storage/index.js"
|
||||
|
|
@ -164,6 +166,23 @@ export async function run(promptArg: string | undefined, flagOptions: FlagOption
|
|||
process.exit(1)
|
||||
}
|
||||
|
||||
// Validate output format
|
||||
const outputFormat: OutputFormat = (flagOptions.outputFormat as OutputFormat) || "text"
|
||||
|
||||
if (!isValidOutputFormat(outputFormat)) {
|
||||
console.error(
|
||||
`[CLI] Error: Invalid output format: ${flagOptions.outputFormat}; must be one of: text, json, stream-json`,
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// 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")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (!isTuiEnabled) {
|
||||
if (!prompt) {
|
||||
console.error("[CLI] Error: prompt is required in print mode")
|
||||
|
|
@ -204,38 +223,53 @@ export async function run(promptArg: string | undefined, flagOptions: FlagOption
|
|||
process.exit(1)
|
||||
}
|
||||
} else {
|
||||
console.log(ASCII_ROO)
|
||||
console.log()
|
||||
console.log(
|
||||
`[roo] Running ${extensionHostOptions.model || "default"} (${extensionHostOptions.reasoningEffort || "default"}) on ${extensionHostOptions.provider} in ${extensionHostOptions.mode || "default"} mode in ${extensionHostOptions.workspacePath} [debug = ${extensionHostOptions.debug}]`,
|
||||
)
|
||||
const useJsonOutput = outputFormat === "json" || outputFormat === "stream-json"
|
||||
|
||||
extensionHostOptions.disableOutput = useJsonOutput
|
||||
|
||||
const host = new ExtensionHost(extensionHostOptions)
|
||||
|
||||
process.on("SIGINT", async () => {
|
||||
console.log("\n[CLI] Received SIGINT, shutting down...")
|
||||
await host.dispose()
|
||||
process.exit(130)
|
||||
})
|
||||
const jsonEmitter = useJsonOutput
|
||||
? new JsonEventEmitter({ mode: outputFormat as "json" | "stream-json" })
|
||||
: null
|
||||
|
||||
process.on("SIGTERM", async () => {
|
||||
console.log("\n[CLI] Received SIGTERM, shutting down...")
|
||||
async function shutdown(signal: string, exitCode: number): Promise<void> {
|
||||
if (!useJsonOutput) {
|
||||
console.log(`\n[CLI] Received ${signal}, shutting down...`)
|
||||
}
|
||||
jsonEmitter?.detach()
|
||||
await host.dispose()
|
||||
process.exit(143)
|
||||
})
|
||||
process.exit(exitCode)
|
||||
}
|
||||
|
||||
process.on("SIGINT", () => shutdown("SIGINT", 130))
|
||||
process.on("SIGTERM", () => shutdown("SIGTERM", 143))
|
||||
|
||||
try {
|
||||
await host.activate()
|
||||
|
||||
if (jsonEmitter) {
|
||||
jsonEmitter.attachToClient(host.client)
|
||||
}
|
||||
|
||||
await host.runTask(prompt!)
|
||||
jsonEmitter?.detach()
|
||||
await host.dispose()
|
||||
process.exit(0)
|
||||
} catch (error) {
|
||||
console.error("[CLI] Error:", error instanceof Error ? error.message : String(error))
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
|
||||
if (error instanceof Error) {
|
||||
console.error(error.stack)
|
||||
if (useJsonOutput) {
|
||||
const errorEvent = { type: "error", id: Date.now(), content: errorMessage }
|
||||
process.stdout.write(JSON.stringify(errorEvent) + "\n")
|
||||
} else {
|
||||
console.error("[CLI] Error:", errorMessage)
|
||||
if (error instanceof Error) {
|
||||
console.error(error.stack)
|
||||
}
|
||||
}
|
||||
|
||||
jsonEmitter?.detach()
|
||||
await host.dispose()
|
||||
process.exit(1)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,6 +30,11 @@ program
|
|||
)
|
||||
.option("--ephemeral", "Run without persisting state (uses temporary storage)", false)
|
||||
.option("--oneshot", "Exit upon task completion", false)
|
||||
.option(
|
||||
"--output-format <format>",
|
||||
'Output format (only works with --print): "text" (default), "json" (single result), or "stream-json" (realtime streaming)',
|
||||
"text",
|
||||
)
|
||||
.action(run)
|
||||
|
||||
const authCommand = program.command("auth").description("Manage authentication for Roo Code Cloud")
|
||||
|
|
|
|||
|
|
@ -1,2 +1,3 @@
|
|||
export * from "./types.js"
|
||||
export * from "./constants.js"
|
||||
export * from "./json-events.js"
|
||||
|
|
|
|||
120
apps/cli/src/types/json-events.ts
Normal file
120
apps/cli/src/types/json-events.ts
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
/**
|
||||
* JSON Event Types for Structured CLI Output
|
||||
*
|
||||
* This module defines the types for structured JSON output from the CLI.
|
||||
* The output format is NDJSON (newline-delimited JSON) for stream-json mode,
|
||||
* or a single JSON object for json mode.
|
||||
*
|
||||
* Schema is optimized for efficiency with high message volume:
|
||||
* - Minimal fields per event
|
||||
* - No redundant wrappers
|
||||
* - `done` flag instead of partial:false
|
||||
*/
|
||||
|
||||
/**
|
||||
* Output format options for the CLI.
|
||||
*/
|
||||
export const OUTPUT_FORMATS = ["text", "json", "stream-json"] as const
|
||||
|
||||
export type OutputFormat = (typeof OUTPUT_FORMATS)[number]
|
||||
|
||||
export function isValidOutputFormat(format: string): format is OutputFormat {
|
||||
return (OUTPUT_FORMATS as readonly string[]).includes(format)
|
||||
}
|
||||
|
||||
/**
|
||||
* Event type discriminators for JSON output.
|
||||
*/
|
||||
export type JsonEventType =
|
||||
| "system" // System messages (init, ready, shutdown)
|
||||
| "assistant" // Assistant text messages
|
||||
| "user" // User messages (echoed input)
|
||||
| "tool_use" // Tool invocations (file ops, commands, browser, MCP)
|
||||
| "tool_result" // Results from tool execution
|
||||
| "thinking" // Reasoning/thinking content
|
||||
| "error" // Errors
|
||||
| "result" // Final task result
|
||||
|
||||
/**
|
||||
* Tool use information for tool_use events.
|
||||
*/
|
||||
export interface JsonEventToolUse {
|
||||
/** Tool name (e.g., "read_file", "write_to_file", "execute_command") */
|
||||
name: string
|
||||
/** Tool input parameters */
|
||||
input?: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool result information for tool_result events.
|
||||
*/
|
||||
export interface JsonEventToolResult {
|
||||
/** Tool name that produced this result */
|
||||
name: string
|
||||
/** Tool output (for successful execution) */
|
||||
output?: string
|
||||
/** Error message (for failed execution) */
|
||||
error?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Cost and token usage information.
|
||||
*/
|
||||
export interface JsonEventCost {
|
||||
/** Total cost in USD */
|
||||
totalCost?: number
|
||||
/** Input tokens used */
|
||||
inputTokens?: number
|
||||
/** Output tokens generated */
|
||||
outputTokens?: number
|
||||
/** Cache write tokens */
|
||||
cacheWrites?: number
|
||||
/** Cache read tokens */
|
||||
cacheReads?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Base JSON event structure.
|
||||
* Optimized for minimal payload size.
|
||||
*
|
||||
* For streaming deltas:
|
||||
* - Each delta includes `id` for easy correlation
|
||||
* - Final message has `done: true`
|
||||
*/
|
||||
export interface JsonEvent {
|
||||
/** Event type discriminator */
|
||||
type: JsonEventType
|
||||
/** Message ID - included on first delta and final message */
|
||||
id?: number
|
||||
/** Content text (for text-based events) */
|
||||
content?: string
|
||||
/** True when this is the final message (stream complete) */
|
||||
done?: boolean
|
||||
/** Optional subtype for more specific categorization */
|
||||
subtype?: string
|
||||
/** Tool use information (for tool_use events) */
|
||||
tool_use?: JsonEventToolUse
|
||||
/** Tool result information (for tool_result events) */
|
||||
tool_result?: JsonEventToolResult
|
||||
/** Whether the task succeeded (for result events) */
|
||||
success?: boolean
|
||||
/** Cost and token usage (for result events) */
|
||||
cost?: JsonEventCost
|
||||
}
|
||||
|
||||
/**
|
||||
* Final JSON output for "json" mode (single object at end).
|
||||
* Contains the result and accumulated messages.
|
||||
*/
|
||||
export interface JsonFinalOutput {
|
||||
/** Final result type */
|
||||
type: "result"
|
||||
/** Whether the task succeeded */
|
||||
success: boolean
|
||||
/** Result content/message */
|
||||
content?: string
|
||||
/** Cost and token usage */
|
||||
cost?: JsonEventCost
|
||||
/** All events that occurred during the task */
|
||||
events: JsonEvent[]
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import type { ProviderName, ReasoningEffortExtended } from "@roo-code/types"
|
||||
import type { OutputFormat } from "./json-events.js"
|
||||
|
||||
export const supportedProviders = [
|
||||
"anthropic",
|
||||
|
|
@ -32,6 +33,7 @@ export type FlagOptions = {
|
|||
reasoningEffort?: ReasoningEffortFlagOptions
|
||||
ephemeral: boolean
|
||||
oneshot: boolean
|
||||
outputFormat?: OutputFormat
|
||||
}
|
||||
|
||||
export enum OnboardingProviderChoice {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue