claus 0.2.1

An I/O less Anthropic API implementation
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
//! Claude Code stream-json protocol types.
//!
//! Defines envelope types for Claude Code's `--output-format stream-json` protocol. The protocol
//! wraps Anthropic API types (from [`crate::anthropic`]) with session metadata.
//!
//! # Protocol Overview
//!
//! Claude Code emits newline-delimited JSON (NDJSON) on stdout. Each line is a JSON object with a
//! `type` field that determines the message variant:
//!
//! - `system` — Session initialization with available tools, model, and configuration
//! - `stream_event` — Wrapped Anthropic API streaming events (only with --include-partial-messages)
//! - `assistant` — Complete assistant message after streaming finishes
//! - `user` — Echoed user messages or tool results
//! - `result` — Final result with statistics (cost, duration, token usage)

use std::process::ExitStatus;

use serde::{Deserialize, Serialize};

use crate::anthropic::{Content, Message, Role, ServerToolUsage, StreamEvent, StreamingMessage};

/// Error from running Claude Code.
#[derive(Debug, thiserror::Error)]
pub enum RunError {
    /// Process exited with non-zero status.
    #[error("process exited with {status}")]
    ProcessFailed {
        /// Process exit status.
        status: ExitStatus,
        /// Captured stderr.
        stderr: String,
    },
    /// No output messages received.
    #[error("no output messages")]
    NoMessages,
    /// Final message was not a result.
    #[error("final message was not a result")]
    MissingResult,
    /// Claude reported an error in the result.
    #[error("claude error: {}", .messages.last().and_then(|m| match m {
        OutputMessage::Result(r) => r.result.as_deref(),
        _ => None,
    }).unwrap_or("unknown"))]
    ResultError {
        /// All messages including the error result.
        messages: Vec<OutputMessage>,
    },
    /// Failed to parse a message.
    #[error("failed to parse message")]
    Parse(#[from] serde_json::Error),
}

/// Parses a single line of Claude Code output.
///
/// Returns `None` for empty lines, `Some(Ok(...))` for valid messages, or
/// `Some(Err(...))` for parse errors.
pub fn parse_line(line: &str) -> Option<Result<OutputMessage, serde_json::Error>> {
    if line.is_empty() {
        return None;
    }
    Some(serde_json::from_str(line))
}

/// Parses Claude Code process output.
///
/// Returns an error if:
/// - The process exited with non-zero status
/// - No messages were received
/// - The final message is not a result
/// - The result indicates an error (`is_error: true`)
/// - Any message fails to parse
///
/// Works with both `std::process::Output` and `tokio::process::Output` (same type).
///
/// # Example
///
/// ```no_run
/// use claus::claudio::{CliBuilder, protocol::{parse_output, OutputMessage}};
///
/// let output = CliBuilder::headless()
///     .prompt("Hello")
///     .build()
///     .output()
///     .expect("failed to run");
///
/// for msg in parse_output(&output).expect("claude failed") {
///     match msg {
///         OutputMessage::Assistant(a) => {
///             println!("Assistant: {:?}", a.message.content);
///         }
///         OutputMessage::Result(r) => {
///             println!("Done: ${:.4}", r.total_cost_usd);
///         }
///         _ => {}
///     }
/// }
/// ```
pub fn parse_output(output: &std::process::Output) -> Result<Vec<OutputMessage>, RunError> {
    if !output.status.success() {
        return Err(RunError::ProcessFailed {
            status: output.status,
            stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
        });
    }

    let stdout = String::from_utf8_lossy(&output.stdout);
    let messages: Vec<OutputMessage> = stdout
        .lines()
        .filter(|line| !line.is_empty())
        .map(serde_json::from_str)
        .collect::<Result<_, _>>()?;

    match messages.last() {
        None => Err(RunError::NoMessages),
        Some(OutputMessage::Result(r)) if r.is_error => Err(RunError::ResultError { messages }),
        Some(OutputMessage::Result(_)) => Ok(messages),
        Some(_) => Err(RunError::MissingResult),
    }
}

