1pub mod install;
4mod system;
5#[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#[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#[must_use]
41pub fn agents_global_skills_dir() -> Option<PathBuf> {
42 dirs::home_dir().map(|p| p.join(".agents").join("skills"))
43}
44
45#[must_use]
51pub fn claude_global_skills_dir() -> Option<PathBuf> {
52 dirs::home_dir().map(|p| p.join(".claude").join("skills"))
53}
54
55#[derive(Debug, Clone)]
59pub struct Skill {
60 pub name: String,
61 pub description: String,
62 pub body: String,
63 pub path: PathBuf,
68}
69
70#[derive(Debug, Clone, Default)]
72pub struct SkillRegistry {
73 skills: Vec<Skill>,
74 warnings: Vec<String>,
75}
76
77impl SkillRegistry {
78 const MAX_DISCOVERY_DEPTH: usize = 8;
84
85 #[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 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 let Ok(ft) = entry.file_type() else { continue };
141 if !ft.is_dir() {
142 continue;
143 }
144
145 let path = entry.path();
146 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 continue;
173 }
174 Err(reason) => {
175 registry.push_warning(format!(
176 "Failed to parse {}: {reason}",
177 skill_path.display()
178 ));
179 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 }
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 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 path: PathBuf::new(),
248 });
249 }
250
251 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 pub fn get(&self, name: &str) -> Option<&Skill> {
273 self.skills.iter().find(|s| s.name == name)
274 }
275
276 pub fn list(&self) -> &[Skill] {
278 &self.skills
279 }
280
281 pub fn warnings(&self) -> &[String] {
283 &self.warnings
284 }
285
286 #[must_use]
288 pub fn is_empty(&self) -> bool {
289 self.skills.is_empty()
290 }
291
292 #[must_use]
294 pub fn len(&self) -> usize {
295 self.skills.len()
296 }
297}
298
299#[must_use]
315#[allow(dead_code)] 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#[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#[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#[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#[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(®istry)
427}
428
429#[must_use]
437pub fn render_available_skills_context(skills_dir: &Path) -> Option<String> {
438 let registry = SkillRegistry::discover(skills_dir);
439 render_skills_block(®istry)
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 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#[allow(dead_code)] pub 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)] pub 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 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 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 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 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 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 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 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 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 #[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 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 write_skill(
987 &root.join("pasky").join("chrome-cdp-skill"),
988 "chrome-cdp",
989 "browser automation",
990 "body",
991 );
992 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 #[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 write_skill(&root, "parent", "outer skill", "outer body");
1028 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 #[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 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 #[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 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}