atomr_agents_coding_cli_vendor_claude/
parser.rs1use atomr_agents_coding_cli_core::{
10 CliEventParser, CliVendorKind, CodingCliEvent, FinishReason, McpServerInit, ParseError,
11 ToolDescriptorInit,
12};
13use serde::Deserialize;
14use serde_json::Value;
15
16#[derive(Default)]
17pub struct ClaudeParser;
18
19impl ClaudeParser {
20 pub fn new() -> Self {
21 Self
22 }
23}
24
25impl CliEventParser for ClaudeParser {
26 fn parse_line(&mut self, line: &str) -> Result<Vec<CodingCliEvent>, ParseError> {
27 let trimmed = line.trim();
28 if trimmed.is_empty() {
29 return Ok(Vec::new());
30 }
31 let value: Value = serde_json::from_str(trimmed)?;
32 Ok(normalize(&value))
33 }
34
35 fn flush(&mut self) -> Result<Vec<CodingCliEvent>, ParseError> {
36 Ok(Vec::new())
37 }
38}
39
40fn normalize(v: &Value) -> Vec<CodingCliEvent> {
41 let kind = v.get("type").and_then(Value::as_str).unwrap_or("");
42 match kind {
43 "system" => normalize_system(v),
44 "stream_event" => normalize_stream_event(v),
45 "tool_use" => vec![normalize_tool_use(v)],
46 "tool_result" => vec![normalize_tool_result(v)],
47 "result" => vec![normalize_result(v)],
48 _ => vec![CodingCliEvent::RawVendorEvent {
49 vendor: CliVendorKind::Claude,
50 payload: v.clone(),
51 }],
52 }
53}
54
55fn normalize_system(v: &Value) -> Vec<CodingCliEvent> {
56 let subtype = v.get("subtype").and_then(Value::as_str).unwrap_or("");
57 match subtype {
58 "init" => {
59 #[derive(Deserialize)]
60 struct Tool {
61 #[serde(default)]
62 name: String,
63 #[serde(default)]
64 description: Option<String>,
65 }
66 #[derive(Deserialize)]
67 struct Mcp {
68 #[serde(default)]
69 name: String,
70 #[serde(default)]
71 status: Option<String>,
72 }
73 let tools: Vec<Tool> = v
74 .get("tools")
75 .and_then(|t| serde_json::from_value(t.clone()).ok())
76 .unwrap_or_default();
77 let mcp: Vec<Mcp> = v
78 .get("mcp_servers")
79 .and_then(|t| serde_json::from_value(t.clone()).ok())
80 .unwrap_or_default();
81 vec![CodingCliEvent::SystemInit {
82 tools: tools
83 .into_iter()
84 .map(|t| ToolDescriptorInit {
85 name: t.name,
86 description: t.description,
87 })
88 .collect(),
89 mcp_servers: mcp
90 .into_iter()
91 .map(|m| McpServerInit {
92 name: m.name,
93 status: m.status,
94 })
95 .collect(),
96 plugins: Vec::new(),
97 }]
98 }
99 "api_retry" | "api_error_retry" => {
100 let attempt = v.get("attempt").and_then(Value::as_u64).unwrap_or(0) as u32;
101 let delay_ms = v.get("delay_ms").and_then(Value::as_u64).unwrap_or(0);
102 let reason = v
103 .get("error")
104 .and_then(Value::as_str)
105 .unwrap_or("retry")
106 .to_string();
107 vec![CodingCliEvent::ApiRetry {
108 attempt,
109 delay_ms,
110 reason,
111 }]
112 }
113 _ => vec![CodingCliEvent::RawVendorEvent {
114 vendor: CliVendorKind::Claude,
115 payload: v.clone(),
116 }],
117 }
118}
119
120fn normalize_stream_event(v: &Value) -> Vec<CodingCliEvent> {
124 if let Some(text) = v
125 .pointer("/event/delta/text")
126 .and_then(Value::as_str)
127 {
128 return vec![CodingCliEvent::AssistantTextDelta {
129 text: text.to_string(),
130 }];
131 }
132 if let Some(text) = v
133 .pointer("/delta/text")
134 .and_then(Value::as_str)
135 {
136 return vec![CodingCliEvent::AssistantTextDelta {
137 text: text.to_string(),
138 }];
139 }
140 vec![CodingCliEvent::RawVendorEvent {
141 vendor: CliVendorKind::Claude,
142 payload: v.clone(),
143 }]
144}
145
146fn normalize_tool_use(v: &Value) -> CodingCliEvent {
147 let id = v
148 .get("id")
149 .or_else(|| v.get("tool_use_id"))
150 .and_then(Value::as_str)
151 .unwrap_or("")
152 .to_string();
153 let name = v.get("name").and_then(Value::as_str).unwrap_or("").to_string();
154 let input = v.get("input").cloned().unwrap_or(Value::Null);
155 CodingCliEvent::ToolCallStarted {
156 tool_call_id: id,
157 name,
158 input,
159 }
160}
161
162fn normalize_tool_result(v: &Value) -> CodingCliEvent {
163 let id = v
164 .get("tool_use_id")
165 .or_else(|| v.get("id"))
166 .and_then(Value::as_str)
167 .unwrap_or("")
168 .to_string();
169 let error = v
170 .get("error")
171 .and_then(Value::as_str)
172 .map(|s| s.to_string());
173 let output = v.get("content").or_else(|| v.get("output")).cloned();
174 CodingCliEvent::ToolCallFinished {
175 tool_call_id: id,
176 output,
177 error,
178 }
179}
180
181fn normalize_result(v: &Value) -> CodingCliEvent {
182 let result_text = v.get("result").and_then(Value::as_str).map(|s| s.to_string());
183 let is_error = v.get("is_error").and_then(Value::as_bool).unwrap_or(false);
184 CodingCliEvent::RunFinished {
185 reason: if is_error {
186 FinishReason::ProcessError
187 } else {
188 FinishReason::Completed
189 },
190 result_text,
191 }
192}
193
194#[cfg(test)]
195mod tests {
196 use super::*;
197
198 #[test]
199 fn parses_system_init() {
200 let mut p = ClaudeParser::new();
201 let line = r#"{"type":"system","subtype":"init","tools":[{"name":"Bash","description":"Run shell"},{"name":"Read"}],"mcp_servers":[{"name":"linear","status":"connected"}]}"#;
202 let events = p.parse_line(line).unwrap();
203 assert_eq!(events.len(), 1);
204 match &events[0] {
205 CodingCliEvent::SystemInit { tools, mcp_servers, .. } => {
206 assert_eq!(tools.len(), 2);
207 assert_eq!(tools[0].name, "Bash");
208 assert_eq!(mcp_servers.len(), 1);
209 }
210 ev => panic!("expected SystemInit, got {ev:?}"),
211 }
212 }
213
214 #[test]
215 fn parses_text_delta() {
216 let mut p = ClaudeParser::new();
217 let line = r#"{"type":"stream_event","event":{"delta":{"text":"Hello"}}}"#;
218 let events = p.parse_line(line).unwrap();
219 assert!(matches!(
220 &events[0],
221 CodingCliEvent::AssistantTextDelta { text } if text == "Hello"
222 ));
223 }
224
225 #[test]
226 fn parses_tool_use_and_result() {
227 let mut p = ClaudeParser::new();
228 let line1 = r#"{"type":"tool_use","id":"toolu_01","name":"Read","input":{"path":"a.rs"}}"#;
229 let line2 = r#"{"type":"tool_result","tool_use_id":"toolu_01","content":"file contents"}"#;
230 let ev1 = p.parse_line(line1).unwrap();
231 let ev2 = p.parse_line(line2).unwrap();
232 assert!(matches!(&ev1[0], CodingCliEvent::ToolCallStarted { name, .. } if name == "Read"));
233 assert!(matches!(&ev2[0], CodingCliEvent::ToolCallFinished { error: None, .. }));
234 }
235
236 #[test]
237 fn parses_result_envelope() {
238 let mut p = ClaudeParser::new();
239 let line = r#"{"type":"result","result":"All done","is_error":false}"#;
240 let ev = p.parse_line(line).unwrap();
241 assert!(matches!(
242 &ev[0],
243 CodingCliEvent::RunFinished {
244 reason: FinishReason::Completed,
245 result_text: Some(t)
246 } if t == "All done"
247 ));
248 }
249
250 #[test]
251 fn unknown_type_passes_through_as_raw() {
252 let mut p = ClaudeParser::new();
253 let line = r#"{"type":"new_event_we_dont_know_yet","payload":42}"#;
254 let ev = p.parse_line(line).unwrap();
255 assert!(matches!(&ev[0], CodingCliEvent::RawVendorEvent { .. }));
256 }
257
258 #[test]
259 fn empty_line_is_ignored() {
260 let mut p = ClaudeParser::new();
261 assert!(p.parse_line("").unwrap().is_empty());
262 assert!(p.parse_line(" ").unwrap().is_empty());
263 }
264}