/// Common envelope fields for Claude Code messages.
///
/// Most message types include session metadata. Use `#[serde(flatten)]` to embed these fields.
#[derive(Clone, Debug, Deserialize)]
pub struct Envelope {
    /// Session identifier.
    pub session_id: String,
    /// Parent tool use ID if this message is part of a tool execution.
    #[serde(default)]
    pub parent_tool_use_id: Option<String>,
    /// Message UUID.
    pub uuid: String,
}

/// Message from Claude Code stdout.
///
/// Each line of stdout is a JSON object representing a message, with a
/// `type` field that determines the variant.
#[derive(Debug, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum OutputMessage {
    /// Session initialization.
    System(SystemMessage),
    /// Wrapped Anthropic API streaming event.
    StreamEvent(StreamEventMessage),
    /// Complete assistant message.
    Assistant(AssistantMessage),
    /// Echoed user message or tool result.
    User(UserMessage),
    /// Final result of a conversation turn.
    Result(ResultMessage),
}

/// System initialization message.
///
/// Sent at the start of a session with configuration details.
#[derive(Clone, Debug, Deserialize)]
pub struct SystemMessage {
    /// Message subtype (e.g., `"init"`).
    pub subtype: String,
    /// Current working directory.
    pub cwd: String,
    /// Available tools.
    pub tools: Vec<String>,
    /// Configured MCP servers.
    #[serde(default)]
    pub mcp_servers: Vec<McpServerStatus>,
    /// Model identifier.
    pub model: String,
    /// Permission mode.
    #[serde(rename = "permissionMode")]
    pub permission_mode: String,
    /// Available slash commands.
    #[serde(default)]
    pub slash_commands: Vec<String>,
    /// API key source.
    #[serde(rename = "apiKeySource")]
    pub api_key_source: String,
    /// Claude Code version.
    pub claude_code_version: String,
    /// Output style.
    pub output_style: String,
    /// Available agents.
    #[serde(default)]
    pub agents: Vec<String>,
    /// Available skills.
    #[serde(default)]
    pub skills: Vec<String>,
    /// Loaded plugins.
    #[serde(default)]
    pub plugins: Vec<String>,
    /// Session metadata.
    #[serde(flatten)]
    pub envelope: Envelope,
}

/// MCP server status in system init.
#[derive(Clone, Debug, Deserialize)]
pub struct McpServerStatus {
    /// Server name.
    pub name: String,
    /// Connection status.
    pub status: String,
}

/// Wrapped Anthropic streaming event.
///
/// Contains a [`StreamEvent`] from the Anthropic API along with session metadata.
#[derive(Debug, Deserialize)]
pub struct StreamEventMessage {
    /// The Anthropic streaming event.
    pub event: StreamEvent,
    /// Session metadata.
    #[serde(flatten)]
    pub envelope: Envelope,
}

/// Complete assistant message.
///
/// Contains a full message from the Anthropic API with response metadata. Sent after all
/// streaming events for a message have been delivered.
#[derive(Debug, Deserialize)]
pub struct AssistantMessage {
    /// The complete Anthropic message.
    pub message: StreamingMessage,
    /// Session metadata.
    #[serde(flatten)]
    pub envelope: Envelope,
}

/// Echoed user message or tool result.
#[derive(Clone, Debug, Deserialize)]
pub struct UserMessage {
    /// The user message content.
    pub message: Message,
    /// Session metadata.
    #[serde(flatten)]
    pub envelope: Envelope,
    /// Structured metadata about tool result (present when message contains a tool result).
    #[serde(default)]
    pub tool_use_result: Option<serde_json::Value>,
}

/// Final result of a conversation turn.
#[derive(Clone, Debug, Deserialize)]
pub struct ResultMessage {
    /// Result subtype (`"success"`, `"error_max_turns"`, etc.).
    pub subtype: String,
    /// Whether this represents an error.
    pub is_error: bool,
    /// Total duration in milliseconds.
    pub duration_ms: u64,
    /// API call duration in milliseconds.
    #[serde(default)]
    pub duration_api_ms: u64,
    /// Number of conversation turns.
    #[serde(default)]
    pub num_turns: u32,
    /// Final text result.
    pub result: Option<String>,
    /// Total cost in USD.
    #[serde(default)]
    pub total_cost_usd: f64,
    /// Token usage statistics.
    pub usage: ResultUsage,
    /// Per-model usage breakdown.
    #[serde(default, rename = "modelUsage")]
    pub model_usage: serde_json::Map<String, serde_json::Value>,
    /// Permission denials during this turn.
    #[serde(default)]
    pub permission_denials: Vec<serde_json::Value>,
    /// Session metadata.
    #[serde(flatten)]
    pub envelope: Envelope,
}

