1use std::collections::{BTreeMap, HashMap, HashSet};
9use std::fs;
10use std::path::{Path, PathBuf};
11
12use markdown::{Constructs, ParseOptions, mdast::Node};
13use serde::{Deserialize, Serialize};
14
15use crate::tools;
16use crate::utils;
17
18enum SkillConstants {
19 NameLen,
20 DescriptionLen,
21 DiscoveryDepth,
22 ReferenceDepth,
23 ReferenceFiles,
24 ReferenceBytes,
25 ReferenceFileBytes,
26}
27
28impl From<SkillConstants> for usize {
29 fn from(val: SkillConstants) -> Self {
30 match val {
31 SkillConstants::NameLen => 64,
32 SkillConstants::DescriptionLen => 1024,
33 SkillConstants::DiscoveryDepth => 8,
34 SkillConstants::ReferenceDepth => 3,
35 SkillConstants::ReferenceFiles => 24,
36 SkillConstants::ReferenceBytes => 64 * 1024,
37 SkillConstants::ReferenceFileBytes => 16 * 1024,
38 }
39 }
40}
41
42#[derive(Debug, Default, Deserialize)]
43#[serde(untagged)]
44enum AllowedTools {
45 #[default]
46 None,
47 One(String),
48 Many(Vec<String>),
49}
50
51impl AllowedTools {
52 fn into_vec(self) -> Vec<String> {
53 self.into()
54 }
55}
56
57impl From<AllowedTools> for Vec<String> {
58 fn from(val: AllowedTools) -> Self {
59 match val {
60 AllowedTools::None => Vec::new(),
61 AllowedTools::One(tool) => vec![tool],
62 AllowedTools::Many(tools) => tools,
63 }
64 .into_iter()
65 .map(|tool| tool.trim().to_string())
66 .filter(|tool| !tool.is_empty())
67 .collect()
68 }
69}
70
71#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
72pub enum SkillSource {
73 User,
74 Project,
75 Configured,
76}
77
78impl SkillSource {
79 pub fn label(self) -> &'static str {
80 match self {
81 SkillSource::User => "user",
82 SkillSource::Project => "project",
83 SkillSource::Configured => "configured",
84 }
85 }
86}
87
88#[derive(Clone, Debug, Eq, PartialEq)]
89pub struct SkillInventory {
90 pub skills: Vec<SkillMetadata>,
91 pub diagnostics: Vec<SkillDiagnostic>,
92 pub duplicates: Vec<SkillDuplicate>,
94}
95
96#[derive(Clone, Debug, Eq, PartialEq)]
98pub struct SkillDuplicate {
99 pub name: String,
100 pub selected_path: PathBuf,
101 pub ignored_paths: Vec<PathBuf>,
102}
103
104#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
105pub struct SkillMetadata {
106 pub name: String,
107 pub description: String,
108 pub path: PathBuf,
109 pub root: PathBuf,
110 pub content_hash: u64,
111 pub byte_count: usize,
112 pub source: SkillSource,
113 pub allowed_tools: Vec<String>,
114 pub license: Option<String>,
115 pub compatibility: Option<String>,
116 pub metadata: Option<serde_json::Value>,
117 pub references: Vec<PathBuf>,
118}
119
120#[derive(Clone, Debug, Eq, PartialEq)]
121pub struct SkillDiagnostic {
122 pub path: PathBuf,
123 pub message: String,
124}
125
126impl SkillDiagnostic {
127 fn new(path: impl Into<PathBuf>, message: impl Into<String>) -> Self {
128 Self { path: path.into(), message: message.into() }
129 }
130
131 pub fn summary(&self) -> String {
132 format!("skill diagnostic {}: {}", self.path.display(), self.message)
133 }
134}
135
136#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
137pub struct SkillActivation {
138 pub name: String,
140 pub path: PathBuf,
142 pub content_hash: u64,
144 pub byte_count: usize,
146 pub rendered_content_hash: u64,
148 pub rendered_byte_count: usize,
150 pub loaded_references: Vec<SkillReferenceMeta>,
152}
153
154#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
155pub struct SkillReferenceMeta {
156 pub path: PathBuf,
157 pub content_hash: u64,
158 pub byte_count: usize,
159 pub truncated: bool,
160}
161
162#[derive(Clone, Debug, Eq, PartialEq)]
163pub struct LoadedSkill {
164 pub activation: SkillActivation,
165 pub diagnostics: Vec<SkillDiagnostic>,
166 pub markdown: String,
167}
168
169#[derive(Clone, Debug, Eq, PartialEq)]
170pub struct LoadedReferences {
171 pub files: Vec<(SkillReferenceMeta, String)>,
172 pub diagnostics: Vec<SkillDiagnostic>,
173}
174
175#[derive(Clone, Debug)]
176struct SkillRoot {
177 path: PathBuf,
178 source: SkillSource,
179}
180
181#[derive(Debug, Default, Deserialize)]
182#[serde(default, deny_unknown_fields)]
183struct Frontmatter {
184 name: Option<String>,
185 description: Option<String>,
186 #[serde(rename = "allowed-tools")]
187 allowed_tools: AllowedTools,
188 license: Option<String>,
189 compatibility: Option<String>,
190 metadata: Option<serde_json::Value>,
191 references: Option<serde_yaml_ng::Value>,
192}
193
194pub fn default_skill_dirs(workspace_root: &Path, configured: &[PathBuf]) -> Vec<(PathBuf, SkillSource)> {
195 let mut roots = Vec::new();
196 if let Some(home) = utils::home_dir() {
197 roots.push((home.join(".thndrs").join("skills"), SkillSource::User));
198 roots.push((home.join(".agents").join("skills"), SkillSource::User));
199 roots.push((home.join(".claude").join("skills"), SkillSource::User));
200 roots.push((home.join(".codex").join("skills"), SkillSource::User));
201 roots.push((home.join(".pi").join("skills"), SkillSource::User));
202 roots.push((home.join(".pi").join("agent").join("skills"), SkillSource::User));
203 }
204 roots.push((workspace_root.join(".thndrs").join("skills"), SkillSource::Project));
205 roots.push((workspace_root.join(".agents").join("skills"), SkillSource::Project));
206 roots.push((workspace_root.join(".claude").join("skills"), SkillSource::Project));
207 roots.push((workspace_root.join(".codex").join("skills"), SkillSource::Project));
208 roots.push((workspace_root.join(".pi").join("skills"), SkillSource::Project));
209 roots.push((
210 workspace_root.join(".pi").join("agent").join("skills"),
211 SkillSource::Project,
212 ));
213
214 roots.extend(configured.iter().map(|path| {
215 let resolved = if path.is_absolute() { path.clone() } else { workspace_root.join(path) };
216 (resolved, SkillSource::Configured)
217 }));
218 roots
219}
220
221pub fn discover(workspace_root: &Path, configured_dirs: &[PathBuf]) -> SkillInventory {
222 let roots = default_skill_dirs(workspace_root, configured_dirs)
223 .into_iter()
224 .map(|(path, source)| SkillRoot { path, source })
225 .collect::<Vec<_>>();
226 discover_from_roots(roots)
227}
228
229pub fn load_skill(skill: &SkillMetadata) -> Result<LoadedSkill, SkillDiagnostic> {
230 let mut markdown = fs::read_to_string(&skill.path)
231 .map_err(|err| SkillDiagnostic::new(&skill.path, format!("failed to read skill: {err}")))?;
232 let references = load_references(skill, &skill.references);
233 append_references_to_markdown(&mut markdown, &references.files);
234 let activation = SkillActivation {
235 name: skill.name.clone(),
236 path: skill.path.clone(),
237 content_hash: skill.content_hash,
238 byte_count: skill.byte_count,
239 rendered_content_hash: tools::hash_content(&markdown),
240 rendered_byte_count: markdown.len(),
241 loaded_references: references.files.into_iter().map(|(meta, _)| meta).collect(),
242 };
243 Ok(LoadedSkill { activation, diagnostics: references.diagnostics, markdown })
244}
245
246pub fn load_references(skill: &SkillMetadata, relative_paths: &[PathBuf]) -> LoadedReferences {
247 let mut files = Vec::new();
248 let mut diagnostics = Vec::new();
249 let mut seen = HashSet::new();
250 let mut total_bytes = 0usize;
251
252 for relative_path in relative_paths {
253 load_reference_path(
254 &skill.root,
255 relative_path,
256 0,
257 &mut seen,
258 &mut total_bytes,
259 &mut files,
260 &mut diagnostics,
261 );
262 if files.len() >= SkillConstants::ReferenceFiles.into() || total_bytes >= SkillConstants::ReferenceBytes.into()
263 {
264 diagnostics.push(SkillDiagnostic::new(&skill.root, "reference traversal budget reached"));
265 break;
266 }
267 }
268
269 LoadedReferences { files, diagnostics }
270}
271
272pub fn format_available_skills(skills: &[SkillMetadata]) -> String {
273 if skills.is_empty() {
274 return String::new();
275 }
276
277 let mut out = String::from(
278 "The following skills provide specialized instructions for matching tasks. Read the full SKILL.md only after the task matches its description.\n<available_skills>\n",
279 );
280 for skill in skills {
281 out.push_str(" <skill>\n");
282 out.push_str(&format!(" <name>{}</name>\n", utils::escape_xml(&skill.name)));
283 out.push_str(&format!(
284 " <description>{}</description>\n",
285 utils::escape_xml(&skill.description)
286 ));
287 out.push_str(&format!(
288 " <location>{}</location>\n",
289 utils::escape_xml(&skill.path.display().to_string())
290 ));
291 out.push_str(&format!(" <source>{}</source>\n", skill.source.label()));
292 if !skill.allowed_tools.is_empty() {
293 out.push_str(&format!(
294 " <allowed_tools>{}</allowed_tools>\n",
295 utils::escape_xml(&skill.allowed_tools.join(", "))
296 ));
297 }
298 out.push_str(" </skill>\n");
299 }
300 out.push_str("</available_skills>");
301 out
302}
303
304fn load_reference_path(
305 root: &Path, relative_path: &Path, depth: usize, seen: &mut HashSet<PathBuf>, total_bytes: &mut usize,
306 files: &mut Vec<(SkillReferenceMeta, String)>, diagnostics: &mut Vec<SkillDiagnostic>,
307) {
308 if depth > SkillConstants::ReferenceDepth.into() {
309 diagnostics.push(SkillDiagnostic::new(
310 root.join(relative_path),
311 "maximum reference depth reached",
312 ));
313 return;
314 }
315 if files.len() >= SkillConstants::ReferenceFiles.into() || *total_bytes >= SkillConstants::ReferenceBytes.into() {
316 return;
317 }
318 if relative_path.is_absolute()
319 || relative_path
320 .components()
321 .any(|c| matches!(c, std::path::Component::ParentDir))
322 {
323 diagnostics.push(SkillDiagnostic::new(
324 root.join(relative_path),
325 "reference path must stay inside skill root",
326 ));
327 return;
328 }
329 let path = root.join(relative_path);
330 let Ok(canonical_root) = root.canonicalize() else {
331 diagnostics.push(SkillDiagnostic::new(root, "failed to canonicalize skill root"));
332 return;
333 };
334 let Ok(canonical_path) = path.canonicalize() else {
335 diagnostics.push(SkillDiagnostic::new(path, "reference path does not exist"));
336 return;
337 };
338 if !canonical_path.starts_with(&canonical_root) {
339 diagnostics.push(SkillDiagnostic::new(path, "reference path must stay inside skill root"));
340 return;
341 }
342 if !seen.insert(canonical_path.clone()) {
343 return;
344 }
345
346 if canonical_path.is_dir() {
347 let Ok(entries) = fs::read_dir(&canonical_path) else {
348 diagnostics.push(SkillDiagnostic::new(
349 canonical_path,
350 "failed to read reference directory",
351 ));
352 return;
353 };
354 let mut entries = entries.filter_map(Result::ok).collect::<Vec<_>>();
355 entries.sort_by_key(|entry| entry.file_name());
356 for entry in entries {
357 let child_rel = match entry.path().canonicalize().and_then(|path| {
358 path.strip_prefix(&canonical_root)
359 .map(Path::to_path_buf)
360 .map_err(std::io::Error::other)
361 }) {
362 Ok(rel) => rel.to_path_buf(),
363 Err(_) => continue,
364 };
365 load_reference_path(root, &child_rel, depth + 1, seen, total_bytes, files, diagnostics);
366 }
367 return;
368 }
369
370 let Ok(bytes) = fs::read(&canonical_path) else {
371 diagnostics.push(SkillDiagnostic::new(canonical_path, "failed to read reference file"));
372 return;
373 };
374 let truncated = bytes.len() > SkillConstants::ReferenceFileBytes.into();
375 let capped_len = bytes.len().min(SkillConstants::ReferenceFileBytes.into());
376 let content = String::from_utf8_lossy(&bytes[..capped_len]).to_string();
377 *total_bytes = total_bytes.saturating_add(capped_len);
378 files.push((
379 SkillReferenceMeta {
380 path: canonical_path,
381 content_hash: tools::hash_content(&content),
382 byte_count: bytes.len(),
383 truncated,
384 },
385 content,
386 ));
387}
388
389fn append_references_to_markdown(markdown: &mut String, references: &[(SkillReferenceMeta, String)]) {
390 if references.is_empty() {
391 return;
392 }
393
394 markdown.push_str("\n\n## Loaded References\n");
395 for (meta, content) in references {
396 markdown.push_str("\n### ");
397 markdown.push_str(&meta.path.display().to_string());
398 if meta.truncated {
399 markdown.push_str(" (truncated)");
400 }
401 markdown.push_str("\n\n```text\n");
402 markdown.push_str(content);
403 if !content.ends_with('\n') {
404 markdown.push('\n');
405 }
406 markdown.push_str("```\n");
407 }
408}
409
410fn parse_frontmatter(raw: &str) -> Result<Frontmatter, String> {
411 serde_yaml_ng::from_str(
412 &match markdown::to_mdast(raw, &frontmatter_parse_options())
413 .map_err(|err| format!("failed to parse Markdown: {err}"))?
414 {
415 Node::Root(root) => root.children.into_iter().find_map(|node| match node {
416 Node::Yaml(yaml) => Some(yaml.value),
417 _ => None,
418 }),
419 _ => None,
420 }
421 .ok_or_else(|| String::from("missing YAML frontmatter"))?,
422 )
423 .map_err(|err| format!("invalid YAML frontmatter: {err}"))
424}
425
426fn frontmatter_parse_options() -> ParseOptions {
427 ParseOptions { constructs: Constructs { frontmatter: true, ..Constructs::default() }, ..ParseOptions::default() }
428}
429
430fn should_skip_dir(name: &str) -> bool {
431 name.starts_with('.') || matches!(name, "node_modules" | "target" | "dist" | "build")
432}
433
434fn load_metadata(path: &Path, root: &SkillRoot) -> Result<SkillMetadata, SkillDiagnostic> {
435 let raw = fs::read_to_string(path)
436 .map_err(|err| SkillDiagnostic::new(path, format!("failed to read SKILL.md: {err}")))?;
437 let byte_count = raw.len();
438 let content_hash = tools::hash_content(&raw);
439 let frontmatter = parse_frontmatter(&raw).map_err(|message| SkillDiagnostic::new(path, message))?;
440 let parent_name = path
441 .parent()
442 .and_then(Path::file_name)
443 .and_then(|name| name.to_str())
444 .unwrap_or_default();
445
446 let name = frontmatter
447 .name
448 .clone()
449 .ok_or_else(|| SkillDiagnostic::new(path, "frontmatter name is required"))?;
450 let description = frontmatter
451 .description
452 .clone()
453 .ok_or_else(|| SkillDiagnostic::new(path, "frontmatter description is required"))?;
454
455 validate_name(path, &name, parent_name)?;
456 if description.trim().is_empty() {
457 return Err(SkillDiagnostic::new(path, "frontmatter description is required"));
458 }
459 if description.len() > SkillConstants::DescriptionLen.into() {
460 let l: usize = SkillConstants::DescriptionLen.into();
461 return Err(SkillDiagnostic::new(
462 path,
463 format!("description exceeds {l} characters"),
464 ));
465 }
466 let references = parse_reference_paths(path, frontmatter.references)?;
467
468 Ok(SkillMetadata {
469 name,
470 description,
471 path: path.to_path_buf(),
472 root: path.parent().unwrap_or(path).to_path_buf(),
473 content_hash,
474 byte_count,
475 source: root.source,
476 allowed_tools: frontmatter.allowed_tools.into_vec(),
477 license: frontmatter.license,
478 compatibility: frontmatter.compatibility,
479 metadata: frontmatter.metadata,
480 references,
481 })
482}
483
484fn parse_reference_paths(
485 skill_path: &Path, references: Option<serde_yaml_ng::Value>,
486) -> Result<Vec<PathBuf>, SkillDiagnostic> {
487 let Some(references) = references else {
488 return Ok(Vec::new());
489 };
490 let serde_yaml_ng::Value::Sequence(values) = references else {
491 return Err(SkillDiagnostic::new(
492 skill_path,
493 "frontmatter references must be a list of relative paths",
494 ));
495 };
496 if values.is_empty() {
497 return Err(SkillDiagnostic::new(
498 skill_path,
499 "frontmatter references must not be empty",
500 ));
501 }
502
503 let mut paths = Vec::with_capacity(values.len());
504 for value in values {
505 let serde_yaml_ng::Value::String(raw) = value else {
506 return Err(SkillDiagnostic::new(
507 skill_path,
508 "frontmatter references entries must be strings",
509 ));
510 };
511 let path = normalize_reference_path(&raw).map_err(|message| {
512 SkillDiagnostic::new(skill_path, format!("invalid frontmatter reference {raw:?}: {message}"))
513 })?;
514 paths.push(path);
515 }
516 Ok(paths)
517}
518
519fn normalize_reference_path(raw: &str) -> Result<PathBuf, &'static str> {
520 let raw = raw.trim();
521 if raw.is_empty() {
522 return Err("path must not be empty");
523 }
524
525 let path = Path::new(raw);
526 if path.is_absolute() {
527 return Err("path must be relative");
528 }
529
530 let mut normalized = PathBuf::new();
531 for component in path.components() {
532 match component {
533 std::path::Component::Normal(part) => normalized.push(part),
534 std::path::Component::CurDir => {}
535 std::path::Component::ParentDir => return Err("path must not contain parent traversal"),
536 std::path::Component::RootDir | std::path::Component::Prefix(_) => {
537 return Err("path must be relative");
538 }
539 }
540 }
541 if normalized.as_os_str().is_empty() {
542 return Err("path must not be empty");
543 }
544 Ok(normalized)
545}
546
547fn validate_name(path: &Path, name: &str, parent_name: &str) -> Result<(), SkillDiagnostic> {
548 if name != parent_name {
549 return Err(SkillDiagnostic::new(
550 path,
551 format!("name {name:?} must match parent directory {parent_name:?}"),
552 ));
553 }
554 if name.len() > SkillConstants::NameLen.into() {
555 let l: usize = SkillConstants::NameLen.into();
556 return Err(SkillDiagnostic::new(path, format!("name exceeds {l} characters")));
557 }
558 if !name
559 .chars()
560 .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-')
561 {
562 return Err(SkillDiagnostic::new(
563 path,
564 "name must contain only lowercase letters, numbers, and hyphens",
565 ));
566 }
567 if name.starts_with('-') || name.ends_with('-') || name.contains("--") {
568 return Err(SkillDiagnostic::new(
569 path,
570 "name must not start or end with a hyphen or contain consecutive hyphens",
571 ));
572 }
573 Ok(())
574}
575
576fn discover_from_roots(roots: Vec<SkillRoot>) -> SkillInventory {
577 let mut skills = Vec::new();
578 let mut diagnostics = Vec::new();
579
580 for root in roots {
581 if !root.path.is_dir() {
582 continue;
583 }
584 discover_dir(&root.path, &root, 0, &mut skills, &mut diagnostics);
585 }
586
587 let mut selected_skills: Vec<SkillMetadata> = Vec::new();
588 let mut selected_by_name: HashMap<String, usize> = HashMap::new();
589 let mut duplicates: BTreeMap<String, SkillDuplicate> = BTreeMap::new();
590 for skill in skills {
591 if let Some(&selected_index) = selected_by_name.get(&skill.name) {
592 let duplicate = duplicates.entry(skill.name.clone()).or_insert_with(|| SkillDuplicate {
593 name: skill.name.clone(),
594 selected_path: selected_skills[selected_index].path.clone(),
595 ignored_paths: Vec::new(),
596 });
597 duplicate.ignored_paths.push(skill.path);
598 continue;
599 }
600 selected_by_name.insert(skill.name.clone(), selected_skills.len());
601 selected_skills.push(skill);
602 }
603
604 SkillInventory {
605 skills: selected_skills,
606 diagnostics: collapse_redundant_diagnostics(diagnostics),
607 duplicates: duplicates.into_values().collect(),
608 }
609}
610
611fn collapse_redundant_diagnostics(diagnostics: Vec<SkillDiagnostic>) -> Vec<SkillDiagnostic> {
612 let mut collapsed = Vec::new();
613 let mut by_skill_install = BTreeMap::<(PathBuf, String), (SkillDiagnostic, usize)>::new();
614
615 for diagnostic in diagnostics {
616 let Some(suffix) = skill_install_suffix(&diagnostic.path) else {
617 collapsed.push(diagnostic);
618 continue;
619 };
620 let key = (suffix, diagnostic.message.clone());
621 by_skill_install
622 .entry(key)
623 .and_modify(|(_, count)| *count += 1)
624 .or_insert((diagnostic, 1));
625 }
626
627 for (_, (mut diagnostic, count)) in by_skill_install {
628 if count > 1 {
629 diagnostic.message = format!(
630 "{} (also found in {} other skill root(s))",
631 diagnostic.message,
632 count - 1
633 );
634 }
635 collapsed.push(diagnostic);
636 }
637 collapsed
638}
639
640fn skill_install_suffix(path: &Path) -> Option<PathBuf> {
641 let mut suffix = PathBuf::new();
642 let mut found = false;
643 for component in path.components() {
644 if found {
645 suffix.push(component.as_os_str());
646 continue;
647 }
648 if component.as_os_str() == "skills" {
649 found = true;
650 }
651 }
652 found.then_some(suffix).filter(|suffix| !suffix.as_os_str().is_empty())
653}
654
655fn discover_dir(
656 dir: &Path, root: &SkillRoot, depth: usize, skills: &mut Vec<SkillMetadata>, diagnostics: &mut Vec<SkillDiagnostic>,
657) {
658 if depth > SkillConstants::DiscoveryDepth.into() {
659 diagnostics.push(SkillDiagnostic::new(dir, "maximum skill discovery depth reached"));
660 return;
661 }
662
663 let skill_file = dir.join("SKILL.md");
664 if skill_file.is_file() {
665 match load_metadata(&skill_file, root) {
666 Ok(skill) => skills.push(skill),
667 Err(diagnostic) => diagnostics.push(diagnostic),
668 }
669 return;
670 }
671
672 let Ok(entries) = fs::read_dir(dir) else {
673 diagnostics.push(SkillDiagnostic::new(dir, "failed to read directory"));
674 return;
675 };
676 let mut entries = entries.filter_map(Result::ok).collect::<Vec<_>>();
677 entries.sort_by_key(|entry| entry.file_name());
678
679 for entry in entries {
680 let name = entry.file_name();
681 let name = name.to_string_lossy();
682 if should_skip_dir(&name) {
683 continue;
684 }
685 let path = entry.path();
686 if path.is_dir() {
687 discover_dir(&path, root, depth + 1, skills, diagnostics);
688 }
689 }
690}
691
692#[cfg(test)]
693mod tests {
694 use super::*;
695 use std::io::Write;
696
697 fn write(path: &Path, content: &str) {
698 fs::create_dir_all(path.parent().unwrap()).expect("create parent");
699 let mut file = fs::File::create(path).expect("create file");
700 file.write_all(content.as_bytes()).expect("write file");
701 }
702
703 fn discover_only(path: &Path) -> SkillInventory {
704 discover_from_roots(vec![SkillRoot {
705 path: path.to_path_buf(),
706 source: SkillSource::Project,
707 }])
708 }
709
710 #[test]
711 fn default_project_skill_dirs_cover_standard_and_compatibility_roots() {
712 let workspace = Path::new("/repo");
713 let project_dirs = default_skill_dirs(workspace, &[PathBuf::from("vendor/skills")])
714 .into_iter()
715 .filter_map(|(path, source)| (source == SkillSource::Project).then_some(path))
716 .collect::<Vec<_>>();
717
718 assert!(project_dirs.contains(&PathBuf::from("/repo/.thndrs/skills")));
719 assert!(project_dirs.contains(&PathBuf::from("/repo/.agents/skills")));
720 assert!(project_dirs.contains(&PathBuf::from("/repo/.claude/skills")));
721 assert!(project_dirs.contains(&PathBuf::from("/repo/.codex/skills")));
722 assert!(project_dirs.contains(&PathBuf::from("/repo/.pi/skills")));
723 assert!(project_dirs.contains(&PathBuf::from("/repo/.pi/agent/skills")));
724 assert!(!project_dirs.contains(&PathBuf::from("/repo/.thdrs/skills")));
725 }
726
727 #[test]
728 fn discover_valid_nested_skill_metadata() {
729 let dir = tempfile::tempdir().expect("temp dir");
730 write(
731 &dir.path().join(".thndrs/skills/example-skill/SKILL.md"),
732 "---\nname: example-skill\ndescription: Helps with examples.\nallowed-tools: [read_file_range]\nlicense: MIT\ncompatibility: thndrs\nmetadata:\n audience: tests\n priority: 2\n---\n# Body\n",
733 );
734
735 let inventory = discover_only(&dir.path().join(".thndrs/skills"));
736 assert_eq!(inventory.skills.len(), 1);
737 assert_eq!(inventory.skills[0].name, "example-skill");
738 assert_eq!(inventory.skills[0].allowed_tools, vec!["read_file_range"]);
739 assert_eq!(inventory.skills[0].license.as_deref(), Some("MIT"));
740 assert_eq!(inventory.skills[0].compatibility.as_deref(), Some("thndrs"));
741 assert!(inventory.skills[0].references.is_empty());
742 assert_eq!(
743 inventory.skills[0].metadata,
744 Some(serde_json::json!({ "audience": "tests", "priority": 2 }))
745 );
746 assert!(inventory.diagnostics.is_empty());
747 }
748
749 #[test]
750 fn discover_recurses_into_agent_skills_collection_shape() {
751 let dir = tempfile::tempdir().expect("temp dir");
752 write(
753 &dir.path()
754 .join("vendor/agent-skills/skills/react-best-practices/SKILL.md"),
755 "---\nname: react-best-practices\ndescription: Review React code.\n---\n# React\n",
756 );
757
758 let inventory = discover_from_roots(vec![SkillRoot {
759 path: dir.path().join("vendor/agent-skills"),
760 source: SkillSource::Configured,
761 }]);
762 assert_eq!(inventory.skills.len(), 1);
763 assert_eq!(inventory.skills[0].name, "react-best-practices");
764 assert_eq!(inventory.skills[0].source, SkillSource::Configured);
765 }
766
767 #[test]
768 fn duplicate_skill_installs_use_the_first_discovery_root_and_are_reported_separately() {
769 let dir = tempfile::tempdir().expect("temp dir");
770 for root in [".agents/skills", ".claude/skills", ".codex/skills"] {
771 write(
772 &dir.path().join(root).join("example-skill/SKILL.md"),
773 "---\nname: example-skill\ndescription: Helps.\n---\n# Skill\n",
774 );
775 }
776
777 let inventory = discover_from_roots(vec![
778 SkillRoot { path: dir.path().join(".agents/skills"), source: SkillSource::User },
779 SkillRoot { path: dir.path().join(".claude/skills"), source: SkillSource::User },
780 SkillRoot { path: dir.path().join(".codex/skills"), source: SkillSource::User },
781 ]);
782
783 assert_eq!(inventory.skills.len(), 1);
784 assert_eq!(
785 inventory.skills[0].path,
786 dir.path().join(".agents/skills/example-skill/SKILL.md")
787 );
788 assert!(inventory.diagnostics.is_empty());
789 assert_eq!(
790 inventory.duplicates,
791 vec![SkillDuplicate {
792 name: "example-skill".to_string(),
793 selected_path: dir.path().join(".agents/skills/example-skill/SKILL.md"),
794 ignored_paths: vec![
795 dir.path().join(".claude/skills/example-skill/SKILL.md"),
796 dir.path().join(".codex/skills/example-skill/SKILL.md"),
797 ],
798 }]
799 );
800 }
801
802 #[test]
803 fn repeated_malformed_skill_installs_are_collapsed() {
804 let dir = tempfile::tempdir().expect("temp dir");
805 for root in [".claude/skills", ".codex/skills"] {
806 write(
807 &dir.path().join(root).join("skill-deslop/SKILL.md"),
808 "---\nname: deslop\ndescription: Helps.\n---\n# Skill\n",
809 );
810 }
811
812 let inventory = discover_from_roots(vec![
813 SkillRoot { path: dir.path().join(".claude/skills"), source: SkillSource::User },
814 SkillRoot { path: dir.path().join(".codex/skills"), source: SkillSource::User },
815 ]);
816
817 assert!(inventory.skills.is_empty());
818 assert_eq!(inventory.diagnostics.len(), 1);
819 assert!(inventory.diagnostics[0].message.contains("must match parent directory"));
820 assert!(
821 inventory.diagnostics[0]
822 .message
823 .contains("also found in 1 other skill root")
824 );
825 }
826
827 #[test]
828 fn malformed_skill_is_ignored_with_diagnostic() {
829 let dir = tempfile::tempdir().expect("temp dir");
830 write(
831 &dir.path().join(".thndrs/skills/bad/SKILL.md"),
832 "# Missing frontmatter\n",
833 );
834
835 let inventory = discover_only(&dir.path().join(".thndrs/skills"));
836 assert!(inventory.skills.is_empty());
837 assert_eq!(inventory.diagnostics.len(), 1);
838 assert!(inventory.diagnostics[0].message.contains("frontmatter"));
839 }
840
841 #[test]
842 fn load_skill_reads_full_markdown_only_on_demand() {
843 let dir = tempfile::tempdir().expect("temp dir");
844 let path = dir.path().join(".thndrs/skills/example-skill/SKILL.md");
845 write(
846 &path,
847 "---\nname: example-skill\ndescription: Helps.\n---\n# Full Body\n",
848 );
849 let skill = discover_only(&dir.path().join(".thndrs/skills")).skills.remove(0);
850
851 let loaded = load_skill(&skill).expect("load skill");
852 assert!(loaded.markdown.contains("# Full Body"));
853 assert_eq!(loaded.activation.name, "example-skill");
854 assert_eq!(loaded.activation.path, path);
855 }
856
857 #[test]
858 fn discover_skill_with_default_references_and_loads_them_on_activation() {
859 let dir = tempfile::tempdir().expect("temp dir");
860 let skill_path = dir.path().join(".thndrs/skills/cloudflare/SKILL.md");
861 let skill_markdown = "---\nname: cloudflare\ndescription: Build on Cloudflare.\nreferences: [workers, ./pages, d1]\n---\n# Cloudflare\n";
862 write(&skill_path, skill_markdown);
863 write(
864 &dir.path().join(".thndrs/skills/cloudflare/workers/intro.md"),
865 "Workers docs",
866 );
867 write(
868 &dir.path().join(".thndrs/skills/cloudflare/pages/intro.md"),
869 "Pages docs",
870 );
871 write(&dir.path().join(".thndrs/skills/cloudflare/d1/intro.md"), "D1 docs");
872
873 let inventory = discover_from_roots(vec![SkillRoot {
874 path: dir.path().join(".thndrs/skills"),
875 source: SkillSource::User,
876 }]);
877 assert!(inventory.diagnostics.is_empty());
878 assert_eq!(inventory.skills.len(), 1);
879 assert_eq!(
880 inventory.skills[0].references,
881 vec![PathBuf::from("workers"), PathBuf::from("pages"), PathBuf::from("d1")]
882 );
883
884 let loaded = load_skill(&inventory.skills[0]).expect("load skill");
885 assert_eq!(loaded.activation.loaded_references.len(), 3);
886 assert!(loaded.diagnostics.is_empty());
887 assert_eq!(loaded.activation.content_hash, tools::hash_content(skill_markdown));
888 assert_eq!(loaded.activation.byte_count, skill_markdown.len());
889 assert_ne!(loaded.activation.rendered_content_hash, loaded.activation.content_hash);
890 assert!(loaded.activation.rendered_byte_count > loaded.activation.byte_count);
891 assert!(loaded.markdown.contains("# Cloudflare"));
892 assert!(loaded.markdown.contains("Workers docs"));
893 assert!(loaded.markdown.contains("Pages docs"));
894 assert!(loaded.markdown.contains("D1 docs"));
895 }
896
897 #[test]
898 fn discover_rejects_invalid_reference_paths() {
899 let cases = [
900 ("not-a-list", "references: workers", "must be a list"),
901 ("empty-list", "references: []", "must not be empty"),
902 ("empty-entry", "references: ['']", "path must not be empty"),
903 ("absolute", "references: ['/tmp/workers']", "path must be relative"),
904 ("parent", "references: ['../workers']", "parent traversal"),
905 ("non-string", "references: [workers, 42]", "entries must be strings"),
906 ];
907
908 for (name, references, expected) in cases {
909 let dir = tempfile::tempdir().expect("temp dir");
910 write(
911 &dir.path().join(format!(".thndrs/skills/{name}/SKILL.md")),
912 &format!("---\nname: {name}\ndescription: Helps.\n{references}\n---\n# Skill\n"),
913 );
914
915 let inventory = discover_only(&dir.path().join(".thndrs/skills"));
916 assert!(inventory.skills.is_empty(), "{name} should not load");
917 assert_eq!(inventory.diagnostics.len(), 1, "{name} should have one diagnostic");
918 assert!(
919 inventory.diagnostics[0].message.contains(expected),
920 "{name} diagnostic {:?} should contain {expected:?}",
921 inventory.diagnostics[0].message
922 );
923 }
924 }
925
926 #[test]
927 fn reference_loading_stays_inside_skill_root() {
928 let dir = tempfile::tempdir().expect("temp dir");
929 let skill_path = dir.path().join(".thndrs/skills/example-skill/SKILL.md");
930 write(
931 &skill_path,
932 "---\nname: example-skill\ndescription: Helps.\n---\n# Skill\n",
933 );
934 write(
935 &dir.path().join(".thndrs/skills/example-skill/references/a.md"),
936 "reference",
937 );
938 let skill = discover_only(&dir.path().join(".thndrs/skills")).skills.remove(0);
939
940 let loaded = load_references(&skill, &[PathBuf::from("references"), PathBuf::from("../secret")]);
941 assert_eq!(loaded.files.len(), 1);
942 assert!(!loaded.diagnostics.is_empty());
943 }
944
945 #[test]
946 fn load_skill_surfaces_missing_default_reference_diagnostics() {
947 let dir = tempfile::tempdir().expect("temp dir");
948 let skill_path = dir.path().join(".thndrs/skills/example-skill/SKILL.md");
949 write(
950 &skill_path,
951 "---\nname: example-skill\ndescription: Helps.\nreferences: [missing.md]\n---\n# Skill\n",
952 );
953 let skill = discover_only(&dir.path().join(".thndrs/skills")).skills.remove(0);
954
955 let loaded = load_skill(&skill).expect("load skill");
956 assert!(loaded.markdown.contains("# Skill"));
957 assert!(loaded.activation.loaded_references.is_empty());
958 assert!(loaded.diagnostics.iter().any(|diagnostic| {
959 diagnostic.path.ends_with("missing.md") && diagnostic.message.contains("does not exist")
960 }));
961 }
962
963 #[cfg(unix)]
964 #[test]
965 fn load_skill_surfaces_symlink_escape_diagnostics() {
966 use std::os::unix::fs::symlink;
967
968 let dir = tempfile::tempdir().expect("temp dir");
969 let skill_root = dir.path().join(".thndrs/skills/example-skill");
970 let skill_path = skill_root.join("SKILL.md");
971 write(
972 &skill_path,
973 "---\nname: example-skill\ndescription: Helps.\nreferences: [outside.md]\n---\n# Skill\n",
974 );
975 write(&dir.path().join("outside.md"), "secret");
976 symlink(dir.path().join("outside.md"), skill_root.join("outside.md")).expect("create symlink");
977 let skill = discover_only(&dir.path().join(".thndrs/skills")).skills.remove(0);
978
979 let loaded = load_skill(&skill).expect("load skill");
980 assert!(loaded.activation.loaded_references.is_empty());
981 assert!(loaded.diagnostics.iter().any(|diagnostic| {
982 diagnostic.path.ends_with("outside.md") && diagnostic.message.contains("inside skill root")
983 }));
984 }
985
986 #[test]
987 fn load_skill_surfaces_reference_traversal_budget_diagnostics() {
988 let dir = tempfile::tempdir().expect("temp dir");
989 let skill_root = dir.path().join(".thndrs/skills/example-skill");
990 let skill_path = skill_root.join("SKILL.md");
991 write(
992 &skill_path,
993 "---\nname: example-skill\ndescription: Helps.\nreferences: [references]\n---\n# Skill\n",
994 );
995 for idx in 0..30 {
996 write(&skill_root.join(format!("references/{idx:02}.md")), "reference");
997 }
998 let skill = discover_only(&dir.path().join(".thndrs/skills")).skills.remove(0);
999
1000 let loaded = load_skill(&skill).expect("load skill");
1001 let max_reference_files: usize = SkillConstants::ReferenceFiles.into();
1002 assert_eq!(loaded.activation.loaded_references.len(), max_reference_files);
1003 assert!(
1004 loaded
1005 .diagnostics
1006 .iter()
1007 .any(|diagnostic| { diagnostic.message.contains("reference traversal budget reached") })
1008 );
1009 }
1010}