Skip to main content

atomcode_core/
skill.rs

1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3use std::process::Command;
4
5/// A loaded skill parsed from a `SKILL.md` or legacy `.md` file.
6#[derive(Debug, Clone)]
7pub struct Skill {
8    /// Command name without leading slash, e.g. "commit" or "superpowers:brainstorming".
9    pub name: String,
10    /// Human-readable description (frontmatter > first paragraph of template).
11    pub description: String,
12    /// Raw template content (everything after the frontmatter block).
13    pub template: String,
14    /// If true, hidden from Claude's context — user must invoke manually via `/name`.
15    pub disable_model_invocation: bool,
16    /// If false, hidden from the `/` menu — Claude can still invoke automatically.
17    pub user_invocable: bool,
18    /// Autocomplete hint shown next to the skill name, e.g. "[issue-number]".
19    pub argument_hint: Option<String>,
20    /// Tools auto-approved when this skill is active.
21    pub allowed_tools: Vec<String>,
22    /// Directory containing the skill file (used for `${CLAUDE_SKILL_DIR}` substitution).
23    pub skill_dir: PathBuf,
24    /// Source file path, for diagnostics.
25    pub source_path: PathBuf,
26}
27
28impl Skill {
29    /// Expand the template, applying all substitutions in order:
30    ///
31    /// 1. `$ARGUMENTS[N]` → positional argument by 0-based index
32    /// 2. `$N`            → shorthand for `$ARGUMENTS[N]`
33    /// 3. `$ARGUMENTS`    → all arguments (appended as `ARGUMENTS: …` if absent)
34    /// 4. `${CLAUDE_SESSION_ID}` → the provided session id
35    /// 5. `${CLAUDE_SKILL_DIR}`  → absolute path of the skill's directory
36    /// 6. `` !`command` ``       → preprocess: run shell command, insert stdout
37    pub fn expand(&self, arguments: &str, session_id: &str) -> String {
38        let positional: Vec<&str> = arguments.split_whitespace().collect();
39        let mut result = self.template.clone();
40
41        // 1. $ARGUMENTS[N]
42        for (i, arg) in positional.iter().enumerate() {
43            result = result.replace(&format!("$ARGUMENTS[{}]", i), arg);
44        }
45
46        // 2. $N shorthand — only when not followed by another digit
47        for (i, arg) in positional.iter().enumerate() {
48            result = replace_positional_short(&result, i, arg);
49        }
50
51        // 3. $ARGUMENTS
52        // Check the ORIGINAL template, not `result`: $ARGUMENTS[N] starts with "$ARGUMENTS",
53        // so this correctly treats positional-bracket templates as "handled" and avoids
54        // the append fallback. Templates that use only $N shorthand (no $ARGUMENTS) still
55        // get the full args appended so Claude can see them.
56        if self.template.contains("$ARGUMENTS") {
57            result = result.replace("$ARGUMENTS", arguments);
58        } else if !arguments.trim().is_empty() {
59            result = format!("{}\n\nARGUMENTS: {}", result.trim_end(), arguments);
60        }
61
62        // 4. ${CLAUDE_SESSION_ID}
63        result = result.replace("${CLAUDE_SESSION_ID}", session_id);
64
65        // 5. ${CLAUDE_SKILL_DIR}
66        result = result.replace("${CLAUDE_SKILL_DIR}", &self.skill_dir.to_string_lossy());
67
68        // 6. !`command` → shell pre-injection
69        result = expand_shell_injections(&result);
70
71        result
72    }
73}
74
75/// Replace `$N` (where N matches `n`) only when the character immediately after
76/// is not a digit — so `$1` does not accidentally match inside `$10`.
77fn replace_positional_short(s: &str, n: usize, replacement: &str) -> String {
78    let pattern = format!("${}", n);
79    let pat = pattern.as_bytes();
80    let src = s.as_bytes();
81    let mut out = Vec::with_capacity(s.len());
82    let mut i = 0;
83
84    while i < src.len() {
85        if src[i..].starts_with(pat) {
86            let after = i + pat.len();
87            let next_is_digit = src.get(after).map(|b| b.is_ascii_digit()).unwrap_or(false);
88            if !next_is_digit {
89                out.extend_from_slice(replacement.as_bytes());
90                i += pat.len();
91                continue;
92            }
93        }
94        out.push(src[i]);
95        i += 1;
96    }
97
98    String::from_utf8_lossy(&out).into_owned()
99}
100
101/// Find all `` !`…` `` occurrences, execute them via `sh -c`, and substitute
102/// their trimmed stdout in-place. Stops on unclosed backtick.
103fn expand_shell_injections(template: &str) -> String {
104    let mut result = template.to_string();
105
106    loop {
107        let Some(start) = result.find("!`") else {
108            break;
109        };
110        let search_from = start + 2;
111        let Some(rel_end) = result[search_from..].find('`') else {
112            break; // unclosed — leave as-is
113        };
114        let end = search_from + rel_end;
115        let cmd = result[search_from..end].to_string();
116        let output = run_shell_command(&cmd);
117        result = format!("{}{}{}", &result[..start], output, &result[end + 1..]);
118    }
119
120    result
121}
122
123fn run_shell_command(cmd: &str) -> String {
124    let mut command = Command::new("sh");
125    command.arg("-c").arg(cmd);
126    crate::process_utils::suppress_console_window_sync(&mut command);
127    match command.output() {
128        Ok(out) => {
129            let mut s = String::from_utf8_lossy(&out.stdout).into_owned();
130            if !out.status.success() {
131                let stderr = String::from_utf8_lossy(&out.stderr);
132                if !stderr.trim().is_empty() {
133                    s.push('\n');
134                    s.push_str(stderr.trim());
135                }
136            }
137            // Trim trailing whitespace so inline substitution looks clean
138            s.trim_end().to_string()
139        }
140        Err(e) => format!("[error: {}]", e),
141    }
142}
143
144// ---------------------------------------------------------------------------
145// Frontmatter
146// ---------------------------------------------------------------------------
147
148struct Frontmatter {
149    name: Option<String>,
150    description: String,
151    disable_model_invocation: bool,
152    user_invocable: bool,
153    argument_hint: Option<String>,
154    allowed_tools: Vec<String>,
155}
156
157impl Frontmatter {
158    fn default() -> Self {
159        Self {
160            name: None,
161            description: String::new(),
162            disable_model_invocation: false,
163            user_invocable: true,
164            argument_hint: None,
165            allowed_tools: Vec::new(),
166        }
167    }
168}
169
170/// Parse YAML frontmatter and return `(Frontmatter, template_body)`.
171///
172/// Requires `---\n` as the very first line. Unclosed or absent frontmatter
173/// returns defaults and treats the entire content as the template body.
174fn parse_frontmatter(content: &str) -> (Frontmatter, String) {
175    let mut fm = Frontmatter::default();
176
177    if !content.starts_with("---\n") && !content.starts_with("---\r\n") {
178        return (fm, content.to_string());
179    }
180
181    let after_open = &content[if content.starts_with("---\r\n") { 5 } else { 4 }..];
182
183    let (close_pos, skip) = match find_frontmatter_close(after_open) {
184        Some(v) => v,
185        None => return (fm, content.to_string()),
186    };
187
188    let fm_text = &after_open[..close_pos];
189    let template = after_open[close_pos + skip..].to_string();
190
191    for line in fm_text.lines() {
192        if let Some(val) = line.strip_prefix("name:") {
193            let v = val.trim().trim_matches('"').trim_matches('\'');
194            if !v.is_empty() {
195                fm.name = Some(v.to_string());
196            }
197        } else if let Some(val) = line.strip_prefix("description:") {
198            fm.description = val.trim().trim_matches('"').trim_matches('\'').to_string();
199        } else if let Some(val) = line.strip_prefix("disable-model-invocation:") {
200            fm.disable_model_invocation = val.trim() == "true";
201        } else if let Some(val) = line.strip_prefix("user-invocable:") {
202            fm.user_invocable = val.trim() != "false";
203        } else if let Some(val) = line.strip_prefix("argument-hint:") {
204            let v = val.trim().trim_matches('"').trim_matches('\'');
205            if !v.is_empty() {
206                fm.argument_hint = Some(v.to_string());
207            }
208        } else if let Some(val) = line.strip_prefix("allowed-tools:") {
209            // AgentSkills spec: space-delimited. Also accept comma for Claude Code compat.
210            fm.allowed_tools = val
211                .split(|c| c == ' ' || c == ',')
212                .map(|s| s.trim().to_string())
213                .filter(|s| !s.is_empty())
214                .collect();
215        }
216    }
217
218    (fm, template)
219}
220
221fn find_frontmatter_close(after_open: &str) -> Option<(usize, usize)> {
222    if after_open == "---" {
223        return Some((0, 3));
224    }
225    if after_open == "---\r" {
226        return Some((0, 4));
227    }
228    if after_open.starts_with("---\n") {
229        return Some((0, 4));
230    }
231    if after_open.starts_with("---\r\n") {
232        return Some((0, 5));
233    }
234
235    after_open
236        .find("\n---\n")
237        .map(|p| (p, 5usize))
238        .or_else(|| after_open.find("\n---\r\n").map(|p| (p, 6)))
239        .or_else(|| after_open.strip_suffix("\n---").map(|_| (after_open.len() - 4, 4)))
240        .or_else(|| {
241            after_open
242                .strip_suffix("\n---\r")
243                .map(|_| (after_open.len() - 5, 5))
244        })
245}
246
247/// Extract a description from the first non-empty paragraph of the template,
248/// used as a fallback when `description` is absent in frontmatter.
249fn first_paragraph(template: &str) -> String {
250    template
251        .lines()
252        .find(|l| !l.trim().is_empty() && !l.trim_start().starts_with('#'))
253        .unwrap_or("")
254        .trim()
255        .to_string()
256}
257
258// ---------------------------------------------------------------------------
259// Skill parsers
260// ---------------------------------------------------------------------------
261
262/// Parse a legacy flat `.md` file: name = file stem.
263fn parse_skill_file(path: &Path, namespace: Option<&str>) -> anyhow::Result<Skill> {
264    let stem = path
265        .file_stem()
266        .and_then(|s| s.to_str())
267        .ok_or_else(|| anyhow::anyhow!("filename is not valid UTF-8"))?;
268
269    validate_skill_name(stem)?;
270
271    let content = std::fs::read_to_string(path)?;
272    let (fm, template) = parse_frontmatter(&content);
273
274    let base_name = fm.name.as_deref().unwrap_or(stem);
275    let name = make_name(base_name, namespace);
276
277    let description = if fm.description.is_empty() {
278        first_paragraph(&template)
279    } else {
280        fm.description
281    };
282
283    Ok(Skill {
284        name,
285        description,
286        template,
287        disable_model_invocation: fm.disable_model_invocation,
288        user_invocable: fm.user_invocable,
289        argument_hint: fm.argument_hint,
290        allowed_tools: fm.allowed_tools,
291        skill_dir: path.parent().unwrap_or(Path::new(".")).to_path_buf(),
292        source_path: path.to_path_buf(),
293    })
294}
295
296/// Parse a directory-style skill: name = directory name (or frontmatter `name`).
297/// The entry point file is `<skill_dir>/SKILL.md`.
298fn parse_skill_dir(
299    skill_dir: &Path,
300    skill_md: &Path,
301    namespace: Option<&str>,
302) -> anyhow::Result<Skill> {
303    let dir_name = skill_dir
304        .file_name()
305        .and_then(|s| s.to_str())
306        .ok_or_else(|| anyhow::anyhow!("directory name is not valid UTF-8"))?;
307
308    let content = std::fs::read_to_string(skill_md)?;
309    let (fm, template) = parse_frontmatter(&content);
310
311    let base_name = fm.name.as_deref().unwrap_or(dir_name);
312    validate_skill_name(base_name)?;
313    let name = make_name(base_name, namespace);
314
315    let description = if fm.description.is_empty() {
316        first_paragraph(&template)
317    } else {
318        fm.description
319    };
320
321    Ok(Skill {
322        name,
323        description,
324        template,
325        disable_model_invocation: fm.disable_model_invocation,
326        user_invocable: fm.user_invocable,
327        argument_hint: fm.argument_hint,
328        allowed_tools: fm.allowed_tools,
329        skill_dir: skill_dir.to_path_buf(),
330        source_path: skill_md.to_path_buf(),
331    })
332}
333
334fn validate_skill_name(name: &str) -> anyhow::Result<()> {
335    if name.is_empty() || name.len() > 64 {
336        anyhow::bail!("skill name '{}' must be 1-64 characters", name);
337    }
338    if !name
339        .chars()
340        .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_')
341    {
342        anyhow::bail!(
343            "skill name '{}' must contain only lowercase letters, digits, hyphens, and underscores",
344            name
345        );
346    }
347    if name.starts_with('-') || name.ends_with('-') {
348        anyhow::bail!("skill name '{}' must not start or end with a hyphen", name);
349    }
350    if name.contains("--") {
351        anyhow::bail!("skill name '{}' must not contain consecutive hyphens", name);
352    }
353    Ok(())
354}
355
356fn make_name(base: &str, namespace: Option<&str>) -> String {
357    match namespace {
358        Some(ns) => format!("{}:{}", ns, base),
359        None => base.to_string(),
360    }
361}
362
363// ---------------------------------------------------------------------------
364// Registry
365// ---------------------------------------------------------------------------
366
367/// Registry of loaded skills, indexed by name.
368pub struct SkillRegistry {
369    skills: HashMap<String, Skill>,
370}
371
372impl SkillRegistry {
373    pub fn new() -> Self {
374        Self {
375            skills: HashMap::new(),
376        }
377    }
378
379    /// Reload skills from all sources.
380    ///
381    /// Load order (later entries overwrite earlier ones — higher priority wins):
382    ///
383    /// Global (home dir or ATOMCODE_HOME):
384    ///   1. `{home}/.claude/commands/*.md`          legacy flat, Claude Code compat
385    ///   2. `{home}/.atomcode/commands/*.md`         legacy flat, atomcode native
386    ///   3. `{home}/.claude/skills/*/SKILL.md`       directory-style, Claude Code compat
387    ///   4. `{home}/.atomcode/skills/*/SKILL.md`     directory-style, atomcode native
388    ///
389    /// Project (working dir):
390    ///   5. `.claude/commands/*.md`
391    ///   6. `.atomcode/commands/*.md`
392    ///   7. `.claude/skills/*/SKILL.md`
393    ///   8. `.atomcode/skills/*/SKILL.md`
394    ///
395    /// Same-name skill from a `skills/` directory beats one from `commands/`
396    /// at the same level because it is loaded after.
397    ///
398    /// Note: If ATOMCODE_HOME env var is set, it overrides the default home directory
399    /// for atomcode-specific paths (.atomcode/commands and .atomcode/skills).
400    /// Claude Code compat paths (.claude/*) always use the system home directory.
401    /// Reload skills. Returns a list of "skipped" diagnostics (one per
402    /// rejected skill on disk). Callers in interactive contexts (TUI) can
403    /// surface these gated behind verbose mode; non-interactive callers
404    /// (agent bootstrap, /cd) drop them.
405    pub fn reload(&mut self, working_dir: &Path) -> Vec<String> {
406        self.skills.clear();
407        let mut warnings: Vec<String> = Vec::new();
408
409        // System home directory (for Claude Code compat paths)
410        let system_home = crate::tool::real_home_dir();
411
412        // AtomCode config dir (respects ATOMCODE_HOME env var; defaults to
413        // ~/.atomcode). This is the SAME root used by config.toml, history,
414        // plugins/, etc. — see Config::config_dir() for the single source of
415        // truth.
416        let atomcode_config_dir = crate::config::Config::config_dir();
417
418        // All "loose" skills (i.e. not loaded through a plugin manifest)
419        // share the synthetic `skills:` namespace so they're visually
420        // distinguishable from built-in slash commands in the `/` menu —
421        // e.g. `/skills:brainstorming`. Plugin loaders (future) will pass
422        // their own namespace derived from the plugin manifest, matching
423        // Claude Code's `<plugin>:<skill>` convention (`superpowers:foo`).
424        const LOOSE_NS: Option<&str> = Some("skills");
425
426        // Load Claude Code compat paths from system home (always)
427        if let Some(ref home) = system_home {
428            self.load_flat_commands(&home.join(".claude").join("commands"), LOOSE_NS, &mut warnings);
429            self.load_skills_dir(&home.join(".claude").join("skills"), LOOSE_NS, &mut warnings);
430        }
431
432        // Load atomcode native paths from the unified config dir.
433        self.load_flat_commands(&atomcode_config_dir.join("commands"), LOOSE_NS, &mut warnings);
434        self.load_skills_dir(&atomcode_config_dir.join("skills"), LOOSE_NS, &mut warnings);
435
436        // Project-level skills (always from working dir)
437        self.load_flat_commands(&working_dir.join(".claude").join("commands"), LOOSE_NS, &mut warnings);
438        self.load_flat_commands(&working_dir.join(".atomcode").join("commands"), LOOSE_NS, &mut warnings);
439        self.load_skills_dir(&working_dir.join(".claude").join("skills"), LOOSE_NS, &mut warnings);
440        self.load_skills_dir(&working_dir.join(".atomcode").join("skills"), LOOSE_NS, &mut warnings);
441
442        // Plugin layer — installed plugins contribute namespaced skills.
443        for assets in crate::plugin::loader::iter_installed_plugin_assets() {
444            for skills_dir in assets.skills_dirs() {
445                self.load_skills_dir(&skills_dir, Some(&assets.plugin), &mut warnings);
446            }
447        }
448        warnings
449    }
450
451    /// Register a pre-built skill directly (used by plugin system).
452    pub fn register(&mut self, skill: Skill) {
453        self.skills.insert(skill.name.clone(), skill);
454    }
455
456    /// Look up a skill by name. Falls back to a unique `*:name` suffix
457    /// match when the exact name misses AND the request is unqualified —
458    /// covers the case where a hook-injected workflow plan or other
459    /// external material refers to a plugin skill by its bare name
460    /// (`ascend-model-verification`) instead of the registered fully
461    /// qualified key (`ascend-model-agent-plugin:ascend-model-verification`).
462    ///
463    /// Discipline: returns `None` when more than one namespace would match
464    /// the bare name. Silent-pick-the-first would mask real ambiguity (and
465    /// the LLM would invoke the wrong plugin) — better to error out so the
466    /// caller surfaces the candidates.
467    pub fn get(&self, name: &str) -> Option<&Skill> {
468        if let Some(s) = self.skills.get(name) {
469            return Some(s);
470        }
471        // Only run the fallback when the request is unqualified. A
472        // qualified-but-missing lookup (`foo:bar` typo'd as `foo:baz`)
473        // should fail loudly, not silently rebind to some other plugin.
474        if name.contains(':') {
475            return None;
476        }
477        let suffix = format!(":{}", name);
478        let mut hits = self.skills.iter().filter(|(k, _)| k.ends_with(&suffix));
479        let first = hits.next()?;
480        if hits.next().is_some() {
481            // Ambiguous — the bare name maps to multiple plugins. Refuse.
482            return None;
483        }
484        Some(first.1)
485    }
486
487    pub fn is_empty(&self) -> bool {
488        self.skills.is_empty()
489    }
490
491    /// All skills, regardless of invocation flags.
492    pub fn all(&self) -> impl Iterator<Item = &Skill> {
493        self.skills.values()
494    }
495
496    /// Skills visible in the `/` menu (user-invocable).
497    pub fn user_invocable(&self) -> impl Iterator<Item = &Skill> {
498        self.skills.values().filter(|s| s.user_invocable)
499    }
500
501    /// Skills that Claude may invoke automatically.
502    pub fn invocable_by_llm(&self) -> impl Iterator<Item = &Skill> {
503        self.skills.values().filter(|s| !s.disable_model_invocation)
504    }
505
506    // -----------------------------------------------------------------------
507
508    /// Load all `.md` files from a flat `commands/` directory.
509    fn load_flat_commands(&mut self, dir: &Path, namespace: Option<&str>, warnings: &mut Vec<String>) {
510        if !dir.is_dir() {
511            return;
512        }
513        let entries = match std::fs::read_dir(dir) {
514            Ok(e) => e,
515            Err(_) => return,
516        };
517        for entry in entries.flatten() {
518            let path = entry.path();
519            if path.extension().and_then(|e| e.to_str()) != Some("md") {
520                continue;
521            }
522            match parse_skill_file(&path, namespace) {
523                Ok(skill) => {
524                    self.skills.insert(skill.name.clone(), skill);
525                }
526                Err(e) => {
527                    warnings.push(format!("[skill] skipping {}: {}", path.display(), e));
528                }
529            }
530        }
531    }
532
533    /// Load directory-style skills from a `skills/` directory.
534    ///
535    /// Two layouts are supported:
536    ///
537    /// 1. **AtomCode / parent-directory layout**: `dir/` contains subdirectories
538    ///    each with a `SKILL.md` — e.g. `dir/brainstorming/SKILL.md`.
539    /// 2. **Claude Code array layout**: `dir/` itself contains a `SKILL.md`,
540    ///    meaning `dir` *is* the skill directory. This happens when `plugin.json`
541    ///    declares `skills: ["./skills/foo"]` — each entry points directly to a
542    ///    skill directory rather than to a parent of skill directories.
543    ///
544    /// Both layouts can coexist: if `dir/SKILL.md` exists it is loaded first,
545    /// then any `dir/*/SKILL.md` subdirectory skills are loaded after (and win
546    /// on name collision, matching the higher-priority-wins convention).
547    fn load_skills_dir(&mut self, dir: &Path, namespace: Option<&str>, warnings: &mut Vec<String>) {
548        if !dir.is_dir() {
549            return;
550        }
551        // CC array layout: the directory itself is a skill directory.
552        let self_md = dir.join("SKILL.md");
553        if self_md.exists() {
554            match parse_skill_dir(dir, &self_md, namespace) {
555                Ok(skill) => {
556                    self.skills.insert(skill.name.clone(), skill);
557                }
558                Err(e) => {
559                    warnings.push(format!("[skill] skipping {}: {}", dir.display(), e));
560                }
561            }
562        }
563        // AtomCode / parent-directory layout: each subdirectory with a SKILL.md.
564        let entries = match std::fs::read_dir(dir) {
565            Ok(e) => e,
566            Err(_) => return,
567        };
568        for entry in entries.flatten() {
569            let skill_dir = entry.path();
570            if !skill_dir.is_dir() {
571                continue;
572            }
573            let skill_md = skill_dir.join("SKILL.md");
574            if !skill_md.exists() {
575                continue;
576            }
577            match parse_skill_dir(&skill_dir, &skill_md, namespace) {
578                Ok(skill) => {
579                    self.skills.insert(skill.name.clone(), skill);
580                }
581                Err(e) => {
582                    warnings.push(format!("[skill] skipping {}: {}", skill_dir.display(), e));
583                }
584            }
585        }
586    }
587}
588
589// ---------------------------------------------------------------------------
590// Tests
591// ---------------------------------------------------------------------------
592
593#[cfg(test)]
594mod tests {
595    use super::*;
596
597    fn make_skill(template: &str) -> Skill {
598        Skill {
599            name: "test".into(),
600            description: "".into(),
601            template: template.into(),
602            disable_model_invocation: false,
603            user_invocable: true,
604            argument_hint: None,
605            allowed_tools: vec![],
606            skill_dir: PathBuf::new(),
607            source_path: PathBuf::new(),
608        }
609    }
610
611    // --- expand: $ARGUMENTS ---
612
613    #[test]
614    fn test_expand_with_arguments() {
615        let s = make_skill("Do $ARGUMENTS please.");
616        assert_eq!(s.expand("foo bar", ""), "Do foo bar please.");
617    }
618
619    #[test]
620    fn test_expand_no_placeholder_with_args() {
621        let s = make_skill("Do something.");
622        assert_eq!(s.expand("extra", ""), "Do something.\n\nARGUMENTS: extra");
623    }
624
625    #[test]
626    fn test_expand_no_placeholder_no_args() {
627        let s = make_skill("Do something.");
628        assert_eq!(s.expand("", ""), "Do something.");
629    }
630
631    // --- expand: $ARGUMENTS[N] and $N ---
632
633    #[test]
634    fn test_expand_positional_brackets() {
635        // $ARGUMENTS[N] starts with "$ARGUMENTS" → treated as handled, no append
636        let s = make_skill("Migrate $ARGUMENTS[0] from $ARGUMENTS[1] to $ARGUMENTS[2].");
637        assert_eq!(
638            s.expand("Button React Vue", ""),
639            "Migrate Button from React to Vue."
640        );
641    }
642
643    #[test]
644    fn test_expand_positional_short() {
645        // $N shorthand: template has no "$ARGUMENTS" literal → full args appended
646        let s = make_skill("Migrate $0 from $1 to $2.");
647        assert_eq!(
648            s.expand("Button React Vue", ""),
649            "Migrate Button from React to Vue.\n\nARGUMENTS: Button React Vue"
650        );
651    }
652
653    #[test]
654    fn test_expand_positional_short_no_partial_match() {
655        // $1 must not eat the '0' from $10; no "$ARGUMENTS" → args appended
656        let s = make_skill("a=$10 b=$1.");
657        assert_eq!(s.expand("x y", ""), "a=$10 b=y.\n\nARGUMENTS: x y");
658    }
659
660    #[test]
661    fn test_expand_session_id() {
662        let s = make_skill("session=${CLAUDE_SESSION_ID}");
663        assert_eq!(s.expand("", "abc-123"), "session=abc-123");
664    }
665
666    #[test]
667    fn test_expand_skill_dir() {
668        let mut s = make_skill("dir=${CLAUDE_SKILL_DIR}");
669        s.skill_dir = PathBuf::from("/home/user/.claude/skills/my-skill");
670        assert_eq!(s.expand("", ""), "dir=/home/user/.claude/skills/my-skill");
671    }
672
673    // --- frontmatter ---
674
675    #[test]
676    fn test_frontmatter_none() {
677        let (fm, tmpl) = parse_frontmatter("Just a template.");
678        assert_eq!(fm.description, "");
679        assert!(!fm.disable_model_invocation);
680        assert!(fm.user_invocable);
681        assert!(fm.name.is_none());
682        assert_eq!(tmpl, "Just a template.");
683    }
684
685    #[test]
686    fn test_frontmatter_full() {
687        let content = "---\nname: my-skill\ndescription: \"My skill\"\ndisable-model-invocation: true\nuser-invocable: false\nargument-hint: \"[file]\"\nallowed-tools: Read Grep\n---\nBody.\n";
688        let (fm, tmpl) = parse_frontmatter(content);
689        assert_eq!(fm.name.as_deref(), Some("my-skill"));
690        assert_eq!(fm.description, "My skill");
691        assert!(fm.disable_model_invocation);
692        assert!(!fm.user_invocable);
693        assert_eq!(fm.argument_hint.as_deref(), Some("[file]"));
694        assert_eq!(fm.allowed_tools, vec!["Read", "Grep"]);
695        assert_eq!(tmpl, "Body.\n");
696    }
697
698    #[test]
699    fn test_frontmatter_closing_delimiter_at_eof() {
700        let content = "---\nname: eof-skill\ndescription: EOF skill\n---";
701        let (fm, tmpl) = parse_frontmatter(content);
702        assert_eq!(fm.name.as_deref(), Some("eof-skill"));
703        assert_eq!(fm.description, "EOF skill");
704        assert_eq!(tmpl, "");
705    }
706
707    #[test]
708    fn test_empty_frontmatter_before_body() {
709        let content = "---\n---\nBody.\n";
710        let (fm, tmpl) = parse_frontmatter(content);
711        assert_eq!(fm.description, "");
712        assert_eq!(tmpl, "Body.\n");
713    }
714
715    #[test]
716    fn test_frontmatter_unclosed() {
717        let content = "---\ndescription: broken\nno closing delimiter";
718        let (fm, tmpl) = parse_frontmatter(content);
719        assert_eq!(fm.description, "");
720        assert_eq!(tmpl, content);
721    }
722
723    #[test]
724    fn test_description_fallback_to_first_paragraph() {
725        // The fallback is tested via first_paragraph directly
726        assert_eq!(
727            first_paragraph("# Title\n\nActual description."),
728            "Actual description."
729        );
730        assert_eq!(first_paragraph("  text  "), "text");
731        assert_eq!(first_paragraph("# Heading"), ""); // heading skipped
732    }
733
734    // --- replace_positional_short ---
735
736    #[test]
737    fn test_replace_positional_short_boundary() {
738        // $1 should not touch $10
739        assert_eq!(replace_positional_short("$10 $1", 1, "Y"), "$10 Y");
740    }
741
742    // --- namespace prefix on disk-loaded skills ---
743
744    #[test]
745    fn test_load_skills_dir_applies_namespace() {
746        // A skill loaded with namespace = Some("skills") must be stored
747        // under the prefixed name `skills:<base>` and lookup by the bare
748        // base name must miss. This pins the loader contract that the
749        // TUI relies on for the visual `/skills:foo` distinction.
750        let tmp = tempfile::tempdir().expect("tempdir");
751        let skill_dir = tmp.path().join("brainstorming");
752        std::fs::create_dir_all(&skill_dir).unwrap();
753        std::fs::write(
754            skill_dir.join("SKILL.md"),
755            "---\ndescription: \"Test\"\n---\nTemplate body.\n",
756        )
757        .unwrap();
758
759        let mut reg = SkillRegistry::new();
760        let mut warnings = Vec::new();
761        reg.load_skills_dir(tmp.path(), Some("skills"), &mut warnings);
762
763        assert!(
764            reg.get("skills:brainstorming").is_some(),
765            "namespaced lookup must succeed"
766        );
767        // Bare-name lookup falls back to a unique `:name` suffix match —
768        // a deliberate accommodation for hook-injected workflow plans
769        // that reference plugin skills without their plugin prefix.
770        assert!(
771            reg.get("brainstorming").is_some(),
772            "bare name must resolve via suffix fallback when unambiguous"
773        );
774        // Verify storage key actually IS the prefixed form (the loader
775        // contract; the fallback above could otherwise mask a regression
776        // where the prefix wasn't applied).
777        assert!(
778            reg.skills.contains_key("skills:brainstorming"),
779            "storage must use prefixed key"
780        );
781        assert!(
782            !reg.skills.contains_key("brainstorming"),
783            "storage must not duplicate under bare key"
784        );
785    }
786
787    #[test]
788    fn test_get_suffix_fallback_ambiguous_misses() {
789        // When two plugins each contribute a skill named "verify", a bare
790        // lookup must NOT silently pick one — that would invoke the wrong
791        // plugin's tool. Caller must use the qualified form.
792        let mut reg = SkillRegistry::new();
793        for ns in ["plugin-a", "plugin-b"] {
794            let key = format!("{}:verify", ns);
795            reg.skills.insert(
796                key.clone(),
797                Skill {
798                    name: key,
799                    description: "v".into(),
800                    template: "body".into(),
801                    disable_model_invocation: false,
802                    user_invocable: true,
803                    argument_hint: None,
804                    allowed_tools: vec![],
805                    skill_dir: PathBuf::new(),
806                    source_path: PathBuf::new(),
807                },
808            );
809        }
810        assert!(
811            reg.get("verify").is_none(),
812            "ambiguous bare name must miss (forces qualified lookup)"
813        );
814        assert!(reg.get("plugin-a:verify").is_some());
815        assert!(reg.get("plugin-b:verify").is_some());
816    }
817
818    #[test]
819    fn test_get_qualified_miss_does_not_fallback() {
820        // A qualified-but-typo'd name must not silently rebind to a
821        // suffix match — that would mask plugin name typos.
822        let mut reg = SkillRegistry::new();
823        reg.skills.insert(
824            "real-plugin:verify".into(),
825            Skill {
826                name: "real-plugin:verify".into(),
827                description: "v".into(),
828                template: "body".into(),
829                disable_model_invocation: false,
830                user_invocable: true,
831                argument_hint: None,
832                allowed_tools: vec![],
833                skill_dir: PathBuf::new(),
834                source_path: PathBuf::new(),
835            },
836        );
837        assert!(reg.get("typo-plugin:verify").is_none());
838    }
839
840    #[test]
841    fn test_load_flat_commands_applies_namespace() {
842        // Same contract for flat `.md` commands (legacy layout).
843        let tmp = tempfile::tempdir().expect("tempdir");
844        std::fs::write(
845            tmp.path().join("commit.md"),
846            "---\ndescription: \"Commit\"\n---\nDo a commit.\n",
847        )
848        .unwrap();
849
850        let mut reg = SkillRegistry::new();
851        let mut warnings = Vec::new();
852        reg.load_flat_commands(tmp.path(), Some("skills"), &mut warnings);
853
854        assert!(reg.get("skills:commit").is_some());
855        // Suffix fallback: unambiguous bare name resolves.
856        assert!(reg.get("commit").is_some());
857        assert!(reg.skills.contains_key("skills:commit"));
858        assert!(!reg.skills.contains_key("commit"));
859    }
860
861    #[test]
862    #[serial_test::serial]
863    fn reload_picks_up_installed_plugin_skills() {
864        let tmp = tempfile::tempdir().unwrap();
865        std::env::set_var("ATOMCODE_HOME", tmp.path());
866
867        // Fake a registered + installed plugin on disk.
868        // Under unified ATOMCODE_HOME semantics, plugins live at $HOME/plugins
869        // (not $HOME/.atomcode/plugins) — see plugin/paths.rs.
870        let plugins_root = tmp.path().join("plugins");
871        let plugin_dir = plugins_root.join("marketplaces/p");
872        let skill_dir = plugin_dir.join("skills/hello");
873        std::fs::create_dir_all(&skill_dir).unwrap();
874        std::fs::write(
875            skill_dir.join("SKILL.md"),
876            "---\nname: hello\ndescription: hi\n---\nhi",
877        )
878        .unwrap();
879        std::fs::write(
880            plugins_root.join("installed_plugins.json"),
881            r#"{"version":1,"plugins":{"p@p":{"marketplace":"p","plugin":"p","plugin_dir":"marketplaces/p","installed_at":"x"}}}"#,
882        )
883        .unwrap();
884
885        let working = tempfile::tempdir().unwrap();
886        let mut reg = SkillRegistry::new();
887        reg.reload(working.path());
888        assert!(reg.get("p:hello").is_some(), "expected namespaced plugin skill");
889
890        std::env::remove_var("ATOMCODE_HOME");
891    }
892
893    /// Regression test: when `load_skills_dir` is given a directory that
894    /// *directly* contains `SKILL.md` (CC array layout where the `skills`
895    /// field points at the skill directory itself, not a parent), the skill
896    /// must still be loaded.
897    #[test]
898    fn test_load_skills_dir_cc_array_layout() {
899        let tmp = tempfile::tempdir().expect("tempdir");
900        // Create a skill directory that contains SKILL.md directly
901        // (CC-style: skills: ["./skills/karpathy-guidelines"])
902        let skill_dir = tmp.path().join("skills/karpathy-guidelines");
903        std::fs::create_dir_all(&skill_dir).unwrap();
904        std::fs::write(
905            skill_dir.join("SKILL.md"),
906            "---\nname: karpathy-guidelines\ndescription: Guidelines\n---\nBe simple.",
907        )
908        .unwrap();
909
910        let mut reg = SkillRegistry::new();
911        let mut warnings = Vec::new();
912        // Pass the skill directory itself, not the parent "skills/" dir
913        reg.load_skills_dir(&skill_dir, Some("karpathy-skills"), &mut warnings);
914
915        assert!(
916            reg.get("karpathy-skills:karpathy-guidelines").is_some(),
917            "CC array layout: skill directory containing SKILL.md should be loaded"
918        );
919        assert!(warnings.is_empty(), "no warnings expected");
920    }
921
922    /// When a directory both contains SKILL.md itself and has subdirectories
923    /// with SKILL.md, both are loaded (subdirectory skills win on collision).
924    #[test]
925    fn test_load_skills_dir_hybrid_layout() {
926        let tmp = tempfile::tempdir().expect("tempdir");
927
928        // Self-SKILL.md
929        std::fs::write(
930            tmp.path().join("SKILL.md"),
931            "---\nname: hybrid\ndescription: self\n---\nself body",
932        )
933        .unwrap();
934
935        // Subdirectory SKILL.md
936        let sub = tmp.path().join("sub-skill");
937        std::fs::create_dir_all(&sub).unwrap();
938        std::fs::write(
939            sub.join("SKILL.md"),
940            "---\nname: sub-skill\ndescription: sub\n---\nsub body",
941        )
942        .unwrap();
943
944        let mut reg = SkillRegistry::new();
945        let mut warnings = Vec::new();
946        reg.load_skills_dir(tmp.path(), Some("test"), &mut warnings);
947
948        assert!(reg.get("test:hybrid").is_some(), "self SKILL.md should load");
949        assert!(reg.get("test:sub-skill").is_some(), "subdirectory SKILL.md should load");
950    }
951
952    /// Regression test for the full plugin install path with CC-style
953    /// `skills: ["./skills/karpathy-guidelines"]` in plugin.json.
954    #[test]
955    #[serial_test::serial]
956    fn reload_picks_up_cc_array_plugin_skills() {
957        let tmp = tempfile::tempdir().unwrap();
958        std::env::set_var("ATOMCODE_HOME", tmp.path());
959
960        // Fake a plugin whose plugin.json uses CC array format.
961        // Plugins live directly under ATOMCODE_HOME (unified semantics).
962        let plugins_root = tmp.path().join("plugins");
963        let plugin_dir = plugins_root.join("marketplaces/karpathy-skills");
964        let skill_dir = plugin_dir.join("skills/karpathy-guidelines");
965        std::fs::create_dir_all(&skill_dir).unwrap();
966        std::fs::write(
967            skill_dir.join("SKILL.md"),
968            "---\nname: karpathy-guidelines\ndescription: Guidelines\n---\nBe simple.",
969        )
970        .unwrap();
971        // CC-style plugin.json with skills as an array of individual skill dirs
972        std::fs::create_dir_all(plugin_dir.join(".claude-plugin")).unwrap();
973        std::fs::write(
974            plugin_dir.join(".claude-plugin/plugin.json"),
975            r#"{"name":"andrej-karpathy-skills","skills":["./skills/karpathy-guidelines"]}"#,
976        )
977        .unwrap();
978        std::fs::write(
979            plugins_root.join("installed_plugins.json"),
980            r#"{"version":1,"plugins":{"andrej-karpathy-skills@karpathy-skills":{"marketplace":"karpathy-skills","plugin":"andrej-karpathy-skills","plugin_dir":"marketplaces/karpathy-skills","installed_at":"x"}}}"#,
981        )
982        .unwrap();
983
984        let working = tempfile::tempdir().unwrap();
985        let mut reg = SkillRegistry::new();
986        reg.reload(working.path());
987        assert!(
988            reg.get("andrej-karpathy-skills:karpathy-guidelines").is_some(),
989            "CC array plugin: skill should be loaded from direct skill directory"
990        );
991
992        std::env::remove_var("ATOMCODE_HOME");
993    }
994}