/// Token usage statistics in result message.
#[derive(Clone, Debug, Default, Deserialize)]
pub struct ResultUsage {
    /// Input tokens.
    #[serde(default)]
    pub input_tokens: u32,
    /// Cache creation input tokens.
    #[serde(default)]
    pub cache_creation_input_tokens: u32,
    /// Cache read input tokens.
    #[serde(default)]
    pub cache_read_input_tokens: u32,
    /// Output tokens.
    #[serde(default)]
    pub output_tokens: u32,
    /// Server tool use statistics.
    #[serde(default)]
    pub server_tool_use: ServerToolUsage,
    /// Service tier.
    #[serde(default)]
    pub service_tier: String,
    /// Cache creation breakdown.
    #[serde(default)]
    pub cache_creation: CacheCreation,
}

/// Cache creation breakdown.
#[derive(Clone, Debug, Default, Deserialize)]
pub struct CacheCreation {
    /// Tokens in 1-hour ephemeral cache.
    #[serde(default)]
    pub ephemeral_1h_input_tokens: u32,
    /// Tokens in 5-minute ephemeral cache.
    #[serde(default)]
    pub ephemeral_5m_input_tokens: u32,
}

// --- Input types ---

/// Input message to Claude Code stdin.
///
/// Send as newline-delimited JSON when using `--input-format stream-json`.
#[derive(Clone, Debug, Serialize)]
pub struct InputMessage {
    /// Message type (always `"user"`).
    #[serde(rename = "type")]
    message_type: &'static str,
    /// The message content.
    pub message: Message,
}

impl InputMessage {
    /// Creates a text input message.
    pub fn text(text: impl Into<String>) -> Self {
        Self {
            message_type: "user",
            message: Message::from_text(Role::User, text),
        }
    }

    /// Creates an input message with custom content blocks.
    pub fn with_content(content: Vec<Content>) -> Self {
        Self {
            message_type: "user",
            message: Message {
                role: Role::User,
                content,
            },
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parse_system_init() {
        let json = r#"{
            "type": "system",
            "subtype": "init",
            "cwd": "/home/user/project",
            "session_id": "6484002d-24fe-4f95-ad4b-6bf7130f1fcb",
            "tools": ["Bash", "Read", "Write"],
            "mcp_servers": [],
            "model": "claude-opus-4-5-20251101",
            "permissionMode": "default",
            "slash_commands": ["commit"],
            "apiKeySource": "none",
            "claude_code_version": "2.1.17",
            "output_style": "default",
            "agents": [],
            "skills": [],
            "plugins": [],
            "uuid": "f34a0e91-06ae-426c-9e5c-317a7572ff29"
        }"#;

        let msg: OutputMessage = serde_json::from_str(json).expect("parse");
        match msg {
            OutputMessage::System(sys) => {
                assert_eq!(sys.subtype, "init");
                assert_eq!(sys.cwd, "/home/user/project");
                assert_eq!(sys.tools, vec!["Bash", "Read", "Write"]);
                assert_eq!(sys.model, "claude-opus-4-5-20251101");
                assert_eq!(sys.permission_mode, "default");
            }
            _ => panic!("expected System variant"),
        }
    }

    #[test]
    fn parse_result_success() {
        let json = r#"{
            "type": "result",
            "subtype": "success",
            "is_error": false,
            "duration_ms": 2633,
            "duration_api_ms": 2600,
            "num_turns": 1,
            "result": "hello",
            "session_id": "6484002d-24fe-4f95-ad4b-6bf7130f1fcb",
            "total_cost_usd": 0.12779625,
            "usage": {
                "input_tokens": 3,
                "cache_creation_input_tokens": 20429,
                "cache_read_input_tokens": 0,
                "output_tokens": 4,
                "server_tool_use": {"web_search_requests": 0, "web_fetch_requests": 0},
                "service_tier": "standard",
                "cache_creation": {"ephemeral_1h_input_tokens": 0, "ephemeral_5m_input_tokens": 20429}
            },
            "modelUsage": {},
            "permission_denials": [],
            "uuid": "4e5d6b84-6129-47b3-bba6-fdb375aa7b3d"
        }"#;

        let msg: OutputMessage = serde_json::from_str(json).expect("parse");
        match msg {
            OutputMessage::Result(res) => {
                assert_eq!(res.subtype, "success");
                assert!(!res.is_error);
                assert_eq!(res.duration_ms, 2633);
                assert_eq!(res.result, Some("hello".to_string()));
                assert_eq!(res.usage.input_tokens, 3);
                assert_eq!(res.usage.cache_creation_input_tokens, 20429);
            }
            _ => panic!("expected Result variant"),
        }
    }

