Skip to main content

thndrs_lib/core/
skills.rs

1//! Agent Skills discovery and bounded loading.
2//!
3//! Skills are filesystem packages. Startup discovery reads only `SKILL.md`
4//! frontmatter, so the prompt can expose compact routing metadata without
5//! loading full instructions. Full Markdown is read only when a skill is
6//! explicitly opened from the UI or a later tool reads the file.
7
8use 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    /// Name collisions resolved by keeping the first skill found in discovery-root order.
93    pub duplicates: Vec<SkillDuplicate>,
94}
95
96/// A duplicate skill name and the deterministic resolution chosen during discovery.
97#[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    /// Skill name from frontmatter.
139    pub name: String,
140    /// Path to the activated `SKILL.md`.
141    pub path: PathBuf,
142    /// Hash of the raw `SKILL.md` file.
143    pub content_hash: u64,
144    /// Byte count of the raw `SKILL.md` file.
145    pub byte_count: usize,
146    /// Hash of the model-visible activation text after references are appended.
147    pub rendered_content_hash: u64,
148    /// Byte count of the model-visible activation text after references are appended.
149    pub rendered_byte_count: usize,
150    /// Metadata for references loaded into the rendered activation text.
151    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}