claude-code-sdk-rust 0.1.0

Async Rust SDK for the Claude Code CLI: streaming agent turns, tool use, and sessions.
Documentation
use claude_agent_sdk::{
    AgentDefinition, ClaudeAgentOptions, ContentBlock, MCPServerConnectionStatus, MCPServerStatus,
    MCPServerStatusConfig, MCPStatusResponse, PermissionMode, PermissionRuleValue,
    PermissionUpdate, SettingSource,
};

#[test]
fn permission_update_round_trips_python_wire_shapes() {
    let add_rules = PermissionUpdate {
        r#type: "addRules".to_string(),
        destination: Some("localSettings".to_string()),
        behavior: Some("allow".to_string()),
        rules: Some(vec![
            PermissionRuleValue {
                tool_name: "Bash".to_string(),
                rule_content: Some("npm *".to_string()),
            },
            PermissionRuleValue {
                tool_name: "Read".to_string(),
                rule_content: None,
            },
        ]),
        mode: None,
        directories: None,
    };

    assert_eq!(
        serde_json::to_value(&add_rules).unwrap(),
        serde_json::json!({
            "type": "addRules",
            "destination": "localSettings",
            "behavior": "allow",
            "rules": [
                {"toolName": "Bash", "ruleContent": "npm *"},
                {"toolName": "Read"}
            ]
        })
    );

    let set_mode: PermissionUpdate = serde_json::from_value(serde_json::json!({
        "type": "setMode",
        "mode": "acceptEdits",
        "destination": "session"
    }))
    .unwrap();
    assert_eq!(set_mode.mode, Some(PermissionMode::AcceptEdits));
    assert!(set_mode.rules.is_none());

    let directories: PermissionUpdate = serde_json::from_value(serde_json::json!({
        "type": "addDirectories",
        "directories": ["/tmp/a", "/tmp/b"],
        "destination": "userSettings"
    }))
    .unwrap();
    assert_eq!(
        directories.directories,
        Some(vec!["/tmp/a".to_string(), "/tmp/b".to_string()])
    );
}

#[test]
fn message_content_blocks_match_python_contracts() {
    let text = ContentBlock::Text {
        text: "Hello, human!".to_string(),
    };
    assert_eq!(
        serde_json::to_value(&text).unwrap(),
        serde_json::json!({"type": "text", "text": "Hello, human!"})
    );

    let thinking = ContentBlock::Thinking {
        thinking: "I'm thinking...".to_string(),
        signature: "sig-123".to_string(),
    };
    assert_eq!(
        serde_json::to_value(&thinking).unwrap(),
        serde_json::json!({
            "type": "thinking",
            "thinking": "I'm thinking...",
            "signature": "sig-123"
        })
    );

    let tool_use = ContentBlock::ToolUse {
        id: "tool-123".to_string(),
        name: "Read".to_string(),
        input: serde_json::json!({"file_path": "/test.txt"})
            .as_object()
            .unwrap()
            .clone(),
    };
    assert_eq!(
        serde_json::to_value(&tool_use).unwrap(),
        serde_json::json!({
            "type": "tool_use",
            "id": "tool-123",
            "name": "Read",
            "input": {"file_path": "/test.txt"}
        })
    );
}

