1use serde::Deserialize;
15use std::path::{Path, PathBuf};
16use tracing::{debug, warn};
17
18#[derive(Debug, Clone)]
24pub struct Skill {
25 pub name: String,
27 pub metadata: SkillMetadata,
29 pub body: String,
31 pub source: PathBuf,
33}
34
35#[derive(Debug, Clone, Default, Deserialize)]
37#[serde(default)]
38pub struct SkillMetadata {
39 pub description: Option<String>,
41 #[serde(rename = "whenToUse")]
43 pub when_to_use: Option<String>,
44 #[serde(rename = "userInvocable")]
46 pub user_invocable: bool,
47 #[serde(rename = "disableNonInteractive")]
49 pub disable_non_interactive: bool,
50 pub paths: Option<Vec<String>>,
52}
53
54impl Skill {
55 pub fn expand(&self, args: Option<&str>) -> String {
57 let mut body = self.body.clone();
58 if let Some(args) = args {
59 body = body.replace("{{arg}}", args);
60 body = body.replace("{{ arg }}", args);
61 }
62 body
63 }
64
65 pub fn expand_safe(&self, args: Option<&str>, disable_shell: bool) -> String {
71 let body = self.expand(args);
72 if !disable_shell {
73 return body;
74 }
75 strip_shell_blocks(&body)
76 }
77}
78
79fn strip_shell_blocks(text: &str) -> String {
81 let mut result = String::with_capacity(text.len());
82 let mut lines = text.lines().peekable();
83
84 while let Some(line) = lines.next() {
85 if is_shell_fence(line) {
86 result.push_str("[Shell execution disabled by security policy]\n");
88 for inner in lines.by_ref() {
89 if inner.trim_start().starts_with("```") {
90 break;
91 }
92 }
93 } else {
94 result.push_str(line);
95 result.push('\n');
96 }
97 }
98
99 result
100}
101
102fn is_shell_fence(line: &str) -> bool {
103 let trimmed = line.trim_start();
104 trimmed.starts_with("```sh")
105 || trimmed.starts_with("```bash")
106 || trimmed.starts_with("```shell")
107 || trimmed.starts_with("```zsh")
108}
109
110pub struct SkillRegistry {
116 skills: Vec<Skill>,
117}
118
119impl SkillRegistry {
120 pub fn new() -> Self {
121 Self { skills: Vec::new() }
122 }
123
124 pub fn load_all(project_root: Option<&Path>) -> Self {
126 let mut registry = Self::new();
127
128 if let Some(root) = project_root {
130 let project_skills = root.join(".agent").join("skills");
131 if project_skills.is_dir() {
132 registry.load_from_dir(&project_skills);
133 }
134 }
135
136 if let Some(dir) = user_skills_dir()
138 && dir.is_dir()
139 {
140 registry.load_from_dir(&dir);
141 }
142
143 registry.load_bundled();
145
146 debug!("Loaded {} skills", registry.skills.len());
147 registry
148 }
149
150 fn load_bundled(&mut self) {
152 let bundled = [
153 (
154 "commit",
155 "Create a well-crafted git commit",
156 true,
157 "Review the current git diff carefully. Create a commit with a clear, \
158 concise message that explains WHY the change was made, not just WHAT changed. \
159 Follow the repository's existing commit style. Stage specific files \
160 (don't use git add -A). Never commit .env or credentials.",
161 ),
162 (
163 "review",
164 "Review code changes for bugs and issues",
165 true,
166 "Review the current git diff against the base branch. Look for: bugs, \
167 security issues (injection, XSS, OWASP top 10), race conditions, \
168 error handling gaps, performance problems (N+1 queries, missing indexes), \
169 and code quality issues. Report findings with file:line references.",
170 ),
171 (
172 "test",
173 "Run tests and fix failures",
174 true,
175 "Run the project's test suite. If any tests fail, read the failing test \
176 and the source code it tests. Identify the root cause. Fix the issue. \
177 Run the tests again to verify the fix. Repeat until all tests pass.",
178 ),
179 (
180 "explain",
181 "Explain how a piece of code works",
182 true,
183 "Read the file or function the user is asking about. Explain what it does, \
184 how it works, and why it's designed that way. Use clear language. \
185 Reference specific line numbers. If there are non-obvious design decisions, \
186 explain the tradeoffs.",
187 ),
188 (
189 "debug",
190 "Debug an error or unexpected behavior",
191 true,
192 "Investigate the error systematically. Read the error message and stack trace. \
193 Find the relevant source code. Identify the root cause (don't guess). \
194 Propose a fix with explanation. Apply the fix and verify it works.",
195 ),
196 (
197 "pr",
198 "Create a pull request",
199 true,
200 "Check git status and diff against the base branch. Analyze ALL commits \
201 on this branch. Draft a PR title (under 70 chars) and body with a summary \
202 section (bullet points) and a test plan. Push to remote and create the PR \
203 using gh pr create. Return the PR URL.",
204 ),
205 (
206 "refactor",
207 "Refactor code for better quality",
208 true,
209 "Read the code the user wants refactored. Identify specific improvements: \
210 extract functions, reduce duplication, simplify conditionals, improve naming, \
211 add missing error handling. Make changes incrementally. Run tests after \
212 each change to verify nothing broke.",
213 ),
214 (
215 "init",
216 "Initialize project configuration",
217 true,
218 "Create an AGENTS.md file in the project root with project context: \
219 tech stack, architecture overview, coding conventions, test commands, \
220 and important file locations. This helps the agent understand the project \
221 in future sessions.",
222 ),
223 (
224 "security-review",
225 "Review code for security vulnerabilities",
226 true,
227 "Perform a security review of the current changes or specified files. \
228 Check for: SQL injection (parameterized queries), XSS (output escaping), \
229 command injection (shell argument safety), hardcoded secrets (API keys, \
230 passwords, tokens), insecure deserialization, broken authentication, \
231 path traversal, and SSRF. Verify input validation at system boundaries. \
232 Report each finding with file:line, severity (critical/high/medium/low), \
233 and a concrete fix.",
234 ),
235 (
236 "advisor",
237 "Analyze project architecture and suggest improvements",
238 true,
239 "Read the project structure, key entry points, and dependency manifest. \
240 Evaluate: code organization (cohesion, coupling), dependency health \
241 (outdated, unused, or vulnerable packages), test coverage gaps, error \
242 handling patterns, and performance bottlenecks. Prioritize findings by \
243 impact. For each suggestion, explain the current state, the risk of \
244 inaction, and a specific next step.",
245 ),
246 (
247 "bughunter",
248 "Systematically search for bugs",
249 true,
250 "Hunt for bugs methodically. Run the test suite and analyze failures. \
251 Read error handling paths and look for: unchecked return values, \
252 off-by-one errors, null/nil/undefined dereferences, resource leaks \
253 (files, connections, locks), race conditions, integer overflow, and \
254 boundary conditions. For each bug found, provide: file:line, a minimal \
255 reproduction, the root cause, and a fix. Verify fixes don't break \
256 existing tests.",
257 ),
258 (
259 "plan",
260 "Create a detailed implementation plan",
261 true,
262 "Explore the codebase to understand the relevant architecture before \
263 planning. Identify all files that need changes. For each change, specify: \
264 the file path, what to modify, and why. Note dependencies between changes \
265 (what must happen first). Flag risks: breaking changes, migration needs, \
266 performance implications. Estimate scope (small/medium/large per file). \
267 Present the plan as an ordered checklist the user can approve before \
268 implementation begins.",
269 ),
270 ];
271
272 for (name, description, user_invocable, body) in bundled {
273 if self.skills.iter().any(|s| s.name == name) {
275 continue;
276 }
277 self.skills.push(Skill {
278 name: name.to_string(),
279 metadata: SkillMetadata {
280 description: Some(description.to_string()),
281 when_to_use: None,
282 user_invocable,
283 disable_non_interactive: false,
284 paths: None,
285 },
286 body: body.to_string(),
287 source: std::path::PathBuf::new(),
288 });
289 }
290 }
291
292 fn load_from_dir(&mut self, dir: &Path) {
294 let entries = match std::fs::read_dir(dir) {
295 Ok(entries) => entries,
296 Err(e) => {
297 warn!("Failed to read skills directory {}: {e}", dir.display());
298 return;
299 }
300 };
301
302 for entry in entries.flatten() {
303 let path = entry.path();
304
305 let skill_path = if path.is_file() && path.extension().is_some_and(|e| e == "md") {
307 path.clone()
308 } else if path.is_dir() {
309 let skill_md = path.join("SKILL.md");
310 if skill_md.exists() {
311 skill_md
312 } else {
313 continue;
314 }
315 } else {
316 continue;
317 };
318
319 match load_skill_file(&skill_path) {
320 Ok(skill) => {
321 debug!(
322 "Loaded skill '{}' from {}",
323 skill.name,
324 skill_path.display()
325 );
326 self.skills.push(skill);
327 }
328 Err(e) => {
329 warn!("Failed to load skill {}: {e}", skill_path.display());
330 }
331 }
332 }
333 }
334
335 pub fn find(&self, name: &str) -> Option<&Skill> {
337 self.skills.iter().find(|s| s.name == name)
338 }
339
340 pub fn user_invocable(&self) -> Vec<&Skill> {
342 self.skills
343 .iter()
344 .filter(|s| s.metadata.user_invocable)
345 .collect()
346 }
347
348 pub fn all(&self) -> &[Skill] {
350 &self.skills
351 }
352}
353
354fn load_skill_file(path: &Path) -> Result<Skill, String> {
356 let content = std::fs::read_to_string(path).map_err(|e| format!("Read error: {e}"))?;
357
358 let name = path
360 .parent()
361 .and_then(|p| {
362 if path.file_name().is_some_and(|f| f == "SKILL.md") {
364 p.file_name().and_then(|n| n.to_str())
365 } else {
366 None
367 }
368 })
369 .or_else(|| path.file_stem().and_then(|s| s.to_str()))
370 .unwrap_or("unknown")
371 .to_string();
372
373 let (metadata, body) = parse_frontmatter(&content)?;
375
376 Ok(Skill {
377 name,
378 metadata,
379 body,
380 source: path.to_path_buf(),
381 })
382}
383
384fn parse_frontmatter(content: &str) -> Result<(SkillMetadata, String), String> {
394 let trimmed = content.trim_start();
395
396 if !trimmed.starts_with("---") {
397 return Ok((SkillMetadata::default(), content.to_string()));
399 }
400
401 let after_first = &trimmed[3..];
403 let closing = after_first
404 .find("\n---")
405 .ok_or("Frontmatter not closed (missing closing ---)")?;
406
407 let yaml = &after_first[..closing].trim();
408 let body = &after_first[closing + 4..].trim_start();
409
410 let metadata: SkillMetadata = serde_yaml_parse(yaml)?;
411
412 Ok((metadata, body.to_string()))
413}
414
415fn serde_yaml_parse(yaml: &str) -> Result<SkillMetadata, String> {
418 let mut map = serde_json::Map::new();
420
421 for line in yaml.lines() {
422 let line = line.trim();
423 if line.is_empty() || line.starts_with('#') {
424 continue;
425 }
426 if let Some((key, value)) = line.split_once(':') {
427 let key = key.trim();
428 let value = value.trim().trim_matches('"').trim_matches('\'');
429
430 let json_value = match value {
432 "true" => serde_json::Value::Bool(true),
433 "false" => serde_json::Value::Bool(false),
434 _ => serde_json::Value::String(value.to_string()),
435 };
436 map.insert(key.to_string(), json_value);
437 }
438 }
439
440 let json = serde_json::Value::Object(map);
441 serde_json::from_value(json).map_err(|e| format!("Invalid frontmatter: {e}"))
442}
443
444fn user_skills_dir() -> Option<PathBuf> {
446 dirs::config_dir().map(|d| d.join("agent-code").join("skills"))
447}
448
449#[cfg(test)]
450mod tests {
451 use super::*;
452
453 #[test]
454 fn test_parse_frontmatter() {
455 let content = "---\ndescription: Test skill\nuserInvocable: true\n---\n\nDo the thing.";
456 let (meta, body) = parse_frontmatter(content).unwrap();
457 assert_eq!(meta.description, Some("Test skill".to_string()));
458 assert!(meta.user_invocable);
459 assert_eq!(body, "Do the thing.");
460 }
461
462 #[test]
463 fn test_parse_no_frontmatter() {
464 let content = "Just a prompt with no frontmatter.";
465 let (meta, body) = parse_frontmatter(content).unwrap();
466 assert!(meta.description.is_none());
467 assert_eq!(body, content);
468 }
469
470 #[test]
471 fn test_skill_expand() {
472 let skill = Skill {
473 name: "test".into(),
474 metadata: SkillMetadata::default(),
475 body: "Review {{arg}} carefully.".into(),
476 source: PathBuf::from("test.md"),
477 };
478 assert_eq!(skill.expand(Some("main.rs")), "Review main.rs carefully.");
479 }
480
481 #[test]
482 fn test_expand_safe_allows_shell_by_default() {
483 let skill = Skill {
484 name: "deploy".into(),
485 metadata: SkillMetadata::default(),
486 body: "Run:\n```bash\ncargo build\n```\nDone.".into(),
487 source: PathBuf::from("deploy.md"),
488 };
489 let result = skill.expand_safe(None, false);
490 assert!(result.contains("cargo build"));
491 }
492
493 #[test]
494 fn test_expand_safe_strips_shell_when_disabled() {
495 let skill = Skill {
496 name: "deploy".into(),
497 metadata: SkillMetadata::default(),
498 body: "Run:\n```bash\ncargo build\n```\nDone.".into(),
499 source: PathBuf::from("deploy.md"),
500 };
501 let result = skill.expand_safe(None, true);
502 assert!(!result.contains("cargo build"));
503 assert!(result.contains("Shell execution disabled"));
504 assert!(result.contains("Done."));
505 }
506
507 #[test]
508 fn test_strip_shell_blocks_multiple_langs() {
509 let text = "a\n```sh\nls\n```\nb\n```zsh\necho hi\n```\nc\n";
510 let result = strip_shell_blocks(text);
511 assert!(!result.contains("ls"));
512 assert!(!result.contains("echo hi"));
513 assert!(result.contains("a\n"));
514 assert!(result.contains("b\n"));
515 assert!(result.contains("c\n"));
516 }
517
518 #[test]
519 fn test_strip_shell_blocks_preserves_non_shell() {
520 let text = "```rust\nfn main() {}\n```\n";
521 let result = strip_shell_blocks(text);
522 assert!(result.contains("fn main()"));
523 }
524}