1pub mod remote;
15
16use serde::Deserialize;
17use std::path::{Path, PathBuf};
18use tracing::{debug, warn};
19
20#[derive(Debug, Clone)]
26pub struct Skill {
27 pub name: String,
29 pub metadata: SkillMetadata,
31 pub body: String,
33 pub source: PathBuf,
35}
36
37#[derive(Debug, Clone, Default, Deserialize)]
39#[serde(default)]
40pub struct SkillMetadata {
41 pub description: Option<String>,
43 #[serde(rename = "whenToUse")]
45 pub when_to_use: Option<String>,
46 #[serde(rename = "userInvocable")]
48 pub user_invocable: bool,
49 #[serde(rename = "disableNonInteractive")]
51 pub disable_non_interactive: bool,
52 pub paths: Option<Vec<String>>,
54}
55
56impl Skill {
57 pub fn expand(&self, args: Option<&str>) -> String {
59 let mut body = self.body.clone();
60 if let Some(args) = args {
61 body = body.replace("{{arg}}", args);
62 body = body.replace("{{ arg }}", args);
63 }
64 body
65 }
66
67 pub fn expand_safe(&self, args: Option<&str>, disable_shell: bool) -> String {
73 let body = self.expand(args);
74 if !disable_shell {
75 return body;
76 }
77 strip_shell_blocks(&body)
78 }
79}
80
81fn strip_shell_blocks(text: &str) -> String {
83 let mut result = String::with_capacity(text.len());
84 let mut lines = text.lines().peekable();
85
86 while let Some(line) = lines.next() {
87 if is_shell_fence(line) {
88 result.push_str("[Shell execution disabled by security policy]\n");
90 for inner in lines.by_ref() {
91 if inner.trim_start().starts_with("```") {
92 break;
93 }
94 }
95 } else {
96 result.push_str(line);
97 result.push('\n');
98 }
99 }
100
101 result
102}
103
104fn is_shell_fence(line: &str) -> bool {
105 let trimmed = line.trim_start();
106 trimmed.starts_with("```sh")
107 || trimmed.starts_with("```bash")
108 || trimmed.starts_with("```shell")
109 || trimmed.starts_with("```zsh")
110}
111
112pub struct SkillRegistry {
118 skills: Vec<Skill>,
119}
120
121impl SkillRegistry {
122 pub fn new() -> Self {
123 Self { skills: Vec::new() }
124 }
125
126 pub fn load_all(project_root: Option<&Path>) -> Self {
128 let mut registry = Self::new();
129
130 if let Some(root) = project_root {
132 let project_skills = root.join(".agent").join("skills");
133 if project_skills.is_dir() {
134 registry.load_from_dir(&project_skills);
135 }
136 }
137
138 if let Some(dir) = user_skills_dir()
140 && dir.is_dir()
141 {
142 registry.load_from_dir(&dir);
143 }
144
145 registry.load_bundled();
147
148 debug!("Loaded {} skills", registry.skills.len());
149 registry
150 }
151
152 fn load_bundled(&mut self) {
154 let bundled = [
155 (
156 "commit",
157 "Create a well-crafted git commit",
158 true,
159 "Review the current git diff carefully. Create a commit with a clear, \
160 concise message that explains WHY the change was made, not just WHAT changed. \
161 Follow the repository's existing commit style. Stage specific files \
162 (don't use git add -A). Never commit .env or credentials.",
163 ),
164 (
165 "review",
166 "Review code changes for bugs and issues",
167 true,
168 "Review the current git diff against the base branch. Look for: bugs, \
169 security issues (injection, XSS, OWASP top 10), race conditions, \
170 error handling gaps, performance problems (N+1 queries, missing indexes), \
171 and code quality issues. Report findings with file:line references.",
172 ),
173 (
174 "test",
175 "Run tests and fix failures",
176 true,
177 "Run the project's test suite. If any tests fail, read the failing test \
178 and the source code it tests. Identify the root cause. Fix the issue. \
179 Run the tests again to verify the fix. Repeat until all tests pass.",
180 ),
181 (
182 "explain",
183 "Explain how a piece of code works",
184 true,
185 "Read the file or function the user is asking about. Explain what it does, \
186 how it works, and why it's designed that way. Use clear language. \
187 Reference specific line numbers. If there are non-obvious design decisions, \
188 explain the tradeoffs.",
189 ),
190 (
191 "debug",
192 "Debug an error or unexpected behavior",
193 true,
194 "Investigate the error systematically. Read the error message and stack trace. \
195 Find the relevant source code. Identify the root cause (don't guess). \
196 Propose a fix with explanation. Apply the fix and verify it works.",
197 ),
198 (
199 "pr",
200 "Create a pull request",
201 true,
202 "Check git status and diff against the base branch. Analyze ALL commits \
203 on this branch. Draft a PR title (under 70 chars) and body with a summary \
204 section (bullet points) and a test plan. Push to remote and create the PR \
205 using gh pr create. Return the PR URL.",
206 ),
207 (
208 "refactor",
209 "Refactor code for better quality",
210 true,
211 "Read the code the user wants refactored. Identify specific improvements: \
212 extract functions, reduce duplication, simplify conditionals, improve naming, \
213 add missing error handling. Make changes incrementally. Run tests after \
214 each change to verify nothing broke.",
215 ),
216 (
217 "init",
218 "Initialize project configuration",
219 true,
220 "Create an AGENTS.md file in the project root with project context: \
221 tech stack, architecture overview, coding conventions, test commands, \
222 and important file locations. This helps the agent understand the project \
223 in future sessions.",
224 ),
225 (
226 "security-review",
227 "Review code for security vulnerabilities",
228 true,
229 "Perform a security review of the current changes or specified files. \
230 Check for: SQL injection (parameterized queries), XSS (output escaping), \
231 command injection (shell argument safety), hardcoded secrets (API keys, \
232 passwords, tokens), insecure deserialization, broken authentication, \
233 path traversal, and SSRF. Verify input validation at system boundaries. \
234 Report each finding with file:line, severity (critical/high/medium/low), \
235 and a concrete fix.",
236 ),
237 (
238 "advisor",
239 "Analyze project architecture and suggest improvements",
240 true,
241 "Read the project structure, key entry points, and dependency manifest. \
242 Evaluate: code organization (cohesion, coupling), dependency health \
243 (outdated, unused, or vulnerable packages), test coverage gaps, error \
244 handling patterns, and performance bottlenecks. Prioritize findings by \
245 impact. For each suggestion, explain the current state, the risk of \
246 inaction, and a specific next step.",
247 ),
248 (
249 "bughunter",
250 "Systematically search for bugs",
251 true,
252 "Hunt for bugs methodically. Run the test suite and analyze failures. \
253 Read error handling paths and look for: unchecked return values, \
254 off-by-one errors, null/nil/undefined dereferences, resource leaks \
255 (files, connections, locks), race conditions, integer overflow, and \
256 boundary conditions. For each bug found, provide: file:line, a minimal \
257 reproduction, the root cause, and a fix. Verify fixes don't break \
258 existing tests.",
259 ),
260 (
261 "plan",
262 "Create a detailed implementation plan",
263 true,
264 "Explore the codebase to understand the relevant architecture before \
265 planning. Identify all files that need changes. For each change, specify: \
266 the file path, what to modify, and why. Note dependencies between changes \
267 (what must happen first). Flag risks: breaking changes, migration needs, \
268 performance implications. Estimate scope (small/medium/large per file). \
269 Present the plan as an ordered checklist the user can approve before \
270 implementation begins.",
271 ),
272 (
273 "changelog",
274 "Update CHANGELOG.md from the current diff",
275 true,
276 "Read CHANGELOG.md to learn the project's format (Keep a Changelog is \
277 common). Inspect the current git diff and recent commits since the last \
278 release entry. Classify changes into Added / Changed / Fixed / Removed / \
279 Security. Draft entries that describe user-visible impact, not internal \
280 refactors. Insert them under an Unreleased section, preserving existing \
281 formatting. Do not invent changes that aren't in the diff.",
282 ),
283 (
284 "release",
285 "Orchestrate a version release",
286 true,
287 "Follow the project's RELEASING.md if present. Determine the next version \
288 (patch / minor / major) from the nature of the changes since the last tag. \
289 Bump version numbers in all manifest files (Cargo.toml, package.json, \
290 pyproject.toml, etc.) consistently. Stamp CHANGELOG.md with the new version \
291 and today's date. Run the full test and lint gate before tagging. Create \
292 the release branch, open a PR, and on merge create the git tag. Never push \
293 tags without user confirmation.",
294 ),
295 (
296 "benchmark",
297 "Run benchmarks and compare results",
298 true,
299 "Locate the project's benchmark suite (cargo bench, pytest-benchmark, \
300 criterion, etc.). Run it on the current branch and capture results. If a \
301 baseline exists (from main or a stored snapshot), compare and report \
302 regressions and improvements as percentages. Flag any metric that \
303 regressed more than 5% with file:line context for the likely cause. \
304 Do not claim a speedup without a baseline to compare against.",
305 ),
306 (
307 "coverage",
308 "Produce a test coverage report and narrative",
309 true,
310 "Run the project's coverage tool (cargo llvm-cov, pytest --cov, c8, etc.). \
311 Summarize overall coverage and identify the lowest-covered modules. For \
312 each gap, classify: (a) untested happy path, (b) untested error path, \
313 (c) untestable boilerplate. Recommend 3-5 high-value tests to add, with \
314 specific function names. Do not propose tests for generated code or \
315 trivial getters.",
316 ),
317 (
318 "migrate",
319 "Analyze a dependency upgrade or breaking API migration",
320 true,
321 "Given a target dependency version or API change, read the dependency's \
322 release notes or migration guide. Grep the codebase for every call site \
323 affected by the change. Produce a migration plan listing each call site \
324 with file:line, the old pattern, the new pattern, and whether the change \
325 is mechanical or requires judgement. Flag any ambiguous call sites for \
326 human review. Do not perform the migration without an approved plan.",
327 ),
328 (
329 "docs",
330 "Sync documentation with code changes",
331 true,
332 "Inspect the current diff. For every public API that changed (function \
333 signatures, config keys, CLI flags, tool contracts), find the corresponding \
334 documentation (rustdoc comments, README sections, docs/ pages, Mintlify \
335 mdx files) and update it to match. Flag any documented behavior that the \
336 diff silently breaks. Do not add documentation for code that isn't part \
337 of the public surface.",
338 ),
339 ];
340
341 for (name, description, user_invocable, body) in bundled {
342 if self.skills.iter().any(|s| s.name == name) {
344 continue;
345 }
346 self.skills.push(Skill {
347 name: name.to_string(),
348 metadata: SkillMetadata {
349 description: Some(description.to_string()),
350 when_to_use: None,
351 user_invocable,
352 disable_non_interactive: false,
353 paths: None,
354 },
355 body: body.to_string(),
356 source: std::path::PathBuf::new(),
357 });
358 }
359 }
360
361 fn load_from_dir(&mut self, dir: &Path) {
363 let entries = match std::fs::read_dir(dir) {
364 Ok(entries) => entries,
365 Err(e) => {
366 warn!("Failed to read skills directory {}: {e}", dir.display());
367 return;
368 }
369 };
370
371 for entry in entries.flatten() {
372 let path = entry.path();
373
374 let skill_path = if path.is_file() && path.extension().is_some_and(|e| e == "md") {
376 path.clone()
377 } else if path.is_dir() {
378 let skill_md = path.join("SKILL.md");
379 if skill_md.exists() {
380 skill_md
381 } else {
382 continue;
383 }
384 } else {
385 continue;
386 };
387
388 match load_skill_file(&skill_path) {
389 Ok(skill) => {
390 debug!(
391 "Loaded skill '{}' from {}",
392 skill.name,
393 skill_path.display()
394 );
395 self.skills.push(skill);
396 }
397 Err(e) => {
398 warn!("Failed to load skill {}: {e}", skill_path.display());
399 }
400 }
401 }
402 }
403
404 pub fn find(&self, name: &str) -> Option<&Skill> {
406 self.skills.iter().find(|s| s.name == name)
407 }
408
409 pub fn user_invocable(&self) -> Vec<&Skill> {
411 self.skills
412 .iter()
413 .filter(|s| s.metadata.user_invocable)
414 .collect()
415 }
416
417 pub fn all(&self) -> &[Skill] {
419 &self.skills
420 }
421}
422
423fn load_skill_file(path: &Path) -> Result<Skill, String> {
425 let content = std::fs::read_to_string(path).map_err(|e| format!("Read error: {e}"))?;
426
427 let name = path
429 .parent()
430 .and_then(|p| {
431 if path.file_name().is_some_and(|f| f == "SKILL.md") {
433 p.file_name().and_then(|n| n.to_str())
434 } else {
435 None
436 }
437 })
438 .or_else(|| path.file_stem().and_then(|s| s.to_str()))
439 .unwrap_or("unknown")
440 .to_string();
441
442 let (metadata, body) = parse_frontmatter(&content)?;
444
445 Ok(Skill {
446 name,
447 metadata,
448 body,
449 source: path.to_path_buf(),
450 })
451}
452
453fn parse_frontmatter(content: &str) -> Result<(SkillMetadata, String), String> {
463 let trimmed = content.trim_start();
464
465 if !trimmed.starts_with("---") {
466 return Ok((SkillMetadata::default(), content.to_string()));
468 }
469
470 let after_first = &trimmed[3..];
472 let closing = after_first
473 .find("\n---")
474 .ok_or("Frontmatter not closed (missing closing ---)")?;
475
476 let yaml = &after_first[..closing].trim();
477 let body = &after_first[closing + 4..].trim_start();
478
479 let metadata: SkillMetadata = serde_yaml_parse(yaml)?;
480
481 Ok((metadata, body.to_string()))
482}
483
484fn serde_yaml_parse(yaml: &str) -> Result<SkillMetadata, String> {
487 let mut map = serde_json::Map::new();
489
490 for line in yaml.lines() {
491 let line = line.trim();
492 if line.is_empty() || line.starts_with('#') {
493 continue;
494 }
495 if let Some((key, value)) = line.split_once(':') {
496 let key = key.trim();
497 let value = value.trim().trim_matches('"').trim_matches('\'');
498
499 let json_value = match value {
501 "true" => serde_json::Value::Bool(true),
502 "false" => serde_json::Value::Bool(false),
503 _ => serde_json::Value::String(value.to_string()),
504 };
505 map.insert(key.to_string(), json_value);
506 }
507 }
508
509 let json = serde_json::Value::Object(map);
510 serde_json::from_value(json).map_err(|e| format!("Invalid frontmatter: {e}"))
511}
512
513fn user_skills_dir() -> Option<PathBuf> {
515 dirs::config_dir().map(|d| d.join("agent-code").join("skills"))
516}
517
518#[cfg(test)]
519mod tests {
520 use super::*;
521
522 #[test]
523 fn test_parse_frontmatter() {
524 let content = "---\ndescription: Test skill\nuserInvocable: true\n---\n\nDo the thing.";
525 let (meta, body) = parse_frontmatter(content).unwrap();
526 assert_eq!(meta.description, Some("Test skill".to_string()));
527 assert!(meta.user_invocable);
528 assert_eq!(body, "Do the thing.");
529 }
530
531 #[test]
532 fn test_parse_no_frontmatter() {
533 let content = "Just a prompt with no frontmatter.";
534 let (meta, body) = parse_frontmatter(content).unwrap();
535 assert!(meta.description.is_none());
536 assert_eq!(body, content);
537 }
538
539 #[test]
540 fn test_skill_expand() {
541 let skill = Skill {
542 name: "test".into(),
543 metadata: SkillMetadata::default(),
544 body: "Review {{arg}} carefully.".into(),
545 source: PathBuf::from("test.md"),
546 };
547 assert_eq!(skill.expand(Some("main.rs")), "Review main.rs carefully.");
548 }
549
550 #[test]
551 fn test_expand_safe_allows_shell_by_default() {
552 let skill = Skill {
553 name: "deploy".into(),
554 metadata: SkillMetadata::default(),
555 body: "Run:\n```bash\ncargo build\n```\nDone.".into(),
556 source: PathBuf::from("deploy.md"),
557 };
558 let result = skill.expand_safe(None, false);
559 assert!(result.contains("cargo build"));
560 }
561
562 #[test]
563 fn test_expand_safe_strips_shell_when_disabled() {
564 let skill = Skill {
565 name: "deploy".into(),
566 metadata: SkillMetadata::default(),
567 body: "Run:\n```bash\ncargo build\n```\nDone.".into(),
568 source: PathBuf::from("deploy.md"),
569 };
570 let result = skill.expand_safe(None, true);
571 assert!(!result.contains("cargo build"));
572 assert!(result.contains("Shell execution disabled"));
573 assert!(result.contains("Done."));
574 }
575
576 #[test]
577 fn test_strip_shell_blocks_multiple_langs() {
578 let text = "a\n```sh\nls\n```\nb\n```zsh\necho hi\n```\nc\n";
579 let result = strip_shell_blocks(text);
580 assert!(!result.contains("ls"));
581 assert!(!result.contains("echo hi"));
582 assert!(result.contains("a\n"));
583 assert!(result.contains("b\n"));
584 assert!(result.contains("c\n"));
585 }
586
587 #[test]
588 fn test_strip_shell_blocks_preserves_non_shell() {
589 let text = "```rust\nfn main() {}\n```\n";
590 let result = strip_shell_blocks(text);
591 assert!(result.contains("fn main()"));
592 }
593}