Skip to main content

supercode_harness/
claude_compat.rs

1//! Claude Code project compatibility helpers.
2//!
3//! Claude stores named subagent definitions as Markdown files under
4//! `<project>/.claude/agents/`. The body is the child's system prompt and a
5//! small YAML-like frontmatter block carries its name, tool allowlist, and
6//! model pin. These helpers import that durable project state without
7//! starting a child or otherwise executing it.
8
9use std::path::{Path, PathBuf};
10
11use crate::subagents::NamedAgentDefinition;
12use crate::{Config, Error, Result};
13
14/// Claude-specific project state installed into a resumed agent config.
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct ClaudeCompatibilitySnapshot {
17    /// Exact global `~/.claude/CLAUDE.md` bytes, when present.
18    pub global_instructions: Option<String>,
19    /// Global instruction path that was checked.
20    pub global_instructions_path: PathBuf,
21    /// Imported project agent definitions and their exact source bytes.
22    pub project_agents: Vec<ClaudeProjectAgent>,
23}
24
25/// One imported Claude project-agent file.
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct ClaudeProjectAgent {
28    /// Parsed definition usable by Supercode's named-subagent runtime.
29    pub definition: NamedAgentDefinition,
30    /// Optional human-facing description from the Claude frontmatter.
31    pub description: Option<String>,
32    /// Exact source path from which the definition was read.
33    pub path: PathBuf,
34    /// Exact Markdown source, retained for snapshot/export fidelity.
35    pub raw_source: String,
36    /// Original model value before alias resolution (for fidelity reporting).
37    pub original_model: Option<String>,
38    /// Original Claude tool names before compatibility mapping.
39    pub original_tools: Option<Vec<String>>,
40}
41
42/// Discover and parse every `<cwd>/.claude/agents/*.md` definition.
43///
44/// Results are sorted by path. A malformed definition fails the whole import
45/// instead of silently omitting a capability that the resumed session may
46/// rely on. A missing agents directory is the ordinary empty result.
47pub 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
68/// Install Claude Code's non-executing compatibility state into `config`.
69///
70/// This imports Claude's global instructions and project named agents, then
71/// enables the inert `Agent` compatibility surface. It does not spawn a child,
72/// start a scheduler, or change sandbox/approval posture. The caller supplies
73/// Claude's home (normally `$HOME/.claude`) explicitly so embedding/tests do
74/// not depend on process-global environment mutation.
75pub fn apply_resume_compatibility(
76    config: &mut Config,
77    claude_home: &Path,
78) -> Result<ClaudeCompatibilitySnapshot> {
79    // Restore Claude's scheduler-shaped tool surface over an inert manifest.
80    // This only enables schemas/paused state mutation; Agent owns no timer
81    // and the runtime manifest has no active execution posture.
82    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
121/// Parse one Claude project-agent Markdown definition.
122pub 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; // opening `---\n`
138    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}