1use anyhow::{Context, Result};
2use serde_json::Value;
3use std::fs;
4use std::path::{Path, PathBuf};
5
6use crate::tools::Tool;
7
8#[derive(Debug, Clone)]
9pub struct SkillInfo {
10 pub name: String,
11 pub description: String,
12 pub path: PathBuf,
13}
14
15pub struct SkillRegistry {
16 skills: Vec<SkillInfo>,
17}
18
19impl SkillRegistry {
20 pub fn discover() -> Self {
21 let mut skills = Vec::new();
22 let mut seen_names = std::collections::HashSet::new();
23
24 for base in Self::search_paths() {
25 if !base.exists() {
26 continue;
27 }
28 let entries = match fs::read_dir(&base) {
29 Ok(e) => e,
30 Err(_) => continue,
31 };
32 for entry in entries.flatten() {
33 let skill_dir = entry.path();
34 if !skill_dir.is_dir() {
35 continue;
36 }
37 let skill_file = skill_dir.join("SKILL.md");
38 if skill_file.exists()
39 && let Some(info) = Self::parse_skill(&skill_file)
40 && seen_names.insert(info.name.clone())
41 {
42 skills.push(info);
43 }
44 }
45 }
46
47 tracing::info!("Discovered {} skills", skills.len());
48 SkillRegistry { skills }
49 }
50
51 fn search_paths() -> Vec<PathBuf> {
52 let mut paths = Vec::new();
53 let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
54 let config_dir = crate::config::Config::config_dir();
55 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
56
57 paths.push(config_dir.join("skills"));
58 paths.push(home.join(".agents").join("skills"));
59 paths.push(home.join(".claude").join("skills"));
60
61 paths.push(cwd.join(".dot").join("skills"));
62 paths.push(cwd.join(".agents").join("skills"));
63 paths.push(cwd.join(".claude").join("skills"));
64
65 paths
66 }
67
68 fn parse_skill(path: &Path) -> Option<SkillInfo> {
69 let content = fs::read_to_string(path).ok()?;
70 let name = path.parent()?.file_name()?.to_string_lossy().to_string();
71
72 let description = if let Some(stripped) = content.strip_prefix("---") {
73 if let Some(end) = stripped.find("---") {
74 let frontmatter = &stripped[..end];
75 Self::extract_field(frontmatter, "description")
76 .unwrap_or_else(|| Self::first_meaningful_line(&content, 3 + end + 3))
77 } else {
78 Self::first_meaningful_line(&content, 0)
79 }
80 } else {
81 Self::first_meaningful_line(&content, 0)
82 };
83
84 Some(SkillInfo {
85 name,
86 description,
87 path: path.to_path_buf(),
88 })
89 }
90
91 fn extract_field(frontmatter: &str, field: &str) -> Option<String> {
92 let prefix = format!("{}:", field);
93 for line in frontmatter.lines() {
94 let trimmed = line.trim();
95 if let Some(value) = trimmed.strip_prefix(&prefix) {
96 let value = value.trim().trim_matches('"').trim_matches('\'');
97 if !value.is_empty() {
98 return Some(value.to_string());
99 }
100 }
101 }
102 None
103 }
104
105 fn first_meaningful_line(content: &str, skip: usize) -> String {
106 content
107 .get(skip..)
108 .unwrap_or("")
109 .lines()
110 .find(|l| {
111 let t = l.trim();
112 !t.is_empty() && !t.starts_with('#') && !t.starts_with("---")
113 })
114 .unwrap_or("No description")
115 .trim()
116 .chars()
117 .take(120)
118 .collect()
119 }
120
121 pub fn skills(&self) -> &[SkillInfo] {
122 &self.skills
123 }
124
125 pub fn is_empty(&self) -> bool {
126 self.skills.is_empty()
127 }
128
129 pub fn into_tool(self) -> Option<SkillTool> {
130 if self.skills.is_empty() {
131 return None;
132 }
133 Some(SkillTool {
134 skills: self.skills,
135 })
136 }
137}
138
139pub struct SkillTool {
140 skills: Vec<SkillInfo>,
141}
142
143impl Tool for SkillTool {
144 fn name(&self) -> &str {
145 "skill"
146 }
147
148 fn description(&self) -> &str {
149 "Load a skill by name for specialized domain guidance. Use this when the task matches an available skill."
150 }
151
152 fn input_schema(&self) -> Value {
153 let skill_names: Vec<&str> = self.skills.iter().map(|s| s.name.as_str()).collect();
154 let desc_list: Vec<String> = self
155 .skills
156 .iter()
157 .map(|s| format!("{}: {}", s.name, s.description))
158 .collect();
159
160 serde_json::json!({
161 "type": "object",
162 "properties": {
163 "name": {
164 "type": "string",
165 "description": format!("Skill to load. Available: {}", desc_list.join("; ")),
166 "enum": skill_names
167 }
168 },
169 "required": ["name"]
170 })
171 }
172
173 fn execute(&self, input: Value) -> Result<String> {
174 let name = input["name"]
175 .as_str()
176 .context("Missing required parameter 'name'")?;
177
178 let info = self
179 .skills
180 .iter()
181 .find(|s| s.name == name)
182 .with_context(|| {
183 format!(
184 "Unknown skill '{}'. Available: {}",
185 name,
186 self.skills
187 .iter()
188 .map(|s| s.name.as_str())
189 .collect::<Vec<_>>()
190 .join(", ")
191 )
192 })?;
193
194 fs::read_to_string(&info.path).with_context(|| format!("Failed to read skill '{}'", name))
195 }
196}