Skip to main content

apm_core/wrapper/builtin/
claude.rs

1use std::os::unix::process::CommandExt;
2use crate::wrapper::{Wrapper, WrapperContext, CONTRACT_VERSION};
3
4pub struct ClaudeWrapper;
5
6impl Wrapper for ClaudeWrapper {
7    fn spawn(&self, ctx: &WrapperContext) -> anyhow::Result<std::process::Child> {
8        let sys = std::fs::read_to_string(&ctx.system_prompt_file)?;
9        let msg = std::fs::read_to_string(&ctx.user_message_file)?;
10
11        let apm_bin = std::env::current_exe()
12            .and_then(|p| p.canonicalize())
13            .map(|p| p.to_string_lossy().into_owned())
14            .unwrap_or_default();
15
16        match &ctx.container {
17            None => spawn_local(ctx, &sys, &msg, &apm_bin),
18            Some(image) => spawn_container(ctx, image, &sys, &msg, &apm_bin),
19        }
20    }
21}
22
23pub(crate) fn build_claude_args(model: Option<&str>, skip_permissions: bool, sys: &str, msg: &str) -> Vec<String> {
24    let mut args: Vec<String> = vec![
25        "--print".into(),
26        "--output-format".into(),
27        "stream-json".into(),
28        "--verbose".into(),
29        "--system-prompt".into(),
30        sys.into(),
31    ];
32    if let Some(m) = model {
33        args.push("--model".into());
34        args.push(m.into());
35    }
36    if skip_permissions {
37        args.push("--dangerously-skip-permissions".into());
38    }
39    args.push(msg.into());
40    args
41}
42
43fn spawn_local(
44    ctx: &WrapperContext,
45    sys: &str,
46    msg: &str,
47    apm_bin: &str,
48) -> anyhow::Result<std::process::Child> {
49    let binary = ctx.command.as_deref().unwrap_or("claude");
50    let mut cmd = std::process::Command::new(binary);
51    cmd.args(build_claude_args(ctx.model.as_deref(), ctx.skip_permissions, sys, msg));
52
53    set_apm_env(&mut cmd, ctx, apm_bin);
54    for (k, v) in &ctx.extra_env {
55        cmd.env(k, v);
56    }
57
58    cmd.current_dir(&ctx.worktree_path);
59
60    let log_file = std::fs::File::create(&ctx.log_path)?;
61    let log_clone = log_file.try_clone()?;
62    cmd.stdout(log_file);
63    cmd.stderr(log_clone);
64    cmd.process_group(0);
65
66    Ok(cmd.spawn()?)
67}
68
69fn spawn_container(
70    ctx: &WrapperContext,
71    image: &str,
72    sys: &str,
73    msg: &str,
74    apm_bin: &str,
75) -> anyhow::Result<std::process::Child> {
76    let api_key = crate::credentials::resolve(
77        "ANTHROPIC_API_KEY",
78        ctx.keychain.get("ANTHROPIC_API_KEY").map(|s| s.as_str()),
79    )?;
80
81    let author_name = std::env::var("GIT_AUTHOR_NAME")
82        .ok()
83        .filter(|v| !v.is_empty())
84        .or_else(|| crate::git_util::git_config_get(&ctx.root, "user.name"))
85        .unwrap_or_default();
86    let author_email = std::env::var("GIT_AUTHOR_EMAIL")
87        .ok()
88        .filter(|v| !v.is_empty())
89        .or_else(|| crate::git_util::git_config_get(&ctx.root, "user.email"))
90        .unwrap_or_default();
91    let committer_name = std::env::var("GIT_COMMITTER_NAME")
92        .ok()
93        .filter(|v| !v.is_empty())
94        .unwrap_or_else(|| author_name.clone());
95    let committer_email = std::env::var("GIT_COMMITTER_EMAIL")
96        .ok()
97        .filter(|v| !v.is_empty())
98        .unwrap_or_else(|| author_email.clone());
99
100    let mut cmd = std::process::Command::new("docker");
101    cmd.arg("run");
102    cmd.arg("--rm");
103    cmd.args(["--volume", &format!("{}:/workspace", ctx.worktree_path.display())]);
104    cmd.args(["--workdir", "/workspace"]);
105    cmd.args(["--env", &format!("ANTHROPIC_API_KEY={api_key}")]);
106    if !author_name.is_empty() {
107        cmd.args(["--env", &format!("GIT_AUTHOR_NAME={author_name}")]);
108    }
109    if !author_email.is_empty() {
110        cmd.args(["--env", &format!("GIT_AUTHOR_EMAIL={author_email}")]);
111    }
112    if !committer_name.is_empty() {
113        cmd.args(["--env", &format!("GIT_COMMITTER_NAME={committer_name}")]);
114    }
115    if !committer_email.is_empty() {
116        cmd.args(["--env", &format!("GIT_COMMITTER_EMAIL={committer_email}")]);
117    }
118
119    let skip_perm_val = if ctx.skip_permissions { "1" } else { "0" };
120    let worktree_str = ctx.worktree_path.to_string_lossy();
121    let sys_file_str = ctx.system_prompt_file.to_string_lossy();
122    let msg_file_str = ctx.user_message_file.to_string_lossy();
123    let contract_version_str = CONTRACT_VERSION.to_string();
124
125    let apm_env_pairs: &[(&str, &str)] = &[
126        ("APM_AGENT_NAME", &ctx.worker_name),
127        ("APM_TICKET_ID", &ctx.ticket_id),
128        ("APM_TICKET_BRANCH", &ctx.ticket_branch),
129        ("APM_TICKET_WORKTREE", &worktree_str),
130        ("APM_SYSTEM_PROMPT_FILE", &sys_file_str),
131        ("APM_USER_MESSAGE_FILE", &msg_file_str),
132        ("APM_SKIP_PERMISSIONS", skip_perm_val),
133        ("APM_PROFILE", &ctx.profile),
134        ("APM_WRAPPER_VERSION", &contract_version_str),
135        ("APM_BIN", apm_bin),
136    ];
137    for (k, v) in apm_env_pairs {
138        cmd.args(["--env", &format!("{k}={v}")]);
139    }
140    if let Some(ref prefix) = ctx.role_prefix {
141        cmd.args(["--env", &format!("APM_ROLE_PREFIX={prefix}")]);
142    }
143    for (k, v) in &ctx.extra_env {
144        cmd.args(["--env", &format!("{k}={v}")]);
145    }
146    // APM_OPT_<KEY> for each option entry
147    for (k, v) in &ctx.options {
148        let env_key = format!(
149            "APM_OPT_{}",
150            k.to_uppercase().replace('.', "_").replace('-', "_")
151        );
152        cmd.args(["--env", &format!("{env_key}={v}")]);
153    }
154
155    cmd.arg(image);
156    cmd.arg("claude");
157    cmd.args(build_claude_args(ctx.model.as_deref(), ctx.skip_permissions, sys, msg));
158
159    let log_file = std::fs::File::create(&ctx.log_path)?;
160    let log_clone = log_file.try_clone()?;
161    cmd.stdout(log_file);
162    cmd.stderr(log_clone);
163    cmd.process_group(0);
164
165    Ok(cmd.spawn()?)
166}
167
168fn set_apm_env(cmd: &mut std::process::Command, ctx: &WrapperContext, apm_bin: &str) {
169    cmd.env("APM_AGENT_NAME", &ctx.worker_name);
170    cmd.env("APM_TICKET_ID", &ctx.ticket_id);
171    cmd.env("APM_TICKET_BRANCH", &ctx.ticket_branch);
172    cmd.env("APM_TICKET_WORKTREE", ctx.worktree_path.to_string_lossy().as_ref());
173    cmd.env("APM_SYSTEM_PROMPT_FILE", ctx.system_prompt_file.to_string_lossy().as_ref());
174    cmd.env("APM_USER_MESSAGE_FILE", ctx.user_message_file.to_string_lossy().as_ref());
175    cmd.env("APM_SKIP_PERMISSIONS", if ctx.skip_permissions { "1" } else { "0" });
176    cmd.env("APM_PROFILE", &ctx.profile);
177    if let Some(ref prefix) = ctx.role_prefix {
178        cmd.env("APM_ROLE_PREFIX", prefix);
179    }
180    cmd.env("APM_WRAPPER_VERSION", CONTRACT_VERSION.to_string());
181    cmd.env("APM_BIN", apm_bin);
182    // APM_OPT_<KEY> for each option entry
183    for (k, v) in &ctx.options {
184        let env_key = format!(
185            "APM_OPT_{}",
186            k.to_uppercase().replace('.', "_").replace('-', "_")
187        );
188        cmd.env(&env_key, v);
189    }
190}
191
192#[cfg(test)]
193mod tests {
194    use super::build_claude_args;
195
196    #[test]
197    fn args_include_model_flag_when_set() {
198        let args = build_claude_args(Some("sonnet"), false, "sys", "msg");
199        let pos = args.iter().position(|a| a == "--model").expect("--model flag must be in argv");
200        assert_eq!(args.get(pos + 1).map(String::as_str), Some("sonnet"), "value must follow --model");
201    }
202
203    #[test]
204    fn args_omit_model_flag_when_unset() {
205        let args = build_claude_args(None, false, "sys", "msg");
206        assert!(!args.iter().any(|a| a == "--model"), "--model must be absent when no model configured: {args:?}");
207    }
208
209    #[test]
210    fn args_include_skip_permissions_when_set() {
211        let args = build_claude_args(None, true, "sys", "msg");
212        assert!(args.iter().any(|a| a == "--dangerously-skip-permissions"), "{args:?}");
213    }
214
215    #[test]
216    fn args_msg_is_last() {
217        let args = build_claude_args(Some("opus"), true, "sys", "the-message");
218        assert_eq!(args.last().map(String::as_str), Some("the-message"));
219    }
220}