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}
100
101impl SkillsConfig {
102    /// Enabled, with the default `.git` marker.
103    #[must_use]
104    pub fn enabled() -> Self {
105        Self {
106            enabled: true,
107            root_markers: vec![".git".to_string()],
108            extends_dirs: Vec::new(),
109            extra: Vec::new(),
110        }
111    }
112}
113
114/// What discovery produced.
115#[derive(Debug, Clone, Default)]
116pub struct DiscoveredSkills {
117    /// Listable skills, highest precedence first, ambiguity already resolved.
118    pub skills: Vec<Skill>,
119    /// Skipped files and unusable names — for stderr, never for the model.
120    pub warnings: Vec<String>,
121}
122
123/// Discover every skill visible from `cwd`.
124///
125/// Precedence, highest first: `<repo>/.agents/skills` → `~/.locode/skills` →
126/// `extends` dotfolders (list order) → `extra` entries (list order). Within one
127/// **qualifier**, a higher tier shadows a lower one and the
128/// loser is dropped; across qualifiers both survive and are rendered qualified.
129#[must_use]
130pub fn discover(cwd: &Path, cfg: &SkillsConfig) -> DiscoveredSkills {
131    // Resolve the user root from the environment here — the only env read — so the
132    // assembly below can be driven with injected paths. Same split as
133    // `locode_instructions::load_project_instructions`, and for the same reason: a test
134    // must never see the developer's real `~/.locode/skills`.
135    let user_root = cfg
136        .enabled
137        .then(|| locode_home().ok().map(|home| home.join("skills")))
138        .flatten();
139    discover_impl(cwd, cfg, user_root.as_deref())
140}
141
142fn discover_impl(cwd: &Path, cfg: &SkillsConfig, user_root: Option<&Path>) -> DiscoveredSkills {
143    if !cfg.enabled {
144        return DiscoveredSkills::default();
145    }
146    let mut warnings = Vec::new();
147    let mut found: Vec<Skill> = Vec::new();
148
149    let root = find_root_from_markers(cwd, &cfg.root_markers, None);
150    collect_root(
151        &root.join(PROJECT_SKILLS_DIR[0]).join(PROJECT_SKILLS_DIR[1]),
152        SkillScope::Project,
153        &mut found,
154        &mut warnings,
155    );
156    if let Some(user_root) = user_root {
157        collect_root(user_root, SkillScope::User, &mut found, &mut warnings);
158    }
159    for dir in &cfg.extends_dirs {
160        collect_root(
161            &dir.join("skills"),
162            SkillScope::User,
163            &mut found,
164            &mut warnings,
165        );
166    }
167    for entry in &cfg.extra {
168        match entry {
169            SkillsExtraEntry::Skill(dir) => {
170                collect_skill_dir(dir, SkillScope::Extra, &mut found, &mut warnings);
171            }
172            SkillsExtraEntry::Folder(dir) => {
173                collect_root(dir, SkillScope::Extra, &mut found, &mut warnings);
174            }
175        }
176    }
177
178    // Same qualifier + same name ⇒ the first (highest-precedence) one wins.
179    let mut seen: Vec<(SkillScope, String)> = Vec::new();
180    found.retain(|s| {
181        let key = (s.scope, s.name.clone());
182        if seen.contains(&key) {
183            false
184        } else {
185            seen.push(key);
186            true
187        }
188    });
189    // A skill the user marked model-invisible never reaches the listing (§2).
190    found.retain(|s| !s.disable_model_invocation);
191
192    DiscoveredSkills {
193        skills: found,
194        warnings,
195    }
196}
197
198/// Names carried by more than one scope — these render qualified.
199#[must_use]
200pub fn ambiguous_names(skills: &[Skill]) -> Vec<String> {
201    let mut counts: HashMap<&str, usize> = HashMap::new();
202    for s in skills {
203        *counts.entry(s.name.as_str()).or_default() += 1;
204    }
205    let mut out: Vec<String> = counts
206        .into_iter()
207        .filter(|&(_, n)| n > 1)
208        .map(|(n, _)| n.to_string())
209        .collect();
210    out.sort();
211    out
212}
213
214/// Scan one `skills/` root: every child directory holding a `SKILL.md`.
215fn collect_root(root: &Path, scope: SkillScope, out: &mut Vec<Skill>, warnings: &mut Vec<String>) {
216    let Ok(read) = std::fs::read_dir(root) else {
217        return; // absent roots are normal
218    };
219    let mut dirs: Vec<PathBuf> = read
220        .flatten()
221        .map(|e| e.path())
222        .filter(|p| p.is_dir())
223        .collect();
224    dirs.sort(); // stable listing order regardless of filesystem order
225    for dir in dirs {
226        collect_skill_dir(&dir, scope, out, warnings);
227    }
228}
229
230/// Load one skill directory, if it holds a readable `SKILL.md`.
231fn collect_skill_dir(
232    dir: &Path,
233    scope: SkillScope,
234    out: &mut Vec<Skill>,
235    warnings: &mut Vec<String>,
236) {
237    let path = dir.join(SKILL_FILE);
238    if !path.is_file() {
239        return;
240    }
241    let Ok(source) = std::fs::read_to_string(&path) else {
242        warnings.push(format!("skills: cannot read {}; skipped", path.display()));
243        return;
244    };
245    let Some((fm, _body)) = frontmatter::parse(&source) else {
246        warnings.push(format!(
247            "skills: {} has no `---` frontmatter block; skipped",
248            path.display()
249        ));
250        return;
251    };
252
253    let raw_name = fm.name.as_deref().unwrap_or_default();
254    let name = if raw_name.trim().is_empty() {
255        dir.file_name().map(|n| n.to_string_lossy().to_string())
256    } else {
257        Some(raw_name.to_string())
258    }
259    .map(|n| normalize_name(&n))
260    .filter(|n| !n.is_empty());
261    let Some(name) = name else {
262        warnings.push(format!(
263            "skills: {} has no usable name (frontmatter `name` and directory name both \
264             normalize to empty); skipped",
265            path.display()
266        ));
267        return;
268    };
269
270    let description = fm
271        .description
272        .as_deref()
273        .map(|d| d.trim().to_string())
274        .unwrap_or_default();
275    if description.is_empty() {
276        warnings.push(format!(
277            "skills: {} has no `description`; skipped (the description is what the \
278             model matches a task against)",
279            path.display()
280        ));
281        return;
282    }
283
284    out.push(Skill {
285        name,
286        scope,
287        description,
288        when_to_use: fm
289            .when_to_use
290            .as_deref()
291            .map(|w| w.trim().to_string())
292            .filter(|w| !w.is_empty()),
293        path,
294        disable_model_invocation: fm.disable_model_invocation(),
295        user_invocable: fm.user_invocable(),
296    });
297}
298
299/// Slug: lowercase, every non-`[a-z0-9]` → `-`, collapse runs, trim, cap at 64.
300///
301/// grok's `normalize_skill_name` verbatim (`discovery.rs:333-348`) — it keeps a
302/// human-written `name: Review PR` usable as `review-pr` instead of dropping the skill.
303#[must_use]
304pub fn normalize_name(name: &str) -> String {
305    let mut out = String::with_capacity(name.len());
306    for c in name.trim().chars() {
307        let c = c.to_ascii_lowercase();
308        let c = if c.is_ascii_lowercase() || c.is_ascii_digit() {
309            c
310        } else {
311            '-'
312        };
313        if c == '-' && out.ends_with('-') {
314            continue;
315        }
316        out.push(c);
317    }
318    let out = out.trim_matches('-');
319    out.chars().take(MAX_NAME_LEN).collect::<String>()
320}
321
322#[cfg(test)]
323mod tests {
324    use super::*;
325    use std::fs;
326    use tempfile::TempDir;
327
328    fn tmp() -> (TempDir, PathBuf) {
329        let d = TempDir::new().unwrap();
330        let p = std::fs::canonicalize(d.path()).unwrap();
331        (d, p)
332    }
333
334    /// Write `<root>/<name>/SKILL.md` with the given frontmatter body.
335    fn skill(root: &Path, name: &str, frontmatter: &str) {
336        let dir = root.join(name);
337        fs::create_dir_all(&dir).unwrap();
338        fs::write(
339            dir.join(SKILL_FILE),
340            format!("---\n{frontmatter}\n---\n# {name}\n"),
341        )
342        .unwrap();
343    }
344
345    fn cfg() -> SkillsConfig {
346        SkillsConfig::enabled()
347    }
348
349    /// Discover with **no** user root, so the dev machine's real `~/.locode/skills`
350    /// can never leak into a test.
351    fn discover_no_home(cwd: &Path, cfg: &SkillsConfig) -> DiscoveredSkills {
352        discover_impl(cwd, cfg, None)
353    }
354
355    fn names(d: &DiscoveredSkills) -> Vec<&str> {
356        d.skills.iter().map(|s| s.name.as_str()).collect()
357    }
358
359    #[test]
360    fn finds_project_skills_sorted_and_reads_the_five_keys() {
361        let (_g, repo) = tmp();
362        fs::create_dir(repo.join(".git")).unwrap();
363        let root = repo.join(".agents/skills");
364        skill(&root, "zebra", "name: zebra\ndescription: Z");
365        skill(
366            &root,
367            "commit",
368            "name: commit\ndescription: Make a commit\nwhen-to-use: on push\nuser-invocable: false",
369        );
370
371        let got = discover_no_home(&repo, &cfg());
372        assert_eq!(names(&got), vec!["commit", "zebra"], "sorted, stable");
373        let c = &got.skills[0];
374        assert_eq!(c.scope, SkillScope::Project);
375        assert_eq!(c.description, "Make a commit");
376        assert_eq!(c.when_to_use.as_deref(), Some("on push"));
377        assert!(!c.user_invocable, "parsed, even though it is inert in v1");
378        assert!(c.path.ends_with("commit/SKILL.md"));
379    }
380
381    #[test]
382    fn name_falls_back_to_the_directory_and_is_slugified() {
383        let (_g, repo) = tmp();
384        fs::create_dir(repo.join(".git")).unwrap();
385        let root = repo.join(".agents/skills");
386        skill(&root, "My_Skill", "description: D"); // no frontmatter name
387        skill(&root, "other", "name: Review PR\ndescription: D");
388
389        let got = discover_no_home(&repo, &cfg());
390        let mut n = names(&got);
391        n.sort_unstable();
392        assert_eq!(n, vec!["my-skill", "review-pr"]);
393    }
394
395    #[test]
396    fn disable_model_invocation_hides_the_skill_entirely() {
397        let (_g, repo) = tmp();
398        fs::create_dir(repo.join(".git")).unwrap();
399        let root = repo.join(".agents/skills");
400        skill(&root, "shown", "description: D");
401        skill(
402            &root,
403            "hidden",
404            "description: D\ndisable-model-invocation: true",
405        );
406
407        let got = discover_no_home(&repo, &cfg());
408        assert_eq!(names(&got), vec!["shown"]);
409    }
410
411    #[test]
412    fn a_broken_skill_is_skipped_with_a_warning_and_does_not_stop_the_others() {
413        let (_g, repo) = tmp();
414        fs::create_dir(repo.join(".git")).unwrap();
415        let root = repo.join(".agents/skills");
416        skill(&root, "good", "description: D");
417        // No frontmatter at all.
418        let bad = root.join("bad");
419        fs::create_dir_all(&bad).unwrap();
420        fs::write(bad.join(SKILL_FILE), "# just markdown\n").unwrap();
421        // Frontmatter, but no description — nothing for the model to match on.
422        skill(&root, "nodesc", "name: nodesc");
423
424        let got = discover_no_home(&repo, &cfg());
425        assert_eq!(names(&got), vec!["good"]);
426        let w = got.warnings.join(" | ");
427        assert!(w.contains("frontmatter"), "{w}");
428        assert!(w.contains("description"), "{w}");
429    }
430
431    /// `extends` skills are `user`-scoped but rank *below* `~/.locode/skills`; an
432    /// `extra` folder is its own scope and survives a same-name collision.
433    #[test]
434    fn precedence_within_a_scope_drops_the_loser_across_scopes_keeps_both() {
435        let (_g, repo) = tmp();
436        fs::create_dir(repo.join(".git")).unwrap();
437        skill(
438            &repo.join(".agents/skills"),
439            "commit",
440            "description: project",
441        );
442        let (_e, team) = tmp();
443        skill(&team.join("skills"), "commit", "description: team");
444        let (_x, extra) = tmp();
445        skill(&extra, "commit", "description: extra");
446
447        let mut c = cfg();
448        c.extends_dirs = vec![team];
449        c.extra = vec![SkillsExtraEntry::Folder(extra)];
450        let got = discover_no_home(&repo, &c);
451
452        let by_scope: Vec<_> = got
453            .skills
454            .iter()
455            .map(|s| (s.scope, &*s.description))
456            .collect();
457        assert_eq!(
458            by_scope,
459            vec![
460                (SkillScope::Project, "project"),
461                (SkillScope::User, "team"),
462                (SkillScope::Extra, "extra"),
463            ],
464            "one per scope survives"
465        );
466        assert_eq!(ambiguous_names(&got.skills), vec!["commit"]);
467        assert_eq!(got.skills[1].display_name(true), "user:commit");
468    }
469
470    #[test]
471    fn a_single_skill_extra_entry_points_straight_at_the_skill_dir() {
472        let (_g, repo) = tmp();
473        fs::create_dir(repo.join(".git")).unwrap();
474        let (_x, dir) = tmp();
475        let one = dir.join("oneoff");
476        fs::create_dir_all(&one).unwrap();
477        fs::write(one.join(SKILL_FILE), "---\ndescription: D\n---\n").unwrap();
478
479        let mut c = cfg();
480        c.extra = vec![SkillsExtraEntry::Skill(one)];
481        let got = discover_no_home(&repo, &c);
482        assert_eq!(names(&got), vec!["oneoff"]);
483        assert_eq!(got.skills[0].scope, SkillScope::Extra);
484    }
485
486    #[test]
487    fn disabled_config_touches_nothing() {
488        let (_g, repo) = tmp();
489        fs::create_dir(repo.join(".git")).unwrap();
490        skill(&repo.join(".agents/skills"), "commit", "description: D");
491        let got = discover_no_home(&repo, &SkillsConfig::default());
492        assert!(got.skills.is_empty() && got.warnings.is_empty());
493    }
494
495    /// The user root is injected, so a real `~/.locode/skills` cannot affect a run —
496    /// and an `extends` dotfolder's skills land in the same `user` scope but below it.
497    #[test]
498    fn user_root_and_extends_share_the_user_scope_with_the_home_root_winning() {
499        let (_g, repo) = tmp();
500        fs::create_dir(repo.join(".git")).unwrap();
501        let (_h, home) = tmp();
502        skill(&home.join("skills"), "commit", "description: home");
503        let (_e, team) = tmp();
504        skill(&team.join("skills"), "commit", "description: team");
505
506        let mut c = cfg();
507        c.extends_dirs = vec![team];
508        let got = discover_impl(&repo, &c, Some(&home.join("skills")));
509        assert_eq!(
510            got.skills
511                .iter()
512                .map(|s| &*s.description)
513                .collect::<Vec<_>>(),
514            vec!["home"],
515            "same scope ⇒ the home root shadows the extended dotfolder"
516        );
517    }
518
519    /// Project skills live in the **cross-agent** `.agents/skills`, not `.locode/skills`
520    /// — a repo's skills should be findable by any agent, while settings stay ours.
521    #[test]
522    fn project_skills_come_from_dot_agents_not_dot_locode() {
523        let (_g, repo) = tmp();
524        fs::create_dir(repo.join(".git")).unwrap();
525        skill(&repo.join(".agents/skills"), "shared", "description: D");
526        skill(&repo.join(".locode/skills"), "ours", "description: D");
527
528        let got = discover_no_home(&repo, &cfg());
529        assert_eq!(
530            names(&got),
531            vec!["shared"],
532            "`.locode/skills` is not a project skills root"
533        );
534    }
535
536    #[test]
537    fn slug_rules() {
538        assert_eq!(normalize_name("Review PR"), "review-pr");
539        assert_eq!(normalize_name("tool-v1.2"), "tool-v1-2");
540        assert_eq!(normalize_name("__weird__"), "weird");
541        assert_eq!(normalize_name("---"), "");
542        assert_eq!(normalize_name(&"a".repeat(100)).len(), 64);
543    }
544}