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    /// Iterate over user-invocable prompts (slash commands).
82    pub fn slash_commands(&self) -> impl Iterator<Item = &PromptFile> {
83        self.specs.iter().filter(|s| s.user_invocable)
84    }
85
86    /// Iterate over agent-invocable prompts (skills).
87    pub fn skills(&self) -> impl Iterator<Item = &PromptFile> {
88        self.specs.iter().filter(|s| s.agent_invocable)
89    }
90
91    /// Find all prompt specs whose read triggers match the given project-relative path.
92    pub fn matching_rules(&self, relative_path: &str) -> Vec<&PromptFile> {
93        self.specs.iter().filter(|s| s.triggers.matches_read(relative_path)).collect()
94    }
95}
96
97fn get_path(entry: &DirEntry) -> Option<PathBuf> {
98    let path = entry.path();
99    if entry.file_name().to_string_lossy().starts_with('.') {
100        return None;
101    }
102    if path.is_dir() && path.join(SKILL_FILENAME).is_file() {
103        Some(path.join(SKILL_FILENAME))
104    } else if path.is_file() && path.extension().is_some_and(|ext| ext == "md") {
105        Some(path)
106    } else {
107        None
108    }
109}
110
111fn validate_catalog(specs: &[PromptFile]) -> Result<(), SettingsError> {
112    let mut seen_names = HashSet::new();
113    for spec in specs {
114        if !seen_names.insert(&spec.name) {
115            return Err(SettingsError::DuplicatePromptName { name: spec.name.clone() });
116        }
117    }
118    Ok(())
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124    use std::fs;
125    use tempfile::TempDir;
126
127    fn create_temp_project() -> TempDir {
128        tempfile::tempdir().unwrap()
129    }
130
131    fn write_skill(dir: &Path, name: &str, content: &str) {
132        let skill_dir = dir.join(name);
133        fs::create_dir_all(&skill_dir).unwrap();
134        fs::write(skill_dir.join(SKILL_FILENAME), content).unwrap();
135    }
136
137    #[test]
138    fn discover_empty_project() {
139        let dir = create_temp_project();
140        let catalog = PromptCatalog::from_dir(dir.path()).unwrap();
141        assert!(catalog.all().is_empty());
142    }
143
144    #[test]
145    fn discover_user_only_prompt() {
146        let dir = create_temp_project();
147        write_skill(
148            dir.path(),
149            "commit",
150            "---\ndescription: Generate commit messages\nuser-invocable: true\n---\nGenerate a commit message.",
151        );
152
153        let catalog = PromptCatalog::from_dir(dir.path()).unwrap();
154        assert_eq!(catalog.all().len(), 1);
155
156        let spec = &catalog.all()[0];
157        assert_eq!(spec.name, "commit");
158        assert!(spec.user_invocable);
159        assert!(!spec.agent_invocable);
160        assert!(spec.triggers.is_empty());
161    }
162
163    #[test]
164    fn discover_agent_only_prompt() {
165        let dir = create_temp_project();
166        write_skill(
167            dir.path(),
168            "explain-code",
169            "---\ndescription: Explain code\nagent-invocable: true\n---\nExplain the code.",
170        );
171
172        let catalog = PromptCatalog::from_dir(dir.path()).unwrap();
173        assert_eq!(catalog.all().len(), 1);
174
175        let spec = &catalog.all()[0];
176        assert!(spec.agent_invocable);
177        assert!(spec.user_invocable);
178    }
179
180    #[test]
181    fn discover_rule_only_prompt() {
182        let dir = create_temp_project();
183        write_skill(
184            dir.path(),
185            "rust-rules",
186            "---\ndescription: Rust conventions\ntriggers:\n  read:\n    - \"packages/**/*.rs\"\n---\nFollow Rust conventions.",
187        );
188
189        let catalog = PromptCatalog::from_dir(dir.path()).unwrap();
190        assert_eq!(catalog.all().len(), 1);
191
192        let spec = &catalog.all()[0];
193        assert!(spec.user_invocable);
194        assert!(!spec.agent_invocable);
195        assert!(!spec.triggers.is_empty());
196        assert!(spec.triggers.matches_read("packages/foo/bar.rs"));
197        assert!(!spec.triggers.matches_read("other/file.py"));
198    }
199
200    #[test]
201    fn discover_dual_use_prompt() {
202        let dir = create_temp_project();
203        write_skill(
204            dir.path(),
205            "explain",
206            "---\ndescription: Explain code\nuser-invocable: true\nagent-invocable: true\nargument-hint: \"[path]\"\n---\nExplain with diagrams.",
207        );
208
209        let catalog = PromptCatalog::from_dir(dir.path()).unwrap();
210        let spec = &catalog.all()[0];
211        assert!(spec.user_invocable);
212        assert!(spec.agent_invocable);
213        assert_eq!(spec.argument_hint.as_deref(), Some("[path]"));
214
215        let user: Vec<_> = catalog.slash_commands().collect();
216        assert_eq!(user.len(), 1);
217        let agent: Vec<_> = catalog.skills().collect();
218        assert_eq!(agent.len(), 1);
219    }
220
221    #[test]
222    fn reject_duplicate_names() {
223        let dir = create_temp_project();
224        write_skill(dir.path(), "foo", "---\ndescription: First\nuser-invocable: true\n---\nContent.");
225        // Second skill with explicit name override to "foo"
226        write_skill(dir.path(), "bar", "---\nname: foo\ndescription: Second\nuser-invocable: true\n---\nContent.");
227
228        let result = PromptCatalog::from_dir(dir.path());
229        assert!(matches!(result, Err(SettingsError::DuplicatePromptName { .. })));
230    }
231
232    #[test]
233    fn empty_description_defaults_to_name() {
234        let dir = create_temp_project();
235        write_skill(dir.path(), "bad", "---\ndescription: \"\"\nuser-invocable: true\n---\nContent.");
236
237        let catalog = PromptCatalog::from_dir(dir.path()).unwrap();
238        assert_eq!(catalog.all().len(), 1);
239        assert_eq!(catalog.all()[0].description, "bad");
240    }
241
242    #[test]
243    fn skill_without_activation_surface_defaults_to_user_invocable() {
244        let dir = create_temp_project();
245        write_skill(dir.path(), "noop", "---\ndescription: Does nothing\n---\nContent.");
246
247        let catalog = PromptCatalog::from_dir(dir.path()).unwrap();
248        assert_eq!(catalog.all().len(), 1);
249        assert!(catalog.all()[0].user_invocable);
250    }
251
252    #[test]
253    fn flat_md_without_activation_surface_is_skipped() {
254        let dir = create_temp_project();
255        write_flat_rule(dir.path(), "noop.md", "---\ndescription: Does nothing\n---\nContent.");
256
257        let catalog = PromptCatalog::from_dir(dir.path()).unwrap();
258        assert!(catalog.all().is_empty());
259    }
260
261    #[test]
262    fn name_defaults_to_directory_name() {
263        let dir = create_temp_project();
264        write_skill(dir.path(), "my-skill", "---\ndescription: My skill\nagent-invocable: true\n---\nContent.");
265
266        let catalog = PromptCatalog::from_dir(dir.path()).unwrap();
267        assert_eq!(catalog.all()[0].name, "my-skill");
268    }
269
270    #[test]
271    fn name_from_frontmatter_overrides_directory() {
272        let dir = create_temp_project();
273        write_skill(
274            dir.path(),
275            "dir-name",
276            "---\nname: custom-name\ndescription: Custom\nuser-invocable: true\n---\nContent.",
277        );
278
279        let catalog = PromptCatalog::from_dir(dir.path()).unwrap();
280        assert_eq!(catalog.all()[0].name, "custom-name");
281    }
282
283    #[test]
284    fn matching_read_rules_finds_matches() {
285        let dir = create_temp_project();
286        write_skill(
287            dir.path(),
288            "rust-rules",
289            "---\ndescription: Rust rules\ntriggers:\n  read:\n    - \"src/**/*.rs\"\n---\nRust rules.",
290        );
291        write_skill(
292            dir.path(),
293            "ts-rules",
294            "---\ndescription: TS rules\ntriggers:\n  read:\n    - \"src/**/*.ts\"\n---\nTS rules.",
295        );
296        write_skill(dir.path(), "commit", "---\ndescription: Commit\nuser-invocable: true\n---\nCommit.");
297
298        let catalog = PromptCatalog::from_dir(dir.path()).unwrap();
299        let matches = catalog.matching_rules("src/main.rs");
300        assert_eq!(matches.len(), 1);
301        assert_eq!(matches[0].name, "rust-rules");
302
303        let matches = catalog.matching_rules("src/app.ts");
304        assert_eq!(matches.len(), 1);
305        assert_eq!(matches[0].name, "ts-rules");
306
307        let matches = catalog.matching_rules("README.md");
308        assert!(matches.is_empty());
309    }
310
311    #[test]
312    fn pure_flat_rule_not_in_user_or_agent_invocable() {
313        let dir = create_temp_project();
314        write_flat_rule(
315            dir.path(),
316            "rule.md",
317            "---\ndescription: A rule\ntriggers:\n  read:\n    - \"*.rs\"\n---\nRule content.",
318        );
319
320        let catalog = PromptCatalog::from_dir(dir.path()).unwrap();
321        assert_eq!(catalog.all().len(), 1);
322        assert_eq!(catalog.slash_commands().count(), 0);
323        assert_eq!(catalog.skills().count(), 0);
324    }
325
326    #[test]
327    fn skips_hidden_directories() {
328        let dir = create_temp_project();
329        write_skill(dir.path(), ".archived", "---\ndescription: Archived\nuser-invocable: true\n---\nOld.");
330        write_skill(dir.path(), "visible", "---\ndescription: Visible\nuser-invocable: true\n---\nNew.");
331
332        let catalog = PromptCatalog::from_dir(dir.path()).unwrap();
333        assert_eq!(catalog.all().len(), 1);
334        assert_eq!(catalog.all()[0].name, "visible");
335    }
336
337    #[test]
338    fn preserves_tags_and_metadata() {
339        let dir = create_temp_project();
340        write_skill(
341            dir.path(),
342            "tagged",
343            "---\ndescription: Tagged skill\nagent-invocable: true\ntags:\n  - rust\n  - testing\nagent_authored: true\nhelpful: 5\nharmful: 1\n---\nContent.",
344        );
345
346        let catalog = PromptCatalog::from_dir(dir.path()).unwrap();
347        let spec = &catalog.all()[0];
348        assert_eq!(spec.tags, vec!["rust", "testing"]);
349        assert!(spec.agent_authored);
350        assert_eq!(spec.helpful, 5);
351        assert_eq!(spec.harmful, 1);
352    }
353
354    #[test]
355    fn from_dirs_last_wins() {
356        let dir_a = create_temp_project();
357        let dir_b = create_temp_project();
358        write_skill(dir_a.path(), "rust", "---\ndescription: Rust A\nagent-invocable: true\n---\nFrom dir A.");
359        write_skill(dir_b.path(), "rust", "---\ndescription: Rust B\nagent-invocable: true\n---\nFrom dir B.");
360
361        let catalog = PromptCatalog::from_dirs(&[dir_a.path().to_path_buf(), dir_b.path().to_path_buf()]);
362        assert_eq!(catalog.all().len(), 1);
363
364        let spec = &catalog.all()[0];
365        assert_eq!(spec.name, "rust");
366        assert_eq!(spec.description, "Rust B");
367        assert!(spec.body.contains("From dir B."));
368    }
369
370    #[test]
371    fn from_dirs_union() {
372        let dir_a = create_temp_project();
373        let dir_b = create_temp_project();
374        write_skill(dir_a.path(), "rust", "---\ndescription: Rust\nagent-invocable: true\n---\nRust content.");
375        write_skill(dir_b.path(), "python", "---\ndescription: Python\nagent-invocable: true\n---\nPython content.");
376
377        let catalog = PromptCatalog::from_dirs(&[dir_a.path().to_path_buf(), dir_b.path().to_path_buf()]);
378        assert_eq!(catalog.all().len(), 2);
379
380        let names: Vec<&str> = catalog.all().iter().map(|s| s.name.as_str()).collect();
381        assert!(names.contains(&"rust"));
382        assert!(names.contains(&"python"));
383    }
384
385    #[test]
386    fn from_dirs_skips_missing() {
387        let dir_a = create_temp_project();
388        let missing = PathBuf::from("/tmp/nonexistent-skills-dir-12345");
389        write_skill(dir_a.path(), "rust", "---\ndescription: Rust\nagent-invocable: true\n---\nRust content.");
390
391        let catalog = PromptCatalog::from_dirs(&[missing, dir_a.path().to_path_buf()]);
392        assert_eq!(catalog.all().len(), 1);
393        assert_eq!(catalog.all()[0].name, "rust");
394    }
395
396    fn write_flat_rule(dir: &Path, filename: &str, content: &str) {
397        fs::write(dir.join(filename), content).unwrap();
398    }
399
400    #[test]
401    fn discover_flat_md_rule_with_globs() {
402        let dir = create_temp_project();
403        write_flat_rule(
404            dir.path(),
405            "rust-conventions.md",
406            "---\ndescription: Rust conventions\nglobs:\n  - \"**/*.rs\"\n---\nFollow Rust conventions.",
407        );
408
409        let catalog = PromptCatalog::from_dir(dir.path()).unwrap();
410        assert_eq!(catalog.all().len(), 1);
411
412        let spec = &catalog.all()[0];
413        assert_eq!(spec.name, "rust-conventions");
414        assert_eq!(spec.description, "Rust conventions");
415        assert!(spec.triggers.matches_read("src/main.rs"));
416        assert!(!spec.triggers.matches_read("README.md"));
417    }
418
419    #[test]
420    fn discover_flat_md_rule_with_paths() {
421        let dir = create_temp_project();
422        write_flat_rule(
423            dir.path(),
424            "ts-rules.md",
425            "---\ndescription: TS rules\npaths:\n  - \"**/*.ts\"\n---\nTypeScript rules.",
426        );
427
428        let catalog = PromptCatalog::from_dir(dir.path()).unwrap();
429        assert_eq!(catalog.all().len(), 1);
430
431        let spec = &catalog.all()[0];
432        assert_eq!(spec.name, "ts-rules");
433        assert!(spec.triggers.matches_read("src/index.ts"));
434    }
435
436    #[test]
437    fn discover_mixed_skill_md_and_flat_rules() {
438        let dir = create_temp_project();
439        write_skill(dir.path(), "commit", "---\ndescription: Commit\nuser-invocable: true\n---\nCommit message.");
440        write_flat_rule(
441            dir.path(),
442            "rust-rules.md",
443            "---\ndescription: Rust rules\nglobs:\n  - \"**/*.rs\"\n---\nRust conventions.",
444        );
445
446        let catalog = PromptCatalog::from_dir(dir.path()).unwrap();
447        assert_eq!(catalog.all().len(), 2);
448
449        let names: Vec<&str> = catalog.all().iter().map(|s| s.name.as_str()).collect();
450        assert!(names.contains(&"commit"));
451        assert!(names.contains(&"rust-rules"));
452    }
453
454    #[test]
455    fn from_dirs_merges_flat_rules() {
456        let dir_a = create_temp_project();
457        let dir_b = create_temp_project();
458        write_skill(dir_a.path(), "commit", "---\ndescription: Commit\nuser-invocable: true\n---\nCommit.");
459        write_flat_rule(
460            dir_b.path(),
461            "rust-rules.md",
462            "---\ndescription: Rust rules\nglobs:\n  - \"**/*.rs\"\n---\nRust conventions.",
463        );
464
465        let catalog = PromptCatalog::from_dirs(&[dir_a.path().to_path_buf(), dir_b.path().to_path_buf()]);
466        assert_eq!(catalog.all().len(), 2);
467
468        let names: Vec<&str> = catalog.all().iter().map(|s| s.name.as_str()).collect();
469        assert!(names.contains(&"commit"));
470        assert!(names.contains(&"rust-rules"));
471    }
472
473    #[test]
474    fn flat_rule_without_description_uses_name() {
475        let dir = create_temp_project();
476        write_flat_rule(dir.path(), "my-rule.md", "---\nglobs:\n  - \"**/*.rs\"\n---\nRule body.");
477
478        let catalog = PromptCatalog::from_dir(dir.path()).unwrap();
479        assert_eq!(catalog.all().len(), 1);
480
481        let spec = &catalog.all()[0];
482        assert_eq!(spec.name, "my-rule");
483        assert_eq!(spec.description, "my-rule");
484    }
485
486    #[test]
487    fn skips_hidden_flat_md_files() {
488        let dir = create_temp_project();
489        write_flat_rule(
490            dir.path(),
491            ".hidden-rule.md",
492            "---\ndescription: Hidden\nglobs:\n  - \"**/*.rs\"\n---\nHidden.",
493        );
494        write_flat_rule(
495            dir.path(),
496            "visible-rule.md",
497            "---\ndescription: Visible\nglobs:\n  - \"**/*.ts\"\n---\nVisible.",
498        );
499
500        let catalog = PromptCatalog::from_dir(dir.path()).unwrap();
501        assert_eq!(catalog.all().len(), 1);
502        assert_eq!(catalog.all()[0].name, "visible-rule");
503    }
504}