use serde_json::Value;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CliOutputFormat {
Raw,
ClaudeStreamJson,
CodexNdjson,
GeminiJson,
}
impl CliOutputFormat {
pub fn detect(args: &[String]) -> Self {
let args_str: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
if args_str
.windows(2)
.any(|w| w == ["--output-format", "stream-json"])
|| args_str.windows(2).any(|w| w == ["-f", "stream-json"])
{
return Self::ClaudeStreamJson;
}
if args_str.contains(&"--json") {
return Self::CodexNdjson;
}
if args_str
.windows(2)
.any(|w| w == ["--output-format", "json"])
{
return Self::GeminiJson;
}
Self::Raw
}
}
#[derive(Debug)]
pub enum ParsedCliLine {
Text(String),
ToolCall { name: String },
FinalResult(String),
Skip,
}
pub fn parse_cli_line(line: &str, format: CliOutputFormat) -> ParsedCliLine {
match format {
CliOutputFormat::Raw => ParsedCliLine::Text(line.to_string()),
CliOutputFormat::ClaudeStreamJson => parse_claude_stream_json(line),
CliOutputFormat::CodexNdjson => parse_codex_ndjson(line),
CliOutputFormat::GeminiJson => parse_gemini_json(line),
}
}
fn parse_claude_stream_json(line: &str) -> ParsedCliLine {
let Ok(val) = serde_json::from_str::<Value>(line) else {
return if line.trim().is_empty() {
ParsedCliLine::Skip
} else {
ParsedCliLine::Text(line.to_string())
};
};
let msg_type = val.get("type").and_then(|v| v.as_str()).unwrap_or("");
match msg_type {
"system" => ParsedCliLine::Skip,
"user" => ParsedCliLine::Skip,
"assistant" => {
let Some(message) = val.get("message") else {
return ParsedCliLine::Skip;
};
let Some(content) = message.get("content").and_then(|c| c.as_array()) else {
return ParsedCliLine::Skip;
};
let mut text_parts: Vec<String> = Vec::new();
let mut tool_name: Option<String> = None;
for block in content {
match block.get("type").and_then(|t| t.as_str()) {
Some("text") => {
if let Some(t) = block.get("text").and_then(|v| v.as_str())
&& !t.is_empty()
{
text_parts.push(t.to_string());
}
}
Some("tool_use") => {
if tool_name.is_none() {
tool_name = block
.get("name")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
}
}
_ => {}
}
}
if let Some(name) = tool_name {
return ParsedCliLine::ToolCall { name };
}
if !text_parts.is_empty() {
return ParsedCliLine::Text(text_parts.join(""));
}
ParsedCliLine::Skip
}
"result" => {
let subtype = val.get("subtype").and_then(|v| v.as_str()).unwrap_or("");
if subtype == "success" {
if let Some(result) = val.get("result").and_then(|v| v.as_str()) {
return ParsedCliLine::FinalResult(result.to_string());
}
} else {
let err = val
.get("error")
.and_then(|v| v.as_str())
.unwrap_or("Unknown CLI error");
return ParsedCliLine::Text(format!("⚠ {err}"));
}
ParsedCliLine::Skip
}
_ => ParsedCliLine::Skip,
}
}
fn parse_codex_ndjson(line: &str) -> ParsedCliLine {
let Ok(val) = serde_json::from_str::<Value>(line) else {
return if line.trim().is_empty() {
ParsedCliLine::Skip
} else {
ParsedCliLine::Text(line.to_string())
};
};
let event_type = val.get("type").and_then(|v| v.as_str()).unwrap_or("");
match event_type {
"message" => {
if val.get("role").and_then(|v| v.as_str()) == Some("assistant")
&& let Some(content) = val.get("content").and_then(|v| v.as_str())
&& !content.is_empty()
{
return ParsedCliLine::Text(content.to_string());
}
ParsedCliLine::Skip
}
"reasoning" => ParsedCliLine::Skip, "tool_call" => {
let name = val
.get("name")
.and_then(|v| v.as_str())
.unwrap_or("unknown_tool")
.to_string();
ParsedCliLine::ToolCall { name }
}
"tool_result" | "system" => ParsedCliLine::Skip,
"error" => {
let msg = val
.get("message")
.and_then(|v| v.as_str())
.unwrap_or("Unknown error");
ParsedCliLine::Text(format!("⚠ {msg}"))
}
_ => ParsedCliLine::Skip,
}
}
fn parse_gemini_json(line: &str) -> ParsedCliLine {
if line.trim().is_empty() {
return ParsedCliLine::Skip;
}
let Ok(val) = serde_json::from_str::<Value>(line) else {
return ParsedCliLine::Text(line.to_string());
};
if let Some(text) = val.get("text").and_then(|v| v.as_str())
&& !text.is_empty()
{
return ParsedCliLine::Text(text.to_string());
}
if let Some(parts) = val.get("parts").and_then(|v| v.as_array()) {
let text: String = parts
.iter()
.filter_map(|p| p.get("text").and_then(|t| t.as_str()))
.collect::<Vec<_>>()
.join("");
if !text.is_empty() {
return ParsedCliLine::Text(text);
}
}
if let Some(candidates) = val.get("candidates").and_then(|v| v.as_array()) {
for candidate in candidates {
if let Some(parts) = candidate
.get("content")
.and_then(|c| c.get("parts"))
.and_then(|p| p.as_array())
{
let text: String = parts
.iter()
.filter_map(|p| p.get("text").and_then(|t| t.as_str()))
.collect::<Vec<_>>()
.join("");
if !text.is_empty() {
return ParsedCliLine::Text(text);
}
}
}
}
ParsedCliLine::Skip
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detect_claude_stream_json() {
let args: Vec<String> = vec!["-p".into(), "--output-format".into(), "stream-json".into()];
assert_eq!(
CliOutputFormat::detect(&args),
CliOutputFormat::ClaudeStreamJson
);
}
#[test]
fn detect_codex_ndjson() {
let args: Vec<String> = vec!["exec".into(), "--json".into()];
assert_eq!(CliOutputFormat::detect(&args), CliOutputFormat::CodexNdjson);
}
#[test]
fn detect_raw() {
let args: Vec<String> = vec!["-p".into()];
assert_eq!(CliOutputFormat::detect(&args), CliOutputFormat::Raw);
}
#[test]
fn parse_claude_text_block() {
let line = r#"{"type":"assistant","message":{"id":"msg_01","type":"message","role":"assistant","model":"claude-sonnet-4-6","content":[{"type":"text","text":"Hello!"}],"stop_reason":"end_turn","usage":{"input_tokens":10,"output_tokens":5}}}"#;
match parse_cli_line(line, CliOutputFormat::ClaudeStreamJson) {
ParsedCliLine::Text(t) => assert_eq!(t, "Hello!"),
other => panic!("expected Text, got {other:?}"),
}
}
#[test]
fn parse_claude_tool_use() {
let line = r#"{"type":"assistant","message":{"content":[{"type":"tool_use","id":"toolu_01","name":"Read","input":{"file_path":"/foo"}}]}}"#;
match parse_cli_line(line, CliOutputFormat::ClaudeStreamJson) {
ParsedCliLine::ToolCall { name } => assert_eq!(name, "Read"),
other => panic!("expected ToolCall, got {other:?}"),
}
}
#[test]
fn parse_claude_result() {
let line =
r#"{"type":"result","subtype":"success","result":"Done!","total_cost_usd":0.001}"#;
match parse_cli_line(line, CliOutputFormat::ClaudeStreamJson) {
ParsedCliLine::FinalResult(r) => assert_eq!(r, "Done!"),
other => panic!("expected FinalResult, got {other:?}"),
}
}
#[test]
fn parse_claude_system_skipped() {
let line = r#"{"type":"system","subtype":"init","model":"claude-sonnet-4-6"}"#;
assert!(matches!(
parse_cli_line(line, CliOutputFormat::ClaudeStreamJson),
ParsedCliLine::Skip
));
}
#[test]
fn parse_raw_passthrough() {
let line = "plain text output";
match parse_cli_line(line, CliOutputFormat::Raw) {
ParsedCliLine::Text(t) => assert_eq!(t, "plain text output"),
other => panic!("expected Text, got {other:?}"),
}
}
#[test]
fn parse_claude_error_result() {
let line = r#"{"type":"result","subtype":"error","error":"rate limit exceeded"}"#;
match parse_cli_line(line, CliOutputFormat::ClaudeStreamJson) {
ParsedCliLine::Text(t) => assert!(t.contains("rate limit exceeded")),
other => panic!("expected Text, got {other:?}"),
}
}
#[test]
fn parse_codex_tool_call() {
let line = r#"{"type":"tool_call","name":"bash","arguments":{"cmd":"ls"}}"#;
match parse_cli_line(line, CliOutputFormat::CodexNdjson) {
ParsedCliLine::ToolCall { name } => assert_eq!(name, "bash"),
other => panic!("expected ToolCall, got {other:?}"),
}
}
#[test]
fn parse_codex_assistant_message() {
let line = r#"{"type":"message","role":"assistant","content":"Here is the result."}"#;
match parse_cli_line(line, CliOutputFormat::CodexNdjson) {
ParsedCliLine::Text(t) => assert_eq!(t, "Here is the result."),
other => panic!("expected Text, got {other:?}"),
}
}
#[test]
fn test_detect_gemini_json() {
let args = vec!["--output-format".to_string(), "json".to_string()];
assert_eq!(CliOutputFormat::detect(&args), CliOutputFormat::GeminiJson);
}
#[test]
fn test_parse_gemini_json_text() {
let line = r#"{"text": "Hello from Gemini"}"#;
assert!(matches!(
parse_gemini_json(line),
ParsedCliLine::Text(t) if t == "Hello from Gemini"
));
}
#[test]
fn test_parse_gemini_json_parts() {
let line = r#"{"parts": [{"text": "part1"}, {"text": "part2"}]}"#;
assert!(matches!(
parse_gemini_json(line),
ParsedCliLine::Text(t) if t == "part1part2"
));
}
#[test]
fn test_parse_gemini_json_candidates() {
let line = r#"{"candidates": [{"content": {"parts": [{"text": "from candidates"}]}}]}"#;
assert!(matches!(
parse_gemini_json(line),
ParsedCliLine::Text(t) if t == "from candidates"
));
}
#[test]
fn test_parse_gemini_json_unknown_shape_skipped() {
let line = r#"{"unknownField": "value"}"#;
assert!(matches!(parse_gemini_json(line), ParsedCliLine::Skip));
}
#[test]
fn test_detect_gemini_json_does_not_steal_stream_json() {
let args = vec!["--output-format".to_string(), "stream-json".to_string()];
assert_eq!(
CliOutputFormat::detect(&args),
CliOutputFormat::ClaudeStreamJson
);
}
}