gate4agent 0.2.9

Universal transport library for 5 CLI AI agents (Claude Code, Codex, Gemini, Cursor, OpenCode). Pipe and PTY transports. TransportSession is a thin router over PipeSession.
Documentation
//! Pipe-mode Claude Code bindings: NDJSON parser + spawn builder.

use super::traits::{CliEvent, NdjsonParser};
use crate::utils::truncate_str;
use crate::transport::SpawnOptions;

/// Claude Code stream-json parser.
///
/// Expects output from: `claude -p "prompt" --output-format stream-json --verbose`
///
/// Event types: "system" (init), "assistant" (text/tool_use), "user" (tool_result), "result" (final)
pub struct ClaudeNdjsonParser {
    session_id: Option<String>,
}

impl ClaudeNdjsonParser {
    pub fn new() -> Self {
        Self { session_id: None }
    }
}

impl Default for ClaudeNdjsonParser {
    fn default() -> Self {
        Self::new()
    }
}

impl NdjsonParser for ClaudeNdjsonParser {
    fn parse_line(&mut self, line: &str) -> Vec<CliEvent> {
        let line = line.trim();
        if line.is_empty() {
            return vec![];
        }

        let v: serde_json::Value = match serde_json::from_str(line) {
            Ok(v) => v,
            Err(_) => {
                return vec![CliEvent::Error {
                    message: format!("invalid JSON: {}", truncate_str(line, 100)),
                }]
            }
        };

        let mut events = Vec::new();

        match v.get("type").and_then(|t| t.as_str()) {
            Some("system") => {
                let sid = v
                    .get("session_id")
                    .and_then(|s| s.as_str())
                    .unwrap_or("")
                    .to_string();
                let model = v
                    .get("model")
                    .and_then(|s| s.as_str())
                    .unwrap_or("unknown")
                    .to_string();
                let tools = v
                    .get("tools")
                    .and_then(|t| t.as_array())
                    .map(|arr| {
                        arr.iter()
                            .filter_map(|v| v.as_str().map(String::from))
                            .collect()
                    })
                    .unwrap_or_default();
                self.session_id = Some(sid.clone());
                events.push(CliEvent::SessionStart {
                    session_id: sid,
                    model,
                    tools,
                });
            }
            Some("assistant") => {
                if let Some(content) =
                    v.pointer("/message/content").and_then(|c| c.as_array())
                {
                    for block in content {
                        match block.get("type").and_then(|t| t.as_str()) {
                            Some("text") => {
                                if let Some(text) =
                                    block.get("text").and_then(|t| t.as_str())
                                {
                                    events.push(CliEvent::AssistantText {
                                        text: text.to_string(),
                                        is_delta: false,
                                    });
                                }
                            }
                            Some("tool_use") => {
                                let id = block
                                    .get("id")
                                    .and_then(|s| s.as_str())
                                    .unwrap_or("")
                                    .to_string();
                                let name = block
                                    .get("name")
                                    .and_then(|s| s.as_str())
                                    .unwrap_or("")
                                    .to_string();
                                let input = block
                                    .get("input")
                                    .cloned()
                                    .unwrap_or(serde_json::Value::Null);
                                events.push(CliEvent::ToolCallStart { id, name, input });
                            }
                            Some("thinking") => {
                                if let Some(text) =
                                    block.get("thinking").and_then(|t| t.as_str())
                                {
                                    events.push(CliEvent::Thinking {
                                        text: text.to_string(),
                                    });
                                }
                            }
                            _ => {}
                        }
                    }
                }
                if let Some(usage) = v.pointer("/message/usage") {
                    let input = usage
                        .get("input_tokens")
                        .and_then(|v| v.as_u64())
                        .unwrap_or(0);
                    let output = usage
                        .get("output_tokens")
                        .and_then(|v| v.as_u64())
                        .unwrap_or(0);
                    if input > 0 || output > 0 {
                        events.push(CliEvent::TurnComplete {
                            input_tokens: input,
                            output_tokens: output,
                        });
                    }
                }
            }
            Some("user") => {
                if let Some(content) =
                    v.pointer("/message/content").and_then(|c| c.as_array())
                {
                    for block in content {
                        if block.get("type").and_then(|t| t.as_str())
                            == Some("tool_result")
                        {
                            let id = block
                                .get("tool_use_id")
                                .and_then(|s| s.as_str())
                                .unwrap_or("")
                                .to_string();
                            let output = block
                                .get("content")
                                .and_then(|s| s.as_str())
                                .unwrap_or("")
                                .to_string();
                            let is_error = block
                                .get("is_error")
                                .and_then(|b| b.as_bool())
                                .unwrap_or(false);
                            let duration_ms = v
                                .pointer("/tool_use_result/durationMs")
                                .and_then(|d| d.as_u64());
                            events.push(CliEvent::ToolCallResult {
                                id,
                                output,
                                is_error,
                                duration_ms,
                            });
                        }
                    }
                }
            }
            Some("result") => {
                let result_text = v
                    .get("result")
                    .and_then(|s| s.as_str())
                    .unwrap_or("")
                    .to_string();
                let cost = v.get("total_cost_usd").and_then(|c| c.as_f64());
                let is_error = v
                    .get("is_error")
                    .and_then(|b| b.as_bool())
                    .unwrap_or(false);
                events.push(CliEvent::SessionEnd {
                    result: result_text,
                    cost_usd: cost,
                    is_error,
                });
            }
            Some("stream_event") => {
                if let Some(delta_text) = v.pointer("/event/delta/text") {
                    if let Some(text) = delta_text.as_str() {
                        events.push(CliEvent::AssistantText {
                            text: text.to_string(),
                            is_delta: true,
                        });
                    }
                }
            }
            _ => {}
        }

        events
    }

