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
274 for (name, description, user_invocable, body) in bundled {
275 if self.skills.iter().any(|s| s.name == name) {
277 continue;
278 }
279 self.skills.push(Skill {
280 name: name.to_string(),
281 metadata: SkillMetadata {
282 description: Some(description.to_string()),
283 when_to_use: None,
284 user_invocable,
285 disable_non_interactive: false,
286 paths: None,
287 },
288 body: body.to_string(),
289 source: std::path::PathBuf::new(),
290 });
291 }
292 }
293
294 fn load_from_dir(&mut self, dir: &Path) {
296 let entries = match std::fs::read_dir(dir) {
297 Ok(entries) => entries,
298 Err(e) => {
299 warn!("Failed to read skills directory {}: {e}", dir.display());
300 return;
301 }
302 };
303
304 for entry in entries.flatten() {
305 let path = entry.path();
306
307 let skill_path = if path.is_file() && path.extension().is_some_and(|e| e == "md") {
309 path.clone()
310 } else if path.is_dir() {
311 let skill_md = path.join("SKILL.md");
312 if skill_md.exists() {
313 skill_md
314 } else {
315 continue;
316 }
317 } else {
318 continue;
319 };
320
321 match load_skill_file(&skill_path) {
322 Ok(skill) => {
323 debug!(
324 "Loaded skill '{}' from {}",
325 skill.name,
326 skill_path.display()
327 );
328 self.skills.push(skill);
329 }
330 Err(e) => {
331 warn!("Failed to load skill {}: {e}", skill_path.display());
332 }
333 }
334 }
335 }
336
337 pub fn find(&self, name: &str) -> Option<&Skill> {
339 self.skills.iter().find(|s| s.name == name)
340 }
341
342 pub fn user_invocable(&self) -> Vec<&Skill> {
344 self.skills
345 .iter()
346 .filter(|s| s.metadata.user_invocable)
347 .collect()
348 }
349
350 pub fn all(&self) -> &[Skill] {
352 &self.skills
353 }
354}
355
356fn load_skill_file(path: &Path) -> Result<Skill, String> {
358 let content = std::fs::read_to_string(path).map_err(|e| format!("Read error: {e}"))?;
359
360 let name = path
362 .parent()
363 .and_then(|p| {
364 if path.file_name().is_some_and(|f| f == "SKILL.md") {
366 p.file_name().and_then(|n| n.to_str())
367 } else {
368 None
369 }
370 })
371 .or_else(|| path.file_stem().and_then(|s| s.to_str()))
372 .unwrap_or("unknown")
373 .to_string();
374
375 let (metadata, body) = parse_frontmatter(&content)?;
377
378 Ok(Skill {
379 name,
380 metadata,
381 body,
382 source: path.to_path_buf(),
383 })
384}
385
386fn parse_frontmatter(content: &str) -> Result<(SkillMetadata, String), String> {
396 let trimmed = content.trim_start();
397
398 if !trimmed.starts_with("---") {
399 return Ok((SkillMetadata::default(), content.to_string()));
401 }
402
403 let after_first = &trimmed[3..];
405 let closing = after_first
406 .find("\n---")
407 .ok_or("Frontmatter not closed (missing closing ---)")?;
408
409 let yaml = &after_first[..closing].trim();
410 let body = &after_first[closing + 4..].trim_start();
411
412 let metadata: SkillMetadata = serde_yaml_parse(yaml)?;
413
414 Ok((metadata, body.to_string()))
415}
416
417fn serde_yaml_parse(yaml: &str) -> Result<SkillMetadata, String> {
420 let mut map = serde_json::Map::new();
422
423 for line in yaml.lines() {
424 let line = line.trim();
425 if line.is_empty() || line.starts_with('#') {
426 continue;
427 }
428 if let Some((key, value)) = line.split_once(':') {
429 let key = key.trim();
430 let value = value.trim().trim_matches('"').trim_matches('\'');
431
432 let json_value = match value {
434 "true" => serde_json::Value::Bool(true),
435 "false" => serde_json::Value::Bool(false),
436 _ => serde_json::Value::String(value.to_string()),
437 };
438 map.insert(key.to_string(), json_value);
439 }
440 }
441
442 let json = serde_json::Value::Object(map);
443 serde_json::from_value(json).map_err(|e| format!("Invalid frontmatter: {e}"))
444}
445
446fn user_skills_dir() -> Option<PathBuf> {
448 dirs::config_dir().map(|d| d.join("agent-code").join("skills"))
449}
450
451#[cfg(test)]
452mod tests {
453 use super::*;
454
455 #[test]
456 fn test_parse_frontmatter() {
457 let content = "---\ndescription: Test skill\nuserInvocable: true\n---\n\nDo the thing.";
458 let (meta, body) = parse_frontmatter(content).unwrap();
459 assert_eq!(meta.description, Some("Test skill".to_string()));
460 assert!(meta.user_invocable);
461 assert_eq!(body, "Do the thing.");
462 }
463
464 #[test]
465 fn test_parse_no_frontmatter() {
466 let content = "Just a prompt with no frontmatter.";
467 let (meta, body) = parse_frontmatter(content).unwrap();
468 assert!(meta.description.is_none());
469 assert_eq!(body, content);
470 }
471
472 #[test]
473 fn test_skill_expand() {
474 let skill = Skill {
475 name: "test".into(),
476 metadata: SkillMetadata::default(),
477 body: "Review {{arg}} carefully.".into(),
478 source: PathBuf::from("test.md"),
479 };
480 assert_eq!(skill.expand(Some("main.rs")), "Review main.rs carefully.");
481 }
482
483 #[test]
484 fn test_expand_safe_allows_shell_by_default() {
485 let skill = Skill {
486 name: "deploy".into(),
487 metadata: SkillMetadata::default(),
488 body: "Run:\n```bash\ncargo build\n```\nDone.".into(),
489 source: PathBuf::from("deploy.md"),
490 };
491 let result = skill.expand_safe(None, false);
492 assert!(result.contains("cargo build"));
493 }
494
495 #[test]
496 fn test_expand_safe_strips_shell_when_disabled() {
497 let skill = Skill {
498 name: "deploy".into(),
499 metadata: SkillMetadata::default(),
500 body: "Run:\n```bash\ncargo build\n```\nDone.".into(),
501 source: PathBuf::from("deploy.md"),
502 };
503 let result = skill.expand_safe(None, true);
504 assert!(!result.contains("cargo build"));
505 assert!(result.contains("Shell execution disabled"));
506 assert!(result.contains("Done."));
507 }
508
509 #[test]
510 fn test_strip_shell_blocks_multiple_langs() {
511 let text = "a\n```sh\nls\n```\nb\n```zsh\necho hi\n```\nc\n";
512 let result = strip_shell_blocks(text);
513 assert!(!result.contains("ls"));
514 assert!(!result.contains("echo hi"));
515 assert!(result.contains("a\n"));
516 assert!(result.contains("b\n"));
517 assert!(result.contains("c\n"));
518 }
519
520 #[test]
521 fn test_strip_shell_blocks_preserves_non_shell() {
522 let text = "```rust\nfn main() {}\n```\n";
523 let result = strip_shell_blocks(text);
524 assert!(result.contains("fn main()"));
525 }
526}