use std::path::Path;
use std::process::{Command, Stdio};
use ag_protocol::agent_response_output_schema_json;
use super::backend::{AgentBackend, AgentBackendError, BuildCommandRequest};
use super::prompt::{CliPromptAccessRootMode, append_cli_prompt_access_directories};
const CLAUDE_ALLOWED_TOOLS: &str =
"Bash,Edit,MultiEdit,Write,WebSearch,WebFetch,EnterPlanMode,ExitPlanMode";
pub(super) struct ClaudeBackend;
impl AgentBackend for ClaudeBackend {
fn setup(&self, _folder: &Path) -> Result<(), AgentBackendError> {
Ok(())
}
fn build_command<'request>(
&'request self,
request: BuildCommandRequest<'request>,
) -> Result<Command, AgentBackendError> {
let BuildCommandRequest {
attachments,
folder,
main_checkout_root,
model,
request_kind,
prompt: _prompt,
replay_transcript: _replay_transcript,
reasoning_level,
} = request;
let mut command = Command::new("claude");
if request_kind.is_resume() {
command.arg("-c");
}
append_cli_prompt_access_directories(
&mut command,
folder,
attachments,
CliPromptAccessRootMode::AttachmentsOnly,
);
command.arg("-p");
command.arg("--allowedTools").arg(CLAUDE_ALLOWED_TOOLS);
append_claude_workspace_settings(&mut command, folder, main_checkout_root);
command.arg("--input-format").arg("text");
command.arg("--strict-mcp-config");
command.arg("--verbose");
command.arg("--effort").arg(reasoning_level.claude());
command.arg("--output-format").arg("stream-json");
command
.arg("--json-schema")
.arg(agent_response_output_schema_json());
command
.env("ANTHROPIC_MODEL", model)
.current_dir(folder)
.stdout(Stdio::piped())
.stderr(Stdio::piped());
Ok(command)
}
}
fn append_claude_workspace_settings(
command: &mut Command,
workspace_folder: &Path,
main_checkout_root: Option<&Path>,
) {
let mut deny_rules = Vec::new();
let mut deny_write_paths = Vec::new();
if let Some(main_checkout_root) = main_checkout_root {
let main_checkout_rule_path = claude_absolute_permission_path(main_checkout_root);
deny_rules.push(format!("Edit({main_checkout_rule_path}/**)"));
deny_write_paths.push(main_checkout_root.to_string_lossy().into_owned());
}
let settings = serde_json::json!({
"permissions": {
"deny": deny_rules,
},
"sandbox": {
"enabled": true,
"filesystem": {
"allowWrite": [workspace_folder.to_string_lossy().into_owned()],
"denyWrite": deny_write_paths,
}
}
});
command.arg("--settings").arg(settings.to_string());
}
fn claude_absolute_permission_path(path: &Path) -> String {
let path = path.to_string_lossy().replace('\\', "/");
let path_without_root = path.trim_start_matches('/');
format!("//{path_without_root}")
}
#[cfg(test)]
mod tests {
use std::ffi::OsStr;
use std::path::PathBuf;
use ag_protocol::ProtocolSchemaInstructionMode;
use serde_json::Value;
use tempfile::tempdir;
use super::*;
use crate::agent::prompt as shared_prompt;
use crate::channel::AgentRequestKind;
use crate::model::agent::ReasoningLevel;
use crate::model::turn_prompt::TurnPromptAttachment;
fn session_start_request_kind() -> AgentRequestKind {
AgentRequestKind::SessionStart
}
fn utility_request_kind() -> AgentRequestKind {
AgentRequestKind::UtilityPrompt
}
fn settings_argument(command: &Command) -> Value {
let args = command
.get_args()
.map(|arg| arg.to_string_lossy().into_owned())
.collect::<Vec<_>>();
let settings_position = args
.iter()
.position(|arg| arg == "--settings")
.expect("--settings flag should be present");
serde_json::from_str(&args[settings_position + 1]).expect("settings JSON should parse")
}
#[test]
fn test_claude_absolute_permission_path_normalizes_windows_separators() {
let path = Path::new(r"C:\Users\dev\project");
let rule_path = claude_absolute_permission_path(path);
assert_eq!(rule_path, "//C:/Users/dev/project");
assert!(!rule_path.contains('\\'));
}
#[test]
fn test_claude_auto_edit_mode_uses_write_capable_allowed_tools() {
let temp_directory = tempdir().expect("failed to create temp dir");
let main_checkout_root = temp_directory.path().join("main");
let backend = ClaudeBackend;
let command = AgentBackend::build_command(
&backend,
BuildCommandRequest {
attachments: &[],
folder: temp_directory.path(),
main_checkout_root: Some(main_checkout_root.as_path()),
replay_transcript: None,
model: "claude-sonnet-5",
prompt: "Plan prompt",
reasoning_level: ReasoningLevel::default(),
request_kind: &session_start_request_kind(),
},
)
.expect("command should build");
let debug_command = format!("{command:?}");
let args = command
.get_args()
.map(|arg| arg.to_string_lossy().into_owned())
.collect::<Vec<_>>();
assert!(debug_command.contains("--allowedTools"));
assert!(debug_command.contains(CLAUDE_ALLOWED_TOOLS));
assert!(debug_command.contains("Bash"));
assert!(debug_command.contains("MultiEdit"));
assert!(debug_command.contains("Write"));
assert!(debug_command.contains("WebSearch"));
assert!(debug_command.contains("WebFetch"));
assert!(debug_command.contains("--strict-mcp-config"));
assert!(debug_command.contains("--settings"));
assert!(debug_command.contains("--effort"));
assert!(debug_command.contains("--output-format"));
assert!(debug_command.contains("stream-json"));
assert!(!debug_command.contains("--permission-mode"));
assert!(!args.iter().any(String::is_empty));
let settings = settings_argument(&command);
let deny_rules = settings
.pointer("/permissions/deny")
.and_then(Value::as_array)
.expect("deny rules should be present");
assert!(deny_rules.iter().any(|rule| {
rule.as_str()
.is_some_and(|rule| rule.starts_with("Edit(//") && rule.ends_with("/main/**)"))
}));
assert_eq!(
settings
.pointer("/sandbox/enabled")
.and_then(Value::as_bool),
Some(true)
);
}
#[test]
fn test_claude_command_sets_anthropic_model_to_claude_opus_48() {
let temp_directory = tempdir().expect("failed to create temp dir");
let backend = ClaudeBackend;
let command = AgentBackend::build_command(
&backend,
BuildCommandRequest {
attachments: &[],
folder: temp_directory.path(),
main_checkout_root: None,
replay_transcript: None,
model: "claude-opus-4-8",
prompt: "Use Opus",
reasoning_level: ReasoningLevel::default(),
request_kind: &session_start_request_kind(),
},
)
.expect("command should build");
let anthropic_model = command
.get_envs()
.find(|(key, _value)| *key == OsStr::new("ANTHROPIC_MODEL"))
.and_then(|(_key, value)| value)
.map(|value| value.to_string_lossy().into_owned());
assert_eq!(anthropic_model, Some("claude-opus-4-8".to_string()));
}
#[test]
fn test_claude_command_passes_effort_flag_for_each_reasoning_level() {
let temp_directory = tempdir().expect("failed to create temp dir");
let backend = ClaudeBackend;
let cases = [
(ReasoningLevel::Low, "low"),
(ReasoningLevel::Medium, "medium"),
(ReasoningLevel::High, "high"),
(ReasoningLevel::XHigh, "max"),
];
for (reasoning_level, expected_effort) in cases {
let command = AgentBackend::build_command(
&backend,
BuildCommandRequest {
attachments: &[],
folder: temp_directory.path(),
main_checkout_root: None,
replay_transcript: None,
model: "claude-sonnet-5",
prompt: "Do work",
reasoning_level,
request_kind: &session_start_request_kind(),
},
)
.expect("command should build");
let args = command
.get_args()
.map(|arg| arg.to_string_lossy().into_owned())
.collect::<Vec<_>>();
let effort_pos = args
.iter()
.position(|arg| arg == "--effort")
.expect("--effort flag should be present");
assert_eq!(
args[effort_pos + 1],
expected_effort,
"expected effort={expected_effort} for {reasoning_level:?}"
);
}
}
#[test]
fn test_claude_command_adds_attachment_access_directories() {
let temp_directory = tempdir().expect("failed to create temp dir");
let backend = ClaudeBackend;
let attachments = vec![
TurnPromptAttachment {
placeholder: "[Image #1]".to_string(),
local_image_path: PathBuf::from("/tmp/agentty/images/one.png"),
},
TurnPromptAttachment {
placeholder: "[Image #2]".to_string(),
local_image_path: PathBuf::from("/tmp/agentty/images/two.png"),
},
];
let command = AgentBackend::build_command(
&backend,
BuildCommandRequest {
attachments: &attachments,
folder: temp_directory.path(),
main_checkout_root: None,
replay_transcript: None,
model: "claude-sonnet-5",
prompt: "Inspect [Image #1] and [Image #2]",
reasoning_level: ReasoningLevel::default(),
request_kind: &session_start_request_kind(),
},
)
.expect("command should build");
let args = command
.get_args()
.map(|arg| arg.to_string_lossy().into_owned())
.collect::<Vec<_>>();
assert_eq!(
args.iter()
.filter(|arg| arg.as_str() == "--add-dir")
.count(),
1
);
assert!(args.contains(&"/tmp/agentty/images".to_string()));
}
#[test]
fn test_claude_prompt_stdin_payload_includes_repo_root_path_instructions() {
let temp_directory = tempdir().expect("failed to create temp dir");
let prompt = String::from_utf8(
shared_prompt::build_prompt_stdin_payload(
BuildCommandRequest {
attachments: &[],
folder: temp_directory.path(),
main_checkout_root: None,
replay_transcript: None,
model: "claude-sonnet-5",
prompt: "Plan prompt",
reasoning_level: ReasoningLevel::default(),
request_kind: &session_start_request_kind(),
},
ProtocolSchemaInstructionMode::TransportSchema,
"Claude",
)
.expect("prompt payload should build"),
)
.expect("prompt payload should be utf-8");
assert!(prompt.contains("repository-root-relative POSIX paths"));
assert!(prompt.contains("Allowed forms: `path`, `path:line`, `path:line:column`."));
assert!(prompt.contains("summary"));
}
#[test]
fn test_claude_one_shot_command_enforces_json_schema_without_summary_prose() {
let temp_directory = tempdir().expect("failed to create temp dir");
let backend = ClaudeBackend;
let command = AgentBackend::build_command(
&backend,
BuildCommandRequest {
attachments: &[],
folder: temp_directory.path(),
main_checkout_root: None,
replay_transcript: None,
model: "claude-sonnet-5",
prompt: "Generate title",
reasoning_level: ReasoningLevel::default(),
request_kind: &utility_request_kind(),
},
)
.expect("command should build");
let debug_command = format!("{command:?}");
let prompt = String::from_utf8(
shared_prompt::build_prompt_stdin_payload(
BuildCommandRequest {
attachments: &[],
folder: temp_directory.path(),
main_checkout_root: None,
replay_transcript: None,
model: "claude-sonnet-5",
prompt: "Generate title",
reasoning_level: ReasoningLevel::default(),
request_kind: &utility_request_kind(),
},
ProtocolSchemaInstructionMode::TransportSchema,
"Claude",
)
.expect("prompt payload should build"),
)
.expect("prompt payload should be utf-8");
assert!(prompt.contains("Structured response protocol:"));
assert!(prompt.contains("summary"));
assert!(!prompt.contains("Authoritative JSON Schema:"));
assert!(debug_command.contains("--output-format"));
assert!(debug_command.contains("stream-json"));
assert!(debug_command.contains("--json-schema"));
assert!(debug_command.contains("--input-format"));
}
#[test]
fn test_claude_start_command_includes_json_schema() {
let temp_directory = tempdir().expect("failed to create temp dir");
let backend = ClaudeBackend;
let command = AgentBackend::build_command(
&backend,
BuildCommandRequest {
attachments: &[],
folder: temp_directory.path(),
main_checkout_root: None,
replay_transcript: None,
model: "claude-sonnet-5",
prompt: "Return protocol response",
reasoning_level: ReasoningLevel::default(),
request_kind: &session_start_request_kind(),
},
)
.expect("command should build");
let debug_command = format!("{command:?}");
let prompt = String::from_utf8(
shared_prompt::build_prompt_stdin_payload(
BuildCommandRequest {
attachments: &[],
folder: temp_directory.path(),
main_checkout_root: None,
replay_transcript: None,
model: "claude-sonnet-5",
prompt: "Return protocol response",
reasoning_level: ReasoningLevel::default(),
request_kind: &session_start_request_kind(),
},
ProtocolSchemaInstructionMode::TransportSchema,
"Claude",
)
.expect("prompt payload should build"),
)
.expect("prompt payload should be utf-8");
assert!(debug_command.contains("--json-schema"));
assert!(debug_command.contains("AgentResponse"));
assert!(prompt.contains("Structured response protocol:"));
assert!(prompt.contains("summary"));
assert!(!prompt.contains("Authoritative JSON Schema:"));
}
}