1use serde::Deserialize;
15use std::path::{Path, PathBuf};
16use tracing::{debug, warn};
17
18#[derive(Debug, Clone)]
20pub struct Skill {
21 pub name: String,
23 pub metadata: SkillMetadata,
25 pub body: String,
27 pub source: PathBuf,
29}
30
31#[derive(Debug, Clone, Default, Deserialize)]
33#[serde(default)]
34pub struct SkillMetadata {
35 pub description: Option<String>,
37 #[serde(rename = "whenToUse")]
39 pub when_to_use: Option<String>,
40 #[serde(rename = "userInvocable")]
42 pub user_invocable: bool,
43 #[serde(rename = "disableNonInteractive")]
45 pub disable_non_interactive: bool,
46 pub paths: Option<Vec<String>>,
48}
49
50impl Skill {
51 pub fn expand(&self, args: Option<&str>) -> String {
53 let mut body = self.body.clone();
54 if let Some(args) = args {
55 body = body.replace("{{arg}}", args);
56 body = body.replace("{{ arg }}", args);
57 }
58 body
59 }
60}
61
62pub struct SkillRegistry {
64 skills: Vec<Skill>,
65}
66
67impl SkillRegistry {
68 pub fn new() -> Self {
69 Self { skills: Vec::new() }
70 }
71
72 pub fn load_all(project_root: Option<&Path>) -> Self {
74 let mut registry = Self::new();
75
76 if let Some(root) = project_root {
78 let project_skills = root.join(".agent").join("skills");
79 if project_skills.is_dir() {
80 registry.load_from_dir(&project_skills);
81 }
82 }
83
84 if let Some(dir) = user_skills_dir()
86 && dir.is_dir()
87 {
88 registry.load_from_dir(&dir);
89 }
90
91 registry.load_bundled();
93
94 debug!("Loaded {} skills", registry.skills.len());
95 registry
96 }
97
98 fn load_bundled(&mut self) {
100 let bundled = [
101 (
102 "commit",
103 "Create a well-crafted git commit",
104 true,
105 "Review the current git diff carefully. Create a commit with a clear, \
106 concise message that explains WHY the change was made, not just WHAT changed. \
107 Follow the repository's existing commit style. Stage specific files \
108 (don't use git add -A). Never commit .env or credentials.",
109 ),
110 (
111 "review",
112 "Review code changes for bugs and issues",
113 true,
114 "Review the current git diff against the base branch. Look for: bugs, \
115 security issues (injection, XSS, OWASP top 10), race conditions, \
116 error handling gaps, performance problems (N+1 queries, missing indexes), \
117 and code quality issues. Report findings with file:line references.",
118 ),
119 (
120 "test",
121 "Run tests and fix failures",
122 true,
123 "Run the project's test suite. If any tests fail, read the failing test \
124 and the source code it tests. Identify the root cause. Fix the issue. \
125 Run the tests again to verify the fix. Repeat until all tests pass.",
126 ),
127 (
128 "explain",
129 "Explain how a piece of code works",
130 true,
131 "Read the file or function the user is asking about. Explain what it does, \
132 how it works, and why it's designed that way. Use clear language. \
133 Reference specific line numbers. If there are non-obvious design decisions, \
134 explain the tradeoffs.",
135 ),
136 (
137 "debug",
138 "Debug an error or unexpected behavior",
139 true,
140 "Investigate the error systematically. Read the error message and stack trace. \
141 Find the relevant source code. Identify the root cause (don't guess). \
142 Propose a fix with explanation. Apply the fix and verify it works.",
143 ),
144 (
145 "pr",
146 "Create a pull request",
147 true,
148 "Check git status and diff against the base branch. Analyze ALL commits \
149 on this branch. Draft a PR title (under 70 chars) and body with a summary \
150 section (bullet points) and a test plan. Push to remote and create the PR \
151 using gh pr create. Return the PR URL.",
152 ),
153 (
154 "refactor",
155 "Refactor code for better quality",
156 true,
157 "Read the code the user wants refactored. Identify specific improvements: \
158 extract functions, reduce duplication, simplify conditionals, improve naming, \
159 add missing error handling. Make changes incrementally. Run tests after \
160 each change to verify nothing broke.",
161 ),
162 (
163 "init",
164 "Initialize project configuration",
165 true,
166 "Create an AGENTS.md file in the project root with project context: \
167 tech stack, architecture overview, coding conventions, test commands, \
168 and important file locations. This helps the agent understand the project \
169 in future sessions.",
170 ),
171 ];
172
173 for (name, description, user_invocable, body) in bundled {
174 if self.skills.iter().any(|s| s.name == name) {
176 continue;
177 }
178 self.skills.push(Skill {
179 name: name.to_string(),
180 metadata: SkillMetadata {
181 description: Some(description.to_string()),
182 when_to_use: None,
183 user_invocable,
184 disable_non_interactive: false,
185 paths: None,
186 },
187 body: body.to_string(),
188 source: std::path::PathBuf::new(),
189 });
190 }
191 }
192
193 fn load_from_dir(&mut self, dir: &Path) {
195 let entries = match std::fs::read_dir(dir) {
196 Ok(entries) => entries,
197 Err(e) => {
198 warn!("Failed to read skills directory {}: {e}", dir.display());
199 return;
200 }
201 };
202
203 for entry in entries.flatten() {
204 let path = entry.path();
205
206 let skill_path = if path.is_file() && path.extension().is_some_and(|e| e == "md") {
208 path.clone()
209 } else if path.is_dir() {
210 let skill_md = path.join("SKILL.md");
211 if skill_md.exists() {
212 skill_md
213 } else {
214 continue;
215 }
216 } else {
217 continue;
218 };
219
220 match load_skill_file(&skill_path) {
221 Ok(skill) => {
222 debug!(
223 "Loaded skill '{}' from {}",
224 skill.name,
225 skill_path.display()
226 );
227 self.skills.push(skill);
228 }
229 Err(e) => {
230 warn!("Failed to load skill {}: {e}", skill_path.display());
231 }
232 }
233 }
234 }
235
236 pub fn find(&self, name: &str) -> Option<&Skill> {
238 self.skills.iter().find(|s| s.name == name)
239 }
240
241 pub fn user_invocable(&self) -> Vec<&Skill> {
243 self.skills
244 .iter()
245 .filter(|s| s.metadata.user_invocable)
246 .collect()
247 }
248
249 pub fn all(&self) -> &[Skill] {
251 &self.skills
252 }
253}
254
255fn load_skill_file(path: &Path) -> Result<Skill, String> {
257 let content = std::fs::read_to_string(path).map_err(|e| format!("Read error: {e}"))?;
258
259 let name = path
261 .parent()
262 .and_then(|p| {
263 if path.file_name().is_some_and(|f| f == "SKILL.md") {
265 p.file_name().and_then(|n| n.to_str())
266 } else {
267 None
268 }
269 })
270 .or_else(|| path.file_stem().and_then(|s| s.to_str()))
271 .unwrap_or("unknown")
272 .to_string();
273
274 let (metadata, body) = parse_frontmatter(&content)?;
276
277 Ok(Skill {
278 name,
279 metadata,
280 body,
281 source: path.to_path_buf(),
282 })
283}
284
285fn parse_frontmatter(content: &str) -> Result<(SkillMetadata, String), String> {
295 let trimmed = content.trim_start();
296
297 if !trimmed.starts_with("---") {
298 return Ok((SkillMetadata::default(), content.to_string()));
300 }
301
302 let after_first = &trimmed[3..];
304 let closing = after_first
305 .find("\n---")
306 .ok_or("Frontmatter not closed (missing closing ---)")?;
307
308 let yaml = &after_first[..closing].trim();
309 let body = &after_first[closing + 4..].trim_start();
310
311 let metadata: SkillMetadata = serde_yaml_parse(yaml)?;
312
313 Ok((metadata, body.to_string()))
314}
315
316fn serde_yaml_parse(yaml: &str) -> Result<SkillMetadata, String> {
319 let mut map = serde_json::Map::new();
321
322 for line in yaml.lines() {
323 let line = line.trim();
324 if line.is_empty() || line.starts_with('#') {
325 continue;
326 }
327 if let Some((key, value)) = line.split_once(':') {
328 let key = key.trim();
329 let value = value.trim().trim_matches('"').trim_matches('\'');
330
331 let json_value = match value {
333 "true" => serde_json::Value::Bool(true),
334 "false" => serde_json::Value::Bool(false),
335 _ => serde_json::Value::String(value.to_string()),
336 };
337 map.insert(key.to_string(), json_value);
338 }
339 }
340
341 let json = serde_json::Value::Object(map);
342 serde_json::from_value(json).map_err(|e| format!("Invalid frontmatter: {e}"))
343}
344
345fn user_skills_dir() -> Option<PathBuf> {
347 dirs::config_dir().map(|d| d.join("agent-code").join("skills"))
348}
349
350#[cfg(test)]
351mod tests {
352 use super::*;
353
354 #[test]
355 fn test_parse_frontmatter() {
356 let content = "---\ndescription: Test skill\nuserInvocable: true\n---\n\nDo the thing.";
357 let (meta, body) = parse_frontmatter(content).unwrap();
358 assert_eq!(meta.description, Some("Test skill".to_string()));
359 assert!(meta.user_invocable);
360 assert_eq!(body, "Do the thing.");
361 }
362
363 #[test]
364 fn test_parse_no_frontmatter() {
365 let content = "Just a prompt with no frontmatter.";
366 let (meta, body) = parse_frontmatter(content).unwrap();
367 assert!(meta.description.is_none());
368 assert_eq!(body, content);
369 }
370
371 #[test]
372 fn test_skill_expand() {
373 let skill = Skill {
374 name: "test".into(),
375 metadata: SkillMetadata::default(),
376 body: "Review {{arg}} carefully.".into(),
377 source: PathBuf::from("test.md"),
378 };
379 assert_eq!(skill.expand(Some("main.rs")), "Review main.rs carefully.");
380 }
381}