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 approval = None;
let mut sandbox = None;
let mut auto_approved_tools = None;
let mut deny: Vec<String> = Vec::new();
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)),
"approval" => {
approval = nonempty(value).and_then(|v| crate::configfile::parse_approval_str(&v))
}
"sandbox" => {
sandbox = nonempty(value).and_then(|v| crate::configfile::parse_sandbox_str(&v))
}
"auto_approved_tools" => {
auto_approved_tools = Some(
parse_tool_list(value)
.iter()
.map(|tool| map_claude_tool(tool))
.collect(),
)
}
"deny" => deny = 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));
let permissions = (approval.is_some()
|| sandbox.is_some()
|| auto_approved_tools.is_some()
|| !deny.is_empty())
.then(|| crate::subagents::AgentPermissions {
approval,
sandbox,
auto_approved_tools,
deny,
});
Ok(ClaudeProjectAgent {
definition: NamedAgentDefinition {
name,
system_prompt: body,
tools: mapped_tools,
model: mapped_model,
permissions,
},
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()
))
}