Skip to main content

aether_project/
prompt_catalog.rs

1//! Unified prompt catalog for discovering and validating `.aether/skills/*/SKILL.md` artifacts.
2//!
3//! A single `SKILL.md` artifact can serve as:
4//! - A **slash command** (`user-invocable: true`)
5//! - A **skill** (`agent-invocable: true`)
6//! - A **rule** (`triggers.read` globs)
7//! - Any combination of the above
8
9use crate::prompt_file::{PromptFile, SKILL_FILENAME};
10use std::collections::BTreeMap;
11use std::fs::{DirEntry, ReadDir, read_dir};
12use std::path::{Path, PathBuf};
13
14/// A catalog of prompt artifacts discovered from `.aether/skills/`.
15#[derive(Debug, Clone)]
16pub struct PromptCatalog {
17    specs: Vec<PromptFile>,
18}
19
20impl PromptCatalog {
21    /// Discover and merge prompt artifacts from skill directories such as
22    /// `<project_root>/.aether/skills`, ordered by prompt name.
23    ///
24    /// On name collision, the last directory wins. Directories that don't exist are skipped.
25    pub fn from_dirs(skills_dirs: impl IntoIterator<Item = impl AsRef<Path>>) -> Self {
26        let mut seen: BTreeMap<String, PromptFile> = BTreeMap::new();
27
28        for dir in skills_dirs {
29            let dir = dir.as_ref();
30            let Ok(entries) = read_dir(dir) else {
31                tracing::warn!("Skills directory does not exist, skipping: {}", dir.display());
32                continue;
33            };
34            seen.extend(parse_skills(entries).map(|spec| (spec.name.clone(), spec)));
35        }
36
37        Self { specs: seen.into_values().collect() }
38    }
39
40    /// Create an empty catalog.
41    pub fn empty() -> Self {
42        Self { specs: Vec::new() }
43    }
44
45    /// All prompt specs, ordered by name.
46    pub fn all(&self) -> &[PromptFile] {
47        &self.specs
48    }
49
50    /// Find a prompt by its resolved prompt name.
51    pub fn find(&self, name: &str) -> Option<&PromptFile> {
52        self.specs.iter().find(|spec| spec.name == name)
53    }
54
55    /// Iterate over user-invocable prompts (slash commands).
56    pub fn slash_commands(&self) -> impl Iterator<Item = &PromptFile> {
57        self.specs.iter().filter(|s| s.user_invocable)
58    }
59
60    /// Iterate over agent-invocable prompts (skills).
61    pub fn skills(&self) -> impl Iterator<Item = &PromptFile> {
62        self.specs.iter().filter(|s| s.agent_invocable)
63    }
64
65    /// Find all prompt specs whose read triggers match the given project-relative path.
66    pub fn matching_rules(&self, relative_path: &str) -> Vec<&PromptFile> {
67        self.specs.iter().filter(|s| s.triggers.matches_read(relative_path)).collect()
68    }
69}
70
71fn parse_skills(entries: ReadDir) -> impl Iterator<Item = PromptFile> {
72    entries.filter_map(Result::ok).filter_map(|entry| get_path(&entry)).filter_map(|path| {
73        PromptFile::parse(&path)
74            .inspect_err(|err| tracing::warn!("Skipping invalid skill at {}: {err}", path.display()))
75            .ok()
76    })
77}
78
79fn get_path(entry: &DirEntry) -> Option<PathBuf> {
80    let path = entry.path();
81    if entry.file_name().to_string_lossy().starts_with('.') {
82        return None;
83    }
84    if path.is_dir() && path.join(SKILL_FILENAME).is_file() {
85        Some(path.join(SKILL_FILENAME))
86    } else if path.is_file() && path.extension().is_some_and(|ext| ext == "md") {
87        Some(path)
88    } else {
89        None
90    }
91}
92
93#[cfg(test)]
94mod tests {
95    use super::*;
96    use crate::testing::project;
97
98    #[test]
99    fn discover_empty_project() {
100        let dir = project();
101        let catalog = PromptCatalog::from_dirs([dir.root()]);
102        assert!(catalog.all().is_empty());
103    }
104
105    #[test]
106    fn discover_user_only_prompt() {
107        let dir = project().file(
108            "commit/SKILL.md",
109            "---\ndescription: Generate commit messages\nuser-invocable: true\nagent-invocable: false\n---\nGenerate a commit message.",
110        );
111
112        let catalog = PromptCatalog::from_dirs([dir.root()]);
113        assert_eq!(catalog.all().len(), 1);
114
115        let spec = &catalog.all()[0];
116        assert_eq!(spec.name, "commit");
117        assert!(spec.user_invocable);
118        assert!(!spec.agent_invocable);
119        assert!(spec.triggers.is_empty());
120    }
121
122    #[test]
123    fn discover_agent_only_prompt() {
124        let dir = project().file(
125            "explain-code/SKILL.md",
126            "---\ndescription: Explain code\nagent-invocable: true\n---\nExplain the code.",
127        );
128
129        let catalog = PromptCatalog::from_dirs([dir.root()]);
130        assert_eq!(catalog.all().len(), 1);
131
132        let spec = &catalog.all()[0];
133        assert!(spec.agent_invocable);
134        assert!(spec.user_invocable);
135    }
136
137    #[test]
138    fn discover_rule_only_prompt() {
139        let dir = project().file(
140            "rust-rules/SKILL.md",
141            "---\ndescription: Rust conventions\nagent-invocable: false\ntriggers:\n  read:\n    - \"crates/**/*.rs\"\n---\nFollow Rust conventions.",
142        );
143
144        let catalog = PromptCatalog::from_dirs([dir.root()]);
145        assert_eq!(catalog.all().len(), 1);
146
147        let spec = &catalog.all()[0];
148        assert!(spec.user_invocable);
149        assert!(!spec.agent_invocable);
150        assert!(!spec.triggers.is_empty());
151        assert!(spec.triggers.matches_read("crates/foo/bar.rs"));
152        assert!(!spec.triggers.matches_read("other/file.py"));
153    }
154
155    #[test]
156    fn discover_dual_use_prompt() {
157        let dir = project().file(
158            "explain/SKILL.md",
159            "---\ndescription: Explain code\nuser-invocable: true\nagent-invocable: true\nargument-hint: \"[path]\"\n---\nExplain with diagrams.",
160        );
161
162        let catalog = PromptCatalog::from_dirs([dir.root()]);
163        let spec = &catalog.all()[0];
164        assert!(spec.user_invocable);
165        assert!(spec.agent_invocable);
166        assert_eq!(spec.argument_hint.as_deref(), Some("[path]"));
167
168        let user: Vec<_> = catalog.slash_commands().collect();
169        assert_eq!(user.len(), 1);
170        let agent: Vec<_> = catalog.skills().collect();
171        assert_eq!(agent.len(), 1);
172    }
173
174    #[test]
175    fn empty_description_defaults_to_name() {
176        let dir = project().file("bad/SKILL.md", "---\ndescription: \"\"\nuser-invocable: true\n---\nContent.");
177
178        let catalog = PromptCatalog::from_dirs([dir.root()]);
179        assert_eq!(catalog.all().len(), 1);
180        assert_eq!(catalog.all()[0].description, "bad");
181    }
182
183    #[test]
184    fn flat_md_without_activation_surface_is_skipped() {
185        let dir = project().file("noop.md", "---\ndescription: Does nothing\nagent-invocable: false\n---\nContent.");
186
187        let catalog = PromptCatalog::from_dirs([dir.root()]);
188        assert!(catalog.all().is_empty());
189    }
190
191    #[test]
192    fn name_from_frontmatter_overrides_directory() {
193        let dir = project().file(
194            "dir-name/SKILL.md",
195            "---\nname: custom-name\ndescription: Custom\nuser-invocable: true\n---\nContent.",
196        );
197
198        let catalog = PromptCatalog::from_dirs([dir.root()]);
199        assert_eq!(catalog.all()[0].name, "custom-name");
200    }
201
202    #[test]
203    fn matching_read_rules_finds_matches() {
204        let dir = project()
205            .file(
206                "rust-rules/SKILL.md",
207                "---\ndescription: Rust rules\ntriggers:\n  read:\n    - \"src/**/*.rs\"\n---\nRust rules.",
208            )
209            .file(
210                "ts-rules/SKILL.md",
211                "---\ndescription: TS rules\ntriggers:\n  read:\n    - \"src/**/*.ts\"\n---\nTS rules.",
212            )
213            .file("commit/SKILL.md", "---\ndescription: Commit\nuser-invocable: true\n---\nCommit.");
214
215        let catalog = PromptCatalog::from_dirs([dir.root()]);
216        let matches = catalog.matching_rules("src/main.rs");
217        assert_eq!(matches.len(), 1);
218        assert_eq!(matches[0].name, "rust-rules");
219
220        let matches = catalog.matching_rules("src/app.ts");
221        assert_eq!(matches.len(), 1);
222        assert_eq!(matches[0].name, "ts-rules");
223
224        let matches = catalog.matching_rules("README.md");
225        assert!(matches.is_empty());
226    }
227
228    #[test]
229    fn pure_flat_rule_not_in_user_or_agent_invocable() {
230        let dir = project().file(
231            "rule.md",
232            "---\ndescription: A rule\nagent-invocable: false\ntriggers:\n  read:\n    - \"*.rs\"\n---\nRule content.",
233        );
234
235        let catalog = PromptCatalog::from_dirs([dir.root()]);
236        assert_eq!(catalog.all().len(), 1);
237        assert_eq!(catalog.slash_commands().count(), 0);
238        assert_eq!(catalog.skills().count(), 0);
239    }
240
241    #[test]
242    fn skips_hidden_directories() {
243        let dir = project()
244            .file(".archived/SKILL.md", "---\ndescription: Archived\nuser-invocable: true\n---\nOld.")
245            .file("visible/SKILL.md", "---\ndescription: Visible\nuser-invocable: true\n---\nNew.");
246
247        let catalog = PromptCatalog::from_dirs([dir.root()]);
248        assert_eq!(catalog.all().len(), 1);
249        assert_eq!(catalog.all()[0].name, "visible");
250    }
251
252    #[test]
253    fn from_dirs_last_wins() {
254        let dir_a =
255            project().file("rust/SKILL.md", "---\ndescription: Rust A\nagent-invocable: true\n---\nFrom dir A.");
256        let dir_b =
257            project().file("rust/SKILL.md", "---\ndescription: Rust B\nagent-invocable: true\n---\nFrom dir B.");
258
259        let catalog = PromptCatalog::from_dirs([dir_a.root(), dir_b.root()]);
260        assert_eq!(catalog.all().len(), 1);
261
262        let spec = &catalog.all()[0];
263        assert_eq!(spec.name, "rust");
264        assert_eq!(spec.description, "Rust B");
265        assert!(spec.body.contains("From dir B."));
266    }
267
268    #[test]
269    fn from_dirs_union() {
270        let dir_a =
271            project().file("rust/SKILL.md", "---\ndescription: Rust\nagent-invocable: true\n---\nRust content.");
272        let dir_b =
273            project().file("python/SKILL.md", "---\ndescription: Python\nagent-invocable: true\n---\nPython content.");
274
275        let catalog = PromptCatalog::from_dirs([dir_a.root(), dir_b.root()]);
276        assert_eq!(catalog.all().len(), 2);
277
278        let names: Vec<&str> = catalog.all().iter().map(|s| s.name.as_str()).collect();
279        assert!(names.contains(&"rust"));
280        assert!(names.contains(&"python"));
281    }
282
283    #[test]
284    fn from_dirs_lists_prompts_by_name() {
285        let names = ["delta", "alpha", "echo", "charlie", "bravo", "foxtrot"];
286        let dir = names.iter().fold(project(), |dir, name| {
287            dir.file(format!("{name}/SKILL.md"), "---\ndescription: A skill\nagent-invocable: true\n---\nBody.")
288        });
289
290        let catalog = PromptCatalog::from_dirs([dir.root()]);
291
292        let listed: Vec<&str> = catalog.all().iter().map(|spec| spec.name.as_str()).collect();
293        assert_eq!(listed, ["alpha", "bravo", "charlie", "delta", "echo", "foxtrot"]);
294    }
295
296    #[test]
297    fn from_dirs_skips_missing() {
298        let dir_a =
299            project().file("rust/SKILL.md", "---\ndescription: Rust\nagent-invocable: true\n---\nRust content.");
300        let missing = PathBuf::from("/tmp/nonexistent-skills-dir-12345");
301
302        let catalog = PromptCatalog::from_dirs([missing.as_path(), dir_a.root()]);
303        assert_eq!(catalog.all().len(), 1);
304        assert_eq!(catalog.all()[0].name, "rust");
305    }
306
307    #[test]
308    fn discover_mixed_skill_md_and_flat_rules() {
309        let dir = project()
310            .file("commit/SKILL.md", "---\ndescription: Commit\nuser-invocable: true\n---\nCommit message.")
311            .file("rust-rules.md", "---\ndescription: Rust rules\nglobs:\n  - \"**/*.rs\"\n---\nRust conventions.");
312
313        let catalog = PromptCatalog::from_dirs([dir.root()]);
314        assert_eq!(catalog.all().len(), 2);
315
316        let names: Vec<&str> = catalog.all().iter().map(|s| s.name.as_str()).collect();
317        assert!(names.contains(&"commit"));
318        assert!(names.contains(&"rust-rules"));
319    }
320
321    #[test]
322    fn from_dirs_merges_flat_rules() {
323        let dir_a = project().file("commit/SKILL.md", "---\ndescription: Commit\nuser-invocable: true\n---\nCommit.");
324        let dir_b = project()
325            .file("rust-rules.md", "---\ndescription: Rust rules\nglobs:\n  - \"**/*.rs\"\n---\nRust conventions.");
326
327        let catalog = PromptCatalog::from_dirs([dir_a.root(), dir_b.root()]);
328        assert_eq!(catalog.all().len(), 2);
329
330        let names: Vec<&str> = catalog.all().iter().map(|s| s.name.as_str()).collect();
331        assert!(names.contains(&"commit"));
332        assert!(names.contains(&"rust-rules"));
333    }
334
335    #[test]
336    fn skips_hidden_flat_md_files() {
337        let dir = project()
338            .file(".hidden-rule.md", "---\ndescription: Hidden\nglobs:\n  - \"**/*.rs\"\n---\nHidden.")
339            .file("visible-rule.md", "---\ndescription: Visible\nglobs:\n  - \"**/*.ts\"\n---\nVisible.");
340
341        let catalog = PromptCatalog::from_dirs([dir.root()]);
342        assert_eq!(catalog.all().len(), 1);
343        assert_eq!(catalog.all()[0].name, "visible-rule");
344    }
345}