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