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 body_start = None;
137 let mut offset = 4usize; for line in lines {
139 if line == "---" {
140 body_start = Some(offset + line.len() + 1);
141 break;
142 }
143 let Some((key, value)) = line.split_once(':') else {
144 return Err(agent_error(
145 path,
146 format!("invalid frontmatter line `{line}`"),
147 ));
148 };
149 let value = unquote(value.trim());
150 match key.trim() {
151 "name" => name = nonempty(value),
152 "description" => description = nonempty(value),
153 "model" => model = nonempty(value),
154 "tools" => tools = Some(parse_tool_list(value)),
155 _ => {}
156 }
157 offset += line.len() + 1;
158 }
159 let Some(body_start) = body_start else {
160 return Err(agent_error(
161 path,
162 "missing closing `---` frontmatter delimiter",
163 ));
164 };
165 let body = normalized[body_start..].trim().to_string();
166 if body.is_empty() {
167 return Err(agent_error(path, "agent system-prompt body is empty"));
168 }
169 let name = name
170 .or_else(|| {
171 path.file_stem()
172 .and_then(|stem| stem.to_str())
173 .map(str::to_string)
174 })
175 .filter(|name| !name.trim().is_empty())
176 .ok_or_else(|| agent_error(path, "agent name is empty"))?;
177 let mapped_tools = tools
178 .as_ref()
179 .map(|tools| tools.iter().map(|tool| map_claude_tool(tool)).collect());
180 let mapped_model = model
181 .as_deref()
182 .map(|model| crate::model_catalog::resolve_alias(model, &[]));
183
184 Ok(ClaudeProjectAgent {
185 definition: NamedAgentDefinition {
186 name,
187 system_prompt: body,
188 tools: mapped_tools,
189 model: mapped_model,
190 },
191 description,
192 path: path.to_path_buf(),
193 raw_source: source.to_string(),
194 original_model: model,
195 original_tools: tools,
196 })
197}
198
199fn parse_tool_list(value: &str) -> Vec<String> {
200 let value = value
201 .strip_prefix('[')
202 .and_then(|value| value.strip_suffix(']'))
203 .unwrap_or(value);
204 value
205 .split(',')
206 .map(|tool| unquote(tool.trim()))
207 .filter(|tool| !tool.is_empty())
208 .map(str::to_string)
209 .collect()
210}
211
212fn map_claude_tool(tool: &str) -> String {
213 match tool {
214 "Bash" => "bash",
215 "Read" => "read_file",
216 "Write" => "write_file",
217 "Edit" => "edit_file",
218 "Glob" => "glob",
219 "Grep" => "search",
220 "Agent" | "Task" => "spawn_subagent",
221 other => other,
222 }
223 .to_string()
224}
225
226fn nonempty(value: &str) -> Option<String> {
227 (!value.is_empty()).then(|| value.to_string())
228}
229
230fn unquote(value: &str) -> &str {
231 value
232 .strip_prefix('"')
233 .and_then(|value| value.strip_suffix('"'))
234 .or_else(|| {
235 value
236 .strip_prefix('\'')
237 .and_then(|value| value.strip_suffix('\''))
238 })
239 .unwrap_or(value)
240}
241
242fn agent_error(path: &Path, message: impl std::fmt::Display) -> Error {
243 Error::Other(format!(
244 "invalid Claude project agent `{}`: {message}",
245 path.display()
246 ))
247}
248
249#[cfg(test)]
250mod tests {
251 use super::*;
252
253 #[test]
254 fn parses_realistic_agent_and_maps_tools_and_model_without_losing_source() {
255 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";
256 let parsed = parse_project_agent(Path::new(".claude/agents/pilot-tick.md"), source)
257 .expect("definition parses");
258 assert_eq!(parsed.definition.name, "pilot-tick");
259 assert_eq!(parsed.description.as_deref(), Some("Sweep the fleet"));
260 assert_eq!(parsed.original_model.as_deref(), Some("sonnet"));
261 assert_eq!(
262 parsed.definition.model.as_deref(),
263 Some("anthropic/claude-sonnet-4-6")
264 );
265 assert_eq!(
266 parsed.definition.tools.as_deref(),
267 Some(
268 ["bash", "read_file", "write_file", "edit_file"]
269 .map(str::to_string)
270 .as_slice()
271 )
272 );
273 assert_eq!(
274 parsed.definition.system_prompt,
275 "You are the pilot.\n\nFollow the drill exactly."
276 );
277 assert_eq!(parsed.raw_source, source);
278 }
279
280 #[test]
281 fn filename_supplies_name_and_bracketed_tools_are_supported() {
282 let parsed = parse_project_agent(
283 Path::new("reviewer.md"),
284 "---\ntools: [Read, Grep, Agent]\n---\nReview carefully.\n",
285 )
286 .unwrap();
287 assert_eq!(parsed.definition.name, "reviewer");
288 assert_eq!(
289 parsed.definition.tools.unwrap(),
290 ["read_file", "search", "spawn_subagent"].map(str::to_string)
291 );
292 }
293
294 #[test]
295 fn malformed_definition_fails_loudly() {
296 let error = parse_project_agent(Path::new("broken.md"), "No frontmatter")
297 .expect_err("must reject malformed agent");
298 assert!(error.to_string().contains("missing opening"));
299 }
300
301 #[test]
302 fn compatibility_install_adds_global_and_named_agent_without_executing_it() {
303 let nonce = std::time::SystemTime::now()
304 .duration_since(std::time::UNIX_EPOCH)
305 .unwrap()
306 .as_nanos();
307 let root = std::env::temp_dir().join(format!(
308 "supercode-claude-compat-{}-{nonce}",
309 std::process::id()
310 ));
311 let project = root.join("project");
312 let claude_home = root.join(".claude");
313 std::fs::create_dir_all(project.join(".claude/agents")).unwrap();
314 std::fs::create_dir_all(&claude_home).unwrap();
315 std::fs::write(claude_home.join("CLAUDE.md"), "GLOBAL CLAUDE RULE").unwrap();
316 std::fs::write(
317 project.join(".claude/agents/pilot-tick.md"),
318 "---\nname: pilot-tick\ntools: Bash, Read\nmodel: sonnet\n---\nPilot exactly.\n",
319 )
320 .unwrap();
321
322 let mut config = Config::builder().cwd(&project).build();
323 let snapshot = apply_resume_compatibility(&mut config, &claude_home).unwrap();
324 assert!(config.system_prompt.contains("GLOBAL CLAUDE RULE"));
325 assert!(config.subagents_enabled);
326 assert!(config.subagents_background);
327 assert!(config.subagents_claude_agent_alias);
328 assert!(config.claude_runtime_tools_enabled);
329 assert_eq!(snapshot.project_agents.len(), 1);
330 assert_eq!(
331 config
332 .subagents_definitions
333 .get("pilot-tick")
334 .and_then(|definition| definition.model.as_deref()),
335 Some("anthropic/claude-sonnet-4-6")
336 );
337 std::fs::remove_dir_all(root).ok();
338 }
339
340 #[test]
341 fn compatibility_always_installs_claudes_builtin_general_purpose_agent() {
342 let nonce = std::time::SystemTime::now()
343 .duration_since(std::time::UNIX_EPOCH)
344 .unwrap()
345 .as_nanos();
346 let root = std::env::temp_dir().join(format!(
347 "supercode-claude-built-in-{}-{nonce}",
348 std::process::id()
349 ));
350 let project = root.join("project");
351 let claude_home = root.join(".claude");
352 std::fs::create_dir_all(&project).unwrap();
353 std::fs::create_dir_all(&claude_home).unwrap();
354
355 let mut config = Config::builder().cwd(&project).build();
356 let snapshot = apply_resume_compatibility(&mut config, &claude_home).unwrap();
357 assert!(snapshot.project_agents.is_empty());
358 assert!(config.subagents_enabled);
359 assert!(config.subagents_background);
360 assert_eq!(
361 config.subagents_background_prompts,
362 Some(crate::subagents::BackgroundPromptsPolicy::Parent)
363 );
364 assert!(config.subagents_claude_agent_alias);
365 std::fs::remove_dir_all(root).ok();
366 }
367}