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}