Skip to main content

hh_cli/agent/
loader.rs

1use crate::agent::{AgentConfig, AgentFrontmatter};
2use anyhow::Context;
3use dirs;
4use glob::glob;
5use std::path::{Path, PathBuf};
6
7pub struct AgentLoader {
8    discovery_paths: Vec<PathBuf>,
9}
10
11impl AgentLoader {
12    pub fn new() -> anyhow::Result<Self> {
13        let cwd = std::env::current_dir()?;
14
15        let discovery_paths = vec![
16            // Project-local, highest priority
17            cwd.join(".agents/agents"),
18            cwd.join(".claude/agents"),
19            // User-level
20            dirs::home_dir()
21                .map(|h| h.join(".agents/agents"))
22                .unwrap_or_else(|| PathBuf::from("~/.agents/agents")),
23            dirs::home_dir()
24                .map(|h| h.join(".claude/agents"))
25                .unwrap_or_else(|| PathBuf::from("~/.claude/agents")),
26        ];
27
28        Ok(Self { discovery_paths })
29    }
30
31    pub fn load_agents(&self) -> anyhow::Result<Vec<AgentConfig>> {
32        let mut agents = vec![
33            // Start with built-in agents
34            AgentConfig::builtin_build(),
35            AgentConfig::builtin_plan(),
36            AgentConfig::builtin_explorer(),
37            AgentConfig::builtin_general(),
38        ];
39
40        // Load user-defined agents from discovery paths
41        for path in &self.discovery_paths {
42            if path.exists() {
43                self.load_agents_from_dir(&mut agents, path)?;
44            }
45        }
46
47        Ok(agents)
48    }
49
50    fn load_agents_from_dir(
51        &self,
52        agents: &mut Vec<AgentConfig>,
53        dir: &Path,
54    ) -> anyhow::Result<()> {
55        let pattern = dir.join("*.md").to_string_lossy().to_string();
56
57        for entry in glob(&pattern).context("Failed to read glob pattern")? {
58            let entry = entry.context("Failed to read glob entry")?;
59            if let Some(agent) = self.load_agent_from_file(&entry)? {
60                // Check if agent with same name already exists
61                if let Some(pos) = agents.iter().position(|a| a.name == agent.name) {
62                    // Override existing agent
63                    agents[pos] = agent;
64                } else {
65                    agents.push(agent);
66                }
67            }
68        }
69
70        Ok(())
71    }
72
73    fn load_agent_from_file(&self, path: &Path) -> anyhow::Result<Option<AgentConfig>> {
74        let content = std::fs::read_to_string(path)?;
75
76        // Parse YAML frontmatter
77        let (frontmatter, body) = self.parse_frontmatter(&content)?;
78
79        // Use filename (without extension) as agent name
80        let name = path
81            .file_stem()
82            .and_then(|s| s.to_str())
83            .context("Invalid agent filename")?
84            .to_string();
85
86        Ok(Some(frontmatter.to_agent_config(name, body)))
87    }
88
89    fn parse_frontmatter(
90        &self,
91        content: &str,
92    ) -> anyhow::Result<(AgentFrontmatter, Option<String>)> {
93        // Check for YAML frontmatter delimited by ---
94        if !content.starts_with("---") {
95            anyhow::bail!("Agent file must start with YAML frontmatter delimited by ---");
96        }
97
98        let rest = &content[3..]; // Skip opening ---
99        let Some(frontmatter_end) = rest.find("---") else {
100            anyhow::bail!("Missing closing --- for frontmatter");
101        };
102
103        let frontmatter_yaml = &rest[..frontmatter_end];
104        let body = if frontmatter_end + 3 < rest.len() {
105            Some(rest[frontmatter_end + 3..].trim().to_string())
106        } else {
107            None
108        };
109
110        let frontmatter: AgentFrontmatter =
111            serde_yaml::from_str(frontmatter_yaml).context("Failed to parse agent frontmatter")?;
112
113        Ok((frontmatter, body))
114    }
115}
116
117impl Default for AgentLoader {
118    fn default() -> Self {
119        Self::new().expect("Failed to create AgentLoader")
120    }
121}