    fn session_id(&self) -> Option<&str> {
        self.session_id.as_deref()
    }
}

/// Pipe-mode spawn builder for Claude Code.
///
/// Argv produced (default, no permission_mode):
///   `claude -p --output-format stream-json --verbose --dangerously-skip-permissions`
///   `[--append-system-prompt "<text>"] [--resume <id> | --continue] [--model <m>]`
///   `[--allowedTools <tools>] [--permission-mode <mode>] [--mcp-config <path>]`
///   `[--max-turns <N>] [<extra>...]`
///
/// When `permission_mode` is set, `--dangerously-skip-permissions` is omitted and
/// `--permission-mode <value>` is added instead.
///
/// The initial prompt is **not** included in argv — it is written to stdin by
/// the caller (`pipe/process.rs`) after spawn.
pub struct ClaudePipeBuilder;

impl super::traits::CliCommandBuilder for ClaudePipeBuilder {
    fn build_command(&self, opts: &SpawnOptions) -> std::process::Command {
        let mut cmd = std::process::Command::new("claude");
        cmd.arg("-p");
        cmd.arg("--output-format");
        cmd.arg("stream-json");
        cmd.arg("--verbose");

        // --dangerously-skip-permissions is the default, but omitted when the
        // caller explicitly sets permission_mode (they conflict).
        if opts.permission_mode.is_none() {
            cmd.arg("--dangerously-skip-permissions");
        }

        if let Some(ref system_prompt) = opts.append_system_prompt {
            cmd.arg("--append-system-prompt");
            cmd.arg(system_prompt);
        }

        if let Some(ref session_id) = opts.resume_session_id {
            cmd.arg("--resume");
            cmd.arg(session_id);
        } else if opts.continue_last {
            cmd.arg("--continue");
        }

        if let Some(ref model) = opts.model {
            cmd.arg("--model");
            cmd.arg(model);
        }

        if !opts.allowed_tools.is_empty() {
            cmd.arg("--allowedTools");
            cmd.arg(opts.allowed_tools.join(","));
        }

        if let Some(ref mode) = opts.permission_mode {
            cmd.arg("--permission-mode");
            cmd.arg(mode);
        }

        if let Some(ref mcp_config) = opts.mcp_config {
            cmd.arg("--mcp-config");
            cmd.arg(mcp_config);
        }

        if let Some(max_turns) = opts.max_turns {
            cmd.arg("--max-turns");
            cmd.arg(max_turns.to_string());
        }

        for arg in &opts.extra_args {
            cmd.arg(arg);
        }
        // No prompt in argv — delivered via stdin after spawn.
        cmd
    }
}

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

    #[test]
    fn claude_parses_system_event() {
        let mut parser = ClaudeNdjsonParser::new();
        let line = r#"{"type":"system","session_id":"abc123","model":"claude-opus-4","tools":["Bash","Read"]}"#;
        let events = parser.parse_line(line);
        assert_eq!(events.len(), 1);
        match &events[0] {
            CliEvent::SessionStart { session_id, model, tools } => {
                assert_eq!(session_id, "abc123");
                assert_eq!(model, "claude-opus-4");
                assert_eq!(tools, &["Bash", "Read"]);
            }
            _ => panic!("expected SessionStart"),
        }
    }

    #[test]
    fn claude_parses_stream_delta() {
        let mut parser = ClaudeNdjsonParser::new();
        let line = r#"{"type":"stream_event","event":{"delta":{"text":"Hello "}}}"#;
        let events = parser.parse_line(line);
        assert_eq!(events.len(), 1);
        match &events[0] {
            CliEvent::AssistantText { text, is_delta } => {
                assert_eq!(text, "Hello ");
                assert!(*is_delta);
            }
            _ => panic!("expected AssistantText"),
        }
    }

    #[test]
    fn malformed_json_returns_error_not_panic() {
        let mut parser = ClaudeNdjsonParser::new();
        let events = parser.parse_line("this is not json {{{");
        assert_eq!(events.len(), 1);
        assert!(matches!(&events[0], CliEvent::Error { .. }));
    }

    #[test]
    fn empty_line_returns_no_events() {
        let mut parser = ClaudeNdjsonParser::new();
        assert!(parser.parse_line("").is_empty());
        assert!(parser.parse_line("   ").is_empty());
    }
}