Skip to main content

basis/
skills.rs

1//! Discovery of the skills directories.
2//!
3//! Skills are on-demand instructions the model loads by name — description
4//! first, body only when it asks. mentra owns the loading (frontmatter
5//! parsing, dedup, the `load_skill` tool); basis owns *where to look*, which is
6//! the convention half.
7//!
8//! # Four roots, one order
9//!
10//! The whole point of the SKILL.md format is that a skill written once is found
11//! by every harness that speaks it, so *where to look* is not basis's to invent:
12//! `.agents/skills/` and `~/.agents/skills/` are the directories pi and Claude
13//! Code already read, and a repository that carries one is making a statement
14//! basis has no reason to ignore. `.basis/skills/` and the global config
15//! directory's `skills/` are what basis added on top, and they stay — a
16//! workspace that wants skills for *this* harness alone has to be able to say
17//! so.
18//!
19//! [`discover`] returns every one that exists, most specific first:
20//!
21//! 1. `<workspace>/.basis/skills` — this repository, this harness
22//! 2. `<workspace>/.agents/skills` — this repository, any harness
23//! 3. `<global config dir>/skills` — this user, this harness
24//! 4. `$HOME/.agents/skills` — this user, any harness
25//!
26//! Within each scope the basis-specific root comes first because naming basis
27//! is the more specific statement: a directory that says *this harness* was
28//! written knowing which harness would read it. Roots layer rather than
29//! replace (`Runtime::register_skills_dirs` is additive, and an earlier root's
30//! name wins), so a repository shadows a personal skill by name and inherits
31//! everything it did not shadow.
32//!
33//! # A root is held, not given away
34//!
35//! Registration is on the *runtime*, which on a shared one (ADR-0018) outlives
36//! every workspace opened on it. So a workspace holds its roots rather than
37//! handing them over: an internal hold releases them when the `Workspace` drops,
38//! and the runtime takes a root off mentra's registry once its last holder
39//! goes. What that buys is a host — an editor server, an ACP session — that
40//! can close a repository without its skills outliving it, and without taking
41//! the user's own `~/.agents/skills` away from the repositories still open.
42//!
43//! # `disable-model-invocation`
44//!
45//! A `SKILL.md` may set `disable-model-invocation: true` in its frontmatter
46//! (the underscore spelling is accepted too). mentra then leaves the skill out
47//! of the list the model is shown and refuses it from `load_skill`, while
48//! keeping it in `Runtime::skills()` with `model_invocable: false` — a skill a
49//! person invokes deliberately rather than one a model reaches for.
50//!
51//! basis reads the flag nowhere: discovery is about *where to look*, and what
52//! a `SKILL.md` says is mentra's to parse. What basis does is carry the answer
53//! out, on [`LoadedSkill::model_invocable`](crate::run::LoadedSkill), so a host
54//! listing a workspace's skills can tell which of them the model will never
55//! reach. It is deliberately **not** offered as a `/name` command beside
56//! templates: rendering one would mean re-reading and re-parsing the
57//! `SKILL.md` basis handed mentra (mentra exposes the body of a skill it will
58//! not invoke through no API at all), and giving it an argument convention
59//! `SKILL.md` does not define — which is [`crate::templates`] a second time,
60//! under a second set of rules.
61
62mod registration;
63
64use std::path::{Path, PathBuf};
65
66use crate::context::ContextScope;
67
68pub(crate) use registration::SkillRoots;
69
70/// Where basis looks inside a workspace, relative to its root.
71pub const DEFAULT_WORKSPACE_SKILLS_DIR: &str = ".basis/skills";
72
73/// The directory other harnesses share, relative to the workspace root and to
74/// `$HOME`. One constant because it is one convention: the same spelling names
75/// the project scope and the user scope, which is what makes it portable.
76pub const SHARED_SKILLS_DIR: &str = ".agents/skills";
77
78/// Where basis looks inside the global config directory.
79pub const DEFAULT_GLOBAL_SKILLS_DIR: &str = "skills";
80
81/// How to look for skills.
82///
83/// Four fields, one per root (D9): the shared `.agents/skills` roots carry no
84/// path of their own — a fixed spelling is what makes them shared, and
85/// relocating one would stop it being the convention `.agents/skills` names —
86/// so those two are plain switches, on by default. The two basis-specific
87/// roots carry the path *and* the switch in one `Option`: `None` disables the
88/// root, `Some(path)` both enables it and says where. Every root disables
89/// independently of the other three.
90#[derive(Debug, Clone, PartialEq, Eq)]
91pub struct SkillsConfig {
92    /// `<workspace>/.basis/skills`, relative to the workspace root. `None`
93    /// disables this root alone.
94    pub workspace_subdir: Option<PathBuf>,
95    /// Whether `<workspace>/.agents/skills` — the convention `pi` and Claude
96    /// Code already read — is one of the roots.
97    pub shared_workspace_dir: bool,
98    /// The global config directory, if any. `skills/` inside it is the third
99    /// root; `None` disables that root alone and says nothing about
100    /// [`shared_home_dir`](Self::shared_home_dir).
101    pub global_dir: Option<PathBuf>,
102    /// Whether `$HOME/.agents/skills` — the shared convention's personal
103    /// scope — is one of the roots. Gated on its own switch rather than on
104    /// [`global_dir`](Self::global_dir), unlike before D9: a caller can now
105    /// keep this while disabling basis's own global root, or the reverse.
106    pub shared_home_dir: bool,
107}
108
109impl Default for SkillsConfig {
110    fn default() -> Self {
111        Self {
112            workspace_subdir: Some(PathBuf::from(DEFAULT_WORKSPACE_SKILLS_DIR)),
113            shared_workspace_dir: true,
114            global_dir: crate::context::default_global_dir(),
115            shared_home_dir: true,
116        }
117    }
118}
119
120impl SkillsConfig {
121    /// No skill discovery at all: none of the four roots is read, so nothing
122    /// is registered on the runtime and the model is offered no `load_skill`.
123    ///
124    /// What `WorkspaceBuilder::without_discovery` leaves of this config.
125    /// Skills are directories on disk with no host-supplied half, so there is
126    /// nothing here for `none` to keep.
127    pub fn none() -> Self {
128        Self {
129            workspace_subdir: None,
130            shared_workspace_dir: false,
131            global_dir: None,
132            shared_home_dir: false,
133        }
134    }
135}
136
137/// A skills directory that exists on disk.
138#[derive(Debug, Clone, PartialEq, Eq)]
139pub struct SkillsSource {
140    pub path: PathBuf,
141    pub scope: ContextScope,
142}
143
144/// Every skills directory that exists, most specific first.
145///
146/// Precedence order, because that is the order
147/// `Runtime::register_skills_dirs` wants: an earlier root's skill shadows the
148/// same name in a later one.
149pub fn discover(workspace: &Path, config: &SkillsConfig) -> Vec<SkillsSource> {
150    discover_in(workspace, config, user_home().as_deref())
151}
152
153/// [`discover`] with the user's home directory passed in rather than read from
154/// the environment, so a test can state one instead of inheriting whichever
155/// machine it runs on.
156fn discover_in(workspace: &Path, config: &SkillsConfig, home: Option<&Path>) -> Vec<SkillsSource> {
157    let mut sources = Vec::new();
158
159    if let Some(subdir) = &config.workspace_subdir {
160        push(
161            &mut sources,
162            workspace.join(subdir),
163            ContextScope::Workspace,
164        );
165    }
166    if config.shared_workspace_dir {
167        push(
168            &mut sources,
169            workspace.join(SHARED_SKILLS_DIR),
170            ContextScope::Workspace,
171        );
172    }
173
174    if let Some(global) = &config.global_dir {
175        push(
176            &mut sources,
177            global.join(DEFAULT_GLOBAL_SKILLS_DIR),
178            ContextScope::Global,
179        );
180    }
181    // `~/.agents/skills` is a fixed path by convention, not somewhere a host
182    // relocates, so the only question a caller answers about it (D9) is
183    // whether it is read at all — independently of the global root above,
184    // unlike before D9, when one `Option` gated both.
185    if config.shared_home_dir
186        && let Some(home) = home
187    {
188        push(
189            &mut sources,
190            home.join(SHARED_SKILLS_DIR),
191            ContextScope::Global,
192        );
193    }
194
195    sources
196}
197
198/// Appends `path` when it is a directory no earlier root already names.
199///
200/// Two roots can reach one directory — a symlink, a global config directory
201/// that *is* the workspace, a `$HOME` that is the workspace root. Registering
202/// the same directory twice is a duplicate-name error in mentra and noise in
203/// the run header either way, and the first mention is the stronger one, so
204/// the later mention is what goes.
205fn push(sources: &mut Vec<SkillsSource>, path: PathBuf, scope: ContextScope) {
206    if !path.is_dir()
207        || sources
208            .iter()
209            .any(|source| crate::paths::same_dir(&source.path, &path))
210    {
211        return;
212    }
213
214    sources.push(SkillsSource { path, scope });
215}
216
217/// `$HOME`, the only place the shared user root can be. `None` leaves that
218/// root out rather than guessing at one.
219fn user_home() -> Option<PathBuf> {
220    std::env::var_os("HOME").map(PathBuf::from)
221}
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226
227    /// The default three roots enabled, `global_dir` as given — the shape
228    /// most tests below vary along exactly one axis.
229    fn config(global: Option<PathBuf>) -> SkillsConfig {
230        SkillsConfig {
231            workspace_subdir: Some(PathBuf::from(DEFAULT_WORKSPACE_SKILLS_DIR)),
232            shared_workspace_dir: true,
233            global_dir: global,
234            shared_home_dir: true,
235        }
236    }
237
238    /// Every test states its own `$HOME`. Reading the real one would make the
239    /// results depend on whether whoever runs the suite keeps skills there.
240    fn found(workspace: &Path, config: &SkillsConfig, home: Option<&Path>) -> Vec<SkillsSource> {
241        discover_in(workspace, config, home)
242    }
243
244    fn dir(parent: &Path, relative: &str) -> PathBuf {
245        let path = parent.join(relative);
246        std::fs::create_dir_all(&path).expect("create dir");
247        path
248    }
249
250    #[test]
251    fn nothing_on_disk_means_no_sources() {
252        let tmp = tempfile::tempdir().expect("tempdir");
253
254        assert!(found(tmp.path(), &config(None), None).is_empty());
255    }
256
257    #[test]
258    fn a_workspace_directory_is_found() {
259        let tmp = tempfile::tempdir().expect("tempdir");
260        let skills = dir(tmp.path(), DEFAULT_WORKSPACE_SKILLS_DIR);
261
262        let sources = found(tmp.path(), &config(None), None);
263
264        assert_eq!(sources.len(), 1);
265        assert_eq!(sources[0].scope, ContextScope::Workspace);
266        assert_eq!(sources[0].path, skills);
267    }
268
269    #[test]
270    fn the_shared_workspace_directory_is_found() {
271        // The point of the SKILL.md format: a repository writes `.agents/skills`
272        // once and every harness that speaks the format reads it.
273        let tmp = tempfile::tempdir().expect("tempdir");
274        let shared = dir(tmp.path(), SHARED_SKILLS_DIR);
275
276        let sources = found(tmp.path(), &config(None), None);
277
278        assert_eq!(sources.len(), 1);
279        assert_eq!(sources[0].scope, ContextScope::Workspace);
280        assert_eq!(sources[0].path, shared);
281    }
282
283    #[test]
284    fn the_basis_directory_outranks_the_shared_one_because_it_names_basis() {
285        // Both roots describe the same repository, so the tie is broken by
286        // which is the more specific statement: `.basis/skills` was written
287        // knowing which harness would read it.
288        let tmp = tempfile::tempdir().expect("tempdir");
289        let basis = dir(tmp.path(), DEFAULT_WORKSPACE_SKILLS_DIR);
290        let shared = dir(tmp.path(), SHARED_SKILLS_DIR);
291
292        let sources = found(tmp.path(), &config(None), None);
293
294        assert_eq!(
295            sources
296                .iter()
297                .map(|source| &source.path)
298                .collect::<Vec<_>>(),
299            vec![&basis, &shared],
300            "registration is strongest-first, so a name defined in both loads from .basis/skills"
301        );
302    }
303
304    #[test]
305    fn the_workspace_directory_outranks_the_global_one() {
306        let tmp = tempfile::tempdir().expect("tempdir");
307        dir(tmp.path(), DEFAULT_WORKSPACE_SKILLS_DIR);
308        let global = tmp.path().join("global");
309        dir(&global, DEFAULT_GLOBAL_SKILLS_DIR);
310
311        let sources = found(tmp.path(), &config(Some(global)), None);
312
313        assert_eq!(sources.len(), 2);
314        assert_eq!(sources[0].scope, ContextScope::Workspace);
315        assert_eq!(sources[1].scope, ContextScope::Global);
316    }
317
318    #[test]
319    fn a_global_directory_alone_is_used() {
320        let tmp = tempfile::tempdir().expect("tempdir");
321        let global = tmp.path().join("global");
322        dir(&global, DEFAULT_GLOBAL_SKILLS_DIR);
323
324        let sources = found(tmp.path(), &config(Some(global)), None);
325
326        assert_eq!(sources.len(), 1);
327        assert_eq!(sources[0].scope, ContextScope::Global);
328    }
329
330    #[test]
331    fn the_shared_user_directory_is_found_behind_the_basis_one() {
332        let tmp = tempfile::tempdir().expect("tempdir");
333        let global = tmp.path().join("global");
334        let home = tmp.path().join("home");
335        let basis_global = dir(&global, DEFAULT_GLOBAL_SKILLS_DIR);
336        let shared_user = dir(&home, SHARED_SKILLS_DIR);
337
338        let sources = found(tmp.path(), &config(Some(global)), Some(&home));
339
340        assert_eq!(
341            sources
342                .iter()
343                .map(|source| &source.path)
344                .collect::<Vec<_>>(),
345            vec![&basis_global, &shared_user]
346        );
347        assert!(
348            sources
349                .iter()
350                .all(|source| source.scope == ContextScope::Global)
351        );
352    }
353
354    #[test]
355    fn all_four_roots_come_back_most_specific_first() {
356        let tmp = tempfile::tempdir().expect("tempdir");
357        let workspace = tmp.path().join("repo");
358        let global = tmp.path().join("global");
359        let home = tmp.path().join("home");
360
361        let expected = [
362            dir(&workspace, DEFAULT_WORKSPACE_SKILLS_DIR),
363            dir(&workspace, SHARED_SKILLS_DIR),
364            dir(&global, DEFAULT_GLOBAL_SKILLS_DIR),
365            dir(&home, SHARED_SKILLS_DIR),
366        ];
367
368        let sources = found(&workspace, &config(Some(global)), Some(&home));
369
370        assert_eq!(
371            sources
372                .iter()
373                .map(|source| &source.path)
374                .collect::<Vec<_>>(),
375            expected.iter().collect::<Vec<_>>()
376        );
377    }
378
379    #[test]
380    fn a_missing_root_is_simply_absent() {
381        // Three of the four exist; the fourth leaves no hole and no error.
382        let tmp = tempfile::tempdir().expect("tempdir");
383        let workspace = tmp.path().join("repo");
384        let global = tmp.path().join("global");
385        let home = tmp.path().join("home");
386
387        let basis = dir(&workspace, DEFAULT_WORKSPACE_SKILLS_DIR);
388        let basis_global = dir(&global, DEFAULT_GLOBAL_SKILLS_DIR);
389        let shared_user = dir(&home, SHARED_SKILLS_DIR);
390
391        let sources = found(&workspace, &config(Some(global)), Some(&home));
392
393        assert_eq!(
394            sources
395                .iter()
396                .map(|source| &source.path)
397                .collect::<Vec<_>>(),
398            vec![&basis, &basis_global, &shared_user]
399        );
400    }
401
402    #[test]
403    fn disabling_the_shared_home_root_alone_leaves_the_other_three() {
404        let tmp = tempfile::tempdir().expect("tempdir");
405        let workspace = tmp.path().join("repo");
406        let global = tmp.path().join("global");
407        let home = tmp.path().join("home");
408        dir(&workspace, DEFAULT_WORKSPACE_SKILLS_DIR);
409        dir(&global, DEFAULT_GLOBAL_SKILLS_DIR);
410        dir(&home, SHARED_SKILLS_DIR);
411
412        let sources = found(
413            &workspace,
414            &SkillsConfig {
415                shared_home_dir: false,
416                ..config(Some(global))
417            },
418            Some(&home),
419        );
420
421        assert_eq!(sources.len(), 2, "workspace root and global root only");
422        assert!(
423            sources
424                .iter()
425                .all(|source| source.path != home.join(SHARED_SKILLS_DIR))
426        );
427    }
428
429    #[test]
430    fn a_missing_global_dir_no_longer_silences_the_home_root() {
431        // Before D9 one `Option` gated both the global root and the home
432        // root; each disables on its own switch now, so a caller can keep
433        // this one while turning the other off, or the reverse.
434        let tmp = tempfile::tempdir().expect("tempdir");
435        let home = tmp.path().join("home");
436        let shared_user = dir(&home, SHARED_SKILLS_DIR);
437
438        let sources = found(tmp.path(), &config(None), Some(&home));
439
440        assert_eq!(
441            sources,
442            vec![SkillsSource {
443                path: shared_user,
444                scope: ContextScope::Global,
445            }]
446        );
447    }
448
449    #[test]
450    fn disabling_the_global_dir_alone_leaves_the_home_root() {
451        let tmp = tempfile::tempdir().expect("tempdir");
452        let global = tmp.path().join("global");
453        let home = tmp.path().join("home");
454        dir(&global, DEFAULT_GLOBAL_SKILLS_DIR);
455        let shared_user = dir(&home, SHARED_SKILLS_DIR);
456
457        let sources = found(
458            tmp.path(),
459            &SkillsConfig {
460                global_dir: None,
461                ..config(Some(global))
462            },
463            Some(&home),
464        );
465
466        assert_eq!(
467            sources,
468            vec![SkillsSource {
469                path: shared_user,
470                scope: ContextScope::Global,
471            }]
472        );
473    }
474
475    #[test]
476    fn disabling_the_basis_workspace_root_alone_leaves_the_shared_one() {
477        let tmp = tempfile::tempdir().expect("tempdir");
478        dir(tmp.path(), DEFAULT_WORKSPACE_SKILLS_DIR);
479        let shared = dir(tmp.path(), SHARED_SKILLS_DIR);
480
481        let sources = found(
482            tmp.path(),
483            &SkillsConfig {
484                workspace_subdir: None,
485                ..config(None)
486            },
487            None,
488        );
489
490        assert_eq!(
491            sources,
492            vec![SkillsSource {
493                path: shared,
494                scope: ContextScope::Workspace,
495            }]
496        );
497    }
498
499    #[test]
500    fn disabling_the_shared_workspace_root_alone_leaves_the_basis_one() {
501        let tmp = tempfile::tempdir().expect("tempdir");
502        let basis = dir(tmp.path(), DEFAULT_WORKSPACE_SKILLS_DIR);
503        dir(tmp.path(), SHARED_SKILLS_DIR);
504
505        let sources = found(
506            tmp.path(),
507            &SkillsConfig {
508                shared_workspace_dir: false,
509                ..config(None)
510            },
511            None,
512        );
513
514        assert_eq!(
515            sources,
516            vec![SkillsSource {
517                path: basis,
518                scope: ContextScope::Workspace,
519            }]
520        );
521    }
522
523    #[test]
524    fn a_file_where_the_directory_should_be_is_ignored() {
525        let tmp = tempfile::tempdir().expect("tempdir");
526        let skills = tmp.path().join(DEFAULT_WORKSPACE_SKILLS_DIR);
527        std::fs::create_dir_all(skills.parent().expect("parent")).expect("create .basis");
528        std::fs::write(&skills, "not a directory").expect("write file");
529
530        assert!(found(tmp.path(), &config(None), None).is_empty());
531    }
532
533    #[test]
534    fn the_same_directory_reached_twice_is_reported_once() {
535        let tmp = tempfile::tempdir().expect("tempdir");
536        let global = tmp.path().join("global");
537        dir(&global, DEFAULT_GLOBAL_SKILLS_DIR);
538
539        // Point the workspace subdir at the very same place.
540        let sources = found(
541            &global,
542            &SkillsConfig {
543                workspace_subdir: Some(PathBuf::from(DEFAULT_GLOBAL_SKILLS_DIR)),
544                shared_workspace_dir: false,
545                global_dir: Some(global.clone()),
546                shared_home_dir: false,
547            },
548            None,
549        );
550
551        assert_eq!(sources.len(), 1);
552        assert_eq!(sources[0].scope, ContextScope::Workspace);
553    }
554
555    #[test]
556    fn a_home_that_is_the_workspace_does_not_register_one_directory_twice() {
557        // The shared spelling is the same in both scopes, so a workspace
558        // opened at `$HOME` reaches `.agents/skills` from either side.
559        let tmp = tempfile::tempdir().expect("tempdir");
560        let shared = dir(tmp.path(), SHARED_SKILLS_DIR);
561        let global = tmp.path().join("global");
562        dir(&global, DEFAULT_GLOBAL_SKILLS_DIR);
563
564        let sources = found(tmp.path(), &config(Some(global)), Some(tmp.path()));
565
566        assert_eq!(
567            sources
568                .iter()
569                .filter(|source| source.path == shared)
570                .count(),
571            1
572        );
573        assert_eq!(sources[0].scope, ContextScope::Workspace);
574    }
575}