    #[test]
    fn parse_assistant() {
        let json = r#"{
            "type": "assistant",
            "message": {
                "model": "claude-opus-4-5-20251101",
                "id": "msg_016erzjGS5oTB6Q8uohJEpAs",
                "type": "message",
                "role": "assistant",
                "content": [{"type": "text", "text": "hello"}],
                "stop_reason": null,
                "stop_sequence": null,
                "usage": {
                    "input_tokens": 3,
                    "output_tokens": 1
                }
            },
            "parent_tool_use_id": null,
            "session_id": "6484002d-24fe-4f95-ad4b-6bf7130f1fcb",
            "uuid": "9e40ea6e-9f3e-43e1-a6c0-59e9de6c347f"
        }"#;

        let msg: OutputMessage = serde_json::from_str(json).expect("parse");
        match msg {
            OutputMessage::Assistant(asst) => {
                assert_eq!(asst.message.id, "msg_016erzjGS5oTB6Q8uohJEpAs");
                assert_eq!(asst.message.content.len(), 1);
                assert!(asst.envelope.parent_tool_use_id.is_none());
            }
            _ => panic!("expected Assistant variant"),
        }
    }

    #[test]
    fn serialize_input_text() {
        let input = InputMessage::text("hello world");
        let json = serde_json::to_value(&input).expect("serialize");

        assert_eq!(json["type"], "user");
        assert_eq!(json["message"]["role"], "user");
        assert_eq!(json["message"]["content"][0]["type"], "text");
        assert_eq!(json["message"]["content"][0]["text"], "hello world");
    }

    #[test]
    fn parse_user_tool_result() {
        use crate::anthropic::Role;

        let json = r#"{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01TV2WdLXaSZwBGgKGPvLEmy","type":"tool_result","content":"hello"}]},"parent_tool_use_id":null,"session_id":"bf7004a5-4781-4c4e-bd35-6f4516db86fd","uuid":"dfc99bb7-55dc-4829-87a8-e9fd6333f970","tool_use_result":{"type":"text","file":{"filePath":"/tmp/hello.txt"}}}"#;

        let msg: OutputMessage = serde_json::from_str(json).expect("parse");
        match msg {
            OutputMessage::User(user) => {
                assert_eq!(user.message.role, Role::User);
                assert_eq!(user.message.content.len(), 1);
                assert!(user.tool_use_result.is_some());
            }
            _ => panic!("expected User variant"),
        }
    }

    /// Test parsing actual Claude CLI output.
    #[test]
    fn parse_real_assistant_with_tool_use() {
        let json = r#"{"type":"assistant","message":{"model":"claude-opus-4-5-20251101","id":"msg_01UQFX7fDMP5CKAWQWTgtodQ","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01TV2WdLXaSZwBGgKGPvLEmy","name":"Read","input":{"file_path":"/tmp/hello.txt"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":3,"cache_creation_input_tokens":22175,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":22175,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"bf7004a5-4781-4c4e-bd35-6f4516db86fd","uuid":"a3c66f24-58f3-4727-b052-961d2205958f"}"#;

        let msg: OutputMessage = serde_json::from_str(json).expect("parse");
        match msg {
            OutputMessage::Assistant(asst) => {
                assert_eq!(asst.message.model, "claude-opus-4-5-20251101");
                assert_eq!(asst.message.content.len(), 1);
            }
            _ => panic!("expected Assistant variant"),
        }
    }
}