1use std::path::{Path, PathBuf};
10
11use crate::subagents::NamedAgentDefinition;
12use crate::{Config, Error, Result};
13
14#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct ClaudeCompatibilitySnapshot {
17 pub global_instructions: Option<String>,
19 pub global_instructions_path: PathBuf,
21 pub project_agents: Vec<ClaudeProjectAgent>,
23}
24
25#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct ClaudeProjectAgent {
28 pub definition: NamedAgentDefinition,
30 pub description: Option<String>,
32 pub path: PathBuf,
34 pub raw_source: String,
36 pub original_model: Option<String>,
38 pub original_tools: Option<Vec<String>>,
40}
41
42pub fn load_project_agents(cwd: &Path) -> Result<Vec<ClaudeProjectAgent>> {
48 let dir = cwd.join(".claude/agents");
49 let entries = match std::fs::read_dir(&dir) {
50 Ok(entries) => entries,
51 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
52 Err(error) => return Err(error.into()),
53 };
54 let mut paths: Vec<PathBuf> = entries
55 .filter_map(|entry| entry.ok().map(|entry| entry.path()))
56 .filter(|path| path.extension().and_then(|ext| ext.to_str()) == Some("md"))
57 .collect();
58 paths.sort();
59 paths
60 .into_iter()
61 .map(|path| {
62 let source = std::fs::read_to_string(&path)?;
63 parse_project_agent(&path, &source)
64 })
65 .collect()
66}
67
68pub fn apply_resume_compatibility(
76 config: &mut Config,
77 claude_home: &Path,
78) -> Result<ClaudeCompatibilitySnapshot> {
79 config.claude_runtime_tools_enabled = true;
83 enable_claude_subagent_compatibility(config);
84 let global_instructions_path = claude_home.join("CLAUDE.md");
85 let global_instructions = match std::fs::read_to_string(&global_instructions_path) {
86 Ok(source) => {
87 if !source.trim().is_empty() {
88 config
89 .system_prompt
90 .push_str("\n\n# Claude global CLAUDE.md\n");
91 config.system_prompt.push_str(source.trim());
92 }
93 Some(source)
94 }
95 Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
96 Err(error) => return Err(error.into()),
97 };
98
99 let project_agents = load_project_agents(&config.cwd)?;
100 for imported in &project_agents {
101 config.subagents_definitions.insert(
102 imported.definition.name.clone(),
103 imported.definition.clone(),
104 );
105 }
106
107 Ok(ClaudeCompatibilitySnapshot {
108 global_instructions,
109 global_instructions_path,
110 project_agents,
111 })
112}
113
114pub(crate) fn enable_claude_subagent_compatibility(config: &mut Config) {
115 config.subagents_enabled = true;
116 config.subagents_background = true;
117 config.subagents_background_prompts = Some(crate::subagents::BackgroundPromptsPolicy::Parent);
118 config.subagents_claude_agent_alias = true;
119}
120
121pub fn parse_project_agent(path: &Path, source: &str) -> Result<ClaudeProjectAgent> {
123 let normalized = source.replace("\r\n", "\n");
124 let mut lines = normalized.lines();
125 if lines.next() != Some("---") {
126 return Err(agent_error(
127 path,
128 "missing opening `---` frontmatter delimiter",
129 ));
130 }
131
132 let mut name = None;
133 let mut description = None;
134 let mut model = None;
135 let mut tools = None;
136 let mut approval = None;
137 let mut sandbox = None;
138 let mut auto_approved_tools = None;
139 let mut deny: Vec<String> = Vec::new();
140 let mut body_start = None;
141 let mut offset = 4usize; for line in lines {
143 if line == "---" {
144 body_start = Some(offset + line.len() + 1);
145 break;
146 }
147 let Some((key, value)) = line.split_once(':') else {
148 return Err(agent_error(
149 path,
150 format!("invalid frontmatter line `{line}`"),
151 ));
152 };
153 let value = unquote(value.trim());
154 match key.trim() {
155 "name" => name = nonempty(value),
156 "description" => description = nonempty(value),
157 "model" => model = nonempty(value),
158 "tools" => tools = Some(parse_tool_list(value)),
159 "approval" => {
167 approval = nonempty(value).and_then(|v| crate::configfile::parse_approval_str(&v))
168 }
169 "sandbox" => {
170 sandbox = nonempty(value).and_then(|v| crate::configfile::parse_sandbox_str(&v))
171 }
172 "auto_approved_tools" => {
173 auto_approved_tools = Some(
174 parse_tool_list(value)
175 .iter()
176 .map(|tool| map_claude_tool(tool))
177 .collect(),
178 )
179 }
180 "deny" => deny = parse_tool_list(value),
181 _ => {}
182 }
183 offset += line.len() + 1;
184 }
185 let Some(body_start) = body_start else {
186 return Err(agent_error(
187 path,
188 "missing closing `---` frontmatter delimiter",
189 ));
190 };
191 let body = normalized[body_start..].trim().to_string();
192 if body.is_empty() {
193 return Err(agent_error(path, "agent system-prompt body is empty"));
194 }
195 let name = name
196 .or_else(|| {
197 path.file_stem()
198 .and_then(|stem| stem.to_str())
199 .map(str::to_string)
200 })
201 .filter(|name| !name.trim().is_empty())
202 .ok_or_else(|| agent_error(path, "agent name is empty"))?;
203 let mapped_tools = tools
204 .as_ref()
205 .map(|tools| tools.iter().map(|tool| map_claude_tool(tool)).collect());
206 let mapped_model = model
207 .as_deref()
208 .map(|model| crate::model_catalog::resolve_alias(model));
209
210 let permissions = (approval.is_some()
211 || sandbox.is_some()
212 || auto_approved_tools.is_some()
213 || !deny.is_empty())
214 .then(|| crate::subagents::AgentPermissions {
215 approval,
216 sandbox,
217 auto_approved_tools,
218 deny,
219 });
220
221 Ok(ClaudeProjectAgent {
222 definition: NamedAgentDefinition {
223 name,
224 system_prompt: body,
225 tools: mapped_tools,
226 model: mapped_model,
227 permissions,
228 },
229 description,
230 path: path.to_path_buf(),
231 raw_source: source.to_string(),
232 original_model: model,
233 original_tools: tools,
234 })
235}
236
237fn parse_tool_list(value: &str) -> Vec<String> {
238 let value = value
239 .strip_prefix('[')
240 .and_then(|value| value.strip_suffix(']'))
241 .unwrap_or(value);
242 value
243 .split(',')
244 .map(|tool| unquote(tool.trim()))
245 .filter(|tool| !tool.is_empty())
246 .map(str::to_string)
247 .collect()
248}
249
250fn map_claude_tool(tool: &str) -> String {
251 match tool {
252 "Bash" => "bash",
253 "Read" => "read_file",
254 "Write" => "write_file",
255 "Edit" => "edit_file",
256 "Glob" => "glob",
257 "Grep" => "search",
258 "Agent" | "Task" => "spawn_subagent",
259 other => other,
260 }
261 .to_string()
262}
263
264fn nonempty(value: &str) -> Option<String> {
265 (!value.is_empty()).then(|| value.to_string())
266}
267
268fn unquote(value: &str) -> &str {
269 value
270 .strip_prefix('"')
271 .and_then(|value| value.strip_suffix('"'))
272 .or_else(|| {
273 value
274 .strip_prefix('\'')
275 .and_then(|value| value.strip_suffix('\''))
276 })
277 .unwrap_or(value)
278}
279
280fn agent_error(path: &Path, message: impl std::fmt::Display) -> Error {
281 Error::Other(format!(
282 "invalid Claude project agent `{}`: {message}",
283 path.display()
284 ))
285}
286
287#[cfg(test)]
288mod tests {
289 use super::*;
290
291 #[test]
292 fn parses_realistic_agent_and_maps_tools_and_model_without_losing_source() {
293 let source = "---\nname: pilot-tick\ndescription: Sweep the fleet\ntools: Bash, Read, Write, Edit\nmodel: sonnet\n---\nYou are the pilot.\n\nFollow the drill exactly.\n";
294 let parsed = parse_project_agent(Path::new(".claude/agents/pilot-tick.md"), source)
295 .expect("definition parses");
296 assert_eq!(parsed.definition.name, "pilot-tick");
297 assert_eq!(parsed.description.as_deref(), Some("Sweep the fleet"));
298 assert_eq!(parsed.original_model.as_deref(), Some("sonnet"));
299 assert_eq!(
300 parsed.definition.model.as_deref(),
301 Some("anthropic/claude-sonnet-4-6")
302 );
303 assert_eq!(
304 parsed.definition.tools.as_deref(),
305 Some(
306 ["bash", "read_file", "write_file", "edit_file"]
307 .map(str::to_string)
308 .as_slice()
309 )
310 );
311 assert_eq!(
312 parsed.definition.system_prompt,
313 "You are the pilot.\n\nFollow the drill exactly."
314 );
315 assert_eq!(parsed.raw_source, source);
316 }
317
318 #[test]
319 fn filename_supplies_name_and_bracketed_tools_are_supported() {
320 let parsed = parse_project_agent(
321 Path::new("reviewer.md"),
322 "---\ntools: [Read, Grep, Agent]\n---\nReview carefully.\n",
323 )
324 .unwrap();
325 assert_eq!(parsed.definition.name, "reviewer");
326 assert_eq!(
327 parsed.definition.tools.unwrap(),
328 ["read_file", "search", "spawn_subagent"].map(str::to_string)
329 );
330 }
331
332 #[test]
333 fn malformed_definition_fails_loudly() {
334 let error = parse_project_agent(Path::new("broken.md"), "No frontmatter")
335 .expect_err("must reject malformed agent");
336 assert!(error.to_string().contains("missing opening"));
337 }
338
339 #[test]
340 fn compatibility_install_adds_global_and_named_agent_without_executing_it() {
341 let nonce = std::time::SystemTime::now()
342 .duration_since(std::time::UNIX_EPOCH)
343 .unwrap()
344 .as_nanos();
345 let root = std::env::temp_dir().join(format!(
346 "supercode-claude-compat-{}-{nonce}",
347 std::process::id()
348 ));
349 let project = root.join("project");
350 let claude_home = root.join(".claude");
351 std::fs::create_dir_all(project.join(".claude/agents")).unwrap();
352 std::fs::create_dir_all(&claude_home).unwrap();
353 std::fs::write(claude_home.join("CLAUDE.md"), "GLOBAL CLAUDE RULE").unwrap();
354 std::fs::write(
355 project.join(".claude/agents/pilot-tick.md"),
356 "---\nname: pilot-tick\ntools: Bash, Read\nmodel: sonnet\n---\nPilot exactly.\n",
357 )
358 .unwrap();
359
360 let mut config = Config::builder().cwd(&project).build();
361 let snapshot = apply_resume_compatibility(&mut config, &claude_home).unwrap();
362 assert!(config.system_prompt.contains("GLOBAL CLAUDE RULE"));
363 assert!(config.subagents_enabled);
364 assert!(config.subagents_background);
365 assert!(config.subagents_claude_agent_alias);
366 assert!(config.claude_runtime_tools_enabled);
367 assert_eq!(snapshot.project_agents.len(), 1);
368 assert_eq!(
369 config
370 .subagents_definitions
371 .get("pilot-tick")
372 .and_then(|definition| definition.model.as_deref()),
373 Some("anthropic/claude-sonnet-4-6")
374 );
375 std::fs::remove_dir_all(root).ok();
376 }
377
378 #[test]
379 fn compatibility_always_installs_claudes_builtin_general_purpose_agent() {
380 let nonce = std::time::SystemTime::now()
381 .duration_since(std::time::UNIX_EPOCH)
382 .unwrap()
383 .as_nanos();
384 let root = std::env::temp_dir().join(format!(
385 "supercode-claude-built-in-{}-{nonce}",
386 std::process::id()
387 ));
388 let project = root.join("project");
389 let claude_home = root.join(".claude");
390 std::fs::create_dir_all(&project).unwrap();
391 std::fs::create_dir_all(&claude_home).unwrap();
392
393 let mut config = Config::builder().cwd(&project).build();
394 let snapshot = apply_resume_compatibility(&mut config, &claude_home).unwrap();
395 assert!(snapshot.project_agents.is_empty());
396 assert!(config.subagents_enabled);
397 assert!(config.subagents_background);
398 assert_eq!(
399 config.subagents_background_prompts,
400 Some(crate::subagents::BackgroundPromptsPolicy::Parent)
401 );
402 assert!(config.subagents_claude_agent_alias);
403 std::fs::remove_dir_all(root).ok();
404 }
405}