1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3use anyhow::Result;
4use crate::wrapper::{self, Wrapper, WrapperContext, WrapperKind};
5use crate::wrapper::custom::CustomWrapper;
6use crate::config::Config;
7
8pub struct WrapperEntry {
9 pub name: String,
10 pub kind: WrapperKind,
11 pub parser: String,
12 pub configured_as: Vec<String>,
13}
14
15#[derive(Debug)]
16pub struct TestReport {
17 pub exit_code: i32,
18 pub canonical_events: usize,
19 pub non_canonical_lines: usize,
20 pub stderr_lines: usize,
21 pub wall_millis: u64,
22 pub passed: bool,
23}
24
25const MANIFEST_TEMPLATE: &str =
26 "[wrapper]\ncontract_version = 1\nparser = \"canonical\"\n";
27
28const WRAPPER_TEMPLATE: &str = r#"#!/usr/bin/env bash
29# APM wrapper skeleton
30#
31# Environment variables provided by APM:
32# APM_AGENT_NAME - name of this worker (from config)
33# APM_TICKET_ID - 8-char hex ticket ID
34# APM_TICKET_BRANCH - git branch for this ticket
35# APM_TICKET_WORKTREE - absolute path to the ticket worktree
36# APM_SYSTEM_PROMPT_FILE - path to a file containing the system prompt
37# APM_USER_MESSAGE_FILE - path to a file containing the user message (ticket content)
38# APM_SKIP_PERMISSIONS - "1" if --dangerously-skip-permissions should be passed; "0" otherwise
39# APM_PROFILE - active worker profile name
40# APM_ROLE_PREFIX - optional role label prepended to the worker identity
41# APM_WRAPPER_VERSION - contract version this APM build implements (currently "1")
42# APM_BIN - absolute path to the running apm binary
43# APM_OPT_* - key-value options from [workers.options] in config.toml
44#
45# Contract:
46# stdout - emit JSONL events (one JSON object per line, each with a "type" key)
47# stderr - free-form log output (not parsed by APM)
48# exit 0 - success; non-zero signals failure
49#
50set -euo pipefail
51
52# Dump all APM_* env vars to stderr for debugging
53env | grep '^APM_' >&2 || true
54
55# Read inputs
56SYSTEM_PROMPT="$(cat "$APM_SYSTEM_PROMPT_FILE")"
57USER_MESSAGE="$(cat "$APM_USER_MESSAGE_FILE")"
58
59# TODO: replace this printf with a real agent invocation that:
60# 1. Sends SYSTEM_PROMPT + USER_MESSAGE to your AI tool
61# 2. Emits JSONL events on stdout as the tool runs
62printf '{"type":"text","text":"wrapper skeleton -- replace with real invocation"}\n'
63
64# TODO: when the agent finishes, transition the ticket:
65# apm state "$APM_TICKET_ID" <target-state>
66
67exit 0
68"#;
69
70const CLAUDE_EJECT_SCRIPT: &str = r#"#!/usr/bin/env bash
71# Ejected from APM built-in: claude
72set -euo pipefail
73
74ARGS=(--print --output-format stream-json --verbose)
75
76ARGS+=(--system-prompt "$(cat "$APM_SYSTEM_PROMPT_FILE")")
77
78if [[ -n "${APM_OPT_MODEL:-}" ]]; then
79 ARGS+=(--model "$APM_OPT_MODEL")
80fi
81
82if [[ "${APM_SKIP_PERMISSIONS:-0}" == "1" ]]; then
83 ARGS+=(--dangerously-skip-permissions)
84fi
85
86exec claude "${ARGS[@]}" "$(cat "$APM_USER_MESSAGE_FILE")"
87"#;
88
89const DEFAULT_WORKER_MD: &str = include_str!("default/agents/default/apm.worker.md");
90const DEFAULT_SPEC_WRITER_MD: &str = include_str!("default/agents/default/apm.spec-writer.md");
91
92fn rand_u16() -> u16 {
93 use std::time::{SystemTime, UNIX_EPOCH};
94 SystemTime::now()
95 .duration_since(UNIX_EPOCH)
96 .unwrap_or_default()
97 .subsec_nanos() as u16
98}
99
100pub fn list_wrappers(root: &Path, config: &Config) -> Result<Vec<WrapperEntry>> {
101 let mut entries: Vec<WrapperEntry> = Vec::new();
102
103 for name in wrapper::list_builtin_names() {
105 entries.push(WrapperEntry {
106 name: name.to_string(),
107 kind: WrapperKind::Builtin(name.to_string()),
108 parser: "canonical".to_string(),
109 configured_as: vec![],
110 });
111 }
112
113 let agents_dir = root.join(".apm").join("agents");
115 if agents_dir.is_dir() {
116 let rd = match std::fs::read_dir(&agents_dir) {
117 Ok(rd) => rd,
118 Err(_) => return Ok(entries),
119 };
120 let mut names: Vec<String> = rd
121 .filter_map(|e| e.ok())
122 .filter(|e| e.path().is_dir())
123 .filter_map(|e| e.file_name().into_string().ok())
124 .collect();
125 names.sort();
126
127 for entry_name in names {
128 if let Ok(Some(WrapperKind::Custom { script_path, manifest })) =
129 wrapper::resolve_wrapper(root, &entry_name)
130 {
131 let parser = manifest
132 .as_ref()
133 .map(|m| m.parser.clone())
134 .unwrap_or_else(|| "canonical".to_string());
135 entries.push(WrapperEntry {
136 name: entry_name,
137 kind: WrapperKind::Custom { script_path, manifest },
138 parser,
139 configured_as: vec![],
140 });
141 }
142 }
143 }
144
145 let global_agent = config.workers.agent.as_deref().unwrap_or("claude").to_string();
147 for entry in &mut entries {
148 if entry.name == global_agent {
149 entry.configured_as.push("(configured)".to_string());
150 }
151 for (profile_name, profile) in &config.worker_profiles {
152 if let Some(ref agent) = profile.agent {
153 if entry.name == *agent {
154 entry.configured_as.push(format!("({profile_name})"));
155 }
156 }
157 }
158 }
159
160 Ok(entries)
161}
162
163pub fn scaffold_wrapper(root: &Path, name: &str, force: bool) -> Result<()> {
164 let dir = root.join(".apm").join("agents").join(name);
165 if dir.exists() && !force {
166 anyhow::bail!(".apm/agents/{name}/ already exists; use --force to overwrite");
167 }
168 std::fs::create_dir_all(&dir)?;
169
170 let wrapper_path = dir.join("wrapper.sh");
172 std::fs::write(&wrapper_path, WRAPPER_TEMPLATE)?;
173 #[cfg(unix)]
174 {
175 use std::os::unix::fs::PermissionsExt;
176 std::fs::set_permissions(&wrapper_path, std::fs::Permissions::from_mode(0o755))?;
177 }
178
179 std::fs::write(dir.join("manifest.toml"), MANIFEST_TEMPLATE)?;
181
182 let worker_md = std::fs::read_to_string(root.join(".apm").join("apm.worker.md"))
184 .unwrap_or_else(|_| DEFAULT_WORKER_MD.to_string());
185 std::fs::write(dir.join("apm.worker.md"), &worker_md)?;
186
187 let spec_writer_md =
189 std::fs::read_to_string(root.join(".apm").join("apm.spec-writer.md"))
190 .unwrap_or_else(|_| DEFAULT_SPEC_WRITER_MD.to_string());
191 std::fs::write(dir.join("apm.spec-writer.md"), &spec_writer_md)?;
192
193 Ok(())
194}
195
196pub fn test_wrapper(root: &Path, name: &str) -> Result<TestReport> {
197 let kind = wrapper::resolve_wrapper(root, name)?.ok_or_else(|| {
198 anyhow::anyhow!(
199 "agent '{}' not found: checked built-ins and .apm/agents/{}/",
200 name,
201 name
202 )
203 })?;
204
205 let tmp: PathBuf =
206 std::env::temp_dir().join(format!("apm-agents-test-{:04x}", rand_u16()));
207 std::fs::create_dir_all(&tmp)?;
208
209 let sys_file = tmp.join("system.txt");
210 let msg_file = tmp.join("message.txt");
211 let log_path = tmp.join("wrapper.log");
212
213 std::fs::write(&sys_file, "You are a test agent.")?;
214 std::fs::write(&msg_file, "Test run -- apm agents test.")?;
215
216 let ctx = WrapperContext {
217 worker_name: "agents-test".to_string(),
218 ticket_id: "00000000".to_string(),
219 ticket_branch: "test/agents-test".to_string(),
220 worktree_path: tmp.clone(),
221 system_prompt_file: sys_file,
222 user_message_file: msg_file,
223 skip_permissions: false,
224 profile: "test".to_string(),
225 role_prefix: None,
226 options: HashMap::new(),
227 model: None,
228 log_path: log_path.clone(),
229 container: None,
230 extra_env: HashMap::new(),
231 root: root.to_path_buf(),
232 keychain: HashMap::new(),
233 current_state: "test".to_string(),
234 command: None,
235 };
236
237 let start = std::time::Instant::now();
238 let mut child = match kind {
239 WrapperKind::Custom { script_path, manifest } => {
240 CustomWrapper { script_path, manifest }.spawn(&ctx)?
241 }
242 WrapperKind::Builtin(n) => {
243 wrapper::resolve_builtin(&n)
244 .expect("registered builtin")
245 .spawn(&ctx)?
246 }
247 };
248
249 let status = child.wait()?;
250 let wall_millis = start.elapsed().as_millis() as u64;
251 let exit_code = status.code().unwrap_or(-1);
252
253 let log_content = std::fs::read_to_string(&log_path).unwrap_or_default();
255 let mut canonical_events = 0usize;
256 let mut non_canonical_lines = 0usize;
257 let mut stderr_lines = 0usize;
258
259 for line in log_content.lines() {
260 if line.is_empty() {
261 continue;
262 }
263 if line.starts_with("APM_") {
264 stderr_lines += 1;
265 } else if let Ok(val) = serde_json::from_str::<serde_json::Value>(line) {
266 if val.get("type").is_some() {
267 canonical_events += 1;
268 } else {
269 non_canonical_lines += 1;
270 }
271 } else {
272 non_canonical_lines += 1;
273 }
274 }
275
276 let passed = status.success() && canonical_events >= 1;
277 let report = TestReport {
278 exit_code,
279 canonical_events,
280 non_canonical_lines,
281 stderr_lines,
282 wall_millis,
283 passed,
284 };
285
286 let _ = std::fs::remove_dir_all(&tmp);
287
288 Ok(report)
289}
290
291pub fn eject_wrapper(root: &Path, name: &str) -> Result<()> {
292 if wrapper::resolve_builtin(name).is_none() {
293 anyhow::bail!(
294 "'{}' is not a known built-in; run apm agents list to see available wrappers",
295 name
296 );
297 }
298
299 let dir = root.join(".apm").join("agents").join(name);
300 if dir.exists() {
301 anyhow::bail!(".apm/agents/{name}/ already exists; delete it first to eject again");
302 }
303
304 std::fs::create_dir_all(&dir)?;
305
306 let script_content = match name {
307 "claude" => CLAUDE_EJECT_SCRIPT,
308 other => anyhow::bail!("eject not yet implemented for built-in {}", other),
309 };
310 let script_path = dir.join("wrapper.sh");
311 std::fs::write(&script_path, script_content)?;
312 #[cfg(unix)]
313 {
314 use std::os::unix::fs::PermissionsExt;
315 std::fs::set_permissions(&script_path, std::fs::Permissions::from_mode(0o755))?;
316 }
317
318 std::fs::write(dir.join("manifest.toml"), MANIFEST_TEMPLATE)?;
322
323 Ok(())
324}