返回 DeepSeek-TUI-2026
mod.rs
根目录 / crates / tui / src / skills / mod.rs
1 //! Skill discovery and registry for local SKILL.md files.
2
3 pub mod install;
4 mod 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)]
10 pub 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,
14 };
15 pub use system::install_system_skills;
16
17 use std::fs;
18 use std::path::{Path, PathBuf};
19
20 use anyhow::{Context, Result};
21 use std::collections::HashMap;
22
23 use crate::logging;
24
25 const MAX_SKILL_DESCRIPTION_CHARS: usize = 512;
26 const MAX_AVAILABLE_SKILLS_CHARS: usize = 12_000;
27
28 // === Defaults ===
29
30 #[allow(dead_code)]
31 #[must_use]
32 pub fn default_skills_dir() -> PathBuf {
33 dirs::home_dir().map_or_else(
34 || PathBuf::from("/tmp/deepseek/skills"),
35 |p| p.join(".deepseek").join("skills"),
36 )
37 }
38
39 /// Global agentskills.io-compatible skills directory (`~/.agents/skills`).
40 #[must_use]
41 pub 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]
51 pub 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)]
59 pub 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)]
72 pub struct SkillRegistry {
73 skills: Vec<Skill>,
74 warnings: Vec<String>,
75 }
76
77 impl 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.
316 pub 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]
354 pub 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
372 fn 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 /// Walk every candidate skills directory for a workspace and merge
383 /// the discovered skills into a single registry. Name conflicts are
384 /// resolved with first-match-wins precedence per
385 /// [`skills_directories`].
386 ///
387 /// Warnings from each scanned directory accumulate so the model
388 /// (and the user via `/skill list`) can see why a skill didn't
389 /// load.
390 #[must_use]
391 pub fn discover_in_workspace(workspace: &Path) -> SkillRegistry {
392 let mut merged = SkillRegistry::default();
393 for dir in skills_directories(workspace) {
394 let registry = SkillRegistry::discover(&dir);
395 for skill in registry.skills {
396 if !merged.skills.iter().any(|s| s.name == skill.name) {
397 merged.skills.push(skill);
398 }
399 }
400 for warning in registry.warnings {
401 merged.warnings.push(warning);
402 }
403 }
404 merged
405 }
406
407 /// Render the system-prompt skills block from every workspace
408 /// candidate directory plus the global default (#432). Wraps
409 /// [`discover_in_workspace`] for callers (e.g. `prompts.rs`) that
410 /// only have the workspace path to hand.
411 #[must_use]
412 pub fn render_available_skills_context_for_workspace(workspace: &Path) -> Option<String> {
413 let registry = discover_in_workspace(workspace);
414 render_skills_block(&registry)
415 }
416
417 /// Codex's progressive-disclosure contract: the model sees skill names,
418 /// descriptions, and paths up front, then opens the specific `SKILL.md` only
419 /// when a skill is relevant.
420 ///
421 /// Single-directory variant — use
422 /// [`render_available_skills_context_for_workspace`] when scanning
423 /// a workspace for cross-tool skill folders (#432).
424 #[must_use]
425 pub fn render_available_skills_context(skills_dir: &Path) -> Option<String> {
426 let registry = SkillRegistry::discover(skills_dir);
427 render_skills_block(&registry)
428 }
429
430 fn render_skills_block(registry: &SkillRegistry) -> Option<String> {
431 if registry.is_empty() {
432 return None;
433 }
434
435 let mut skills = registry.list().to_vec();
436 skills.sort_by(|a, b| a.name.cmp(&b.name));
437
438 let mut out = String::new();
439 out.push_str("## Skills\n");
440 out.push_str(
441 "A skill is a set of local instructions stored in a `SKILL.md` file. \
442 Below is the list of skills available in this session. Each entry includes a \
443 name, description, and file path so you can open the source for full \
444 instructions when using a specific skill.\n\n",
445 );
446 out.push_str("### Available skills\n");
447
448 let mut omitted = 0usize;
449 for skill in skills {
450 // Use the real on-disk path captured at discovery — the directory
451 // name can differ from the frontmatter `name` for community
452 // installs, in which case `<dir>/<name>/SKILL.md` would not exist
453 // and the model would fail to open it.
454 let description = truncate_for_prompt(&skill.description, MAX_SKILL_DESCRIPTION_CHARS);
455 let line = if description.is_empty() {
456 format!("- {}: (file: {})\n", skill.name, skill.path.display())
457 } else {
458 format!(
459 "- {}: {} (file: {})\n",
460 skill.name,
461 description,
462 skill.path.display()
463 )
464 };
465
466 if out.chars().count() + line.chars().count() > MAX_AVAILABLE_SKILLS_CHARS {
467 omitted += 1;
468 } else {
469 out.push_str(&line);
470 }
471 }
472
473 if omitted > 0 {
474 out.push_str(&format!(
475 "- ... {omitted} additional skills omitted from this prompt budget.\n"
476 ));
477 }
478
479 if !registry.warnings().is_empty() {
480 out.push_str("\n### Skill load warnings\n");
481 for warning in registry.warnings().iter().take(8) {
482 out.push_str("- ");
483 out.push_str(&truncate_for_prompt(warning, MAX_SKILL_DESCRIPTION_CHARS));
484 out.push('\n');
485 }
486 }
487
488 out.push_str(
489 "\n### How to use skills\n\
490 - Discovery: The list above is the skills available in this session. Skill bodies live on disk at the listed paths.\n\
491 - 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\
492 - 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\
493 - 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\
494 - Context hygiene: Load only the specific referenced files needed for the task. Avoid bulk-loading unrelated skill resources.\n\
495 - Safety: Do not execute scripts from a community skill unless the user explicitly asks or the skill has been trusted for script use.\n",
496 );
497
498 Some(out)
499 }
500
501 fn truncate_for_prompt(value: &str, max_chars: usize) -> String {
502 let single_line = value.split_whitespace().collect::<Vec<_>>().join(" ");
503 if single_line.chars().count() <= max_chars {
504 return single_line;
505 }
506
507 let mut truncated = single_line
508 .chars()
509 .take(max_chars.saturating_sub(1))
510 .collect::<String>();
511 truncated.push('…');
512 truncated
513 }
514
515 // === CLI Helpers ===
516
517 #[allow(dead_code)] // CLI utility for future use
518 pub fn list(skills_dir: &Path) -> Result<()> {
519 if !skills_dir.exists() {
520 println!("No skills directory found at {}", skills_dir.display());
521 return Ok(());
522 }
523
524 let mut entries = Vec::new();
525 for entry in fs::read_dir(skills_dir)? {
526 let entry = entry?;
527 if entry.file_type()?.is_dir() {
528 entries.push(entry.file_name().to_string_lossy().to_string());
529 }
530 }
531
532 if entries.is_empty() {
533 println!("No skills found in {}", skills_dir.display());
534 return Ok(());
535 }
536
537 entries.sort();
538 for entry in entries {
539 println!("{entry}");
540 }
541 Ok(())
542 }
543
544 #[allow(dead_code)] // CLI utility for future use
545 pub fn show(skills_dir: &Path, name: &str) -> Result<()> {
546 let path = skills_dir.join(name).join("SKILL.md");
547 let contents =
548 fs::read_to_string(&path).with_context(|| format!("Failed to read {}", path.display()))?;
549 println!("{contents}");
550 Ok(())
551 }
552
553 #[cfg(test)]
554 mod tests {
555 use tempfile::TempDir;
556
557 fn create_skill_dir(tmpdir: &TempDir, skill_name: &str, skill_content: &str) {
558 let skill_dir = tmpdir.path().join("skills").join(skill_name);
559 std::fs::create_dir_all(&skill_dir).unwrap();
560 std::fs::write(skill_dir.join("SKILL.md"), skill_content).unwrap();
561 }
562
563 #[test]
564 fn render_available_skills_context_lists_paths_and_usage() {
565 let tmpdir = TempDir::new().unwrap();
566 create_skill_dir(
567 &tmpdir,
568 "test-skill",
569 "---\nname: test-skill\ndescription: A test skill\n---\nDo something special",
570 );
571
572 let rendered =
573 crate::skills::render_available_skills_context(&tmpdir.path().join("skills"))
574 .expect("skill context");
575
576 let expected_path = tmpdir
577 .path()
578 .join("skills")
579 .join("test-skill")
580 .join("SKILL.md")
581 .display()
582 .to_string();
583
584 assert!(rendered.contains("## Skills"));
585 assert!(rendered.contains("- test-skill: A test skill"));
586 assert!(
587 rendered.contains(&expected_path),
588 "expected path {expected_path:?} not in rendered output"
589 );
590 assert!(rendered.contains("### How to use skills"));
591 }
592
593 #[test]
594 fn render_available_skills_context_uses_real_dir_name_not_frontmatter_name() {
595 // Regression: when a community-installed or manually-placed skill
596 // lives in a directory whose name differs from its frontmatter
597 // `name`, the rendered prompt must point to the real on-disk file
598 // path, not <skills_dir>/<frontmatter-name>/SKILL.md (which does
599 // not exist).
600 let tmpdir = TempDir::new().unwrap();
601 create_skill_dir(
602 &tmpdir,
603 "weird-dir-name",
604 "---\nname: friendly-name\ndescription: drift case\n---\nbody",
605 );
606
607 let rendered =
608 crate::skills::render_available_skills_context(&tmpdir.path().join("skills"))
609 .expect("skill context");
610
611 let real_path = tmpdir
612 .path()
613 .join("skills")
614 .join("weird-dir-name")
615 .join("SKILL.md")
616 .display()
617 .to_string();
618 let stale_path = tmpdir
619 .path()
620 .join("skills")
621 .join("friendly-name")
622 .join("SKILL.md")
623 .display()
624 .to_string();
625
626 assert!(
627 rendered.contains(&real_path),
628 "expected real on-disk path {real_path:?} in rendered output, got:\n{rendered}"
629 );
630 assert!(
631 !rendered.contains(&stale_path),
632 "rendered output must not invent a path under the frontmatter name:\n{rendered}"
633 );
634 }
635
636 #[test]
637 fn render_available_skills_context_returns_none_when_empty() {
638 let tmpdir = TempDir::new().unwrap();
639 let empty = tmpdir.path().join("skills");
640 std::fs::create_dir_all(&empty).unwrap();
641 assert!(crate::skills::render_available_skills_context(&empty).is_none());
642
643 let missing = tmpdir.path().join("does-not-exist");
644 assert!(crate::skills::render_available_skills_context(&missing).is_none());
645 }
646
647 #[test]
648 fn render_available_skills_context_truncates_long_descriptions() {
649 let tmpdir = TempDir::new().unwrap();
650 let long_desc = "x".repeat(2_000);
651 let body = format!("---\nname: bigdesc\ndescription: {long_desc}\n---\nbody");
652 create_skill_dir(&tmpdir, "bigdesc", &body);
653
654 let rendered =
655 crate::skills::render_available_skills_context(&tmpdir.path().join("skills"))
656 .expect("skill context");
657
658 let max = super::MAX_SKILL_DESCRIPTION_CHARS;
659 assert!(rendered.contains('…'), "expected truncation marker");
660 assert!(
661 !rendered.contains(&"x".repeat(max + 1)),
662 "untruncated long run should not appear"
663 );
664 }
665
666 #[test]
667 fn render_available_skills_context_collapses_internal_whitespace() {
668 let tmpdir = TempDir::new().unwrap();
669 create_skill_dir(
670 &tmpdir,
671 "spaced-skill",
672 "---\nname: spaced-skill\ndescription: alpha \t beta gamma\n---\nbody",
673 );
674
675 let rendered =
676 crate::skills::render_available_skills_context(&tmpdir.path().join("skills"))
677 .expect("skill context");
678
679 let line = rendered
680 .lines()
681 .find(|l| l.starts_with("- spaced-skill:"))
682 .expect("skill line");
683 assert!(line.contains("alpha beta gamma"), "got: {line:?}");
684 }
685
686 #[test]
687 fn render_available_skills_context_omits_overflowing_skills() {
688 let tmpdir = TempDir::new().unwrap();
689 let big_desc = "y".repeat(super::MAX_SKILL_DESCRIPTION_CHARS - 20);
690 for i in 0..200 {
691 let body = format!("---\nname: skill-{i:03}\ndescription: {big_desc}\n---\nbody");
692 create_skill_dir(&tmpdir, &format!("skill-{i:03}"), &body);
693 }
694
695 let rendered =
696 crate::skills::render_available_skills_context(&tmpdir.path().join("skills"))
697 .expect("skill context");
698
699 assert!(
700 rendered.contains("additional skills omitted from this prompt budget"),
701 "expected overflow notice"
702 );
703 assert!(
704 rendered.chars().count() < super::MAX_AVAILABLE_SKILLS_CHARS + 4_000,
705 "rendered length should stay near the budget"
706 );
707 }
708
709 fn write_skill(dir: &std::path::Path, name: &str, description: &str, body: &str) {
710 let skill_dir = dir.join(name);
711 std::fs::create_dir_all(&skill_dir).unwrap();
712 std::fs::write(
713 skill_dir.join("SKILL.md"),
714 format!("---\nname: {name}\ndescription: {description}\n---\n{body}\n"),
715 )
716 .unwrap();
717 }
718
719 #[test]
720 fn skills_directories_returns_existing_dirs_in_precedence_order() {
721 let tmpdir = TempDir::new().unwrap();
722 let workspace = tmpdir.path();
723
724 // Create four of the five workspace candidate dirs (skip `.opencode`).
725 std::fs::create_dir_all(workspace.join(".agents").join("skills")).unwrap();
726 std::fs::create_dir_all(workspace.join("skills")).unwrap();
727 std::fs::create_dir_all(workspace.join(".claude").join("skills")).unwrap();
728 std::fs::create_dir_all(workspace.join(".cursor").join("skills")).unwrap();
729
730 let dirs = super::skills_directories(workspace);
731 // We don't assert on the global default position because it's
732 // host-dependent (may not exist on the test machine).
733 let mut idx = 0;
734 let agents = workspace.join(".agents").join("skills");
735 let local = workspace.join("skills");
736 let claude = workspace.join(".claude").join("skills");
737 let cursor = workspace.join(".cursor").join("skills");
738
739 assert_eq!(dirs.get(idx), Some(&agents), "agents must come first");
740 idx += 1;
741 assert_eq!(dirs.get(idx), Some(&local), "local must come second");
742 idx += 1;
743 // .opencode/skills was not created — it must NOT appear.
744 assert!(
745 !dirs
746 .iter()
747 .any(|p| p == &workspace.join(".opencode").join("skills")),
748 "missing dir must be omitted, got: {dirs:?}"
749 );
750 assert_eq!(dirs.get(idx), Some(&claude), "claude must come after local");
751 idx += 1;
752 assert_eq!(
753 dirs.get(idx),
754 Some(&cursor),
755 "cursor must come after claude"
756 );
757 }
758
759 #[test]
760 fn claude_global_skills_dir_returns_home_relative_path() {
761 // Smoke test for the #902 helper. We don't assert the exact path
762 // because dirs::home_dir() is host-dependent; we just pin the
763 // suffix shape so a future refactor can't silently rename it.
764 let path = super::claude_global_skills_dir().expect("home dir resolves on test host");
765 assert!(path.ends_with(".claude/skills") || path.ends_with(r".claude\skills"));
766 }
767
768 #[test]
769 fn existing_skill_dirs_orders_globals_agents_then_claude_then_deepseek() {
770 // Pins the precedence among the three global skill roots (#902).
771 // Workspace candidates are tested separately above; here we only
772 // exercise the global ordering at the existing_skill_dirs level
773 // so the assertion is host-independent.
774 let tmpdir = TempDir::new().unwrap();
775 let agents_global = tmpdir.path().join(".agents").join("skills");
776 let claude_global = tmpdir.path().join(".claude").join("skills");
777 let deepseek_global = tmpdir.path().join(".deepseek").join("skills");
778 std::fs::create_dir_all(&agents_global).unwrap();
779 std::fs::create_dir_all(&claude_global).unwrap();
780 std::fs::create_dir_all(&deepseek_global).unwrap();
781
782 let dirs = super::existing_skill_dirs(vec![
783 agents_global.clone(),
784 claude_global.clone(),
785 deepseek_global.clone(),
786 ]);
787
788 assert_eq!(dirs, vec![agents_global, claude_global, deepseek_global]);
789 }
790
791 #[test]
792 fn existing_skill_dirs_keeps_agents_global_before_deepseek_global() {
793 let tmpdir = TempDir::new().unwrap();
794 let agents_global = tmpdir.path().join(".agents").join("skills");
795 let deepseek_global = tmpdir.path().join(".deepseek").join("skills");
796 let missing = tmpdir.path().join("missing").join("skills");
797 std::fs::create_dir_all(&agents_global).unwrap();
798 std::fs::create_dir_all(&deepseek_global).unwrap();
799
800 let dirs = super::existing_skill_dirs(vec![
801 missing,
802 agents_global.clone(),
803 deepseek_global.clone(),
804 agents_global.clone(),
805 ]);
806
807 assert_eq!(dirs, vec![agents_global, deepseek_global]);
808 }
809
810 #[test]
811 fn discover_in_workspace_merges_with_first_wins_precedence() {
812 let tmpdir = TempDir::new().unwrap();
813 let workspace = tmpdir.path();
814
815 // Same skill name `shared` in two locations — the higher-precedence
816 // dir's version should win.
817 write_skill(
818 &workspace.join(".agents").join("skills"),
819 "shared",
820 "agents wins",
821 "from agents",
822 );
823 write_skill(
824 &workspace.join(".claude").join("skills"),
825 "shared",
826 "claude loses",
827 "from claude",
828 );
829 // Unique skill in claude — should still be discovered.
830 write_skill(
831 &workspace.join(".claude").join("skills"),
832 "unique-claude",
833 "only here",
834 "claude-only",
835 );
836
837 let registry = super::discover_in_workspace(workspace);
838 let names: Vec<&str> = registry.list().iter().map(|s| s.name.as_str()).collect();
839 assert!(
840 names.contains(&"shared"),
841 "shared must be present: {names:?}"
842 );
843 assert!(names.contains(&"unique-claude"));
844
845 let shared = registry.get("shared").expect("shared present");
846 assert_eq!(
847 shared.description, "agents wins",
848 "first-wins precedence should keep .agents/skills version"
849 );
850 assert!(
851 shared.path.starts_with(workspace.join(".agents")),
852 "shared.path should be from .agents/skills, got {:?}",
853 shared.path
854 );
855 }
856
857 #[test]
858 fn discover_in_workspace_pulls_skills_from_opencode_dir() {
859 let tmpdir = TempDir::new().unwrap();
860 let workspace = tmpdir.path();
861 write_skill(
862 &workspace.join(".opencode").join("skills"),
863 "opencode-only",
864 "for interop",
865 "body",
866 );
867
868 let registry = super::discover_in_workspace(workspace);
869 assert!(
870 registry.get("opencode-only").is_some(),
871 ".opencode/skills must be scanned (#432)"
872 );
873 }
874
875 #[test]
876 fn discover_in_workspace_pulls_skills_from_cursor_dir() {
877 let tmpdir = TempDir::new().unwrap();
878 let workspace = tmpdir.path();
879 write_skill(
880 &workspace.join(".cursor").join("skills"),
881 "cursor-only",
882 "for cursor interop",
883 "body",
884 );
885
886 let registry = super::discover_in_workspace(workspace);
887 assert!(
888 registry.get("cursor-only").is_some(),
889 ".cursor/skills must be scanned"
890 );
891 }
892
893 #[test]
894 fn discover_accepts_plain_markdown_heading_without_frontmatter() {
895 let tmpdir = TempDir::new().unwrap();
896 let skill_dir = tmpdir.path().join("plain-skill");
897 std::fs::create_dir_all(&skill_dir).unwrap();
898 std::fs::write(
899 skill_dir.join("SKILL.md"),
900 "# Plain Skill\n\nUse this skill without YAML frontmatter.\n",
901 )
902 .unwrap();
903
904 let registry = super::SkillRegistry::discover(tmpdir.path());
905 let skill = registry.get("Plain Skill").expect("plain skill parsed");
906 assert_eq!(skill.description, "");
907 assert!(skill.body.contains("Use this skill"));
908 }
909
910 #[test]
911 fn discover_warns_for_plain_markdown_without_heading() {
912 let tmpdir = TempDir::new().unwrap();
913 let skill_dir = tmpdir.path().join("plain-skill");
914 std::fs::create_dir_all(&skill_dir).unwrap();
915 std::fs::write(
916 skill_dir.join("SKILL.md"),
917 "Use this skill without a heading or YAML frontmatter.\n",
918 )
919 .unwrap();
920
921 let registry = super::SkillRegistry::discover(tmpdir.path());
922 assert!(registry.is_empty());
923 assert!(
924 registry
925 .warnings()
926 .iter()
927 .any(|warning| warning.contains("no `# Heading` found")),
928 "expected missing-heading warning, got {:?}",
929 registry.warnings()
930 );
931 }
932
933 #[test]
934 fn render_available_skills_context_for_workspace_picks_up_cross_tool_dirs() {
935 let tmpdir = TempDir::new().unwrap();
936 let workspace = tmpdir.path();
937 write_skill(
938 &workspace.join(".claude").join("skills"),
939 "from-claude",
940 "claude-style skill",
941 "body",
942 );
943 let rendered =
944 super::render_available_skills_context_for_workspace(workspace).expect("non-empty");
945 assert!(rendered.contains("from-claude"));
946 }
947
948 /// Regression for the GitHub issue where users organize skills under
949 /// vendor / category subdirectories (e.g. cloned skill repos that
950 /// bundle several skills together). The old single-level `read_dir`
951 /// only ever surfaced `<root>/<skill>/SKILL.md` and silently ignored
952 /// `<root>/<vendor>/<skill>/SKILL.md`.
953 #[test]
954 fn discover_finds_skills_nested_under_vendor_subdirectory() {
955 let tmpdir = TempDir::new().unwrap();
956 let root = tmpdir.path().join("skills");
957
958 // Two-level nesting: `<root>/<vendor>/<skill>/SKILL.md`. This
959 // matches the `clawhub-skills/clawhub/SKILL.md` layout in the
960 // bug report.
961 write_skill(
962 &root.join("clawhub-skills"),
963 "clawhub",
964 "claw search",
965 "body",
966 );
967 write_skill(
968 &root.join("clawhub-skills"),
969 "github",
970 "github helpers",
971 "body",
972 );
973 // Three-level nesting: `<root>/<org>/<repo>/<skill>/SKILL.md`.
974 write_skill(
975 &root.join("pasky").join("chrome-cdp-skill"),
976 "chrome-cdp",
977 "browser automation",
978 "body",
979 );
980 // Mixed-depth: a flat skill alongside the nested layout still
981 // works (this is what the bundled `skill-creator` looks like).
982 write_skill(&root, "skill-creator", "make skills", "body");
983
984 let registry = super::SkillRegistry::discover(&root);
985 let names: Vec<&str> = registry.list().iter().map(|s| s.name.as_str()).collect();
986 assert!(names.contains(&"clawhub"), "vendor/skill missed: {names:?}");
987 assert!(names.contains(&"github"), "vendor/skill missed: {names:?}");
988 assert!(
989 names.contains(&"chrome-cdp"),
990 "deeply-nested skill missed: {names:?}"
991 );
992 assert!(
993 names.contains(&"skill-creator"),
994 "flat top-level skill must still load: {names:?}"
995 );
996 assert!(
997 registry.warnings().is_empty(),
998 "well-formed nested layout should not warn: {:?}",
999 registry.warnings()
1000 );
1001 }
1002
1003 /// Once a directory is identified as a skill (has `SKILL.md`), the
1004 /// walker must NOT descend into it: any nested `SKILL.md` would be
1005 /// a fixture / example bundled with the parent skill, not a
1006 /// separately-installable one. This mirrors the contract that
1007 /// `tools::skill::collect_companion_files` already documents
1008 /// ("nested directory — skipped").
1009 #[test]
1010 fn discover_does_not_descend_into_a_skill_directory() {
1011 let tmpdir = TempDir::new().unwrap();
1012 let root = tmpdir.path().join("skills");
1013
1014 // Parent skill: <root>/parent/SKILL.md.
1015 write_skill(&root, "parent", "outer skill", "outer body");
1016 // Fixture bundled inside the parent's directory:
1017 // <root>/parent/examples/inner-fixture/SKILL.md. The walker
1018 // must NOT descend into <root>/parent/ after finding its
1019 // SKILL.md, so `inner-fixture` must not be loaded.
1020 write_skill(
1021 &root.join("parent").join("examples"),
1022 "inner-fixture",
1023 "should not load",
1024 "fixture body",
1025 );
1026
1027 let registry = super::SkillRegistry::discover(&root);
1028 let names: Vec<&str> = registry.list().iter().map(|s| s.name.as_str()).collect();
1029 assert!(names.contains(&"parent"));
1030 assert!(
1031 !names.contains(&"inner-fixture"),
1032 "nested SKILL.md inside an existing skill must be ignored: {names:?}"
1033 );
1034 }
1035
1036 /// Hidden subdirectories below the root (e.g. `.git`, `.cache`) must
1037 /// be skipped so a `skills_dir` that lives inside a checked-out repo
1038 /// doesn't accidentally load random `SKILL.md`-named fixtures from
1039 /// the VCS metadata. The root itself is exempt — the user explicitly
1040 /// pointed `skills_dir` at it.
1041 #[test]
1042 fn discover_skips_hidden_subdirectories_below_root() {
1043 let tmpdir = TempDir::new().unwrap();
1044 let root = tmpdir.path().join("skills");
1045
1046 write_skill(&root, "real-skill", "ok", "body");
1047 // A `<root>/.git/<junk>/SKILL.md` lookalike that mustn't load.
1048 // `.git` is a direct child of the user-provided root (depth 0
1049 // of the walk), which is exactly the case the old `depth > 0`
1050 // gate missed.
1051 write_skill(&root.join(".git"), "vcs-noise", "should not load", "body");
1052
1053 let registry = super::SkillRegistry::discover(&root);
1054 let names: Vec<&str> = registry.list().iter().map(|s| s.name.as_str()).collect();
1055 assert!(names.contains(&"real-skill"));
1056 assert!(
1057 !names.contains(&"vcs-noise"),
1058 "skills under hidden subdirs must be skipped: {names:?}"
1059 );
1060 }
1061
1062 /// The user explicitly chooses the root, so even a hidden path like
1063 /// `~/.agents/skills` (the layout in the bug report) must work.
1064 #[test]
1065 fn discover_honors_a_hidden_root_directory() {
1066 let tmpdir = TempDir::new().unwrap();
1067 let root = tmpdir.path().join(".agents").join("skills");
1068
1069 // Matches the bug report: skills_dir = "~/.agents/skills"
1070 // with a skill nested at <root>/custom-skills/git-conventions/SKILL.md.
1071 write_skill(
1072 &root.join("custom-skills"),
1073 "git-conventions",
1074 "conventions",
1075 "body",
1076 );
1077
1078 let registry = super::SkillRegistry::discover(&root);
1079 let names: Vec<&str> = registry.list().iter().map(|s| s.name.as_str()).collect();
1080 assert!(
1081 names.contains(&"git-conventions"),
1082 "hidden root must still be walked: {names:?}"
1083 );
1084 }
1085 }
1086
1086 lines RUST