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::error::SettingsError;
10use crate::prompt_file::{PromptFile, SKILL_FILENAME};
11use std::collections::{HashMap, HashSet};
12use std::fs::{DirEntry, read_dir};
13use std::path::{Path, PathBuf};
14
15/// A catalog of prompt artifacts discovered from `.aether/skills/`.
16#[derive(Debug, Clone)]
17pub struct PromptCatalog {
18    specs: Vec<PromptFile>,
19}
20
21impl PromptCatalog {
22    /// Discover and validate all prompt artifacts under `skills_dir/*/SKILL.md`.
23    ///
24    /// `skills_dir` is typically `<project_root>/.aether/skills` or `<base_dir>/skills`.
25    pub fn from_dir(skills_dir: &Path) -> Result<Self, SettingsError> {
26        let mut prompts = Vec::new();
27
28        for entry in read_dir(skills_dir).map_err(|e| SettingsError::IoError(e.to_string()))?.filter_map(Result::ok) {
29            if let Some(p) = get_path(&entry) {
30                match PromptFile::parse(&p) {
31                    Ok(spec) => prompts.push(spec),
32                    Err(err) => tracing::warn!("Skipping invalid skill at {}: {err}", p.display()),
33                }
34            }
35        }
36
37        validate_catalog(&prompts)?;
38
39        Ok(Self { specs: prompts })
40    }
41
42    /// Discover and merge prompt artifacts from multiple skill directories.
43    ///
44    /// On name collision, the last directory wins. Directories that don't exist are skipped.
45    pub fn from_dirs(skills_dirs: &[PathBuf]) -> Self {
46        let mut seen: HashMap<String, PromptFile> = HashMap::new();
47
48        for dir in skills_dirs {
49            let Ok(entries) = read_dir(dir) else {
50                tracing::warn!("Skills directory does not exist, skipping: {}", dir.display());
51                continue;
52            };
53
54            for entry in entries.filter_map(Result::ok) {
55                if let Some(p) = get_path(&entry) {
56                    match PromptFile::parse(&p) {
57                        Ok(spec) => {
58                            seen.insert(spec.name.clone(), spec);
59                        }
60                        Err(err) => {
61                            tracing::warn!("Skipping invalid skill at {}: {err}", p.display());
62                        }
63                    }
64                }
65            }
66        }
67
68        Self { specs: seen.into_values().collect() }
69    }
70
71    /// Create an empty catalog.
72    pub fn empty() -> Self {
73        Self { specs: Vec::new() }
74    }
75
76    /// All prompt specs in catalog order.
77    pub fn all(&self) -> &[PromptFile] {
78        &self.specs
79    }
80
81    /// Find a prompt by its resolved prompt name.
82    pub fn find(&self, name: &str) -> Option<&PromptFile> {
83        self.specs.iter().find(|spec| spec.name == name)
84    }
85
86    /// Iterate over user-invocable prompts (slash commands).
87    pub fn slash_commands(&self) -> impl Iterator<Item = &PromptFile> {
88        self.specs.iter().filter(|s| s.user_invocable)
89    }
90
91    /// Iterate over agent-invocable prompts (skills).
92    pub fn skills(&self) -> impl Iterator<Item = &PromptFile> {
93        self.specs.iter().filter(|s| s.agent_invocable)
94    }
95
96    /// Find all prompt specs whose read triggers match the given project-relative path.
97    pub fn matching_rules(&self, relative_path: &str) -> Vec<&PromptFile> {
98        self.specs.iter().filter(|s| s.triggers.matches_read(relative_path)).collect()
99    }
100}
101
102fn get_path(entry: &DirEntry) -> Option<PathBuf> {
103    let path = entry.path();
104    if entry.file_name().to_string_lossy().starts_with('.') {
105        return None;
106    }
107    if path.is_dir() && path.join(SKILL_FILENAME).is_file() {
108        Some(path.join(SKILL_FILENAME))
109    } else if path.is_file() && path.extension().is_some_and(|ext| ext == "md") {
110        Some(path)
111    } else {
112        None
113    }
114}
115
116fn validate_catalog(specs: &[PromptFile]) -> Result<(), SettingsError> {
117    let mut seen_names = HashSet::new();
118    for spec in specs {
119        if !seen_names.insert(&spec.name) {
120            return Err(SettingsError::DuplicatePromptName { name: spec.name.clone() });
121        }
122    }
123    Ok(())
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129    use crate::testing::project;
130
131    #[test]
132    fn discover_empty_project() {
133        let dir = project();
134        let catalog = PromptCatalog::from_dir(dir.root()).unwrap();
135        assert!(catalog.all().is_empty());
136    }
137
138    #[test]
139    fn discover_user_only_prompt() {
140        let dir = project().skill(
141            "commit",
142            "---\ndescription: Generate commit messages\nuser-invocable: true\nagent-invocable: false\n---\nGenerate a commit message.",
143        );
144
145        let catalog = PromptCatalog::from_dir(dir.root()).unwrap();
146        assert_eq!(catalog.all().len(), 1);
147
148        let spec = &catalog.all()[0];
149        assert_eq!(spec.name, "commit");
150        assert!(spec.user_invocable);
151        assert!(!spec.agent_invocable);
152        assert!(spec.triggers.is_empty());
153    }
154
155    #[test]
156    fn discover_agent_only_prompt() {
157        let dir = project()
158            .skill("explain-code", "---\ndescription: Explain code\nagent-invocable: true\n---\nExplain the code.");
159
160        let catalog = PromptCatalog::from_dir(dir.root()).unwrap();
161        assert_eq!(catalog.all().len(), 1);
162
163        let spec = &catalog.all()[0];
164        assert!(spec.agent_invocable);
165        assert!(spec.user_invocable);
166    }
167
168    #[test]
169    fn discover_rule_only_prompt() {
170        let dir = project().skill(
171            "rust-rules",
172            "---\ndescription: Rust conventions\nagent-invocable: false\ntriggers:\n  read:\n    - \"crates/**/*.rs\"\n---\nFollow Rust conventions.",
173        );
174
175        let catalog = PromptCatalog::from_dir(dir.root()).unwrap();
176        assert_eq!(catalog.all().len(), 1);
177
178        let spec = &catalog.all()[0];
179        assert!(spec.user_invocable);
180        assert!(!spec.agent_invocable);
181        assert!(!spec.triggers.is_empty());
182        assert!(spec.triggers.matches_read("crates/foo/bar.rs"));
183        assert!(!spec.triggers.matches_read("other/file.py"));
184    }
185
186    #[test]
187    fn discover_dual_use_prompt() {
188        let dir = project().skill(
189            "explain",
190            "---\ndescription: Explain code\nuser-invocable: true\nagent-invocable: true\nargument-hint: \"[path]\"\n---\nExplain with diagrams.",
191        );
192
193        let catalog = PromptCatalog::from_dir(dir.root()).unwrap();
194        let spec = &catalog.all()[0];
195        assert!(spec.user_invocable);
196        assert!(spec.agent_invocable);
197        assert_eq!(spec.argument_hint.as_deref(), Some("[path]"));
198
199        let user: Vec<_> = catalog.slash_commands().collect();
200        assert_eq!(user.len(), 1);
201        let agent: Vec<_> = catalog.skills().collect();
202        assert_eq!(agent.len(), 1);
203    }
204
205    #[test]
206    fn reject_duplicate_names() {
207        let dir = project()
208            // Second skill has an explicit name override so it collides with "foo"
209            .skill("foo", "---\ndescription: First\nuser-invocable: true\n---\nContent.")
210            .skill("bar", "---\nname: foo\ndescription: Second\nuser-invocable: true\n---\nContent.");
211
212        let result = PromptCatalog::from_dir(dir.root());
213        assert!(matches!(result, Err(SettingsError::DuplicatePromptName { .. })));
214    }
215
216    #[test]
217    fn empty_description_defaults_to_name() {
218        let dir = project().skill("bad", "---\ndescription: \"\"\nuser-invocable: true\n---\nContent.");
219
220        let catalog = PromptCatalog::from_dir(dir.root()).unwrap();
221        assert_eq!(catalog.all().len(), 1);
222        assert_eq!(catalog.all()[0].description, "bad");
223    }
224
225    #[test]
226    fn skill_without_activation_surface_defaults_to_user_invocable() {
227        let dir = project().skill("noop", "---\ndescription: Does nothing\n---\nContent.");
228
229        let catalog = PromptCatalog::from_dir(dir.root()).unwrap();
230        assert_eq!(catalog.all().len(), 1);
231        assert!(catalog.all()[0].user_invocable);
232    }
233
234    #[test]
235    fn flat_md_without_activation_surface_is_skipped() {
236        let dir = project().file("noop.md", "---\ndescription: Does nothing\nagent-invocable: false\n---\nContent.");
237
238        let catalog = PromptCatalog::from_dir(dir.root()).unwrap();
239        assert!(catalog.all().is_empty());
240    }
241
242    #[test]
243    fn name_defaults_to_directory_name() {
244        let dir = project().skill("my-skill", "---\ndescription: My skill\nagent-invocable: true\n---\nContent.");
245
246        let catalog = PromptCatalog::from_dir(dir.root()).unwrap();
247        assert_eq!(catalog.all()[0].name, "my-skill");
248    }
249
250    #[test]
251    fn name_from_frontmatter_overrides_directory() {
252        let dir = project()
253            .skill("dir-name", "---\nname: custom-name\ndescription: Custom\nuser-invocable: true\n---\nContent.");
254
255        let catalog = PromptCatalog::from_dir(dir.root()).unwrap();
256        assert_eq!(catalog.all()[0].name, "custom-name");
257    }
258
259    #[test]
260    fn matching_read_rules_finds_matches() {
261        let dir = project()
262            .skill(
263                "rust-rules",
264                "---\ndescription: Rust rules\ntriggers:\n  read:\n    - \"src/**/*.rs\"\n---\nRust rules.",
265            )
266            .skill("ts-rules", "---\ndescription: TS rules\ntriggers:\n  read:\n    - \"src/**/*.ts\"\n---\nTS rules.")
267            .skill("commit", "---\ndescription: Commit\nuser-invocable: true\n---\nCommit.");
268
269        let catalog = PromptCatalog::from_dir(dir.root()).unwrap();
270        let matches = catalog.matching_rules("src/main.rs");
271        assert_eq!(matches.len(), 1);
272        assert_eq!(matches[0].name, "rust-rules");
273
274        let matches = catalog.matching_rules("src/app.ts");
275        assert_eq!(matches.len(), 1);
276        assert_eq!(matches[0].name, "ts-rules");
277
278        let matches = catalog.matching_rules("README.md");
279        assert!(matches.is_empty());
280    }
281
282    #[test]
283    fn pure_flat_rule_not_in_user_or_agent_invocable() {
284        let dir = project().file(
285            "rule.md",
286            "---\ndescription: A rule\nagent-invocable: false\ntriggers:\n  read:\n    - \"*.rs\"\n---\nRule content.",
287        );
288
289        let catalog = PromptCatalog::from_dir(dir.root()).unwrap();
290        assert_eq!(catalog.all().len(), 1);
291        assert_eq!(catalog.slash_commands().count(), 0);
292        assert_eq!(catalog.skills().count(), 0);
293    }
294
295    #[test]
296    fn skips_hidden_directories() {
297        let dir = project()
298            .skill(".archived", "---\ndescription: Archived\nuser-invocable: true\n---\nOld.")
299            .skill("visible", "---\ndescription: Visible\nuser-invocable: true\n---\nNew.");
300
301        let catalog = PromptCatalog::from_dir(dir.root()).unwrap();
302        assert_eq!(catalog.all().len(), 1);
303        assert_eq!(catalog.all()[0].name, "visible");
304    }
305
306    #[test]
307    fn preserves_tags_and_metadata() {
308        let dir = project().skill(
309            "tagged",
310            "---\ndescription: Tagged skill\nagent-invocable: true\ntags:\n  - rust\n  - testing\nagent_authored: true\nhelpful: 5\nharmful: 1\n---\nContent.",
311        );
312
313        let catalog = PromptCatalog::from_dir(dir.root()).unwrap();
314        let spec = &catalog.all()[0];
315        assert_eq!(spec.tags, vec!["rust", "testing"]);
316        assert!(spec.agent_authored);
317        assert_eq!(spec.helpful, 5);
318        assert_eq!(spec.harmful, 1);
319    }
320
321    #[test]
322    fn from_dirs_last_wins() {
323        let dir_a = project().skill("rust", "---\ndescription: Rust A\nagent-invocable: true\n---\nFrom dir A.");
324        let dir_b = project().skill("rust", "---\ndescription: Rust B\nagent-invocable: true\n---\nFrom dir B.");
325
326        let catalog = PromptCatalog::from_dirs(&[dir_a.root().to_path_buf(), dir_b.root().to_path_buf()]);
327        assert_eq!(catalog.all().len(), 1);
328
329        let spec = &catalog.all()[0];
330        assert_eq!(spec.name, "rust");
331        assert_eq!(spec.description, "Rust B");
332        assert!(spec.body.contains("From dir B."));
333    }
334
335    #[test]
336    fn from_dirs_union() {
337        let dir_a = project().skill("rust", "---\ndescription: Rust\nagent-invocable: true\n---\nRust content.");
338        let dir_b = project().skill("python", "---\ndescription: Python\nagent-invocable: true\n---\nPython content.");
339
340        let catalog = PromptCatalog::from_dirs(&[dir_a.root().to_path_buf(), dir_b.root().to_path_buf()]);
341        assert_eq!(catalog.all().len(), 2);
342
343        let names: Vec<&str> = catalog.all().iter().map(|s| s.name.as_str()).collect();
344        assert!(names.contains(&"rust"));
345        assert!(names.contains(&"python"));
346    }
347
348    #[test]
349    fn from_dirs_skips_missing() {
350        let dir_a = project().skill("rust", "---\ndescription: Rust\nagent-invocable: true\n---\nRust content.");
351        let missing = PathBuf::from("/tmp/nonexistent-skills-dir-12345");
352
353        let catalog = PromptCatalog::from_dirs(&[missing, dir_a.root().to_path_buf()]);
354        assert_eq!(catalog.all().len(), 1);
355        assert_eq!(catalog.all()[0].name, "rust");
356    }
357
358    #[test]
359    fn discover_flat_md_rule_with_globs() {
360        let dir = project().file(
361            "rust-conventions.md",
362            "---\ndescription: Rust conventions\nglobs:\n  - \"**/*.rs\"\n---\nFollow Rust conventions.",
363        );
364
365        let catalog = PromptCatalog::from_dir(dir.root()).unwrap();
366        assert_eq!(catalog.all().len(), 1);
367
368        let spec = &catalog.all()[0];
369        assert_eq!(spec.name, "rust-conventions");
370        assert_eq!(spec.description, "Rust conventions");
371        assert!(spec.triggers.matches_read("src/main.rs"));
372        assert!(!spec.triggers.matches_read("README.md"));
373    }
374
375    #[test]
376    fn discover_flat_md_rule_with_paths() {
377        let dir = project()
378            .file("ts-rules.md", "---\ndescription: TS rules\npaths:\n  - \"**/*.ts\"\n---\nTypeScript rules.");
379
380        let catalog = PromptCatalog::from_dir(dir.root()).unwrap();
381        assert_eq!(catalog.all().len(), 1);
382
383        let spec = &catalog.all()[0];
384        assert_eq!(spec.name, "ts-rules");
385        assert!(spec.triggers.matches_read("src/index.ts"));
386    }
387
388    #[test]
389    fn discover_mixed_skill_md_and_flat_rules() {
390        let dir = project()
391            .skill("commit", "---\ndescription: Commit\nuser-invocable: true\n---\nCommit message.")
392            .file("rust-rules.md", "---\ndescription: Rust rules\nglobs:\n  - \"**/*.rs\"\n---\nRust conventions.");
393
394        let catalog = PromptCatalog::from_dir(dir.root()).unwrap();
395        assert_eq!(catalog.all().len(), 2);
396
397        let names: Vec<&str> = catalog.all().iter().map(|s| s.name.as_str()).collect();
398        assert!(names.contains(&"commit"));
399        assert!(names.contains(&"rust-rules"));
400    }
401
402    #[test]
403    fn from_dirs_merges_flat_rules() {
404        let dir_a = project().skill("commit", "---\ndescription: Commit\nuser-invocable: true\n---\nCommit.");
405        let dir_b = project()
406            .file("rust-rules.md", "---\ndescription: Rust rules\nglobs:\n  - \"**/*.rs\"\n---\nRust conventions.");
407
408        let catalog = PromptCatalog::from_dirs(&[dir_a.root().to_path_buf(), dir_b.root().to_path_buf()]);
409        assert_eq!(catalog.all().len(), 2);
410
411        let names: Vec<&str> = catalog.all().iter().map(|s| s.name.as_str()).collect();
412        assert!(names.contains(&"commit"));
413        assert!(names.contains(&"rust-rules"));
414    }
415
416    #[test]
417    fn flat_rule_without_description_uses_name() {
418        let dir = project().file("my-rule.md", "---\nglobs:\n  - \"**/*.rs\"\n---\nRule body.");
419
420        let catalog = PromptCatalog::from_dir(dir.root()).unwrap();
421        assert_eq!(catalog.all().len(), 1);
422
423        let spec = &catalog.all()[0];
424        assert_eq!(spec.name, "my-rule");
425        assert_eq!(spec.description, "my-rule");
426    }
427
428    #[test]
429    fn skips_hidden_flat_md_files() {
430        let dir = project()
431            .file(".hidden-rule.md", "---\ndescription: Hidden\nglobs:\n  - \"**/*.rs\"\n---\nHidden.")
432            .file("visible-rule.md", "---\ndescription: Visible\nglobs:\n  - \"**/*.ts\"\n---\nVisible.");
433
434        let catalog = PromptCatalog::from_dir(dir.root()).unwrap();
435        assert_eq!(catalog.all().len(), 1);
436        assert_eq!(catalog.all()[0].name, "visible-rule");
437    }
438}