pub mod logs;
pub mod models;
use crate::agent::{Agent, ModelSize};
pub fn projects_dir() -> Option<std::path::PathBuf> {
dirs::home_dir().map(|h| h.join(".claude/projects"))
}
use crate::output::AgentOutput;
use crate::providers::common::CommonAgentState;
use anyhow::{Context, Result};
use async_trait::async_trait;
use std::process::Stdio;
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::Command;
pub const DEFAULT_MODEL: &str = "default";
pub const AVAILABLE_MODELS: &[&str] = &["default", "sonnet", "opus", "haiku"];
pub type EventHandler = Box<dyn Fn(&crate::output::Event, bool) + Send + Sync>;
pub struct Claude {
pub common: CommonAgentState,
pub session_id: Option<String>,
pub input_format: Option<String>,
pub verbose: bool,
pub json_schema: Option<String>,
pub event_handler: Option<EventHandler>,
pub replay_user_messages: bool,
pub include_partial_messages: bool,
pub mcp_config_path: Option<String>,
}
impl Claude {
pub fn new() -> Self {
Self {
common: CommonAgentState::new(DEFAULT_MODEL),
session_id: None,
input_format: None,
verbose: false,
json_schema: None,
event_handler: None,
replay_user_messages: false,
include_partial_messages: false,
mcp_config_path: None,
}
}
pub fn set_input_format(&mut self, format: Option<String>) {
self.input_format = format;
}
pub fn set_session_id(&mut self, session_id: String) {
self.session_id = Some(session_id);
}
pub fn set_verbose(&mut self, verbose: bool) {
self.verbose = verbose;
}
pub fn set_json_schema(&mut self, schema: Option<String>) {
self.json_schema = schema;
}
pub fn set_replay_user_messages(&mut self, replay: bool) {
self.replay_user_messages = replay;
}
pub fn set_include_partial_messages(&mut self, include: bool) {
self.include_partial_messages = include;
}
pub fn set_mcp_config(&mut self, config: Option<String>) {
self.mcp_config_path = config.map(|c| {
if c.trim_start().starts_with('{') {
let path =
std::env::temp_dir().join(format!("zag-mcp-{}.json", uuid::Uuid::new_v4()));
if let Err(e) = std::fs::write(&path, &c) {
log::warn!("Failed to write MCP config temp file: {e}");
return c;
}
path.to_string_lossy().into_owned()
} else {
c
}
});
}
pub fn set_event_handler(&mut self, handler: EventHandler) {
self.event_handler = Some(handler);
}
fn build_run_args(
&self,
interactive: bool,
prompt: Option<&str>,
effective_output_format: &Option<String>,
) -> Vec<String> {
let mut args = Vec::new();
let in_sandbox = self.common.sandbox.is_some();
if !interactive {
args.push("--print".to_string());
match effective_output_format.as_deref() {
Some("json") | Some("json-pretty") => {
args.extend(["--verbose", "--output-format", "json"].map(String::from));
}
Some("stream-json") | None => {
args.extend(["--verbose", "--output-format", "stream-json"].map(String::from));
}
Some("native-json") => {
args.extend(["--verbose", "--output-format", "json"].map(String::from));
}
Some("text") => {}
_ => {}
}
}
if self.common.skip_permissions && !in_sandbox {
args.push("--dangerously-skip-permissions".to_string());
}
args.extend(["--model".to_string(), self.common.model.clone()]);
if interactive && let Some(session_id) = &self.session_id {
args.extend(["--session-id".to_string(), session_id.clone()]);
}
for dir in &self.common.add_dirs {
args.extend(["--add-dir".to_string(), dir.clone()]);
}
if !self.common.system_prompt.is_empty() {
args.extend([
"--append-system-prompt".to_string(),
self.common.system_prompt.clone(),
]);
}
if !interactive && let Some(ref input_fmt) = self.input_format {
args.extend(["--input-format".to_string(), input_fmt.clone()]);
}
if !interactive && self.replay_user_messages {
args.push("--replay-user-messages".to_string());
}
if !interactive && self.include_partial_messages {
args.push("--include-partial-messages".to_string());
}
if let Some(ref schema) = self.json_schema {
args.extend(["--json-schema".to_string(), schema.clone()]);
}
if let Some(turns) = self.common.max_turns {
args.extend(["--max-turns".to_string(), turns.to_string()]);
}
if let Some(ref path) = self.mcp_config_path {
args.extend(["--mcp-config".to_string(), path.clone()]);
}
if let Some(p) = prompt {
args.push(p.to_string());
}
args
}
fn build_resume_args(&self, session_id: Option<&str>) -> Vec<String> {
let mut args = Vec::new();
let in_sandbox = self.common.sandbox.is_some();
if let Some(id) = session_id {
args.extend(["--resume".to_string(), id.to_string()]);
} else {
args.push("--continue".to_string());
}
if self.common.skip_permissions && !in_sandbox {
args.push("--dangerously-skip-permissions".to_string());
}
args.extend(["--model".to_string(), self.common.model.clone()]);
for dir in &self.common.add_dirs {
args.extend(["--add-dir".to_string(), dir.clone()]);
}
args
}
fn make_command(&self, agent_args: Vec<String>) -> Command {
self.common.make_command("claude", agent_args)
}
pub fn execute_streaming(
&self,
prompt: Option<&str>,
) -> Result<crate::streaming::StreamingSession> {
let mut args = Vec::new();
let in_sandbox = self.common.sandbox.is_some();
args.push("--print".to_string());
args.extend(["--verbose", "--output-format", "stream-json"].map(String::from));
if self.common.skip_permissions && !in_sandbox {
args.push("--dangerously-skip-permissions".to_string());
}
args.extend(["--model".to_string(), self.common.model.clone()]);
for dir in &self.common.add_dirs {
args.extend(["--add-dir".to_string(), dir.clone()]);
}
if !self.common.system_prompt.is_empty() {
args.extend([
"--append-system-prompt".to_string(),
self.common.system_prompt.clone(),
]);
}
args.extend(["--input-format".to_string(), "stream-json".to_string()]);
args.push("--replay-user-messages".to_string());
if self.include_partial_messages {
args.push("--include-partial-messages".to_string());
}
if let Some(ref schema) = self.json_schema {
args.extend(["--json-schema".to_string(), schema.clone()]);
}
if let Some(p) = prompt {
args.push(p.to_string());
}
log::debug!("Claude streaming command: claude {}", args.join(" "));
let mut cmd = self.make_command(args);
cmd.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let child = cmd
.spawn()
.context("Failed to execute 'claude' CLI. Is it installed and in PATH?")?;
crate::streaming::StreamingSession::new(child)
}
fn build_streaming_resume_args(&self, session_id: &str) -> Vec<String> {
let mut args = Vec::new();
let in_sandbox = self.common.sandbox.is_some();
args.push("--print".to_string());
args.extend(["--resume".to_string(), session_id.to_string()]);
args.extend(["--verbose", "--output-format", "stream-json"].map(String::from));
if self.common.skip_permissions && !in_sandbox {
args.push("--dangerously-skip-permissions".to_string());
}
args.extend(["--model".to_string(), self.common.model.clone()]);
for dir in &self.common.add_dirs {
args.extend(["--add-dir".to_string(), dir.clone()]);
}
args.extend(["--input-format".to_string(), "stream-json".to_string()]);
args.push("--replay-user-messages".to_string());
if self.include_partial_messages {
args.push("--include-partial-messages".to_string());
}
args
}
pub fn execute_streaming_resume(
&self,
session_id: &str,
) -> Result<crate::streaming::StreamingSession> {
let args = self.build_streaming_resume_args(session_id);
log::debug!("Claude streaming resume command: claude {}", args.join(" "));
let mut cmd = self.make_command(args);
cmd.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let child = cmd
.spawn()
.context("Failed to execute 'claude' CLI. Is it installed and in PATH?")?;
crate::streaming::StreamingSession::new(child)
}
async fn execute(
&self,
interactive: bool,
prompt: Option<&str>,
) -> Result<Option<AgentOutput>> {
let effective_output_format =
if self.common.capture_output && self.common.output_format.is_none() {
Some("json".to_string())
} else {
self.common.output_format.clone()
};
let capture_json = !interactive
&& effective_output_format
.as_ref()
.is_none_or(|f| f == "json" || f == "json-pretty" || f == "stream-json");
let agent_args = self.build_run_args(interactive, prompt, &effective_output_format);
log::debug!("Claude command: claude {}", agent_args.join(" "));
if !self.common.system_prompt.is_empty() {
log::debug!("Claude system prompt: {}", self.common.system_prompt);
}
if let Some(p) = prompt {
log::debug!("Claude user prompt: {p}");
}
log::debug!(
"Claude mode: interactive={interactive}, capture_json={capture_json}, output_format={effective_output_format:?}"
);
let mut cmd = self.make_command(agent_args);
let is_native_json = effective_output_format.as_deref() == Some("native-json");
if interactive {
cmd.stdin(Stdio::inherit())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit());
let status = cmd
.status()
.await
.context("Failed to execute 'claude' CLI. Is it installed and in PATH?")?;
if !status.success() {
return Err(crate::process::ProcessError {
exit_code: status.code(),
stderr: String::new(),
agent_name: "Claude".to_string(),
}
.into());
}
Ok(None)
} else if is_native_json {
cmd.stdin(Stdio::inherit()).stdout(Stdio::inherit());
crate::process::run_with_captured_stderr(&mut cmd).await?;
Ok(None)
} else if capture_json {
let output_format = effective_output_format.as_deref();
let is_streaming = output_format == Some("stream-json") || output_format.is_none();
if is_streaming {
cmd.stdin(Stdio::inherit());
cmd.stdout(Stdio::piped());
let mut child = crate::process::spawn_with_captured_stderr(&mut cmd).await?;
let stdout = child
.stdout
.take()
.ok_or_else(|| anyhow::anyhow!("Failed to capture stdout"))?;
let reader = BufReader::new(stdout);
let mut lines = reader.lines();
let format_as_text = output_format.is_none(); let format_as_json = output_format == Some("stream-json");
let mut translator = ClaudeEventTranslator::new();
while let Some(line) = lines.next_line().await? {
if format_as_text || format_as_json {
match serde_json::from_str::<models::ClaudeEvent>(&line) {
Ok(claude_event) => {
for unified_event in translator.translate(&claude_event) {
if let Some(ref handler) = self.event_handler {
handler(&unified_event, self.verbose);
}
}
}
Err(e) => {
log::debug!(
"Failed to parse streaming Claude event: {}. Line: {}",
e,
crate::truncate_str(&line, 200)
);
}
}
}
}
if let Some(ref handler) = self.event_handler {
handler(
&crate::output::Event::Result {
success: true,
message: None,
duration_ms: None,
num_turns: None,
},
self.verbose,
);
}
crate::process::wait_with_stderr(child).await?;
Ok(None)
} else {
cmd.stdin(Stdio::inherit());
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
let output = cmd.output().await?;
crate::process::handle_output(&output, "Claude")?;
let json_str = String::from_utf8(output.stdout)?;
log::debug!("Parsing Claude JSON output ({} bytes)", json_str.len());
let claude_output: models::ClaudeOutput =
serde_json::from_str(&json_str).map_err(|e| {
log::debug!(
"Failed to parse Claude JSON output: {}. First 500 chars: {}",
e,
crate::truncate_str(&json_str, 500)
);
anyhow::anyhow!("Failed to parse Claude JSON output: {e}")
})?;
log::debug!("Parsed {} Claude events successfully", claude_output.len());
if let Ok(raw_events) = serde_json::from_str::<Vec<serde_json::Value>>(&json_str) {
let known = ["system", "assistant", "user", "result"];
for raw in &raw_events {
if let Some(t) = raw.get("type").and_then(|v| v.as_str()) {
if !known.contains(&t) {
log::debug!(
"Unknown Claude event type: {:?} (first 300 chars: {})",
t,
crate::truncate_str(
&serde_json::to_string(raw).unwrap_or_default(),
300
)
);
}
}
}
}
let agent_output: AgentOutput =
models::claude_output_to_agent_output(claude_output);
Ok(Some(agent_output))
}
} else {
cmd.stdin(Stdio::inherit()).stdout(Stdio::inherit());
crate::process::run_with_captured_stderr(&mut cmd).await?;
Ok(None)
}
}
}
#[derive(Debug, Default)]
pub(crate) struct ClaudeEventTranslator {
pending_stop_reason: Option<String>,
pending_usage: Option<crate::output::Usage>,
next_turn_index: u32,
last_assistant_text: Option<String>,
tool_name_by_id: std::collections::HashMap<String, String>,
}
impl ClaudeEventTranslator {
pub(crate) fn new() -> Self {
Self::default()
}
pub(crate) fn translate(&mut self, event: &models::ClaudeEvent) -> Vec<crate::output::Event> {
use crate::output::{Event as UnifiedEvent, Usage as UnifiedUsage};
if let models::ClaudeEvent::Assistant { message, .. } = event {
if let Some(reason) = &message.stop_reason {
self.pending_stop_reason = Some(reason.clone());
}
self.pending_usage = Some(UnifiedUsage {
input_tokens: message.usage.input_tokens,
output_tokens: message.usage.output_tokens,
cache_read_tokens: Some(message.usage.cache_read_input_tokens),
cache_creation_tokens: Some(message.usage.cache_creation_input_tokens),
web_search_requests: message
.usage
.server_tool_use
.as_ref()
.map(|s| s.web_search_requests),
web_fetch_requests: message
.usage
.server_tool_use
.as_ref()
.map(|s| s.web_fetch_requests),
});
let text_parts: Vec<&str> = message
.content
.iter()
.filter_map(|b| match b {
models::ContentBlock::Text { text } => Some(text.as_str()),
_ => None,
})
.collect();
if !text_parts.is_empty() {
self.last_assistant_text = Some(text_parts.join("\n"));
}
for block in &message.content {
if let models::ContentBlock::ToolUse { id, name, .. } = block {
self.tool_name_by_id.insert(id.clone(), name.clone());
}
}
}
let unified = convert_claude_event_to_unified(event);
match unified {
Some(UnifiedEvent::Result {
success,
message,
duration_ms,
num_turns,
}) if message.as_deref() == Some("") => {
let fallback = self.last_assistant_text.take();
if fallback.is_some() {
log::debug!(
"Streaming Result.message is empty; using last assistant text as fallback"
);
}
let result_event = UnifiedEvent::Result {
success,
message: fallback.or(message),
duration_ms,
num_turns,
};
let turn_complete = UnifiedEvent::TurnComplete {
stop_reason: self.pending_stop_reason.take(),
turn_index: self.next_turn_index,
usage: self.pending_usage.take(),
};
self.next_turn_index = self.next_turn_index.saturating_add(1);
vec![turn_complete, result_event]
}
Some(UnifiedEvent::Result { .. }) => {
let turn_complete = UnifiedEvent::TurnComplete {
stop_reason: self.pending_stop_reason.take(),
turn_index: self.next_turn_index,
usage: self.pending_usage.take(),
};
self.next_turn_index = self.next_turn_index.saturating_add(1);
vec![turn_complete, unified.unwrap()]
}
Some(UnifiedEvent::ToolExecution {
tool_name,
tool_id,
input,
result,
parent_tool_use_id,
}) => {
let resolved_name = self
.tool_name_by_id
.get(&tool_id)
.cloned()
.unwrap_or(tool_name);
vec![UnifiedEvent::ToolExecution {
tool_name: resolved_name,
tool_id,
input,
result,
parent_tool_use_id,
}]
}
Some(ev) => vec![ev],
None => Vec::new(),
}
}
}
pub(crate) fn convert_claude_event_to_unified(
event: &models::ClaudeEvent,
) -> Option<crate::output::Event> {
use crate::output::{
ContentBlock as UnifiedContentBlock, Event as UnifiedEvent, ToolResult,
Usage as UnifiedUsage,
};
use models::ClaudeEvent;
match event {
ClaudeEvent::System {
model, tools, cwd, ..
} => {
let mut metadata = std::collections::HashMap::new();
if let Some(cwd_val) = cwd {
metadata.insert("cwd".to_string(), serde_json::json!(cwd_val));
}
Some(UnifiedEvent::Init {
model: model.clone(),
tools: tools.clone(),
working_directory: cwd.clone(),
metadata,
})
}
ClaudeEvent::Assistant {
message,
parent_tool_use_id,
..
} => {
let content: Vec<UnifiedContentBlock> = message
.content
.iter()
.filter_map(|block| match block {
models::ContentBlock::Text { text } => {
Some(UnifiedContentBlock::Text { text: text.clone() })
}
models::ContentBlock::ToolUse { id, name, input } => {
Some(UnifiedContentBlock::ToolUse {
id: id.clone(),
name: name.clone(),
input: input.clone(),
})
}
models::ContentBlock::Thinking { .. } | models::ContentBlock::Other => None,
})
.collect();
let usage = Some(UnifiedUsage {
input_tokens: message.usage.input_tokens,
output_tokens: message.usage.output_tokens,
cache_read_tokens: Some(message.usage.cache_read_input_tokens),
cache_creation_tokens: Some(message.usage.cache_creation_input_tokens),
web_search_requests: message
.usage
.server_tool_use
.as_ref()
.map(|s| s.web_search_requests),
web_fetch_requests: message
.usage
.server_tool_use
.as_ref()
.map(|s| s.web_fetch_requests),
});
Some(UnifiedEvent::AssistantMessage {
content,
usage,
parent_tool_use_id: parent_tool_use_id.clone(),
})
}
ClaudeEvent::User {
message,
tool_use_result,
parent_tool_use_id,
..
} => {
let first_tool_result = message.content.iter().find_map(|b| {
if let models::UserContentBlock::ToolResult {
tool_use_id,
content,
is_error,
} = b
{
Some((tool_use_id, content, is_error))
} else {
None
}
});
if let Some((tool_use_id, content, is_error)) = first_tool_result {
let tool_result = ToolResult {
success: !is_error,
output: if !is_error {
Some(content.clone())
} else {
None
},
error: if *is_error {
Some(content.clone())
} else {
None
},
data: tool_use_result.clone(),
};
Some(UnifiedEvent::ToolExecution {
tool_name: "unknown".to_string(),
tool_id: tool_use_id.clone(),
input: serde_json::Value::Null,
result: tool_result,
parent_tool_use_id: parent_tool_use_id.clone(),
})
} else {
let text_blocks: Vec<UnifiedContentBlock> = message
.content
.iter()
.filter_map(|b| {
if let models::UserContentBlock::Text { text } = b {
Some(UnifiedContentBlock::Text { text: text.clone() })
} else {
None
}
})
.collect();
if !text_blocks.is_empty() {
Some(UnifiedEvent::UserMessage {
content: text_blocks,
})
} else {
None
}
}
}
ClaudeEvent::Other => {
log::debug!("Skipping unknown Claude event type during streaming conversion");
None
}
ClaudeEvent::Result {
is_error,
result,
duration_ms,
num_turns,
structured_output,
..
} => {
let effective_result = if result.is_empty() {
if let Some(so) = structured_output {
log::debug!("Streaming Result.result is empty; using structured_output");
serde_json::to_string(so).unwrap_or_default()
} else {
result.clone()
}
} else {
result.clone()
};
Some(UnifiedEvent::Result {
success: !is_error,
message: Some(effective_result),
duration_ms: Some(*duration_ms),
num_turns: Some(*num_turns),
})
}
}
}
#[cfg(test)]
#[path = "claude_tests.rs"]
mod tests;
impl Default for Claude {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl Agent for Claude {
fn name(&self) -> &str {
"claude"
}
fn default_model() -> &'static str {
DEFAULT_MODEL
}
fn model_for_size(size: ModelSize) -> &'static str {
match size {
ModelSize::Small => "haiku",
ModelSize::Medium => "sonnet",
ModelSize::Large => "default",
}
}
fn available_models() -> &'static [&'static str] {
AVAILABLE_MODELS
}
crate::providers::common::impl_common_agent_setters!();
fn set_skip_permissions(&mut self, skip: bool) {
self.common.skip_permissions = skip;
}
crate::providers::common::impl_as_any!();
async fn run(&self, prompt: Option<&str>) -> Result<Option<AgentOutput>> {
self.execute(false, prompt).await
}
async fn run_interactive(&self, prompt: Option<&str>) -> Result<()> {
self.execute(true, prompt).await?;
Ok(())
}
async fn run_resume(&self, session_id: Option<&str>, _last: bool) -> Result<()> {
let agent_args = self.build_resume_args(session_id);
let mut cmd = self.make_command(agent_args);
cmd.stdin(Stdio::inherit())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit());
let status = cmd
.status()
.await
.context("Failed to execute 'claude' CLI. Is it installed and in PATH?")?;
if !status.success() {
return Err(crate::process::ProcessError {
exit_code: status.code(),
stderr: String::new(),
agent_name: "Claude".to_string(),
}
.into());
}
Ok(())
}
async fn run_resume_with_prompt(
&self,
session_id: &str,
prompt: &str,
) -> Result<Option<AgentOutput>> {
log::debug!("Claude resume with prompt: session={session_id}, prompt={prompt}");
let in_sandbox = self.common.sandbox.is_some();
let mut args = vec!["--print".to_string()];
args.extend(["--resume".to_string(), session_id.to_string()]);
args.extend(["--verbose", "--output-format", "json"].map(String::from));
if self.common.skip_permissions && !in_sandbox {
args.push("--dangerously-skip-permissions".to_string());
}
args.extend(["--model".to_string(), self.common.model.clone()]);
for dir in &self.common.add_dirs {
args.extend(["--add-dir".to_string(), dir.clone()]);
}
if let Some(ref schema) = self.json_schema {
args.extend(["--json-schema".to_string(), schema.clone()]);
}
args.push(prompt.to_string());
let mut cmd = self.make_command(args);
cmd.stdin(Stdio::inherit());
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
let output = cmd.output().await?;
crate::process::handle_output(&output, "Claude")?;
let json_str = String::from_utf8(output.stdout)?;
log::debug!(
"Parsing Claude resume JSON output ({} bytes)",
json_str.len()
);
let claude_output: models::ClaudeOutput = serde_json::from_str(&json_str)
.map_err(|e| anyhow::anyhow!("Failed to parse Claude resume JSON output: {e}"))?;
let agent_output: AgentOutput = models::claude_output_to_agent_output(claude_output);
Ok(Some(agent_output))
}
async fn cleanup(&self) -> Result<()> {
Ok(())
}
}