Skip to main content

locode_skills/
discover.rs

1//! Skill discovery: the four roots, the five-key contract, precedence, and collisions
2//! (ADR-0025 §2).
3
4use std::collections::HashMap;
5use std::path::{Path, PathBuf};
6
7use locode_host::{SkillsExtraEntry, find_root_from_markers, locode_home};
8
9use crate::frontmatter;
10
11/// The canonical per-skill file.
12pub const SKILL_FILE: &str = "SKILL.md";
13/// The **project** skills directory: `<repo>/.agents/skills`.
14///
15/// Deliberately not `<repo>/.locode/skills`, unlike every other project-scoped thing
16/// we read (ADR-0025 §2 amendment 2026-07-24). `.agents/` is the cross-agent
17/// convention: codex scans `<root>/.agents/skills` via its own `AGENTS_DIR_NAME`
18/// constant, and grok hard-codes `.agents` alongside `.grok` as an always-scanned
19/// config root (`.claude` is merely opt-in compat). A skill is a portable artifact —
20/// the format is the one thing all four harnesses converged on — so a repo's skills
21/// belong where any agent can find them. Settings stay under `.locode/`, because those
22/// are ours alone.
23pub const PROJECT_SKILLS_DIR: [&str; 2] = [".agents", "skills"];
24/// Slug cap, grok's `MAX_NAME_LEN` (`discovery.rs:16`).
25const MAX_NAME_LEN: usize = 64;
26
27/// Where a skill came from — and, on a collision, how it is addressed.
28///
29/// Three scopes, not four: an `extends` dotfolder's skills are **`User`**, because
30/// `extends` is how a user composes their own configuration rather than a separate
31/// authority (ADR-0025 §2, user decision). It still ranks *below* `~/.locode/skills`
32/// in precedence — same qualifier, different tier.
33#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
34pub enum SkillScope {
35    /// `<repo>/.agents/skills/` — the cross-agent location (see [`PROJECT_SKILLS_DIR`]).
36    Project,
37    /// `~/.locode/skills/`, and every `extends` dotfolder's `skills/`.
38    User,
39    /// A `skills.extra` entry.
40    Extra,
41}
42
43impl SkillScope {
44    /// The qualifier used in `<scope>:<name>`.
45    #[must_use]
46    pub fn as_str(self) -> &'static str {
47        match self {
48            SkillScope::Project => "project",
49            SkillScope::User => "user",
50            SkillScope::Extra => "extra",
51        }
52    }
53}
54
55/// One discovered skill.
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct Skill {
58    /// Slug-normalized identity (ADR-0025 §2).
59    pub name: String,
60    /// Which tier it came from, and its collision qualifier.
61    pub scope: SkillScope,
62    /// The router text shown in the listing.
63    pub description: String,
64    /// Optional trigger phrasing, rendered as a separate `Use when:` line.
65    pub when_to_use: Option<String>,
66    /// Absolute path to the `SKILL.md` — this *is* the invocation mechanism
67    /// (ADR-0025 §4: the model reads it).
68    pub path: PathBuf,
69    /// `true` ⇒ never listed, so the model never learns it exists.
70    pub disable_model_invocation: bool,
71    /// Parsed and carried; **no observable effect until slash invocation exists**.
72    pub user_invocable: bool,
73}
74
75impl Skill {
76    /// `name`, or `scope:name` when another scope also has this name.
77    #[must_use]
78    pub fn display_name(&self, ambiguous: bool) -> String {
79        if ambiguous {
80            format!("{}:{}", self.scope.as_str(), self.name)
81        } else {
82            self.name.clone()
83        }
84    }
85}
86
87/// Inputs for [`discover`] — all of it derived from resolved settings, which is what
88/// makes the load order an invariant (ADR-0025 §6.1).
89#[derive(Debug, Clone, Default)]
90pub struct SkillsConfig {
91    /// Master switch. `false` ⇒ discovery returns nothing and touches no disk.
92    pub enabled: bool,
93    /// Root markers for locating `<repo>` (default `[".git"]`).
94    pub root_markers: Vec<String>,
95    /// Resolved `extends` dotfolders (`SettingsLoad::extends_dirs`).
96    pub extends_dirs: Vec<PathBuf>,
97    /// `skills.extra` entries from settings.
98    pub extra: Vec<SkillsExtraEntry>,
99    /// Extra project roots (`--add-dir`). Each contributes its own
100    /// `.agents/skills` at `Project` scope, so a directory added on the command
101    /// line brings its skills with it.
102    pub extra_roots: Vec<PathBuf>,
103}
104
105impl SkillsConfig {
106    /// Enabled, with the default `.git` marker.
107    #[must_use]
108    pub fn enabled() -> Self {
109        Self {
110            enabled: true,
111            root_markers: vec![".git".to_string()],
112            extends_dirs: Vec::new(),
113            extra_roots: Vec::new(),
114            extra: Vec::new(),
115        }
116    }
117}
118
119/// What discovery produced.
120#[derive(Debug, Clone, Default)]
121pub struct DiscoveredSkills {
122    /// Listable skills, highest precedence first, ambiguity already resolved.
123    pub skills: Vec<Skill>,
124    /// Skipped files and unusable names — for stderr, never for the model.
125    pub warnings: Vec<String>,
126}
127
128/// Discover every skill visible from `cwd`.
129///
130/// Precedence, highest first: `<repo>/.agents/skills` → `~/.locode/skills` →
131/// `extends` dotfolders (list order) → `extra` entries (list order). Within one
132/// **qualifier**, a higher tier shadows a lower one and the
133/// loser is dropped; across qualifiers both survive and are rendered qualified.
134#[must_use]
135pub fn discover(cwd: &Path, cfg: &SkillsConfig) -> DiscoveredSkills {
136    // Resolve the user root from the environment here — the only env read — so the
137    // assembly below can be driven with injected paths. Same split as
138    // `locode_instructions::load_project_instructions`, and for the same reason: a test
139    // must never see the developer's real `~/.locode/skills`.
140    let user_root = cfg
141        .enabled
142        .then(|| locode_home().ok().map(|home| home.join("skills")))
143        .flatten();
144    discover_impl(cwd, cfg, user_root.as_deref())
145}
146
147fn discover_impl(cwd: &Path, cfg: &SkillsConfig, user_root: Option<&Path>) -> DiscoveredSkills {
148    if !cfg.enabled {
149        return DiscoveredSkills::default();
150    }
151    let mut warnings = Vec::new();
152    let mut found: Vec<Skill> = Vec::new();
153
154    let root = find_root_from_markers(cwd, &cfg.root_markers, None);
155    collect_root(
156        &root.join(PROJECT_SKILLS_DIR[0]).join(PROJECT_SKILLS_DIR[1]),
157        SkillScope::Project,
158        &mut found,
159        &mut warnings,
160    );
161    if let Some(user_root) = user_root {
162        collect_root(user_root, SkillScope::User, &mut found, &mut warnings);
163    }
164    for dir in &cfg.extends_dirs {
165        collect_root(
166            &dir.join("skills"),
167            SkillScope::User,
168            &mut found,
169            &mut warnings,
170        );
171    }
172    for dir in &cfg.extra_roots {
173        collect_root(
174            &dir.join(PROJECT_SKILLS_DIR[0]).join(PROJECT_SKILLS_DIR[1]),
175            SkillScope::Project,
176            &mut found,
177            &mut warnings,
178        );
179    }
180    for entry in &cfg.extra {
181        match entry {
182            SkillsExtraEntry::Skill(dir) => {
183                collect_skill_dir(dir, SkillScope::Extra, &mut found, &mut warnings);
184            }
185            SkillsExtraEntry::Folder(dir) => {
186                collect_root(dir, SkillScope::Extra, &mut found, &mut warnings);
187            }
188        }
189    }
190
191    // Same qualifier + same name ⇒ the first (highest-precedence) one wins.
192    let mut seen: Vec<(SkillScope, String)> = Vec::new();
193    found.retain(|s| {
194        let key = (s.scope, s.name.clone());
195        if seen.contains(&key) {
196            false
197        } else {
198            seen.push(key);
199            true
200        }
201    });
202    // A skill the user marked model-invisible never reaches the listing (§2).
203    found.retain(|s| !s.disable_model_invocation);
204
205    DiscoveredSkills {
206        skills: found,
207        warnings,
208    }
209}
210
211/// Names carried by more than one scope — these render qualified.
212#[must_use]
213pub fn ambiguous_names(skills: &[Skill]) -> Vec<String> {
214    let mut counts: HashMap<&str, usize> = HashMap::new();
215    for s in skills {
216        *counts.entry(s.name.as_str()).or_default() += 1;
217    }
218    let mut out: Vec<String> = counts
219        .into_iter()
220        .filter(|&(_, n)| n > 1)
221        .map(|(n, _)| n.to_string())
222        .collect();
223    out.sort();
224    out
225}
226
227/// Scan one `skills/` root: every child directory holding a `SKILL.md`.
228fn collect_root(root: &Path, scope: SkillScope, out: &mut Vec<Skill>, warnings: &mut Vec<String>) {
229    let Ok(read) = std::fs::read_dir(root) else {
230        return; // absent roots are normal
231    };
232    let mut dirs: Vec<PathBuf> = read
233        .flatten()
234        .map(|e| e.path())
235        .filter(|p| p.is_dir())
236        .collect();
237    dirs.sort(); // stable listing order regardless of filesystem order
238    for dir in dirs {
239        collect_skill_dir(&dir, scope, out, warnings);
240    }
241}
242
243/// Load one skill directory, if it holds a readable `SKILL.md`.
244fn collect_skill_dir(
245    dir: &Path,
246    scope: SkillScope,
247    out: &mut Vec<Skill>,
248    warnings: &mut Vec<String>,
249) {
250    let path = dir.join(SKILL_FILE);
251    if !path.is_file() {
252        return;
253    }
254    let Ok(source) = std::fs::read_to_string(&path) else {
255        warnings.push(format!("skills: cannot read {}; skipped", path.display()));
256        return;
257    };
258    let Some((fm, _body)) = frontmatter::parse(&source) else {
259        warnings.push(format!(
260            "skills: {} has no `---` frontmatter block; skipped",
261            path.display()
262        ));
263        return;
264    };
265
266    let raw_name = fm.name.as_deref().unwrap_or_default();
267    let name = if raw_name.trim().is_empty() {
268        dir.file_name().map(|n| n.to_string_lossy().to_string())
269    } else {
270        Some(raw_name.to_string())
271    }
272    .map(|n| normalize_name(&n))
273    .filter(|n| !n.is_empty());
274    let Some(name) = name else {
275        warnings.push(format!(
276            "skills: {} has no usable name (frontmatter `name` and directory name both \
277             normalize to empty); skipped",
278            path.display()
279        ));
280        return;
281    };
282
283    let description = fm
284        .description
285        .as_deref()
286        .map(|d| d.trim().to_string())
287        .unwrap_or_default();
288    if description.is_empty() {
289        warnings.push(format!(
290            "skills: {} has no `description`; skipped (the description is what the \
291             model matches a task against)",
292            path.display()
293        ));
294        return;
295    }
296
297    out.push(Skill {
298        name,
299        scope,
300        description,
301        when_to_use: fm
302            .when_to_use
303            .as_deref()
304            .map(|w| w.trim().to_string())
305            .filter(|w| !w.is_empty()),
306        path,
307        disable_model_invocation: fm.disable_model_invocation(),
308        user_invocable: fm.user_invocable(),
309    });
310}
311
312/// Slug: lowercase, every non-`[a-z0-9]` → `-`, collapse runs, trim, cap at 64.
313///
314/// grok's `normalize_skill_name` verbatim (`discovery.rs:333-348`) — it keeps a
315/// human-written `name: Review PR` usable as `review-pr` instead of dropping the skill.
316#[must_use]
317pub fn normalize_name(name: &str) -> String {
318    let mut out = String::with_capacity(name.len());
319    for c in name.trim().chars() {
320        let c = c.to_ascii_lowercase();
321        let c = if c.is_ascii_lowercase() || c.is_ascii_digit() {
322            c
323        } else {
324            '-'
325        };
326        if c == '-' && out.ends_with('-') {
327            continue;
328        }
329        out.push(c);
330    }
331    let out = out.trim_matches('-');
332    out.chars().take(MAX_NAME_LEN).collect::<String>()
333}
334
335#[cfg(test)]
336mod tests {
337    use super::*;
338    use std::fs;
339    use tempfile::TempDir;
340
341    fn tmp() -> (TempDir, PathBuf) {
342        let d = TempDir::new().unwrap();
343        let p = std::fs::canonicalize(d.path()).unwrap();
344        (d, p)
345    }
346
347    /// Write `<root>/<name>/SKILL.md` with the given frontmatter body.
348    fn skill(root: &Path, name: &str, frontmatter: &str) {
349        let dir = root.join(name);
350        fs::create_dir_all(&dir).unwrap();
351        fs::write(
352            dir.join(SKILL_FILE),
353            format!("---\n{frontmatter}\n---\n# {name}\n"),
354        )
355        .unwrap();
356    }
357
358    fn cfg() -> SkillsConfig {
359        SkillsConfig::enabled()
360    }
361
362    /// Discover with **no** user root, so the dev machine's real `~/.locode/skills`
363    /// can never leak into a test.
364    fn discover_no_home(cwd: &Path, cfg: &SkillsConfig) -> DiscoveredSkills {
365        discover_impl(cwd, cfg, None)
366    }
367
368    fn names(d: &DiscoveredSkills) -> Vec<&str> {
369        d.skills.iter().map(|s| s.name.as_str()).collect()
370    }
371
372    #[test]
373    fn finds_project_skills_sorted_and_reads_the_five_keys() {
374        let (_g, repo) = tmp();
375        fs::create_dir(repo.join(".git")).unwrap();
376        let root = repo.join(".agents/skills");
377        skill(&root, "zebra", "name: zebra\ndescription: Z");
378        skill(
379            &root,
380            "commit",
381            "name: commit\ndescription: Make a commit\nwhen-to-use: on push\nuser-invocable: false",
382        );
383
384        let got = discover_no_home(&repo, &cfg());
385        assert_eq!(names(&got), vec!["commit", "zebra"], "sorted, stable");
386        let c = &got.skills[0];
387        assert_eq!(c.scope, SkillScope::Project);
388        assert_eq!(c.description, "Make a commit");
389        assert_eq!(c.when_to_use.as_deref(), Some("on push"));
390        assert!(!c.user_invocable, "parsed, even though it is inert in v1");
391        assert!(c.path.ends_with("commit/SKILL.md"));
392    }
393
394    #[test]
395    fn name_falls_back_to_the_directory_and_is_slugified() {
396        let (_g, repo) = tmp();
397        fs::create_dir(repo.join(".git")).unwrap();
398        let root = repo.join(".agents/skills");
399        skill(&root, "My_Skill", "description: D"); // no frontmatter name
400        skill(&root, "other", "name: Review PR\ndescription: D");
401
402        let got = discover_no_home(&repo, &cfg());
403        let mut n = names(&got);
404        n.sort_unstable();
405        assert_eq!(n, vec!["my-skill", "review-pr"]);
406    }
407
408    #[test]
409    fn disable_model_invocation_hides_the_skill_entirely() {
410        let (_g, repo) = tmp();
411        fs::create_dir(repo.join(".git")).unwrap();
412        let root = repo.join(".agents/skills");
413        skill(&root, "shown", "description: D");
414        skill(
415            &root,
416            "hidden",
417            "description: D\ndisable-model-invocation: true",
418        );
419
420        let got = discover_no_home(&repo, &cfg());
421        assert_eq!(names(&got), vec!["shown"]);
422    }
423
424    #[test]
425    fn a_broken_skill_is_skipped_with_a_warning_and_does_not_stop_the_others() {
426        let (_g, repo) = tmp();
427        fs::create_dir(repo.join(".git")).unwrap();
428        let root = repo.join(".agents/skills");
429        skill(&root, "good", "description: D");
430        // No frontmatter at all.
431        let bad = root.join("bad");
432        fs::create_dir_all(&bad).unwrap();
433        fs::write(bad.join(SKILL_FILE), "# just markdown\n").unwrap();
434        // Frontmatter, but no description — nothing for the model to match on.
435        skill(&root, "nodesc", "name: nodesc");
436
437        let got = discover_no_home(&repo, &cfg());
438        assert_eq!(names(&got), vec!["good"]);
439        let w = got.warnings.join(" | ");
440        assert!(w.contains("frontmatter"), "{w}");
441        assert!(w.contains("description"), "{w}");
442    }
443
444    /// `extends` skills are `user`-scoped but rank *below* `~/.locode/skills`; an
445    /// `extra` folder is its own scope and survives a same-name collision.
446    #[test]
447    fn precedence_within_a_scope_drops_the_loser_across_scopes_keeps_both() {
448        let (_g, repo) = tmp();
449        fs::create_dir(repo.join(".git")).unwrap();
450        skill(
451            &repo.join(".agents/skills"),
452            "commit",
453            "description: project",
454        );
455        let (_e, team) = tmp();
456        skill(&team.join("skills"), "commit", "description: team");
457        let (_x, extra) = tmp();
458        skill(&extra, "commit", "description: extra");
459
460        let mut c = cfg();
461        c.extends_dirs = vec![team];
462        c.extra = vec![SkillsExtraEntry::Folder(extra)];
463        let got = discover_no_home(&repo, &c);
464
465        let by_scope: Vec<_> = got
466            .skills
467            .iter()
468            .map(|s| (s.scope, &*s.description))
469            .collect();
470        assert_eq!(
471            by_scope,
472            vec![
473                (SkillScope::Project, "project"),
474                (SkillScope::User, "team"),
475                (SkillScope::Extra, "extra"),
476            ],
477            "one per scope survives"
478        );
479        assert_eq!(ambiguous_names(&got.skills), vec!["commit"]);
480        assert_eq!(got.skills[1].display_name(true), "user:commit");
481    }
482
483    #[test]
484    fn a_single_skill_extra_entry_points_straight_at_the_skill_dir() {
485        let (_g, repo) = tmp();
486        fs::create_dir(repo.join(".git")).unwrap();
487        let (_x, dir) = tmp();
488        let one = dir.join("oneoff");
489        fs::create_dir_all(&one).unwrap();
490        fs::write(one.join(SKILL_FILE), "---\ndescription: D\n---\n").unwrap();
491
492        let mut c = cfg();
493        c.extra = vec![SkillsExtraEntry::Skill(one)];
494        let got = discover_no_home(&repo, &c);
495        assert_eq!(names(&got), vec!["oneoff"]);
496        assert_eq!(got.skills[0].scope, SkillScope::Extra);
497    }
498
499    #[test]
500    fn disabled_config_touches_nothing() {
501        let (_g, repo) = tmp();
502        fs::create_dir(repo.join(".git")).unwrap();
503        skill(&repo.join(".agents/skills"), "commit", "description: D");
504        let got = discover_no_home(&repo, &SkillsConfig::default());
505        assert!(got.skills.is_empty() && got.warnings.is_empty());
506    }
507
508    /// The user root is injected, so a real `~/.locode/skills` cannot affect a run —
509    /// and an `extends` dotfolder's skills land in the same `user` scope but below it.
510    #[test]
511    fn user_root_and_extends_share_the_user_scope_with_the_home_root_winning() {
512        let (_g, repo) = tmp();
513        fs::create_dir(repo.join(".git")).unwrap();
514        let (_h, home) = tmp();
515        skill(&home.join("skills"), "commit", "description: home");
516        let (_e, team) = tmp();
517        skill(&team.join("skills"), "commit", "description: team");
518
519        let mut c = cfg();
520        c.extends_dirs = vec![team];
521        let got = discover_impl(&repo, &c, Some(&home.join("skills")));
522        assert_eq!(
523            got.skills
524                .iter()
525                .map(|s| &*s.description)
526                .collect::<Vec<_>>(),
527            vec!["home"],
528            "same scope ⇒ the home root shadows the extended dotfolder"
529        );
530    }
531
532    /// Project skills live in the **cross-agent** `.agents/skills`, not `.locode/skills`
533    /// — a repo's skills should be findable by any agent, while settings stay ours.
534    #[test]
535    fn project_skills_come_from_dot_agents_not_dot_locode() {
536        let (_g, repo) = tmp();
537        fs::create_dir(repo.join(".git")).unwrap();
538        skill(&repo.join(".agents/skills"), "shared", "description: D");
539        skill(&repo.join(".locode/skills"), "ours", "description: D");
540
541        let got = discover_no_home(&repo, &cfg());
542        assert_eq!(
543            names(&got),
544            vec!["shared"],
545            "`.locode/skills` is not a project skills root"
546        );
547    }
548
549    #[test]
550    fn slug_rules() {
551        assert_eq!(normalize_name("Review PR"), "review-pr");
552        assert_eq!(normalize_name("tool-v1.2"), "tool-v1-2");
553        assert_eq!(normalize_name("__weird__"), "weird");
554        assert_eq!(normalize_name("---"), "");
555        assert_eq!(normalize_name(&"a".repeat(100)).len(), 64);
556    }
557}