Skip to main content

claude_pool/
skill.rs

1//! Skill definitions — reusable prompt templates.
2//!
3//! Skills are parameterized templates that define how to approach a specific
4//! kind of task. The coordinator discovers them via MCP prompt listing,
5//! then references them by name in `pool/run` or `pool/submit`.
6//!
7//! # Skill directory layout
8//!
9//! Skills follow the [Agent Skills](https://agentskills.io) standard directory
10//! layout. Each skill lives in its own folder with a `SKILL.md` file:
11//!
12//! ```text
13//! .claude-pool/skills/
14//!   my_skill/
15//!     SKILL.md          # Required: frontmatter + prompt
16//!     scripts/           # Optional: bundled scripts
17//!       analyze.py
18//!     templates/         # Optional: prompt templates
19//!       report.md
20//!     examples/          # Optional: example inputs/outputs
21//!       input.json
22//! ```
23//!
24//! Supporting files can be referenced in prompts via `${CLAUDE_SKILL_DIR}`:
25//!
26//! ```text
27//! ---
28//! name: analyze
29//! description: Run analysis script
30//! ---
31//! Run the analysis:
32//! python ${CLAUDE_SKILL_DIR}/scripts/analyze.py .
33//! ```
34//!
35//! The `${CLAUDE_SKILL_DIR}` variable resolves to the skill's directory path
36//! at render time. It is available for project and global skills loaded from
37//! disk, but not for builtins or runtime-added skills.
38
39use std::collections::HashMap;
40use std::path::{Path, PathBuf};
41
42use serde::{Deserialize, Serialize};
43
44use crate::types::SlotConfig;
45
46/// How a skill was registered in the registry.
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
48#[serde(rename_all = "snake_case")]
49pub enum SkillSource {
50    /// Ships with the pool binary.
51    Builtin,
52    /// Loaded from `~/.claude-pool/skills/` (user global).
53    Global,
54    /// Loaded from `.claude-pool/skills/` (project).
55    Project,
56    /// Added at runtime via `pool_skill_add`.
57    Runtime,
58}
59
60impl std::fmt::Display for SkillSource {
61    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
62        match self {
63            Self::Builtin => write!(f, "builtin"),
64            Self::Global => write!(f, "global"),
65            Self::Project => write!(f, "project"),
66            Self::Runtime => write!(f, "runtime"),
67        }
68    }
69}
70
71/// A skill paired with its registration source.
72#[derive(Debug, Clone)]
73pub struct RegisteredSkill {
74    /// The skill definition.
75    pub skill: Skill,
76    /// How this skill was registered.
77    pub source: SkillSource,
78}
79
80/// Where a skill is intended to run.
81///
82/// Advisory only — the pool does not enforce scope. Coordinators and agents
83/// use it to decide whether a skill makes sense in a given context.
84#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
85#[serde(rename_all = "snake_case")]
86pub enum SkillScope {
87    /// Single unit of work, any slot can run it.
88    #[default]
89    Task,
90
91    /// Needs MCP access, human interaction, or cross-cutting visibility.
92    /// Should run at the coordinator level, not in a pool slot.
93    Coordinator,
94
95    /// Multi-step workflow template. Used as a chain definition, not a
96    /// single task.
97    Chain,
98}
99
100impl std::fmt::Display for SkillScope {
101    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
102        match self {
103            Self::Task => write!(f, "task"),
104            Self::Coordinator => write!(f, "coordinator"),
105            Self::Chain => write!(f, "chain"),
106        }
107    }
108}
109
110/// A reusable skill template.
111#[derive(Debug, Clone, Serialize, Deserialize)]
112pub struct Skill {
113    /// Unique skill name (e.g. "code_review", "write_tests").
114    pub name: String,
115
116    /// Human-readable description of what this skill does.
117    pub description: String,
118
119    /// Prompt template. Use `{arg_name}` placeholders for arguments.
120    pub prompt: String,
121
122    /// Argument definitions (name -> description).
123    pub arguments: Vec<SkillArgument>,
124
125    /// Per-skill config overrides (model, effort, etc.).
126    pub config: Option<SlotConfig>,
127
128    /// Where this skill is intended to run (advisory).
129    #[serde(default)]
130    pub scope: SkillScope,
131
132    /// Hint shown in skill listings to indicate expected arguments.
133    ///
134    /// Follows the Agent Skills standard `argument-hint` field.
135    /// Example: `"[issue-number]"`, `"<file> [--verbose]"`.
136    #[serde(default, skip_serializing_if = "Option::is_none")]
137    pub argument_hint: Option<String>,
138
139    /// Path to the skill's directory on disk.
140    ///
141    /// Set when loaded from a SKILL.md folder (project or global skills).
142    /// Used for `${CLAUDE_SKILL_DIR}` substitution in prompts, allowing
143    /// skills to reference bundled scripts and supporting files.
144    /// `None` for builtins and runtime-added skills.
145    #[serde(skip)]
146    pub skill_dir: Option<PathBuf>,
147}
148
149/// An argument accepted by a skill.
150#[derive(Debug, Clone, Serialize, Deserialize)]
151pub struct SkillArgument {
152    /// Argument name (used as `{name}` in the prompt template).
153    pub name: String,
154
155    /// Human-readable description.
156    pub description: String,
157
158    /// Whether this argument is required.
159    pub required: bool,
160}
161
162/// YAML frontmatter from a SKILL.md file.
163///
164/// Follows the [Agent Skills standard](https://agentskills.io/specification):
165/// name, description, standard fields (`allowed-tools`, `argument-hint`),
166/// and pool-specific extensions under `metadata`.
167#[derive(Debug, Deserialize)]
168struct SkillFrontmatter {
169    name: String,
170    description: String,
171    /// Standard `allowed-tools` field (comma-separated tool names).
172    /// Takes precedence over `metadata.config.allowed_tools`.
173    #[serde(default, rename = "allowed-tools")]
174    allowed_tools: Option<String>,
175    /// Standard `argument-hint` field showing expected arguments.
176    #[serde(default, rename = "argument-hint")]
177    argument_hint: Option<String>,
178    #[serde(default)]
179    metadata: SkillMetadata,
180}
181
182/// Pool-specific metadata extensions in SKILL.md frontmatter.
183#[derive(Debug, Default, Deserialize)]
184struct SkillMetadata {
185    #[serde(default)]
186    scope: Option<SkillScope>,
187    #[serde(default)]
188    arguments: Vec<SkillArgument>,
189    #[serde(default)]
190    config: Option<SlotConfig>,
191}
192
193/// Parse a SKILL.md file into a [`Skill`].
194///
195/// The file format is YAML frontmatter between `---` delimiters, followed
196/// by a markdown body that becomes the prompt template.
197fn parse_skill_md(content: &str) -> crate::Result<Skill> {
198    let content = content.trim();
199    if !content.starts_with("---") {
200        return Err(crate::Error::Store(
201            "SKILL.md must start with YAML frontmatter (---)".into(),
202        ));
203    }
204
205    let after_first = &content[3..];
206    let end = after_first.find("---").ok_or_else(|| {
207        crate::Error::Store("SKILL.md missing closing frontmatter delimiter (---)".into())
208    })?;
209
210    let yaml = &after_first[..end];
211    let body = after_first[end + 3..].trim();
212
213    let fm: SkillFrontmatter = serde_yaml::from_str(yaml)
214        .map_err(|e| crate::Error::Store(format!("SKILL.md YAML parse error: {e}")))?;
215
216    // Infer scope from name prefix if not set explicitly.
217    let scope = fm.metadata.scope.unwrap_or_else(|| infer_scope(&fm.name));
218
219    // Standard `allowed-tools` field takes precedence over metadata.config.allowed_tools.
220    let config = if let Some(ref tools_str) = fm.allowed_tools {
221        let tools: Vec<String> = tools_str
222            .split(',')
223            .map(|s| s.trim().to_string())
224            .filter(|s| !s.is_empty())
225            .collect();
226        let mut config = fm.metadata.config.unwrap_or_default();
227        config.allowed_tools = Some(tools);
228        Some(config)
229    } else {
230        fm.metadata.config
231    };
232
233    Ok(Skill {
234        name: fm.name,
235        description: fm.description,
236        prompt: body.to_string(),
237        arguments: fm.metadata.arguments,
238        config,
239        scope,
240        argument_hint: fm.argument_hint,
241        skill_dir: None,
242    })
243}
244
245/// Infer skill scope from name prefix convention.
246fn infer_scope(name: &str) -> SkillScope {
247    if name.starts_with("cps-coordinator") {
248        SkillScope::Coordinator
249    } else if name.starts_with("cps-chain") {
250        SkillScope::Chain
251    } else {
252        SkillScope::Task
253    }
254}
255
256impl Skill {
257    /// Render the prompt template with the given arguments.
258    ///
259    /// Replaces `{arg_name}` placeholders in the prompt with values
260    /// from the arguments map. Missing required arguments return an error.
261    pub fn render(&self, args: &HashMap<String, String>) -> crate::Result<String> {
262        // Check required arguments.
263        for arg in &self.arguments {
264            if arg.required && !args.contains_key(&arg.name) {
265                return Err(crate::Error::Store(format!(
266                    "missing required argument '{}' for skill '{}'",
267                    arg.name, self.name
268                )));
269            }
270        }
271
272        let mut rendered = self.prompt.clone();
273
274        // Legacy {key} substitution (our original format).
275        for (key, value) in args {
276            rendered = rendered.replace(&format!("{{{key}}}"), value);
277        }
278
279        // Standard $ARGUMENTS substitution (Agent Skills / Claude Code format).
280        // Build positional args list from argument definitions order.
281        let positional: Vec<&str> = self
282            .arguments
283            .iter()
284            .filter_map(|a| args.get(&a.name).map(|v| v.as_str()))
285            .collect();
286
287        let all_args = positional.join(" ");
288        let has_arguments_var = rendered.contains("$ARGUMENTS") || rendered.contains("$0");
289
290        // $ARGUMENTS[N] and $N positional substitution.
291        for (i, val) in positional.iter().enumerate() {
292            rendered = rendered.replace(&format!("$ARGUMENTS[{i}]"), val);
293            rendered = rendered.replace(&format!("${i}"), val);
294        }
295
296        // $ARGUMENTS (all args as a single string).
297        rendered = rendered.replace("$ARGUMENTS", &all_args);
298
299        // If neither $ARGUMENTS/$N nor {key} placeholders were used and there
300        // are args, append them (matches Claude Code behavior).
301        let had_legacy_placeholders = self
302            .arguments
303            .iter()
304            .any(|a| self.prompt.contains(&format!("{{{}}}", a.name)));
305        if !has_arguments_var && !had_legacy_placeholders && !all_args.is_empty() {
306            rendered.push_str(&format!("\n\nARGUMENTS: {all_args}"));
307        }
308
309        // ${CLAUDE_SKILL_DIR} substitution (Agent Skills standard).
310        if let Some(ref dir) = self.skill_dir {
311            rendered = rendered.replace("${CLAUDE_SKILL_DIR}", &dir.display().to_string());
312        } else if rendered.contains("${CLAUDE_SKILL_DIR}") {
313            rendered = rendered.replace(
314                "${CLAUDE_SKILL_DIR}",
315                "[CLAUDE_SKILL_DIR unavailable: skill has no directory]",
316            );
317        }
318
319        // Dynamic command injection: !`command` is replaced with stdout.
320        rendered = execute_command_injections(&rendered);
321
322        Ok(rendered)
323    }
324}
325
326/// Execute `` !`command` `` injections in a rendered prompt.
327///
328/// Each occurrence of `` !`...` `` is replaced with the command's stdout
329/// (or an error message if the command fails). This runs shell commands
330/// synchronously via `sh -c`.
331fn execute_command_injections(input: &str) -> String {
332    use std::process::Command;
333
334    let mut result = String::with_capacity(input.len());
335    let mut remaining = input;
336
337    while let Some(start) = remaining.find("!`") {
338        result.push_str(&remaining[..start]);
339        let after_marker = &remaining[start + 2..];
340        if let Some(end) = after_marker.find('`') {
341            let cmd = &after_marker[..end];
342            let output = Command::new("sh")
343                .arg("-c")
344                .arg(cmd)
345                .output()
346                .map(|o| {
347                    if o.status.success() {
348                        String::from_utf8_lossy(&o.stdout).trim().to_string()
349                    } else {
350                        let stderr = String::from_utf8_lossy(&o.stderr).trim().to_string();
351                        format!("[command failed: {stderr}]")
352                    }
353                })
354                .unwrap_or_else(|e| format!("[command error: {e}]"));
355            result.push_str(&output);
356            remaining = &after_marker[end + 1..];
357        } else {
358            // No closing backtick — emit literally.
359            result.push_str("!`");
360            remaining = after_marker;
361        }
362    }
363    result.push_str(remaining);
364    result
365}
366
367/// Registry of available skills.
368#[derive(Debug, Clone, Default)]
369pub struct SkillRegistry {
370    skills: HashMap<String, RegisteredSkill>,
371}
372
373impl SkillRegistry {
374    /// Create a new empty registry.
375    pub fn new() -> Self {
376        Self::default()
377    }
378
379    /// Create a registry pre-loaded with built-in skills.
380    pub fn with_builtins() -> Self {
381        let mut registry = Self::new();
382        for skill in builtin_skills() {
383            registry.register(skill, SkillSource::Builtin);
384        }
385        registry
386    }
387
388    /// Register a skill with a given source.
389    pub fn register(&mut self, skill: Skill, source: SkillSource) {
390        self.skills
391            .insert(skill.name.clone(), RegisteredSkill { skill, source });
392    }
393
394    /// Look up a skill by name.
395    pub fn get(&self, name: &str) -> Option<&Skill> {
396        self.skills.get(name).map(|rs| &rs.skill)
397    }
398
399    /// Look up a registered skill (with source metadata) by name.
400    pub fn get_registered(&self, name: &str) -> Option<&RegisteredSkill> {
401        self.skills.get(name)
402    }
403
404    /// List all registered skills.
405    pub fn list(&self) -> Vec<&Skill> {
406        self.skills.values().map(|rs| &rs.skill).collect()
407    }
408
409    /// List all registered skills with source metadata.
410    pub fn list_registered(&self) -> Vec<&RegisteredSkill> {
411        self.skills.values().collect()
412    }
413
414    /// Remove a skill by name. Returns the removed skill if found.
415    pub fn remove(&mut self, name: &str) -> Option<Skill> {
416        self.skills.remove(name).map(|rs| rs.skill)
417    }
418
419    /// Remove multiple skills by name.
420    pub fn remove_many(&mut self, names: &[&str]) {
421        for name in names {
422            self.skills.remove(*name);
423        }
424    }
425
426    /// List skills filtered by scope.
427    pub fn list_by_scope(&self, scope: SkillScope) -> Vec<&Skill> {
428        self.skills
429            .values()
430            .filter(|rs| rs.skill.scope == scope)
431            .map(|rs| &rs.skill)
432            .collect()
433    }
434
435    /// Load skill definitions from a directory.
436    ///
437    /// Discovers skills in two formats, in sorted order:
438    /// - **SKILL.md folders**: `skill-name/SKILL.md` (Agent Skills standard)
439    /// - **JSON files**: `skill_name.json` (legacy, with deprecation warning)
440    ///
441    /// Skills are registered with the given `source`. Skills loaded this way
442    /// override any existing skill with the same name. Returns the number of
443    /// skills loaded. If the directory does not exist, returns `Ok(0)`.
444    pub fn load_from_dir(&mut self, dir: &Path) -> crate::Result<usize> {
445        self.load_from_dir_with_source(dir, SkillSource::Project)
446    }
447
448    /// Load skill definitions from a directory with the specified source.
449    pub fn load_from_dir_with_source(
450        &mut self,
451        dir: &Path,
452        source: SkillSource,
453    ) -> crate::Result<usize> {
454        if !dir.is_dir() {
455            return Ok(0);
456        }
457
458        let mut entries: Vec<_> = std::fs::read_dir(dir)?.filter_map(|e| e.ok()).collect();
459        entries.sort_by_key(|e| e.file_name());
460
461        let mut count = 0;
462        for entry in entries {
463            let path = entry.path();
464
465            // SKILL.md folder format (preferred).
466            if path.is_dir() {
467                let skill_md = path.join("SKILL.md");
468                if skill_md.is_file() {
469                    let contents = std::fs::read_to_string(&skill_md)?;
470                    let mut skill = parse_skill_md(&contents)?;
471                    skill.skill_dir = Some(path.clone());
472                    self.register(skill, source);
473                    count += 1;
474                }
475                continue;
476            }
477
478            // Legacy JSON format (deprecated).
479            if path.extension().is_some_and(|ext| ext == "json") {
480                tracing::warn!(
481                    path = %path.display(),
482                    "loading skill from JSON format (deprecated — migrate to SKILL.md folder)"
483                );
484                let contents = std::fs::read_to_string(&path)?;
485                let skill: Skill = serde_json::from_str(&contents)?;
486                self.register(skill, source);
487                count += 1;
488            }
489        }
490
491        Ok(count)
492    }
493}
494
495/// Built-in skill definitions.
496///
497/// Each skill is defined as a SKILL.md file under `skills/` and embedded
498/// at compile time via `include_str!`. This keeps prompts readable and
499/// editable as standard markdown while ensuring they ship with the binary.
500pub fn builtin_skills() -> Vec<Skill> {
501    const SKILL_SOURCES: &[&str] = &[
502        include_str!("../skills/code_review/SKILL.md"),
503        include_str!("../skills/implement/SKILL.md"),
504        include_str!("../skills/write_tests/SKILL.md"),
505        include_str!("../skills/refactor/SKILL.md"),
506        include_str!("../skills/summarize/SKILL.md"),
507        include_str!("../skills/pre_push/SKILL.md"),
508        include_str!("../skills/create_pr/SKILL.md"),
509        include_str!("../skills/issue_watcher/SKILL.md"),
510        include_str!("../skills/loop_monitor/SKILL.md"),
511        include_str!("../skills/pool_dashboard/SKILL.md"),
512        include_str!("../skills/chain_watcher/SKILL.md"),
513        include_str!("../skills/plan_then_execute/SKILL.md"),
514        include_str!("../skills/rebase_onto_main/SKILL.md"),
515        include_str!("../skills/chain_implement_issue/SKILL.md"),
516        include_str!("../skills/issue_triage/SKILL.md"),
517    ];
518
519    SKILL_SOURCES
520        .iter()
521        .map(|src| parse_skill_md(src).expect("builtin SKILL.md should be valid"))
522        .collect()
523}
524
525#[cfg(test)]
526mod tests {
527    use super::*;
528
529    #[test]
530    fn render_skill_template() {
531        let skill = Skill {
532            name: "greet".into(),
533            description: "Greet someone".into(),
534            prompt: "Hello, {name}! Welcome to {place}.".into(),
535            arguments: vec![
536                SkillArgument {
537                    name: "name".into(),
538                    description: "Name".into(),
539                    required: true,
540                },
541                SkillArgument {
542                    name: "place".into(),
543                    description: "Place".into(),
544                    required: false,
545                },
546            ],
547            config: None,
548            scope: SkillScope::Task,
549            argument_hint: None,
550            skill_dir: None,
551        };
552
553        let mut args = HashMap::new();
554        args.insert("name".into(), "Alice".into());
555        args.insert("place".into(), "the pool".into());
556
557        let rendered = skill.render(&args).unwrap();
558        assert_eq!(rendered, "Hello, Alice! Welcome to the pool.");
559    }
560
561    #[test]
562    fn missing_required_argument() {
563        let skill = Skill {
564            name: "test".into(),
565            description: "Test".into(),
566            prompt: "{x}".into(),
567            arguments: vec![SkillArgument {
568                name: "x".into(),
569                description: "X".into(),
570                required: true,
571            }],
572            config: None,
573            scope: SkillScope::Task,
574            argument_hint: None,
575            skill_dir: None,
576        };
577
578        let result = skill.render(&HashMap::new());
579        assert!(result.is_err());
580    }
581
582    #[test]
583    fn render_dollar_arguments_all() {
584        let skill = Skill {
585            name: "fix".into(),
586            description: "Fix issue".into(),
587            prompt: "Fix issue $ARGUMENTS following conventions.".into(),
588            arguments: vec![SkillArgument {
589                name: "issue".into(),
590                description: "Issue number".into(),
591                required: true,
592            }],
593            config: None,
594            scope: SkillScope::Task,
595            argument_hint: None,
596            skill_dir: None,
597        };
598
599        let mut args = HashMap::new();
600        args.insert("issue".into(), "123".into());
601        let rendered = skill.render(&args).unwrap();
602        assert_eq!(rendered, "Fix issue 123 following conventions.");
603    }
604
605    #[test]
606    fn render_dollar_positional() {
607        let skill = Skill {
608            name: "migrate".into(),
609            description: "Migrate component".into(),
610            prompt: "Migrate $0 from $1 to $2.".into(),
611            arguments: vec![
612                SkillArgument {
613                    name: "component".into(),
614                    description: "Component name".into(),
615                    required: true,
616                },
617                SkillArgument {
618                    name: "from".into(),
619                    description: "Source framework".into(),
620                    required: true,
621                },
622                SkillArgument {
623                    name: "to".into(),
624                    description: "Target framework".into(),
625                    required: true,
626                },
627            ],
628            config: None,
629            scope: SkillScope::Task,
630            argument_hint: None,
631            skill_dir: None,
632        };
633
634        let mut args = HashMap::new();
635        args.insert("component".into(), "SearchBar".into());
636        args.insert("from".into(), "React".into());
637        args.insert("to".into(), "Vue".into());
638        let rendered = skill.render(&args).unwrap();
639        assert_eq!(rendered, "Migrate SearchBar from React to Vue.");
640    }
641
642    #[test]
643    fn render_dollar_arguments_n_bracket() {
644        let skill = Skill {
645            name: "test".into(),
646            description: "Test".into(),
647            prompt: "Process $ARGUMENTS[0] then $ARGUMENTS[1].".into(),
648            arguments: vec![
649                SkillArgument {
650                    name: "a".into(),
651                    description: "A".into(),
652                    required: true,
653                },
654                SkillArgument {
655                    name: "b".into(),
656                    description: "B".into(),
657                    required: true,
658                },
659            ],
660            config: None,
661            scope: SkillScope::Task,
662            argument_hint: None,
663            skill_dir: None,
664        };
665
666        let mut args = HashMap::new();
667        args.insert("a".into(), "foo".into());
668        args.insert("b".into(), "bar".into());
669        let rendered = skill.render(&args).unwrap();
670        assert_eq!(rendered, "Process foo then bar.");
671    }
672
673    #[test]
674    fn render_no_placeholder_appends_arguments() {
675        let skill = Skill {
676            name: "test".into(),
677            description: "Test".into(),
678            prompt: "Do the thing.".into(),
679            arguments: vec![SkillArgument {
680                name: "target".into(),
681                description: "Target".into(),
682                required: true,
683            }],
684            config: None,
685            scope: SkillScope::Task,
686            argument_hint: None,
687            skill_dir: None,
688        };
689
690        let mut args = HashMap::new();
691        args.insert("target".into(), "src/main.rs".into());
692        let rendered = skill.render(&args).unwrap();
693        assert_eq!(rendered, "Do the thing.\n\nARGUMENTS: src/main.rs");
694    }
695
696    #[test]
697    fn render_legacy_placeholder_no_append() {
698        let skill = Skill {
699            name: "test".into(),
700            description: "Test".into(),
701            prompt: "Review {target} carefully.".into(),
702            arguments: vec![SkillArgument {
703                name: "target".into(),
704                description: "Target".into(),
705                required: true,
706            }],
707            config: None,
708            scope: SkillScope::Task,
709            argument_hint: None,
710            skill_dir: None,
711        };
712
713        let mut args = HashMap::new();
714        args.insert("target".into(), "src/main.rs".into());
715        let rendered = skill.render(&args).unwrap();
716        // Legacy placeholder consumed the arg, no ARGUMENTS append.
717        assert_eq!(rendered, "Review src/main.rs carefully.");
718    }
719
720    #[test]
721    fn registry_crud() {
722        let mut registry = SkillRegistry::new();
723        assert!(registry.list().is_empty());
724
725        registry.register(
726            Skill {
727                name: "test".into(),
728                description: "A test skill".into(),
729                prompt: "do {thing}".into(),
730                arguments: vec![],
731                config: None,
732                scope: SkillScope::Task,
733                argument_hint: None,
734                skill_dir: None,
735            },
736            SkillSource::Runtime,
737        );
738
739        assert_eq!(registry.list().len(), 1);
740        assert!(registry.get("test").is_some());
741        assert!(registry.get("nope").is_none());
742
743        registry.remove("test");
744        assert!(registry.list().is_empty());
745    }
746
747    #[test]
748    fn load_from_nonexistent_dir() {
749        let mut registry = SkillRegistry::new();
750        let count = registry
751            .load_from_dir(Path::new("/tmp/does-not-exist-claude-pool-test"))
752            .unwrap();
753        assert_eq!(count, 0);
754    }
755
756    #[test]
757    fn load_from_dir_with_json_files() {
758        let dir = tempfile::tempdir().unwrap();
759
760        let skill_json = serde_json::json!({
761            "name": "my_skill",
762            "description": "A test skill",
763            "prompt": "Do {thing}",
764            "arguments": [
765                { "name": "thing", "description": "What to do", "required": true }
766            ],
767            "config": null
768        });
769        std::fs::write(
770            dir.path().join("my_skill.json"),
771            serde_json::to_string_pretty(&skill_json).unwrap(),
772        )
773        .unwrap();
774
775        // Non-json file should be ignored.
776        std::fs::write(dir.path().join("readme.txt"), "not a skill").unwrap();
777
778        let mut registry = SkillRegistry::new();
779        let count = registry.load_from_dir(dir.path()).unwrap();
780        assert_eq!(count, 1);
781
782        let skill = registry.get("my_skill").unwrap();
783        assert_eq!(skill.description, "A test skill");
784        assert_eq!(skill.arguments.len(), 1);
785        assert!(skill.arguments[0].required);
786    }
787
788    #[test]
789    fn project_skills_override_builtins() {
790        let dir = tempfile::tempdir().unwrap();
791
792        let override_json = serde_json::json!({
793            "name": "code_review",
794            "description": "Custom project review",
795            "prompt": "Review with custom rules: {target}",
796            "arguments": [
797                { "name": "target", "description": "What to review", "required": true }
798            ],
799            "config": null
800        });
801        std::fs::write(
802            dir.path().join("code_review.json"),
803            serde_json::to_string_pretty(&override_json).unwrap(),
804        )
805        .unwrap();
806
807        let mut registry = SkillRegistry::with_builtins();
808        assert_eq!(
809            registry.get("code_review").unwrap().description,
810            "Review code for bugs, style issues, and improvements."
811        );
812
813        let count = registry.load_from_dir(dir.path()).unwrap();
814        assert_eq!(count, 1);
815        assert_eq!(
816            registry.get("code_review").unwrap().description,
817            "Custom project review"
818        );
819    }
820
821    #[test]
822    fn builtins_load() {
823        let registry = SkillRegistry::with_builtins();
824        // 8 task + 5 coordinator + 2 chain = 15 builtins
825        assert_eq!(registry.list().len(), 15);
826        // Task-scoped
827        assert!(registry.get("code_review").is_some());
828        assert!(registry.get("implement").is_some());
829        assert!(registry.get("write_tests").is_some());
830        assert!(registry.get("refactor").is_some());
831        assert!(registry.get("summarize").is_some());
832        assert!(registry.get("pre_push").is_some());
833        assert!(registry.get("create_pr").is_some());
834        assert!(registry.get("rebase_onto_main").is_some());
835        // Coordinator-scoped
836        assert!(registry.get("issue_watcher").is_some());
837        assert!(registry.get("loop_monitor").is_some());
838        assert!(registry.get("pool_dashboard").is_some());
839        assert!(registry.get("chain_watcher").is_some());
840        assert!(registry.get("issue_triage").is_some());
841        // Chain-scoped
842        assert!(registry.get("chain_implement_issue").is_some());
843    }
844
845    #[test]
846    fn list_by_scope() {
847        let registry = SkillRegistry::with_builtins();
848        let tasks = registry.list_by_scope(SkillScope::Task);
849        let coordinators = registry.list_by_scope(SkillScope::Coordinator);
850        let chains = registry.list_by_scope(SkillScope::Chain);
851
852        assert_eq!(tasks.len(), 8);
853        assert_eq!(coordinators.len(), 5);
854        assert_eq!(chains.len(), 2);
855    }
856
857    #[test]
858    fn remove_many_skills() {
859        let mut registry = SkillRegistry::with_builtins();
860        let before = registry.list().len();
861        registry.remove_many(&["create_pr", "issue_watcher"]);
862        assert_eq!(registry.list().len(), before - 2);
863        assert!(registry.get("create_pr").is_none());
864        assert!(registry.get("issue_watcher").is_none());
865    }
866
867    #[test]
868    fn scope_default_is_task() {
869        assert_eq!(SkillScope::default(), SkillScope::Task);
870    }
871
872    #[test]
873    fn scope_serde_roundtrip() {
874        let json = serde_json::json!("coordinator");
875        let scope: SkillScope = serde_json::from_value(json).unwrap();
876        assert_eq!(scope, SkillScope::Coordinator);
877
878        let serialized = serde_json::to_value(scope).unwrap();
879        assert_eq!(serialized, "coordinator");
880    }
881
882    #[test]
883    fn source_tracking() {
884        let registry = SkillRegistry::with_builtins();
885        let rs = registry.get_registered("code_review").unwrap();
886        assert_eq!(rs.source, SkillSource::Builtin);
887    }
888
889    #[test]
890    fn list_registered_includes_source() {
891        let mut registry = SkillRegistry::new();
892        registry.register(
893            Skill {
894                name: "a".into(),
895                description: "A".into(),
896                prompt: "do a".into(),
897                arguments: vec![],
898                config: None,
899                scope: SkillScope::Task,
900                argument_hint: None,
901                skill_dir: None,
902            },
903            SkillSource::Builtin,
904        );
905        registry.register(
906            Skill {
907                name: "b".into(),
908                description: "B".into(),
909                prompt: "do b".into(),
910                arguments: vec![],
911                config: None,
912                scope: SkillScope::Task,
913                argument_hint: None,
914                skill_dir: None,
915            },
916            SkillSource::Runtime,
917        );
918
919        let all = registry.list_registered();
920        assert_eq!(all.len(), 2);
921
922        let builtin = registry.get_registered("a").unwrap();
923        assert_eq!(builtin.source, SkillSource::Builtin);
924
925        let runtime = registry.get_registered("b").unwrap();
926        assert_eq!(runtime.source, SkillSource::Runtime);
927    }
928
929    #[test]
930    fn project_skills_have_project_source() {
931        let dir = tempfile::tempdir().unwrap();
932        let skill_json = serde_json::json!({
933            "name": "proj_skill",
934            "description": "Project skill",
935            "prompt": "do {thing}",
936            "arguments": [
937                { "name": "thing", "description": "What", "required": true }
938            ]
939        });
940        std::fs::write(
941            dir.path().join("proj_skill.json"),
942            serde_json::to_string_pretty(&skill_json).unwrap(),
943        )
944        .unwrap();
945
946        let mut registry = SkillRegistry::new();
947        registry.load_from_dir(dir.path()).unwrap();
948
949        let rs = registry.get_registered("proj_skill").unwrap();
950        assert_eq!(rs.source, SkillSource::Project);
951    }
952
953    #[test]
954    fn source_serde_roundtrip() {
955        let json = serde_json::json!("runtime");
956        let source: SkillSource = serde_json::from_value(json).unwrap();
957        assert_eq!(source, SkillSource::Runtime);
958
959        let serialized = serde_json::to_value(source).unwrap();
960        assert_eq!(serialized, "runtime");
961    }
962
963    #[test]
964    fn source_display() {
965        assert_eq!(SkillSource::Builtin.to_string(), "builtin");
966        assert_eq!(SkillSource::Global.to_string(), "global");
967        assert_eq!(SkillSource::Project.to_string(), "project");
968        assert_eq!(SkillSource::Runtime.to_string(), "runtime");
969    }
970
971    #[test]
972    fn source_global_serde_roundtrip() {
973        let json = serde_json::json!("global");
974        let source: SkillSource = serde_json::from_value(json).unwrap();
975        assert_eq!(source, SkillSource::Global);
976
977        let serialized = serde_json::to_value(source).unwrap();
978        assert_eq!(serialized, "global");
979    }
980
981    #[test]
982    fn parse_skill_md_basic() {
983        let content = "\
984---
985name: test-skill
986description: A test skill for parsing.
987metadata:
988  arguments:
989    - name: target
990      description: What to test
991      required: true
992---
993
994Run tests on {target}.
995
996Report results.
997";
998
999        let skill = parse_skill_md(content).unwrap();
1000        assert_eq!(skill.name, "test-skill");
1001        assert_eq!(skill.description, "A test skill for parsing.");
1002        assert_eq!(skill.prompt, "Run tests on {target}.\n\nReport results.");
1003        assert_eq!(skill.arguments.len(), 1);
1004        assert_eq!(skill.arguments[0].name, "target");
1005        assert!(skill.arguments[0].required);
1006        assert_eq!(skill.scope, SkillScope::Task);
1007    }
1008
1009    #[test]
1010    fn parse_skill_md_with_scope() {
1011        let content = "\
1012---
1013name: cps-coordinator-watcher
1014description: Watches things.
1015metadata:
1016  scope: coordinator
1017---
1018
1019Watch stuff.
1020";
1021        let skill = parse_skill_md(content).unwrap();
1022        assert_eq!(skill.scope, SkillScope::Coordinator);
1023    }
1024
1025    #[test]
1026    fn parse_skill_md_infers_scope_from_prefix() {
1027        let content = "\
1028---
1029name: cps-chain-deploy
1030description: Deploy chain.
1031---
1032
1033Deploy stuff.
1034";
1035        let skill = parse_skill_md(content).unwrap();
1036        assert_eq!(skill.scope, SkillScope::Chain);
1037    }
1038
1039    #[test]
1040    fn parse_skill_md_no_metadata() {
1041        let content = "\
1042---
1043name: simple
1044description: Simple skill.
1045---
1046
1047Just do it.
1048";
1049        let skill = parse_skill_md(content).unwrap();
1050        assert_eq!(skill.name, "simple");
1051        assert!(skill.arguments.is_empty());
1052        assert_eq!(skill.scope, SkillScope::Task);
1053    }
1054
1055    #[test]
1056    fn parse_skill_md_missing_frontmatter() {
1057        let result = parse_skill_md("no frontmatter here");
1058        assert!(result.is_err());
1059    }
1060
1061    #[test]
1062    fn parse_skill_md_missing_closing_delimiter() {
1063        let result = parse_skill_md("---\nname: broken\n");
1064        assert!(result.is_err());
1065    }
1066
1067    #[test]
1068    fn load_from_dir_with_skill_md_folders() {
1069        let dir = tempfile::tempdir().unwrap();
1070
1071        // Create a SKILL.md folder.
1072        let skill_dir = dir.path().join("my-skill");
1073        std::fs::create_dir(&skill_dir).unwrap();
1074        std::fs::write(
1075            skill_dir.join("SKILL.md"),
1076            "\
1077---
1078name: my-skill
1079description: A folder-based skill.
1080metadata:
1081  arguments:
1082    - name: input
1083      description: The input
1084      required: true
1085---
1086
1087Process {input}.
1088",
1089        )
1090        .unwrap();
1091
1092        let mut registry = SkillRegistry::new();
1093        let count = registry.load_from_dir(dir.path()).unwrap();
1094        assert_eq!(count, 1);
1095
1096        let skill = registry.get("my-skill").unwrap();
1097        assert_eq!(skill.description, "A folder-based skill.");
1098        assert_eq!(skill.prompt, "Process {input}.");
1099        assert_eq!(skill.arguments.len(), 1);
1100    }
1101
1102    #[test]
1103    fn load_from_dir_mixed_formats() {
1104        let dir = tempfile::tempdir().unwrap();
1105
1106        // SKILL.md folder.
1107        let skill_dir = dir.path().join("new-skill");
1108        std::fs::create_dir(&skill_dir).unwrap();
1109        std::fs::write(
1110            skill_dir.join("SKILL.md"),
1111            "---\nname: new-skill\ndescription: New format.\n---\n\nNew prompt.\n",
1112        )
1113        .unwrap();
1114
1115        // Legacy JSON.
1116        let skill_json = serde_json::json!({
1117            "name": "old_skill",
1118            "description": "Legacy format",
1119            "prompt": "Old prompt",
1120            "arguments": []
1121        });
1122        std::fs::write(
1123            dir.path().join("old_skill.json"),
1124            serde_json::to_string_pretty(&skill_json).unwrap(),
1125        )
1126        .unwrap();
1127
1128        let mut registry = SkillRegistry::new();
1129        let count = registry.load_from_dir(dir.path()).unwrap();
1130        assert_eq!(count, 2);
1131        assert!(registry.get("new-skill").is_some());
1132        assert!(registry.get("old_skill").is_some());
1133    }
1134
1135    #[test]
1136    fn load_from_dir_with_source() {
1137        let dir = tempfile::tempdir().unwrap();
1138        let skill_dir = dir.path().join("global-skill");
1139        std::fs::create_dir(&skill_dir).unwrap();
1140        std::fs::write(
1141            skill_dir.join("SKILL.md"),
1142            "---\nname: global-skill\ndescription: Global.\n---\n\nDo global things.\n",
1143        )
1144        .unwrap();
1145
1146        let mut registry = SkillRegistry::new();
1147        let count = registry
1148            .load_from_dir_with_source(dir.path(), SkillSource::Global)
1149            .unwrap();
1150        assert_eq!(count, 1);
1151
1152        let rs = registry.get_registered("global-skill").unwrap();
1153        assert_eq!(rs.source, SkillSource::Global);
1154    }
1155
1156    #[test]
1157    fn skill_md_folder_overrides_builtin() {
1158        let dir = tempfile::tempdir().unwrap();
1159
1160        let skill_dir = dir.path().join("code_review");
1161        std::fs::create_dir(&skill_dir).unwrap();
1162        std::fs::write(
1163            skill_dir.join("SKILL.md"),
1164            "\
1165---
1166name: code_review
1167description: Custom review via SKILL.md.
1168metadata:
1169  arguments:
1170    - name: target
1171      description: What to review
1172      required: true
1173---
1174
1175Custom review: {target}
1176",
1177        )
1178        .unwrap();
1179
1180        let mut registry = SkillRegistry::with_builtins();
1181        assert_eq!(
1182            registry.get("code_review").unwrap().description,
1183            "Review code for bugs, style issues, and improvements."
1184        );
1185
1186        registry.load_from_dir(dir.path()).unwrap();
1187        assert_eq!(
1188            registry.get("code_review").unwrap().description,
1189            "Custom review via SKILL.md."
1190        );
1191        assert_eq!(
1192            registry.get_registered("code_review").unwrap().source,
1193            SkillSource::Project
1194        );
1195    }
1196
1197    #[test]
1198    fn parse_skill_md_allowed_tools() {
1199        let content = "\
1200---
1201name: safe-reader
1202description: Read-only exploration.
1203allowed-tools: Read, Grep, Glob
1204---
1205
1206Explore the codebase.
1207";
1208        let skill = parse_skill_md(content).unwrap();
1209        assert_eq!(skill.name, "safe-reader");
1210        let tools = skill.config.unwrap().allowed_tools.unwrap();
1211        assert_eq!(tools, vec!["Read", "Grep", "Glob"]);
1212    }
1213
1214    #[test]
1215    fn parse_skill_md_allowed_tools_overrides_metadata() {
1216        let content = "\
1217---
1218name: reader
1219description: Read stuff.
1220allowed-tools: Read, Grep
1221metadata:
1222  config:
1223    allowed_tools:
1224      - Bash
1225      - Write
1226---
1227
1228Read things.
1229";
1230        let skill = parse_skill_md(content).unwrap();
1231        // Standard field takes precedence over metadata.
1232        let tools = skill.config.unwrap().allowed_tools.unwrap();
1233        assert_eq!(tools, vec!["Read", "Grep"]);
1234    }
1235
1236    #[test]
1237    fn parse_skill_md_argument_hint() {
1238        let content = "\
1239---
1240name: fix-issue
1241description: Fix a GitHub issue.
1242argument-hint: \"[issue-number]\"
1243metadata:
1244  arguments:
1245    - name: issue
1246      description: Issue number
1247      required: true
1248---
1249
1250Fix issue $ARGUMENTS.
1251";
1252        let skill = parse_skill_md(content).unwrap();
1253        assert_eq!(skill.argument_hint.as_deref(), Some("[issue-number]"));
1254    }
1255
1256    #[test]
1257    fn command_injection_basic() {
1258        let result = execute_command_injections("before !`echo hello` after");
1259        assert_eq!(result, "before hello after");
1260    }
1261
1262    #[test]
1263    fn command_injection_no_markers() {
1264        let input = "no commands here";
1265        assert_eq!(execute_command_injections(input), input);
1266    }
1267
1268    #[test]
1269    fn command_injection_failed_command() {
1270        let result = execute_command_injections("result: !`false`");
1271        assert!(result.starts_with("result: [command failed"));
1272    }
1273
1274    #[test]
1275    fn command_injection_multiple() {
1276        let result = execute_command_injections("!`echo a` and !`echo b`");
1277        assert_eq!(result, "a and b");
1278    }
1279
1280    #[test]
1281    fn command_injection_unclosed_backtick() {
1282        let result = execute_command_injections("before !`unclosed");
1283        assert_eq!(result, "before !`unclosed");
1284    }
1285
1286    #[test]
1287    fn render_with_command_injection() {
1288        let skill = Skill {
1289            name: "test".into(),
1290            description: "Test".into(),
1291            prompt: "Context: !`echo injected`\n\nDo {task}.".into(),
1292            arguments: vec![SkillArgument {
1293                name: "task".into(),
1294                description: "Task".into(),
1295                required: true,
1296            }],
1297            config: None,
1298            scope: SkillScope::Task,
1299            argument_hint: None,
1300            skill_dir: None,
1301        };
1302
1303        let mut args = HashMap::new();
1304        args.insert("task".into(), "the thing".into());
1305        let rendered = skill.render(&args).unwrap();
1306        assert_eq!(rendered, "Context: injected\n\nDo the thing.");
1307    }
1308
1309    #[test]
1310    fn skill_dir_substitution() {
1311        let skill = Skill {
1312            name: "vis".into(),
1313            description: "Visualize".into(),
1314            prompt: "Run: python ${CLAUDE_SKILL_DIR}/scripts/viz.py .".into(),
1315            arguments: vec![],
1316            config: None,
1317            scope: SkillScope::Task,
1318            argument_hint: None,
1319            skill_dir: Some(PathBuf::from("/home/user/.claude-pool/skills/vis")),
1320        };
1321
1322        let rendered = skill.render(&HashMap::new()).unwrap();
1323        assert_eq!(
1324            rendered,
1325            "Run: python /home/user/.claude-pool/skills/vis/scripts/viz.py ."
1326        );
1327    }
1328
1329    #[test]
1330    fn skill_dir_substitution_missing() {
1331        let skill = Skill {
1332            name: "vis".into(),
1333            description: "Visualize".into(),
1334            prompt: "Run: python ${CLAUDE_SKILL_DIR}/scripts/viz.py .".into(),
1335            arguments: vec![],
1336            config: None,
1337            scope: SkillScope::Task,
1338            argument_hint: None,
1339            skill_dir: None,
1340        };
1341
1342        let rendered = skill.render(&HashMap::new()).unwrap();
1343        assert!(rendered.contains("[CLAUDE_SKILL_DIR unavailable"));
1344    }
1345
1346    #[test]
1347    fn skill_dir_no_substitution_when_absent() {
1348        let skill = Skill {
1349            name: "simple".into(),
1350            description: "Simple".into(),
1351            prompt: "Do the thing.".into(),
1352            arguments: vec![],
1353            config: None,
1354            scope: SkillScope::Task,
1355            argument_hint: None,
1356            skill_dir: None,
1357        };
1358
1359        let rendered = skill.render(&HashMap::new()).unwrap();
1360        assert_eq!(rendered, "Do the thing.");
1361    }
1362
1363    #[test]
1364    fn skill_dir_set_from_directory_load() {
1365        let dir = tempfile::tempdir().unwrap();
1366        let skill_dir = dir.path().join("my_skill");
1367        std::fs::create_dir(&skill_dir).unwrap();
1368        std::fs::write(
1369            skill_dir.join("SKILL.md"),
1370            "---\nname: my_skill\ndescription: Test\n---\n\nRun ${CLAUDE_SKILL_DIR}/run.sh",
1371        )
1372        .unwrap();
1373
1374        let mut registry = SkillRegistry::new();
1375        registry.load_from_dir(dir.path()).unwrap();
1376
1377        let skill = registry.get("my_skill").unwrap();
1378        assert_eq!(skill.skill_dir.as_deref(), Some(skill_dir.as_path()));
1379
1380        let rendered = skill.render(&HashMap::new()).unwrap();
1381        assert!(rendered.contains(&skill_dir.display().to_string()));
1382    }
1383}