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 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; // opening `---\n`
142    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            // BP-7 (catalog §4a "Named agent definitions as data":
160            // "prompt+model+tools+PERMISSIONS in a file/config"). CC's own
161            // frontmatter has no permission keys; these are supercode's
162            // extension, and they can only TIGHTEN — see
163            // `crate::subagents::AgentPermissions`. A frontmatter file is
164            // repo-trust-tier data, so anything looser than the parent's
165            // posture is ignored rather than honored.
166            "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}