Skip to content

Commit 44b84fe

Browse files
authored
Merge pull request #3371 from Kilo-Org/cli-json-renderer
CLI: Add --json flag to complement --auto
2 parents db8d647 + 22bdbf6 commit 44b84fe

File tree

7 files changed

+128
-0
lines changed

7 files changed

+128
-0
lines changed

.changeset/happy-bananas-notice.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@kilocode/cli": patch
3+
---
4+
5+
Add a --json flag to render a stream of JSON objects while in --auto mode

cli/src/cli.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export interface CLIOptions {
1919
mode?: string
2020
workspace?: string
2121
ci?: boolean
22+
json?: boolean
2223
prompt?: string
2324
timeout?: number
2425
}
@@ -161,6 +162,7 @@ export class CLI {
161162
mode: this.options.mode || "code",
162163
workspace: this.options.workspace || process.cwd(),
163164
ci: this.options.ci || false,
165+
json: this.options.json || false,
164166
prompt: this.options.prompt || "",
165167
...(this.options.timeout !== undefined && { timeout: this.options.timeout }),
166168
},

cli/src/index.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ program
2727
.option("-m, --mode <mode>", `Set the mode of operation (${validModes.join(", ")})`)
2828
.option("-w, --workspace <path>", "Path to the workspace directory", process.cwd())
2929
.option("-a, --auto", "Run in autonomous mode (non-interactive)", false)
30+
.option("-j, --json", "Output messages as JSON (requires --auto)", false)
3031
.option("-t, --timeout <seconds>", "Timeout in seconds for autonomous mode (requires --auto)", parseInt)
3132
.argument("[prompt]", "The prompt or command to execute")
3233
.action(async (prompt, options) => {
@@ -48,6 +49,12 @@ program
4849
process.exit(1)
4950
}
5051

52+
// Validate that JSON mode requires autonomous mode
53+
if (options.json && !options.auto) {
54+
console.error("Error: --json option requires --auto flag to be enabled")
55+
process.exit(1)
56+
}
57+
5158
// Read from stdin if no prompt argument is provided and stdin is piped
5259
let finalPrompt = prompt
5360
if (!finalPrompt && !process.stdin.isTTY) {
@@ -92,6 +99,7 @@ program
9299
mode: options.mode,
93100
workspace: options.workspace,
94101
ci: options.auto,
102+
json: options.json,
95103
prompt: finalPrompt,
96104
timeout: options.timeout,
97105
})

cli/src/ui/App.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export interface AppOptions {
99
mode?: string
1010
workspace?: string
1111
ci?: boolean
12+
json?: boolean
1213
prompt?: string
1314
timeout?: number
1415
}

cli/src/ui/JsonRenderer.tsx

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { useEffect, useRef } from "react"
2+
import { useAtomValue } from "jotai"
3+
import { mergedMessagesAtom, type UnifiedMessage } from "../state/atoms/ui.js"
4+
import { outputJsonMessage } from "./utils/jsonOutput.js"
5+
6+
function getMessageKey(message: UnifiedMessage): string {
7+
const baseKey = `${message.source}-${message.message.ts}`
8+
const content = message.source === "cli" ? message.message.content : message.message.text || ""
9+
const partial = message.message.partial ? "partial" : "complete"
10+
return `${baseKey}-${content.length}-${partial}`
11+
}
12+
13+
export function JsonRenderer() {
14+
const messages = useAtomValue(mergedMessagesAtom)
15+
const lastOutputKeysRef = useRef<string[]>([])
16+
17+
useEffect(() => {
18+
const currentKeys = messages.map(getMessageKey)
19+
20+
for (let i = 0; i < messages.length; i++) {
21+
const message = messages[i]
22+
const currentKey = currentKeys[i]
23+
const lastKey = lastOutputKeysRef.current[i]
24+
25+
if (!message || !currentKey) continue
26+
27+
if (currentKey !== lastKey) {
28+
outputJsonMessage(message)
29+
}
30+
}
31+
32+
lastOutputKeysRef.current = currentKeys
33+
}, [messages])
34+
35+
return null
36+
}

cli/src/ui/UI.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { setCIModeAtom } from "../state/atoms/ci.js"
1111
import { configValidationAtom } from "../state/atoms/config.js"
1212
import { addToHistoryAtom, resetHistoryNavigationAtom, exitHistoryModeAtom } from "../state/atoms/history.js"
1313
import { MessageDisplay } from "./messages/MessageDisplay.js"
14+
import { JsonRenderer } from "./JsonRenderer.js"
1415
import { CommandInput } from "./components/CommandInput.js"
1516
import { StatusBar } from "./components/StatusBar.js"
1617
import { StatusIndicator } from "./components/StatusIndicator.js"
@@ -201,6 +202,11 @@ export const UI: React.FC<UIAppProps> = ({ options, onExit }) => {
201202
}
202203
}, [configValidation])
203204

205+
// If JSON mode is enabled, use JSON renderer instead of UI components
206+
if (options.json && options.ci) {
207+
return <JsonRenderer />
208+
}
209+
204210
return (
205211
// Using stdout.rows causes layout shift during renders
206212
<Box key={resetCounter} flexDirection="column">

cli/src/ui/utils/jsonOutput.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
/**
2+
* JSON output utilities for CI mode
3+
* Converts messages to JSON format for non-interactive output
4+
*/
5+
6+
import type { UnifiedMessage } from "../../state/atoms/ui.js"
7+
import type { ExtensionChatMessage } from "../../types/messages.js"
8+
import type { CliMessage } from "../../types/cli.js"
9+
10+
/**
11+
* Convert a CLI message to JSON output format
12+
*/
13+
function formatCliMessage(message: CliMessage) {
14+
const { ts, ...restOfMessage } = message
15+
return {
16+
timestamp: ts,
17+
source: "cli",
18+
...restOfMessage,
19+
}
20+
}
21+
22+
/**
23+
* Convert an extension message to JSON output format
24+
*/
25+
function formatExtensionMessage(message: ExtensionChatMessage) {
26+
const { ts, text, ...restOfMessage } = message
27+
28+
const output: Record<string, unknown> = {
29+
timestamp: ts,
30+
source: "extension",
31+
...restOfMessage,
32+
}
33+
34+
try {
35+
output.metadata = JSON.parse(text || "")
36+
} catch {
37+
if (text) {
38+
output.content = text
39+
}
40+
}
41+
42+
return output
43+
}
44+
45+
/**
46+
* Convert a unified message to JSON output format
47+
*/
48+
export function formatMessageAsJson(unifiedMessage: UnifiedMessage) {
49+
if (unifiedMessage.source === "cli") {
50+
return formatCliMessage(unifiedMessage.message)
51+
} else {
52+
return formatExtensionMessage(unifiedMessage.message)
53+
}
54+
}
55+
56+
/**
57+
* Output a message as JSON to stdout
58+
*/
59+
export function outputJsonMessage(unifiedMessage: UnifiedMessage): void {
60+
const jsonOutput = formatMessageAsJson(unifiedMessage)
61+
console.log(JSON.stringify(jsonOutput))
62+
}
63+
64+
/**
65+
* Output multiple messages as JSON array to stdout
66+
*/
67+
export function outputJsonMessages(messages: UnifiedMessage[]): void {
68+
const jsonOutputs = messages.map(formatMessageAsJson)
69+
console.log(JSON.stringify(jsonOutputs))
70+
}

0 commit comments

Comments
 (0)