#[test]
fn default_and_builder_options_cover_python_option_contracts() {
    let default_options = ClaudeAgentOptions::default();
    assert!(default_options.allowed_tools.is_empty());
    assert!(default_options.system_prompt.is_none());
    assert!(default_options.permission_mode.is_none());
    assert!(!default_options.continue_conversation);
    assert!(default_options.disallowed_tools.is_empty());

    let options = ClaudeAgentOptions::builder()
        .allowed_tools(vec![
            "Read".to_string(),
            "Write".to_string(),
            "Edit".to_string(),
        ])
        .disallowed_tools(vec!["Bash".to_string()])
        .permission_mode(PermissionMode::BypassPermissions)
        .system_prompt("You are a helpful assistant.")
        .continue_conversation(true)
        .resume("session-123")
        .model("claude-sonnet-4-5")
        .permission_prompt_tool_name("CustomTool")
        .build();

    assert_eq!(options.allowed_tools, ["Read", "Write", "Edit"]);
    assert_eq!(options.disallowed_tools, ["Bash"]);
    assert_eq!(
        options.permission_mode,
        Some(PermissionMode::BypassPermissions)
    );
    assert_eq!(
        options.system_prompt.as_deref(),
        Some("You are a helpful assistant.")
    );
    assert!(options.continue_conversation);
    assert_eq!(options.resume.as_deref(), Some("session-123"));
    assert_eq!(options.model.as_deref(), Some("claude-sonnet-4-5"));
    assert_eq!(
        options.permission_prompt_tool_name.as_deref(),
        Some("CustomTool")
    );
}

#[test]
fn agent_definition_serializes_with_cli_camelcase_keys() {
    let agent = AgentDefinition {
        description: "test".to_string(),
        prompt: "p".to_string(),
        tools: None,
        disallowed_tools: Some(vec!["Bash".to_string(), "Write".to_string()]),
        model: Some("claude-opus-4-5".to_string()),
        skills: Some(vec!["skill-a".to_string(), "skill-b".to_string()]),
        memory: Some(SettingSource::Project),
        mcp_servers: Some(vec![
            serde_json::json!("slack"),
            serde_json::json!({"local": {"command": "python", "args": ["server.py"]}}),
        ]),
        initial_prompt: Some("/review-pr 123".to_string()),
        max_turns: Some(10),
        background: None,
        effort: None,
        permission_mode: None,
    };
    let payload = serde_json::to_value(agent).unwrap();

    assert_eq!(
        payload["disallowedTools"],
        serde_json::json!(["Bash", "Write"])
    );
    assert!(payload.get("disallowed_tools").is_none());
    assert_eq!(payload["maxTurns"], 10);
    assert!(payload.get("max_turns").is_none());
    assert_eq!(payload["initialPrompt"], "/review-pr 123");
    assert!(payload.get("initial_prompt").is_none());
    assert_eq!(payload["mcpServers"][0], "slack");
    assert_eq!(payload["memory"], "project");
    assert_eq!(payload["model"], "claude-opus-4-5");
}

#[test]
fn mcp_status_types_accept_python_wire_shapes() {
    let connected: MCPServerStatus = serde_json::from_value(serde_json::json!({
        "name": "my-server",
        "status": "connected",
        "serverInfo": {"name": "my-server", "version": "1.2.3"},
        "config": {"type": "http", "url": "https://example.com"},
        "scope": "project",
        "tools": [{
            "name": "greet",
            "description": "Greet a user",
            "annotations": {
                "readOnly": true,
                "destructive": false,
                "openWorld": false
            }
        }]
    }))
    .unwrap();
    assert_eq!(connected.status, MCPServerConnectionStatus::Connected);
    assert_eq!(connected.server_info.unwrap().version, "1.2.3");
    assert!(connected.tools.unwrap()[0]
        .annotations
        .as_ref()
        .unwrap()
        .read_only
        .unwrap());

    let proxy: MCPServerStatus = serde_json::from_value(serde_json::json!({
        "name": "proxy-server",
        "status": "needs-auth",
        "config": {
            "type": "claudeai-proxy",
            "url": "https://claude.ai/proxy",
            "id": "proxy-abc"
        }
    }))
    .unwrap();
    assert!(matches!(
        proxy.config,
        Some(MCPServerStatusConfig::ClaudeAiProxy { id, .. }) if id == "proxy-abc"
    ));

    let response: MCPStatusResponse = serde_json::from_value(serde_json::json!({
        "mcpServers": [
            {"name": "a", "status": "connected"},
            {"name": "b", "status": "disabled"}
        ]
    }))
    .unwrap();
    assert_eq!(response.mcp_servers.len(), 2);
}