use serde::Deserialize;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AgentDefinitionSource {
Project,
Global,
}
#[derive(Debug, Clone)]
pub struct AgentDefinition {
pub name: String,
pub description: Option<String>,
pub model: Option<String>,
pub isolation: Option<String>,
pub source: AgentDefinitionSource,
pub file_path: PathBuf,
}
#[derive(Debug, Deserialize)]
struct Frontmatter {
#[serde(default)]
name: Option<String>,
#[serde(default)]
description: Option<String>,
#[serde(default)]
model: Option<String>,
#[serde(default)]
isolation: Option<String>,
}
fn parse_frontmatter(content: &str) -> Option<Frontmatter> {
let trimmed = content.trim_start();
if !trimmed.starts_with("---") {
return None;
}
let after_first = &trimmed[3..];
let after_first = after_first.strip_prefix('\n').unwrap_or(after_first);
let end_idx = after_first.find("\n---")?;
let yaml_block = &after_first[..end_idx];
serde_yaml::from_str(yaml_block).ok()
}
fn scan_directory(dir: &Path, source: AgentDefinitionSource) -> Vec<AgentDefinition> {
let entries = match std::fs::read_dir(dir) {
Ok(entries) => entries,
Err(_) => return Vec::new(),
};
let mut definitions = Vec::new();
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("md") {
continue;
}
let content = match std::fs::read_to_string(&path) {
Ok(c) => c,
Err(_) => continue,
};
let filename_stem = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("unknown")
.to_string();
let frontmatter = parse_frontmatter(&content);
let (name, description, model, isolation) = match frontmatter {
Some(fm) => (
fm.name.unwrap_or(filename_stem),
fm.description,
fm.model,
fm.isolation,
),
None => (filename_stem, None, None, None),
};
definitions.push(AgentDefinition {
name,
description,
model,
isolation,
source: source.clone(),
file_path: path,
});
}
definitions
}
pub fn scan_agent_definitions(project_dir: Option<&Path>) -> Vec<AgentDefinition> {
let mut definitions = Vec::new();
if let Some(home) = dirs::home_dir() {
let global_dir = home.join(".claude").join("agents");
definitions.extend(scan_directory(&global_dir, AgentDefinitionSource::Global));
}
if let Some(project) = project_dir {
let project_dir = project.join(".claude").join("agents");
let project_defs = scan_directory(&project_dir, AgentDefinitionSource::Project);
for def in project_defs {
definitions.retain(|d| d.name != def.name);
definitions.push(def);
}
}
definitions
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn write_agent_file(dir: &Path, filename: &str, content: &str) {
let agents_dir = dir.join(".claude").join("agents");
fs::create_dir_all(&agents_dir).unwrap();
fs::write(agents_dir.join(filename), content).unwrap();
}
#[test]
fn test_parse_frontmatter_valid() {
let content = r#"---
name: parallel-worker
description: Works on tasks in isolation
model: sonnet
isolation: worktree
allowed_tools:
- Read
- Glob
---
You are a parallel worker that..."#;
let fm = parse_frontmatter(content).unwrap();
assert_eq!(fm.name.as_deref(), Some("parallel-worker"));
assert_eq!(
fm.description.as_deref(),
Some("Works on tasks in isolation")
);
assert_eq!(fm.model.as_deref(), Some("sonnet"));
assert_eq!(fm.isolation.as_deref(), Some("worktree"));
}
#[test]
fn test_parse_frontmatter_no_frontmatter() {
let content = "Just regular markdown content\nwith no frontmatter.";
assert!(parse_frontmatter(content).is_none());
}
#[test]
fn test_parse_frontmatter_empty_yaml() {
let content = "---\n---\nBody content.";
let fm = parse_frontmatter(content);
assert!(fm.is_none());
}
#[test]
fn test_parse_frontmatter_invalid_yaml() {
let content = "---\n[invalid yaml: {{\n---\nBody.";
assert!(parse_frontmatter(content).is_none());
}
#[test]
fn test_parse_frontmatter_partial_fields() {
let content = "---\nname: my-agent\n---\nBody.";
let fm = parse_frontmatter(content).unwrap();
assert_eq!(fm.name.as_deref(), Some("my-agent"));
assert!(fm.description.is_none());
assert!(fm.model.is_none());
}
#[test]
fn test_scan_directory_basic() {
let tmp = TempDir::new().unwrap();
write_agent_file(
tmp.path(),
"researcher.md",
"---\nname: researcher\ndescription: Research agent\nmodel: opus\n---\nPrompt body.",
);
write_agent_file(
tmp.path(),
"coder.md",
"---\nname: coder\nisolation: worktree\n---\nCoder prompt.",
);
let agents_dir = tmp.path().join(".claude").join("agents");
let defs = scan_directory(&agents_dir, AgentDefinitionSource::Project);
assert_eq!(defs.len(), 2);
let researcher = defs.iter().find(|d| d.name == "researcher").unwrap();
assert_eq!(researcher.description.as_deref(), Some("Research agent"));
assert_eq!(researcher.model.as_deref(), Some("opus"));
assert_eq!(researcher.source, AgentDefinitionSource::Project);
let coder = defs.iter().find(|d| d.name == "coder").unwrap();
assert!(coder.description.is_none());
assert_eq!(coder.isolation.as_deref(), Some("worktree"));
}
#[test]
fn test_scan_directory_no_frontmatter_uses_filename() {
let tmp = TempDir::new().unwrap();
write_agent_file(
tmp.path(),
"simple-agent.md",
"No frontmatter here, just a prompt.",
);
let agents_dir = tmp.path().join(".claude").join("agents");
let defs = scan_directory(&agents_dir, AgentDefinitionSource::Global);
assert_eq!(defs.len(), 1);
assert_eq!(defs[0].name, "simple-agent");
assert!(defs[0].description.is_none());
assert_eq!(defs[0].source, AgentDefinitionSource::Global);
}
#[test]
fn test_scan_directory_ignores_non_md() {
let tmp = TempDir::new().unwrap();
let agents_dir = tmp.path().join(".claude").join("agents");
fs::create_dir_all(&agents_dir).unwrap();
fs::write(agents_dir.join("notes.txt"), "Not an agent def").unwrap();
fs::write(agents_dir.join("config.json"), "{}").unwrap();
let defs = scan_directory(&agents_dir, AgentDefinitionSource::Project);
assert!(defs.is_empty());
}
#[test]
fn test_scan_directory_nonexistent() {
let defs = scan_directory(
Path::new("/nonexistent/path"),
AgentDefinitionSource::Global,
);
assert!(defs.is_empty());
}
#[test]
fn test_scan_agent_definitions_project_overrides_global() {
let global_dir = TempDir::new().unwrap();
let project_dir = TempDir::new().unwrap();
write_agent_file(
global_dir.path(),
"worker.md",
"---\nname: worker\ndescription: Global worker\n---\n",
);
write_agent_file(
project_dir.path(),
"worker.md",
"---\nname: worker\ndescription: Project worker\nisolation: worktree\n---\n",
);
let global_agents = global_dir.path().join(".claude").join("agents");
let project_agents = project_dir.path().join(".claude").join("agents");
let mut definitions = scan_directory(&global_agents, AgentDefinitionSource::Global);
let project_defs = scan_directory(&project_agents, AgentDefinitionSource::Project);
for def in project_defs {
definitions.retain(|d| d.name != def.name);
definitions.push(def);
}
assert_eq!(definitions.len(), 1);
assert_eq!(definitions[0].name, "worker");
assert_eq!(
definitions[0].description.as_deref(),
Some("Project worker")
);
assert_eq!(definitions[0].source, AgentDefinitionSource::Project);
}
}