use std::path::{Path, PathBuf};
use crate::subagents::NamedAgentDefinition;
use crate::{Config, Error, Result};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ClaudeCompatibilitySnapshot {
pub global_instructions: Option<String>,
pub global_instructions_path: PathBuf,
pub project_agents: Vec<ClaudeProjectAgent>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ClaudeProjectAgent {
pub definition: NamedAgentDefinition,
pub description: Option<String>,
pub path: PathBuf,
pub raw_source: String,
pub original_model: Option<String>,
pub original_tools: Option<Vec<String>>,
}
pub fn load_project_agents(cwd: &Path) -> Result<Vec<ClaudeProjectAgent>> {
let dir = cwd.join(".claude/agents");
let entries = match std::fs::read_dir(&dir) {
Ok(entries) => entries,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(error) => return Err(error.into()),
};
let mut paths: Vec<PathBuf> = entries
.filter_map(|entry| entry.ok().map(|entry| entry.path()))
.filter(|path| path.extension().and_then(|ext| ext.to_str()) == Some("md"))
.collect();
paths.sort();
paths
.into_iter()
.map(|path| {
let source = std::fs::read_to_string(&path)?;
parse_project_agent(&path, &source)
})
.collect()
}
pub fn apply_resume_compatibility(
config: &mut Config,
claude_home: &Path,
) -> Result<ClaudeCompatibilitySnapshot> {
config.claude_runtime_tools_enabled = true;
enable_claude_subagent_compatibility(config);
let global_instructions_path = claude_home.join("CLAUDE.md");
let global_instructions = match std::fs::read_to_string(&global_instructions_path) {
Ok(source) => {
if !source.trim().is_empty() {
config
.system_prompt
.push_str("\n\n# Claude global CLAUDE.md\n");
config.system_prompt.push_str(source.trim());
}
Some(source)
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
Err(error) => return Err(error.into()),
};
let project_agents = load_project_agents(&config.cwd)?;
for imported in &project_agents {
config.subagents_definitions.insert(
imported.definition.name.clone(),
imported.definition.clone(),
);
}
Ok(ClaudeCompatibilitySnapshot {
global_instructions,
global_instructions_path,
project_agents,
})
}
pub(crate) fn enable_claude_subagent_compatibility(config: &mut Config) {
config.subagents_enabled = true;
config.subagents_background = true;
config.subagents_background_prompts = Some(crate::subagents::BackgroundPromptsPolicy::Parent);
config.subagents_claude_agent_alias = true;
}
pub fn parse_project_agent(path: &Path, source: &str) -> Result<ClaudeProjectAgent> {
let normalized = source.replace("\r\n", "\n");
let mut lines = normalized.lines();
if lines.next() != Some("---") {
return Err(agent_error(
path,
"missing opening `---` frontmatter delimiter",
));
}
let mut name = None;
let mut description = None;
let mut model = None;
let mut tools = None;
let mut body_start = None;
let mut offset = 4usize; for line in lines {
if line == "---" {
body_start = Some(offset + line.len() + 1);
break;
}
let Some((key, value)) = line.split_once(':') else {
return Err(agent_error(
path,
format!("invalid frontmatter line `{line}`"),
));
};
let value = unquote(value.trim());
match key.trim() {
"name" => name = nonempty(value),
"description" => description = nonempty(value),
"model" => model = nonempty(value),
"tools" => tools = Some(parse_tool_list(value)),
_ => {}
}
offset += line.len() + 1;
}
let Some(body_start) = body_start else {
return Err(agent_error(
path,
"missing closing `---` frontmatter delimiter",
));
};
let body = normalized[body_start..].trim().to_string();
if body.is_empty() {
return Err(agent_error(path, "agent system-prompt body is empty"));
}
let name = name
.or_else(|| {
path.file_stem()
.and_then(|stem| stem.to_str())
.map(str::to_string)
})
.filter(|name| !name.trim().is_empty())
.ok_or_else(|| agent_error(path, "agent name is empty"))?;
let mapped_tools = tools
.as_ref()
.map(|tools| tools.iter().map(|tool| map_claude_tool(tool)).collect());
let mapped_model = model
.as_deref()
.map(|model| crate::model_catalog::resolve_alias(model, &[]));
Ok(ClaudeProjectAgent {
definition: NamedAgentDefinition {
name,
system_prompt: body,
tools: mapped_tools,
model: mapped_model,
},
description,
path: path.to_path_buf(),
raw_source: source.to_string(),
original_model: model,
original_tools: tools,
})
}
fn parse_tool_list(value: &str) -> Vec<String> {
let value = value
.strip_prefix('[')
.and_then(|value| value.strip_suffix(']'))
.unwrap_or(value);
value
.split(',')
.map(|tool| unquote(tool.trim()))
.filter(|tool| !tool.is_empty())
.map(str::to_string)
.collect()
}
fn map_claude_tool(tool: &str) -> String {
match tool {
"Bash" => "bash",
"Read" => "read_file",
"Write" => "write_file",
"Edit" => "edit_file",
"Glob" => "glob",
"Grep" => "search",
"Agent" | "Task" => "spawn_subagent",
other => other,
}
.to_string()
}
fn nonempty(value: &str) -> Option<String> {
(!value.is_empty()).then(|| value.to_string())
}
fn unquote(value: &str) -> &str {
value
.strip_prefix('"')
.and_then(|value| value.strip_suffix('"'))
.or_else(|| {
value
.strip_prefix('\'')
.and_then(|value| value.strip_suffix('\''))
})
.unwrap_or(value)
}
fn agent_error(path: &Path, message: impl std::fmt::Display) -> Error {
Error::Other(format!(
"invalid Claude project agent `{}`: {message}",
path.display()
))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_realistic_agent_and_maps_tools_and_model_without_losing_source() {
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";
let parsed = parse_project_agent(Path::new(".claude/agents/pilot-tick.md"), source)
.expect("definition parses");
assert_eq!(parsed.definition.name, "pilot-tick");
assert_eq!(parsed.description.as_deref(), Some("Sweep the fleet"));
assert_eq!(parsed.original_model.as_deref(), Some("sonnet"));
assert_eq!(
parsed.definition.model.as_deref(),
Some("anthropic/claude-sonnet-4-6")
);
assert_eq!(
parsed.definition.tools.as_deref(),
Some(
["bash", "read_file", "write_file", "edit_file"]
.map(str::to_string)
.as_slice()
)
);
assert_eq!(
parsed.definition.system_prompt,
"You are the pilot.\n\nFollow the drill exactly."
);
assert_eq!(parsed.raw_source, source);
}
#[test]
fn filename_supplies_name_and_bracketed_tools_are_supported() {
let parsed = parse_project_agent(
Path::new("reviewer.md"),
"---\ntools: [Read, Grep, Agent]\n---\nReview carefully.\n",
)
.unwrap();
assert_eq!(parsed.definition.name, "reviewer");
assert_eq!(
parsed.definition.tools.unwrap(),
["read_file", "search", "spawn_subagent"].map(str::to_string)
);
}
#[test]
fn malformed_definition_fails_loudly() {
let error = parse_project_agent(Path::new("broken.md"), "No frontmatter")
.expect_err("must reject malformed agent");
assert!(error.to_string().contains("missing opening"));
}
#[test]
fn compatibility_install_adds_global_and_named_agent_without_executing_it() {
let nonce = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let root = std::env::temp_dir().join(format!(
"supercode-claude-compat-{}-{nonce}",
std::process::id()
));
let project = root.join("project");
let claude_home = root.join(".claude");
std::fs::create_dir_all(project.join(".claude/agents")).unwrap();
std::fs::create_dir_all(&claude_home).unwrap();
std::fs::write(claude_home.join("CLAUDE.md"), "GLOBAL CLAUDE RULE").unwrap();
std::fs::write(
project.join(".claude/agents/pilot-tick.md"),
"---\nname: pilot-tick\ntools: Bash, Read\nmodel: sonnet\n---\nPilot exactly.\n",
)
.unwrap();
let mut config = Config::builder().cwd(&project).build();
let snapshot = apply_resume_compatibility(&mut config, &claude_home).unwrap();
assert!(config.system_prompt.contains("GLOBAL CLAUDE RULE"));
assert!(config.subagents_enabled);
assert!(config.subagents_background);
assert!(config.subagents_claude_agent_alias);
assert!(config.claude_runtime_tools_enabled);
assert_eq!(snapshot.project_agents.len(), 1);
assert_eq!(
config
.subagents_definitions
.get("pilot-tick")
.and_then(|definition| definition.model.as_deref()),
Some("anthropic/claude-sonnet-4-6")
);
std::fs::remove_dir_all(root).ok();
}
#[test]
fn compatibility_always_installs_claudes_builtin_general_purpose_agent() {
let nonce = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let root = std::env::temp_dir().join(format!(
"supercode-claude-built-in-{}-{nonce}",
std::process::id()
));
let project = root.join("project");
let claude_home = root.join(".claude");
std::fs::create_dir_all(&project).unwrap();
std::fs::create_dir_all(&claude_home).unwrap();
let mut config = Config::builder().cwd(&project).build();
let snapshot = apply_resume_compatibility(&mut config, &claude_home).unwrap();
assert!(snapshot.project_agents.is_empty());
assert!(config.subagents_enabled);
assert!(config.subagents_background);
assert_eq!(
config.subagents_background_prompts,
Some(crate::subagents::BackgroundPromptsPolicy::Parent)
);
assert!(config.subagents_claude_agent_alias);
std::fs::remove_dir_all(root).ok();
}
}