Skip to main content

zagens_runtime/skills/
mod.rs

1//! Skill discovery and registry for local SKILL.md files.
2
3pub mod install;
4mod system;
5// Re-exports kept for documentation parity and downstream consumers; the
6// binary itself imports directly from `skills::install`. `#[allow(...)]`
7// silences the dead-code warning that fires because no `bin` source path
8// references these names through `skills::*`.
9#[allow(unused_imports)]
10pub use install::{
11    DEFAULT_MAX_SIZE_BYTES, DEFAULT_REGISTRY_URL, INSTALLED_FROM_MARKER, InstallOutcome,
12    InstallSource, InstalledSkill, RegistryDocument, RegistryEntry, RegistryFetchResult,
13    SkillSyncOutcome, SyncResult, UpdateResult, default_cache_skills_dir, import_local_directory,
14};
15pub use system::install_system_skills;
16
17use std::fs;
18use std::path::{Path, PathBuf};
19
20use anyhow::{Context, Result};
21use std::collections::HashMap;
22
23use crate::logging;
24
25const MAX_SKILL_DESCRIPTION_CHARS: usize = 512;
26const MAX_AVAILABLE_SKILLS_CHARS: usize = 12_000;
27
28// === Defaults ===
29
30#[allow(dead_code)]
31#[must_use]
32pub fn default_skills_dir() -> PathBuf {
33    dirs::home_dir().map_or_else(
34        || PathBuf::from("/tmp/deepseek/skills"),
35        |_| zagens_config::user_data_path_or_relative("skills"),
36    )
37}
38
39/// Global agentskills.io-compatible skills directory (`~/.agents/skills`).
40#[must_use]
41pub fn agents_global_skills_dir() -> Option<PathBuf> {
42    dirs::home_dir().map(|p| p.join(".agents").join("skills"))
43}
44
45/// Global Claude-compatible skills directory (`~/.claude/skills`). The
46/// SKILL.md frontmatter convention is shared across the broader Claude
47/// ecosystem, so picking up the global path lets users inherit skills
48/// they already installed for other Claude-compatible tools without
49/// re-authoring them in DeepSeek's native layout (#902).
50#[must_use]
51pub fn claude_global_skills_dir() -> Option<PathBuf> {
52    dirs::home_dir().map(|p| p.join(".claude").join("skills"))
53}
54
55// === Types ===
56
57/// Parsed representation of a SKILL.md definition.
58#[derive(Debug, Clone)]
59pub struct Skill {
60    pub name: String,
61    pub description: String,
62    pub body: String,
63    /// On-disk path to the `SKILL.md` this was loaded from. The directory
64    /// name can differ from the frontmatter `name` for community installs
65    /// or manually-placed skills, so callers must use this rather than
66    /// reconstructing `<dir>/<name>/SKILL.md`.
67    pub path: PathBuf,
68}
69
70/// Collection of discovered skills.
71#[derive(Debug, Clone, Default)]
72pub struct SkillRegistry {
73    skills: Vec<Skill>,
74    warnings: Vec<String>,
75}
76
77impl SkillRegistry {
78    /// Maximum directory-traversal depth when discovering skills.
79    ///
80    /// Defends against pathological configurations (e.g. a user pointing
81    /// `skills_dir` at `~`) without artificially limiting realistic
82    /// vendored layouts like `<root>/<org>/<repo>/<skill>/SKILL.md`.
83    const MAX_DISCOVERY_DEPTH: usize = 8;
84
85    /// Discover skills from the given directory.
86    ///
87    /// The search walks `dir` recursively: any directory that contains a
88    /// `SKILL.md` is loaded as a single skill, and the walk does **not**
89    /// descend further into that directory (companion files live next to
90    /// `SKILL.md`, and `tools::skill::collect_companion_files` already
91    /// treats nested subdirs as out-of-scope). This lets users organize
92    /// skills by vendor / category — e.g.
93    /// `<root>/<vendor>/<skill>/SKILL.md` — instead of being forced into
94    /// a flat `<root>/<skill>/SKILL.md` layout.
95    ///
96    /// Hidden subdirectories (names starting with `.`) below the root
97    /// are skipped to avoid descending into VCS / cache trees like
98    /// `.git/`. The provided `dir` itself is always honored, even if
99    /// hidden — that's what the user explicitly configured.
100    /// Symlinked directories are not followed, which keeps the walk
101    /// finite when a skills layout contains symlinks. The depth is also
102    /// capped at [`Self::MAX_DISCOVERY_DEPTH`].
103    #[must_use]
104    pub fn discover(dir: &Path) -> Self {
105        let mut registry = Self::default();
106        if !dir.exists() {
107            return registry;
108        }
109
110        Self::discover_recursive(dir, 0, &mut registry);
111        registry
112    }
113
114    fn discover_recursive(dir: &Path, depth: usize, registry: &mut Self) {
115        if depth > Self::MAX_DISCOVERY_DEPTH {
116            return;
117        }
118
119        let entries = match fs::read_dir(dir) {
120            Ok(e) => e,
121            Err(err) => {
122                // Only surface a warning for the user-provided root
123                // (depth == 0). Nested permission errors are usually
124                // noise (e.g. a stray `.Trash` inside someone's
125                // `~/.agents/skills`).
126                if depth == 0 {
127                    registry.push_warning(format!(
128                        "Failed to read skills directory {}: {err}",
129                        dir.display()
130                    ));
131                }
132                return;
133            }
134        };
135
136        for entry in entries.flatten() {
137            // Use `file_type()` (which on Unix returns symlink metadata
138            // without following) so we don't traverse into symlinked
139            // directories — that closes the door on cycles.
140            let Ok(ft) = entry.file_type() else { continue };
141            if !ft.is_dir() {
142                continue;
143            }
144
145            let path = entry.path();
146            // Skip hidden subdirectories. Common offenders are `.git`,
147            // `.cache`, `.Trash`. The provided root itself is exempt:
148            // the user explicitly pointed `skills_dir` at it and we
149            // never filter it (it's passed directly to this function,
150            // not iterated). This check applies to *children* of the
151            // current directory at every depth — including depth 0,
152            // because a `.git/` right next to the skills we want is
153            // exactly the kind of noise we must not descend into.
154            if path
155                .file_name()
156                .and_then(|s| s.to_str())
157                .is_some_and(|name| name.starts_with('.'))
158            {
159                continue;
160            }
161
162            let skill_path = path.join("SKILL.md");
163            match fs::read_to_string(&skill_path) {
164                Ok(content) => match Self::parse_skill(&skill_path, &content) {
165                    Ok(mut skill) => {
166                        skill.path = skill_path.clone();
167                        registry.skills.push(skill);
168                        // This directory IS a skill. Don't descend further:
169                        // any nested `SKILL.md` would be a fixture or
170                        // example bundled with the parent skill, not a
171                        // separately-installable skill.
172                        continue;
173                    }
174                    Err(reason) => {
175                        registry.push_warning(format!(
176                            "Failed to parse {}: {reason}",
177                            skill_path.display()
178                        ));
179                        // Still treat this directory as "claimed" — a
180                        // malformed SKILL.md shouldn't cause us to
181                        // double-load nested fixtures as skills.
182                        continue;
183                    }
184                },
185                Err(err) if skill_path.exists() => {
186                    registry
187                        .push_warning(format!("Failed to read {}: {err}", skill_path.display()));
188                    continue;
189                }
190                Err(_) => {
191                    // No SKILL.md here — recurse to look for nested
192                    // skill directories (e.g. `<vendor>/<skill>/SKILL.md`).
193                }
194            }
195
196            Self::discover_recursive(&path, depth + 1, registry);
197        }
198    }
199
200    fn push_warning(&mut self, warning: String) {
201        logging::warn(&warning);
202        self.warnings.push(warning);
203    }
204
205    fn parse_skill(_path: &Path, content: &str) -> std::result::Result<Skill, String> {
206        let trimmed = content.trim_start();
207
208        // Try to parse frontmatter block first. If absent, fall back to
209        // extracting the first `# Heading` as the skill name so that plain
210        // Markdown files (no `---` fence) are accepted instead of rejected.
211        if trimmed.starts_with("---") {
212            let start = content
213                .find("---")
214                .ok_or_else(|| "missing frontmatter opening delimiter".to_string())?;
215            let rest = &content[start + 3..];
216            let end = rest
217                .find("---")
218                .ok_or_else(|| "missing frontmatter closing delimiter".to_string())?;
219            let frontmatter = &rest[..end];
220            let body = &rest[end + 3..];
221
222            let mut metadata = HashMap::new();
223            for raw in frontmatter.lines() {
224                let line = raw.trim();
225                if line.is_empty() || line.starts_with('#') {
226                    continue;
227                }
228                if let Some((key, value)) = line.split_once(':') {
229                    metadata.insert(key.trim().to_ascii_lowercase(), value.trim().to_string());
230                }
231            }
232
233            let name = metadata
234                .get("name")
235                .filter(|name| !name.is_empty())
236                .cloned()
237                .ok_or_else(|| "missing required frontmatter field: name".to_string())?;
238
239            let description = metadata.get("description").cloned().unwrap_or_default();
240
241            return Ok(Skill {
242                name,
243                description,
244                body: body.trim().to_string(),
245                // Filled in by `discover` after parse succeeds; default to an
246                // empty path so direct constructors (e.g. tests) compile.
247                path: PathBuf::new(),
248            });
249        }
250
251        // Graceful degradation: no frontmatter fence found.
252        // Extract the first `# Heading` as the skill name.
253        let heading_re = regex::Regex::new(r"(?m)^#\s+(.+)$").expect("static regex is valid");
254        let name = heading_re
255            .captures(content)
256            .and_then(|c| c.get(1))
257            .map(|m| m.as_str().trim().to_string())
258            .filter(|s| !s.is_empty())
259            .ok_or_else(|| {
260                "no frontmatter and no `# Heading` found to use as skill name".to_string()
261            })?;
262
263        Ok(Skill {
264            name,
265            description: String::new(),
266            body: content.trim().to_string(),
267            path: PathBuf::new(),
268        })
269    }
270
271    /// Lookup a skill by name.
272    pub fn get(&self, name: &str) -> Option<&Skill> {
273        self.skills.iter().find(|s| s.name == name)
274    }
275
276    /// Return all loaded skills.
277    pub fn list(&self) -> &[Skill] {
278        &self.skills
279    }
280
281    /// Parse or I/O warnings encountered while discovering skills.
282    pub fn warnings(&self) -> &[String] {
283        &self.warnings
284    }
285
286    /// Check whether any skills were loaded.
287    #[must_use]
288    pub fn is_empty(&self) -> bool {
289        self.skills.is_empty()
290    }
291
292    /// Return the number of loaded skills.
293    #[must_use]
294    pub fn len(&self) -> usize {
295        self.skills.len()
296    }
297}
298
299/// Render a compact model-visible skills block.
300///
301/// The full `SKILL.md` body is intentionally not included here. This mirrors
302/// Resolve the active skills directory given a workspace, mirroring the
303/// hierarchy `App::new` walks: `<workspace>/.agents/skills` →
304/// `<workspace>/skills` → [`agents_global_skills_dir`] (`~/.agents/skills`,
305/// when present) → [`default_skills_dir`] (`~/.deepseek/skills`).
306/// Returns the first directory that exists, or the global default
307/// (which itself falls back to `/tmp/deepseek/skills` if the user
308/// has no home directory).
309///
310/// Kept for callers that want a single canonical directory (e.g.
311/// "where do I install a new skill?"). For session-time discovery
312/// that should pick up cross-tool skill folders too, use
313/// [`skills_directories`] / [`discover_in_workspace`] (#432).
314#[must_use]
315#[allow(dead_code)] // Intentionally kept for the "single canonical install dir" surface; live callers use discover_in_workspace.
316pub fn resolve_skills_dir(workspace: &Path) -> PathBuf {
317    let agents = workspace.join(".agents").join("skills");
318    if agents.exists() {
319        return agents;
320    }
321    let local = workspace.join("skills");
322    if local.exists() {
323        return local;
324    }
325    if let Some(global_agents) = agents_global_skills_dir()
326        && global_agents.exists()
327    {
328        return global_agents;
329    }
330    default_skills_dir()
331}
332
333/// Resolve every candidate skills directory for a workspace, in
334/// precedence order — most specific first. Used for session-time
335/// skill discovery so the model sees skills that originated in
336/// other AI-tool conventions installed in the same workspace
337/// (#432).
338///
339/// Precedence (first match wins on name conflicts):
340///
341/// 1. `<workspace>/.agents/skills` — deepseek-native convention.
342/// 2. `<workspace>/skills` — flat, project-local.
343/// 3. `<workspace>/.opencode/skills` — OpenCode interop.
344/// 4. `<workspace>/.claude/skills` — Claude Code interop.
345/// 5. `<workspace>/.cursor/skills` — Cursor interop.
346/// 6. [`agents_global_skills_dir`] — agentskills.io global.
347/// 7. [`claude_global_skills_dir`] — Claude-ecosystem global (#902).
348/// 8. [`default_skills_dir`] — DeepSeek global, user-installed.
349///
350/// Only directories that exist on disk are returned — callers don't
351/// need to filter further. Returns an empty vec when nothing is
352/// installed (the system-prompt skills block is then suppressed).
353#[must_use]
354pub fn skills_directories(workspace: &Path) -> Vec<PathBuf> {
355    let mut candidates = vec![
356        workspace.join(".agents").join("skills"),
357        workspace.join("skills"),
358        workspace.join(".opencode").join("skills"),
359        workspace.join(".claude").join("skills"),
360        workspace.join(".cursor").join("skills"),
361    ];
362    if let Some(global_agents) = agents_global_skills_dir() {
363        candidates.push(global_agents);
364    }
365    if let Some(global_claude) = claude_global_skills_dir() {
366        candidates.push(global_claude);
367    }
368    candidates.push(default_skills_dir());
369    existing_skill_dirs(candidates)
370}
371
372fn existing_skill_dirs(candidates: impl IntoIterator<Item = PathBuf>) -> Vec<PathBuf> {
373    let mut out = Vec::new();
374    for path in candidates {
375        if path.is_dir() && !out.iter().any(|p: &PathBuf| p == &path) {
376            out.push(path);
377        }
378    }
379    out
380}
381
382/// Canonical skill roots for [`ToolContext::trusted_external_paths`].
383///
384/// Lets `read_file` / `list_dir` open `SKILL.md` companions under
385/// `~/.deepseek/skills` (and other discovery dirs) after `load_skill`.
386#[must_use]
387pub fn trusted_skill_roots(workspace: &Path) -> Vec<PathBuf> {
388    skills_directories(workspace)
389        .into_iter()
390        .filter_map(|p| p.canonicalize().ok())
391        .collect()
392}
393
394/// Walk every candidate skills directory for a workspace and merge
395/// the discovered skills into a single registry. Name conflicts are
396/// resolved with first-match-wins precedence per
397/// [`skills_directories`].
398///
399/// Warnings from each scanned directory accumulate so the model
400/// (and the user via `/skill list`) can see why a skill didn't
401/// load.
402#[must_use]
403pub fn discover_in_workspace(workspace: &Path) -> SkillRegistry {
404    let mut merged = SkillRegistry::default();
405    for dir in skills_directories(workspace) {
406        let registry = SkillRegistry::discover(&dir);
407        for skill in registry.skills {
408            if !merged.skills.iter().any(|s| s.name == skill.name) {
409                merged.skills.push(skill);
410            }
411        }
412        for warning in registry.warnings {
413            merged.warnings.push(warning);
414        }
415    }
416    merged
417}
418
419/// Render the system-prompt skills block from every workspace
420/// candidate directory plus the global default (#432). Wraps
421/// [`discover_in_workspace`] for callers (e.g. `prompts.rs`) that
422/// only have the workspace path to hand.
423#[must_use]
424pub fn render_available_skills_context_for_workspace(workspace: &Path) -> Option<String> {
425    let registry = discover_in_workspace(workspace);
426    render_skills_block(&registry)
427}
428
429/// Codex's progressive-disclosure contract: the model sees skill names,
430/// descriptions, and paths up front, then opens the specific `SKILL.md` only
431/// when a skill is relevant.
432///
433/// Single-directory variant — use
434/// [`render_available_skills_context_for_workspace`] when scanning
435/// a workspace for cross-tool skill folders (#432).
436#[must_use]
437pub fn render_available_skills_context(skills_dir: &Path) -> Option<String> {
438    let registry = SkillRegistry::discover(skills_dir);
439    render_skills_block(&registry)
440}
441
442fn render_skills_block(registry: &SkillRegistry) -> Option<String> {
443    if registry.is_empty() {
444        return None;
445    }
446
447    let mut skills = registry.list().to_vec();
448    skills.sort_by(|a, b| a.name.cmp(&b.name));
449
450    let mut out = String::new();
451    out.push_str("## Skills\n");
452    out.push_str(
453        "A skill is a set of local instructions stored in a `SKILL.md` file. \
454Below is the list of skills available in this session. Each entry includes a \
455name, description, and file path so you can open the source for full \
456instructions when using a specific skill.\n\n",
457    );
458    out.push_str("### Available skills\n");
459
460    let mut omitted = 0usize;
461    for skill in skills {
462        // Use the real on-disk path captured at discovery — the directory
463        // name can differ from the frontmatter `name` for community
464        // installs, in which case `<dir>/<name>/SKILL.md` would not exist
465        // and the model would fail to open it.
466        let description = truncate_for_prompt(&skill.description, MAX_SKILL_DESCRIPTION_CHARS);
467        let line = if description.is_empty() {
468            format!("- {}: (file: {})\n", skill.name, skill.path.display())
469        } else {
470            format!(
471                "- {}: {} (file: {})\n",
472                skill.name,
473                description,
474                skill.path.display()
475            )
476        };
477
478        if out.chars().count() + line.chars().count() > MAX_AVAILABLE_SKILLS_CHARS {
479            omitted += 1;
480        } else {
481            out.push_str(&line);
482        }
483    }
484
485    if omitted > 0 {
486        out.push_str(&format!(
487            "- ... {omitted} additional skills omitted from this prompt budget.\n"
488        ));
489    }
490
491    if !registry.warnings().is_empty() {
492        out.push_str("\n### Skill load warnings\n");
493        for warning in registry.warnings().iter().take(8) {
494            out.push_str("- ");
495            out.push_str(&truncate_for_prompt(warning, MAX_SKILL_DESCRIPTION_CHARS));
496            out.push('\n');
497        }
498    }
499
500    out.push_str(
501        "\n### How to use skills\n\
502- Discovery: The list above is the skills available in this session. Skill bodies live on disk at the listed paths.\n\
503- Trigger rules: If the user names a skill (with `$SkillName`, `/skill <name>`, or plain text) OR the task clearly matches a skill description above, use that skill for that turn. Multiple mentions mean use them all. Do not carry skills across turns unless re-mentioned.\n\
504- Missing/blocked: If a named skill is missing or its `SKILL.md` cannot be read, say so briefly and continue with the best fallback.\n\
505- Progressive disclosure: After deciding to use a skill, read only that skill's `SKILL.md`. When it references relative paths such as `scripts/foo.py`, resolve them relative to the skill directory.\n\
506- Context hygiene: Load only the specific referenced files needed for the task. Avoid bulk-loading unrelated skill resources.\n\
507- Safety: Do not execute scripts from a community skill unless the user explicitly asks or the skill has been trusted for script use.\n",
508    );
509
510    Some(out)
511}
512
513fn truncate_for_prompt(value: &str, max_chars: usize) -> String {
514    let single_line = value.split_whitespace().collect::<Vec<_>>().join(" ");
515    if single_line.chars().count() <= max_chars {
516        return single_line;
517    }
518
519    let mut truncated = single_line
520        .chars()
521        .take(max_chars.saturating_sub(1))
522        .collect::<String>();
523    truncated.push('…');
524    truncated
525}
526
527// === CLI Helpers ===
528
529#[allow(dead_code)] // CLI utility for future use
530pub fn list(skills_dir: &Path) -> Result<()> {
531    if !skills_dir.exists() {
532        println!("No skills directory found at {}", skills_dir.display());
533        return Ok(());
534    }
535
536    let mut entries = Vec::new();
537    for entry in fs::read_dir(skills_dir)? {
538        let entry = entry?;
539        if entry.file_type()?.is_dir() {
540            entries.push(entry.file_name().to_string_lossy().to_string());
541        }
542    }
543
544    if entries.is_empty() {
545        println!("No skills found in {}", skills_dir.display());
546        return Ok(());
547    }
548
549    entries.sort();
550    for entry in entries {
551        println!("{entry}");
552    }
553    Ok(())
554}
555
556#[allow(dead_code)] // CLI utility for future use
557pub fn show(skills_dir: &Path, name: &str) -> Result<()> {
558    let path = skills_dir.join(name).join("SKILL.md");
559    let contents =
560        fs::read_to_string(&path).with_context(|| format!("Failed to read {}", path.display()))?;
561    println!("{contents}");
562    Ok(())
563}
564
565#[cfg(test)]
566mod tests {
567    use tempfile::TempDir;
568
569    fn create_skill_dir(tmpdir: &TempDir, skill_name: &str, skill_content: &str) {
570        let skill_dir = tmpdir.path().join("skills").join(skill_name);
571        std::fs::create_dir_all(&skill_dir).unwrap();
572        std::fs::write(skill_dir.join("SKILL.md"), skill_content).unwrap();
573    }
574
575    #[test]
576    fn render_available_skills_context_lists_paths_and_usage() {
577        let tmpdir = TempDir::new().unwrap();
578        create_skill_dir(
579            &tmpdir,
580            "test-skill",
581            "---\nname: test-skill\ndescription: A test skill\n---\nDo something special",
582        );
583
584        let rendered =
585            crate::skills::render_available_skills_context(&tmpdir.path().join("skills"))
586                .expect("skill context");
587
588        let expected_path = tmpdir
589            .path()
590            .join("skills")
591            .join("test-skill")
592            .join("SKILL.md")
593            .display()
594            .to_string();
595
596        assert!(rendered.contains("## Skills"));
597        assert!(rendered.contains("- test-skill: A test skill"));
598        assert!(
599            rendered.contains(&expected_path),
600            "expected path {expected_path:?} not in rendered output"
601        );
602        assert!(rendered.contains("### How to use skills"));
603    }
604
605    #[test]
606    fn render_available_skills_context_uses_real_dir_name_not_frontmatter_name() {
607        // Regression: when a community-installed or manually-placed skill
608        // lives in a directory whose name differs from its frontmatter
609        // `name`, the rendered prompt must point to the real on-disk file
610        // path, not <skills_dir>/<frontmatter-name>/SKILL.md (which does
611        // not exist).
612        let tmpdir = TempDir::new().unwrap();
613        create_skill_dir(
614            &tmpdir,
615            "weird-dir-name",
616            "---\nname: friendly-name\ndescription: drift case\n---\nbody",
617        );
618
619        let rendered =
620            crate::skills::render_available_skills_context(&tmpdir.path().join("skills"))
621                .expect("skill context");
622
623        let real_path = tmpdir
624            .path()
625            .join("skills")
626            .join("weird-dir-name")
627            .join("SKILL.md")
628            .display()
629            .to_string();
630        let stale_path = tmpdir
631            .path()
632            .join("skills")
633            .join("friendly-name")
634            .join("SKILL.md")
635            .display()
636            .to_string();
637
638        assert!(
639            rendered.contains(&real_path),
640            "expected real on-disk path {real_path:?} in rendered output, got:\n{rendered}"
641        );
642        assert!(
643            !rendered.contains(&stale_path),
644            "rendered output must not invent a path under the frontmatter name:\n{rendered}"
645        );
646    }
647
648    #[test]
649    fn render_available_skills_context_returns_none_when_empty() {
650        let tmpdir = TempDir::new().unwrap();
651        let empty = tmpdir.path().join("skills");
652        std::fs::create_dir_all(&empty).unwrap();
653        assert!(crate::skills::render_available_skills_context(&empty).is_none());
654
655        let missing = tmpdir.path().join("does-not-exist");
656        assert!(crate::skills::render_available_skills_context(&missing).is_none());
657    }
658
659    #[test]
660    fn render_available_skills_context_truncates_long_descriptions() {
661        let tmpdir = TempDir::new().unwrap();
662        let long_desc = "x".repeat(2_000);
663        let body = format!("---\nname: bigdesc\ndescription: {long_desc}\n---\nbody");
664        create_skill_dir(&tmpdir, "bigdesc", &body);
665
666        let rendered =
667            crate::skills::render_available_skills_context(&tmpdir.path().join("skills"))
668                .expect("skill context");
669
670        let max = super::MAX_SKILL_DESCRIPTION_CHARS;
671        assert!(rendered.contains('…'), "expected truncation marker");
672        assert!(
673            !rendered.contains(&"x".repeat(max + 1)),
674            "untruncated long run should not appear"
675        );
676    }
677
678    #[test]
679    fn render_available_skills_context_collapses_internal_whitespace() {
680        let tmpdir = TempDir::new().unwrap();
681        create_skill_dir(
682            &tmpdir,
683            "spaced-skill",
684            "---\nname: spaced-skill\ndescription: alpha  \t  beta   gamma\n---\nbody",
685        );
686
687        let rendered =
688            crate::skills::render_available_skills_context(&tmpdir.path().join("skills"))
689                .expect("skill context");
690
691        let line = rendered
692            .lines()
693            .find(|l| l.starts_with("- spaced-skill:"))
694            .expect("skill line");
695        assert!(line.contains("alpha beta gamma"), "got: {line:?}");
696    }
697
698    #[test]
699    fn render_available_skills_context_omits_overflowing_skills() {
700        let tmpdir = TempDir::new().unwrap();
701        let big_desc = "y".repeat(super::MAX_SKILL_DESCRIPTION_CHARS - 20);
702        for i in 0..200 {
703            let body = format!("---\nname: skill-{i:03}\ndescription: {big_desc}\n---\nbody");
704            create_skill_dir(&tmpdir, &format!("skill-{i:03}"), &body);
705        }
706
707        let rendered =
708            crate::skills::render_available_skills_context(&tmpdir.path().join("skills"))
709                .expect("skill context");
710
711        assert!(
712            rendered.contains("additional skills omitted from this prompt budget"),
713            "expected overflow notice"
714        );
715        assert!(
716            rendered.chars().count() < super::MAX_AVAILABLE_SKILLS_CHARS + 4_000,
717            "rendered length should stay near the budget"
718        );
719    }
720
721    fn write_skill(dir: &std::path::Path, name: &str, description: &str, body: &str) {
722        let skill_dir = dir.join(name);
723        std::fs::create_dir_all(&skill_dir).unwrap();
724        std::fs::write(
725            skill_dir.join("SKILL.md"),
726            format!("---\nname: {name}\ndescription: {description}\n---\n{body}\n"),
727        )
728        .unwrap();
729    }
730
731    #[test]
732    fn skills_directories_returns_existing_dirs_in_precedence_order() {
733        let tmpdir = TempDir::new().unwrap();
734        let workspace = tmpdir.path();
735
736        // Create four of the five workspace candidate dirs (skip `.opencode`).
737        std::fs::create_dir_all(workspace.join(".agents").join("skills")).unwrap();
738        std::fs::create_dir_all(workspace.join("skills")).unwrap();
739        std::fs::create_dir_all(workspace.join(".claude").join("skills")).unwrap();
740        std::fs::create_dir_all(workspace.join(".cursor").join("skills")).unwrap();
741
742        let dirs = super::skills_directories(workspace);
743        // We don't assert on the global default position because it's
744        // host-dependent (may not exist on the test machine).
745        let mut idx = 0;
746        let agents = workspace.join(".agents").join("skills");
747        let local = workspace.join("skills");
748        let claude = workspace.join(".claude").join("skills");
749        let cursor = workspace.join(".cursor").join("skills");
750
751        assert_eq!(dirs.get(idx), Some(&agents), "agents must come first");
752        idx += 1;
753        assert_eq!(dirs.get(idx), Some(&local), "local must come second");
754        idx += 1;
755        // .opencode/skills was not created — it must NOT appear.
756        assert!(
757            !dirs
758                .iter()
759                .any(|p| p == &workspace.join(".opencode").join("skills")),
760            "missing dir must be omitted, got: {dirs:?}"
761        );
762        assert_eq!(dirs.get(idx), Some(&claude), "claude must come after local");
763        idx += 1;
764        assert_eq!(
765            dirs.get(idx),
766            Some(&cursor),
767            "cursor must come after claude"
768        );
769    }
770
771    #[test]
772    fn claude_global_skills_dir_returns_home_relative_path() {
773        // Smoke test for the #902 helper. We don't assert the exact path
774        // because dirs::home_dir() is host-dependent; we just pin the
775        // suffix shape so a future refactor can't silently rename it.
776        let path = super::claude_global_skills_dir().expect("home dir resolves on test host");
777        assert!(path.ends_with(".claude/skills") || path.ends_with(r".claude\skills"));
778    }
779
780    #[test]
781    fn existing_skill_dirs_orders_globals_agents_then_claude_then_deepseek() {
782        // Pins the precedence among the three global skill roots (#902).
783        // Workspace candidates are tested separately above; here we only
784        // exercise the global ordering at the existing_skill_dirs level
785        // so the assertion is host-independent.
786        let tmpdir = TempDir::new().unwrap();
787        let agents_global = tmpdir.path().join(".agents").join("skills");
788        let claude_global = tmpdir.path().join(".claude").join("skills");
789        let deepseek_global = tmpdir.path().join(".deepseek").join("skills");
790        std::fs::create_dir_all(&agents_global).unwrap();
791        std::fs::create_dir_all(&claude_global).unwrap();
792        std::fs::create_dir_all(&deepseek_global).unwrap();
793
794        let dirs = super::existing_skill_dirs(vec![
795            agents_global.clone(),
796            claude_global.clone(),
797            deepseek_global.clone(),
798        ]);
799
800        assert_eq!(dirs, vec![agents_global, claude_global, deepseek_global]);
801    }
802
803    #[test]
804    fn existing_skill_dirs_keeps_agents_global_before_deepseek_global() {
805        let tmpdir = TempDir::new().unwrap();
806        let agents_global = tmpdir.path().join(".agents").join("skills");
807        let deepseek_global = tmpdir.path().join(".deepseek").join("skills");
808        let missing = tmpdir.path().join("missing").join("skills");
809        std::fs::create_dir_all(&agents_global).unwrap();
810        std::fs::create_dir_all(&deepseek_global).unwrap();
811
812        let dirs = super::existing_skill_dirs(vec![
813            missing,
814            agents_global.clone(),
815            deepseek_global.clone(),
816            agents_global.clone(),
817        ]);
818
819        assert_eq!(dirs, vec![agents_global, deepseek_global]);
820    }
821
822    #[test]
823    fn discover_in_workspace_merges_with_first_wins_precedence() {
824        let tmpdir = TempDir::new().unwrap();
825        let workspace = tmpdir.path();
826
827        // Same skill name `shared` in two locations — the higher-precedence
828        // dir's version should win.
829        write_skill(
830            &workspace.join(".agents").join("skills"),
831            "shared",
832            "agents wins",
833            "from agents",
834        );
835        write_skill(
836            &workspace.join(".claude").join("skills"),
837            "shared",
838            "claude loses",
839            "from claude",
840        );
841        // Unique skill in claude — should still be discovered.
842        write_skill(
843            &workspace.join(".claude").join("skills"),
844            "unique-claude",
845            "only here",
846            "claude-only",
847        );
848
849        let registry = super::discover_in_workspace(workspace);
850        let names: Vec<&str> = registry.list().iter().map(|s| s.name.as_str()).collect();
851        assert!(
852            names.contains(&"shared"),
853            "shared must be present: {names:?}"
854        );
855        assert!(names.contains(&"unique-claude"));
856
857        let shared = registry.get("shared").expect("shared present");
858        assert_eq!(
859            shared.description, "agents wins",
860            "first-wins precedence should keep .agents/skills version"
861        );
862        assert!(
863            shared.path.starts_with(workspace.join(".agents")),
864            "shared.path should be from .agents/skills, got {:?}",
865            shared.path
866        );
867    }
868
869    #[test]
870    fn discover_in_workspace_pulls_skills_from_opencode_dir() {
871        let tmpdir = TempDir::new().unwrap();
872        let workspace = tmpdir.path();
873        write_skill(
874            &workspace.join(".opencode").join("skills"),
875            "opencode-only",
876            "for interop",
877            "body",
878        );
879
880        let registry = super::discover_in_workspace(workspace);
881        assert!(
882            registry.get("opencode-only").is_some(),
883            ".opencode/skills must be scanned (#432)"
884        );
885    }
886
887    #[test]
888    fn discover_in_workspace_pulls_skills_from_cursor_dir() {
889        let tmpdir = TempDir::new().unwrap();
890        let workspace = tmpdir.path();
891        write_skill(
892            &workspace.join(".cursor").join("skills"),
893            "cursor-only",
894            "for cursor interop",
895            "body",
896        );
897
898        let registry = super::discover_in_workspace(workspace);
899        assert!(
900            registry.get("cursor-only").is_some(),
901            ".cursor/skills must be scanned"
902        );
903    }
904
905    #[test]
906    fn discover_accepts_plain_markdown_heading_without_frontmatter() {
907        let tmpdir = TempDir::new().unwrap();
908        let skill_dir = tmpdir.path().join("plain-skill");
909        std::fs::create_dir_all(&skill_dir).unwrap();
910        std::fs::write(
911            skill_dir.join("SKILL.md"),
912            "# Plain Skill\n\nUse this skill without YAML frontmatter.\n",
913        )
914        .unwrap();
915
916        let registry = super::SkillRegistry::discover(tmpdir.path());
917        let skill = registry.get("Plain Skill").expect("plain skill parsed");
918        assert_eq!(skill.description, "");
919        assert!(skill.body.contains("Use this skill"));
920    }
921
922    #[test]
923    fn discover_warns_for_plain_markdown_without_heading() {
924        let tmpdir = TempDir::new().unwrap();
925        let skill_dir = tmpdir.path().join("plain-skill");
926        std::fs::create_dir_all(&skill_dir).unwrap();
927        std::fs::write(
928            skill_dir.join("SKILL.md"),
929            "Use this skill without a heading or YAML frontmatter.\n",
930        )
931        .unwrap();
932
933        let registry = super::SkillRegistry::discover(tmpdir.path());
934        assert!(registry.is_empty());
935        assert!(
936            registry
937                .warnings()
938                .iter()
939                .any(|warning| warning.contains("no `# Heading` found")),
940            "expected missing-heading warning, got {:?}",
941            registry.warnings()
942        );
943    }
944
945    #[test]
946    fn render_available_skills_context_for_workspace_picks_up_cross_tool_dirs() {
947        let tmpdir = TempDir::new().unwrap();
948        let workspace = tmpdir.path();
949        write_skill(
950            &workspace.join(".claude").join("skills"),
951            "from-claude",
952            "claude-style skill",
953            "body",
954        );
955        let rendered =
956            super::render_available_skills_context_for_workspace(workspace).expect("non-empty");
957        assert!(rendered.contains("from-claude"));
958    }
959
960    /// Regression for the GitHub issue where users organize skills under
961    /// vendor / category subdirectories (e.g. cloned skill repos that
962    /// bundle several skills together). The old single-level `read_dir`
963    /// only ever surfaced `<root>/<skill>/SKILL.md` and silently ignored
964    /// `<root>/<vendor>/<skill>/SKILL.md`.
965    #[test]
966    fn discover_finds_skills_nested_under_vendor_subdirectory() {
967        let tmpdir = TempDir::new().unwrap();
968        let root = tmpdir.path().join("skills");
969
970        // Two-level nesting: `<root>/<vendor>/<skill>/SKILL.md`. This
971        // matches the `clawhub-skills/clawhub/SKILL.md` layout in the
972        // bug report.
973        write_skill(
974            &root.join("clawhub-skills"),
975            "clawhub",
976            "claw search",
977            "body",
978        );
979        write_skill(
980            &root.join("clawhub-skills"),
981            "github",
982            "github helpers",
983            "body",
984        );
985        // Three-level nesting: `<root>/<org>/<repo>/<skill>/SKILL.md`.
986        write_skill(
987            &root.join("pasky").join("chrome-cdp-skill"),
988            "chrome-cdp",
989            "browser automation",
990            "body",
991        );
992        // Mixed-depth: a flat skill alongside the nested layout still
993        // works (this is what the bundled `skill-creator` looks like).
994        write_skill(&root, "skill-creator", "make skills", "body");
995
996        let registry = super::SkillRegistry::discover(&root);
997        let names: Vec<&str> = registry.list().iter().map(|s| s.name.as_str()).collect();
998        assert!(names.contains(&"clawhub"), "vendor/skill missed: {names:?}");
999        assert!(names.contains(&"github"), "vendor/skill missed: {names:?}");
1000        assert!(
1001            names.contains(&"chrome-cdp"),
1002            "deeply-nested skill missed: {names:?}"
1003        );
1004        assert!(
1005            names.contains(&"skill-creator"),
1006            "flat top-level skill must still load: {names:?}"
1007        );
1008        assert!(
1009            registry.warnings().is_empty(),
1010            "well-formed nested layout should not warn: {:?}",
1011            registry.warnings()
1012        );
1013    }
1014
1015    /// Once a directory is identified as a skill (has `SKILL.md`), the
1016    /// walker must NOT descend into it: any nested `SKILL.md` would be
1017    /// a fixture / example bundled with the parent skill, not a
1018    /// separately-installable one. This mirrors the contract that
1019    /// `tools::skill::collect_companion_files` already documents
1020    /// ("nested directory — skipped").
1021    #[test]
1022    fn discover_does_not_descend_into_a_skill_directory() {
1023        let tmpdir = TempDir::new().unwrap();
1024        let root = tmpdir.path().join("skills");
1025
1026        // Parent skill: <root>/parent/SKILL.md.
1027        write_skill(&root, "parent", "outer skill", "outer body");
1028        // Fixture bundled inside the parent's directory:
1029        // <root>/parent/examples/inner-fixture/SKILL.md. The walker
1030        // must NOT descend into <root>/parent/ after finding its
1031        // SKILL.md, so `inner-fixture` must not be loaded.
1032        write_skill(
1033            &root.join("parent").join("examples"),
1034            "inner-fixture",
1035            "should not load",
1036            "fixture body",
1037        );
1038
1039        let registry = super::SkillRegistry::discover(&root);
1040        let names: Vec<&str> = registry.list().iter().map(|s| s.name.as_str()).collect();
1041        assert!(names.contains(&"parent"));
1042        assert!(
1043            !names.contains(&"inner-fixture"),
1044            "nested SKILL.md inside an existing skill must be ignored: {names:?}"
1045        );
1046    }
1047
1048    /// Hidden subdirectories below the root (e.g. `.git`, `.cache`) must
1049    /// be skipped so a `skills_dir` that lives inside a checked-out repo
1050    /// doesn't accidentally load random `SKILL.md`-named fixtures from
1051    /// the VCS metadata. The root itself is exempt — the user explicitly
1052    /// pointed `skills_dir` at it.
1053    #[test]
1054    fn discover_skips_hidden_subdirectories_below_root() {
1055        let tmpdir = TempDir::new().unwrap();
1056        let root = tmpdir.path().join("skills");
1057
1058        write_skill(&root, "real-skill", "ok", "body");
1059        // A `<root>/.git/<junk>/SKILL.md` lookalike that mustn't load.
1060        // `.git` is a direct child of the user-provided root (depth 0
1061        // of the walk), which is exactly the case the old `depth > 0`
1062        // gate missed.
1063        write_skill(&root.join(".git"), "vcs-noise", "should not load", "body");
1064
1065        let registry = super::SkillRegistry::discover(&root);
1066        let names: Vec<&str> = registry.list().iter().map(|s| s.name.as_str()).collect();
1067        assert!(names.contains(&"real-skill"));
1068        assert!(
1069            !names.contains(&"vcs-noise"),
1070            "skills under hidden subdirs must be skipped: {names:?}"
1071        );
1072    }
1073
1074    /// The user explicitly chooses the root, so even a hidden path like
1075    /// `~/.agents/skills` (the layout in the bug report) must work.
1076    #[test]
1077    fn discover_honors_a_hidden_root_directory() {
1078        let tmpdir = TempDir::new().unwrap();
1079        let root = tmpdir.path().join(".agents").join("skills");
1080
1081        // Matches the bug report: skills_dir = "~/.agents/skills"
1082        // with a skill nested at <root>/custom-skills/git-conventions/SKILL.md.
1083        write_skill(
1084            &root.join("custom-skills"),
1085            "git-conventions",
1086            "conventions",
1087            "body",
1088        );
1089
1090        let registry = super::SkillRegistry::discover(&root);
1091        let names: Vec<&str> = registry.list().iter().map(|s| s.name.as_str()).collect();
1092        assert!(
1093            names.contains(&"git-conventions"),
1094            "hidden root must still be walked: {names:?}"
1095        );
1096    }
1097}