Skip to main content

aether_project/
prompt_file.rs

1use std::fs;
2use std::path::{Path, PathBuf};
3
4use globset::{Glob, GlobSet, GlobSetBuilder};
5use serde::{Deserialize, Serialize};
6
7pub const SKILL_FILENAME: &str = "SKILL.md";
8
9#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
10pub(crate) struct PromptFrontmatter {
11    #[serde(default)]
12    pub description: String,
13    #[serde(default, skip_serializing_if = "Option::is_none")]
14    pub name: Option<String>,
15    #[serde(default, rename = "user-invocable", skip_serializing_if = "Option::is_none")]
16    pub user_invocable: Option<bool>,
17    #[serde(default, rename = "agent-invocable", skip_serializing_if = "Option::is_none")]
18    pub agent_invocable: Option<bool>,
19    #[serde(default, rename = "argument-hint", skip_serializing_if = "Option::is_none")]
20    pub argument_hint: Option<String>,
21    #[serde(default, skip_serializing_if = "Vec::is_empty")]
22    pub tags: Vec<String>,
23    #[serde(default, skip_serializing_if = "Option::is_none")]
24    pub triggers: Option<Triggers>,
25    /// Claude Code compatibility: top-level glob patterns (alias for `triggers.read`).
26    #[serde(default, skip_serializing_if = "Vec::is_empty")]
27    pub globs: Vec<String>,
28    /// Cursor compatibility: top-level path patterns (alias for `triggers.read`).
29    #[serde(default, skip_serializing_if = "Vec::is_empty")]
30    pub paths: Vec<String>,
31    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
32    pub agent_authored: bool,
33    #[serde(default, skip_serializing_if = "zero")]
34    pub helpful: u32,
35    #[serde(default, skip_serializing_if = "zero")]
36    pub harmful: u32,
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
40pub struct Triggers {
41    #[serde(default, skip_serializing_if = "Vec::is_empty")]
42    pub read: Vec<String>,
43}
44
45/// A resolved skill artifact discovered from a `SKILL.md` file.
46#[derive(Debug, Clone)]
47pub struct PromptFile {
48    pub name: String,
49    pub description: String,
50    pub body: String,
51    pub path: PathBuf,
52    pub user_invocable: bool,
53    pub agent_invocable: bool,
54    pub argument_hint: Option<String>,
55    pub tags: Vec<String>,
56    pub triggers: PromptTriggers,
57    pub agent_authored: bool,
58    pub helpful: u32,
59    pub harmful: u32,
60}
61
62impl PromptFile {
63    /// Parse a prompt file at the given path into a `PromptFile`.
64    ///
65    /// The name defaults to the parent directory name unless overridden in frontmatter.
66    pub fn parse(path: &Path) -> Result<Self, PromptFileError> {
67        let raw = fs::read_to_string(path)?;
68        let is_skill_file = path.file_name().is_some_and(|n| n == SKILL_FILENAME);
69
70        let (frontmatter, body) = Self::parse_frontmatter(raw.trim())?;
71
72        let default_name = if is_skill_file {
73            path.parent().and_then(|p| p.file_name()).map(|n| n.to_string_lossy().to_string()).unwrap_or_default()
74        } else {
75            path.file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_default()
76        };
77
78        let name = frontmatter.name.unwrap_or(default_name);
79        let description = frontmatter.description.trim().to_string();
80        let description = if description.is_empty() { name.clone() } else { description };
81        let user_invocable = frontmatter.user_invocable.unwrap_or(is_skill_file);
82        let agent_invocable = frontmatter.agent_invocable.unwrap_or(true);
83
84        let mut read_globs = frontmatter.triggers.map(|t| t.read).unwrap_or_default();
85        read_globs.extend(frontmatter.globs);
86        read_globs.extend(frontmatter.paths);
87
88        if !user_invocable && !agent_invocable && read_globs.is_empty() {
89            return Err(PromptFileError::NoActivationSurface { name });
90        }
91
92        let triggers = PromptTriggers::new(read_globs)?;
93
94        Ok(Self {
95            name,
96            description,
97            body,
98            path: path.to_path_buf(),
99            user_invocable,
100            agent_invocable,
101            argument_hint: frontmatter.argument_hint,
102            tags: frontmatter.tags,
103            triggers,
104            agent_authored: frontmatter.agent_authored,
105            helpful: frontmatter.helpful,
106            harmful: frontmatter.harmful,
107        })
108    }
109
110    /// Validate this prompt file has a non-empty description and at least one activation surface.
111    pub fn validate(&self) -> Result<(), PromptFileError> {
112        if self.description.trim().is_empty() {
113            return Err(PromptFileError::MissingDescription { name: self.name.clone() });
114        }
115
116        let has_read_triggers = !self.triggers.is_empty();
117        if !self.user_invocable && !self.agent_invocable && !has_read_triggers {
118            return Err(PromptFileError::NoActivationSurface { name: self.name.clone() });
119        }
120
121        Ok(())
122    }
123
124    /// Write this prompt file to the given path, creating parent directories as needed.
125    pub fn write(&self, path: &Path) -> Result<(), PromptFileError> {
126        self.validate()?;
127
128        if let Some(parent) = path.parent() {
129            fs::create_dir_all(parent)?;
130        }
131
132        let triggers =
133            if self.triggers.is_empty() { None } else { Some(Triggers { read: self.triggers.patterns().to_vec() }) };
134
135        let frontmatter = PromptFrontmatter {
136            description: self.description.clone(),
137            name: Some(self.name.clone()),
138            user_invocable: self.user_invocable.then_some(true),
139            agent_invocable: (!self.agent_invocable).then_some(false),
140            argument_hint: self.argument_hint.clone(),
141            tags: self.tags.clone(),
142            triggers,
143            globs: vec![],
144            paths: vec![],
145            agent_authored: self.agent_authored,
146            helpful: self.helpful,
147            harmful: self.harmful,
148        };
149
150        let yaml = noyalib::to_string(&frontmatter)?;
151        let yaml = normalize_frontmatter_yaml(&yaml);
152
153        let file_content = if self.body.is_empty() {
154            format!("---\n{yaml}\n---\n")
155        } else {
156            format!("---\n{yaml}\n---\n{}\n", self.body)
157        };
158        fs::write(path, file_content)?;
159        Ok(())
160    }
161
162    /// Confidence score based on helpful/harmful ratings.
163    pub fn confidence(&self) -> f64 {
164        f64::from(self.helpful) / (f64::from(self.helpful) + f64::from(self.harmful) + 1.0)
165    }
166
167    /// Parse YAML frontmatter and body from a SKILL.md content string (no I/O).
168    fn parse_frontmatter(content: &str) -> Result<(PromptFrontmatter, String), PromptFileError> {
169        let (yaml_str, body) = split_frontmatter(content).ok_or(PromptFileError::MissingFrontmatter)?;
170
171        let frontmatter: PromptFrontmatter = noyalib::from_str(yaml_str)?;
172
173        Ok((frontmatter, body.to_string()))
174    }
175}
176
177/// Trigger configuration for automatic prompt activation.
178#[derive(Debug, Clone, Default)]
179pub struct PromptTriggers {
180    patterns: Vec<String>,
181    globs: Option<GlobSet>,
182}
183
184impl PromptTriggers {
185    pub(crate) fn new(glob_patterns: Vec<String>) -> Result<Self, PromptFileError> {
186        if glob_patterns.is_empty() {
187            return Ok(Self { patterns: Vec::new(), globs: None });
188        }
189
190        let mut builder = GlobSetBuilder::new();
191        for pattern in &glob_patterns {
192            let glob = Glob::new(pattern)
193                .map_err(|e| PromptFileError::InvalidTriggerGlob { pattern: pattern.clone(), error: e.to_string() })?;
194            builder.add(glob);
195        }
196
197        let globs = builder.build().map_err(|e| PromptFileError::InvalidTriggerGlob {
198            pattern: glob_patterns.join(", "),
199            error: e.to_string(),
200        })?;
201
202        Ok(Self { patterns: glob_patterns, globs: Some(globs) })
203    }
204
205    pub fn patterns(&self) -> &[String] {
206        &self.patterns
207    }
208
209    pub fn is_empty(&self) -> bool {
210        self.globs.is_none()
211    }
212
213    /// Check if a project-relative path matches any read trigger glob.
214    pub fn matches_read(&self, relative_path: &str) -> bool {
215        self.globs.as_ref().is_some_and(|gs| gs.is_match(relative_path))
216    }
217}
218
219#[derive(Debug, thiserror::Error)]
220pub enum PromptFileError {
221    #[error("IO error: {0}")]
222    Io(#[from] std::io::Error),
223    #[error("YAML error: {0}")]
224    Yaml(#[from] noyalib::Error),
225    #[error("missing YAML frontmatter")]
226    MissingFrontmatter,
227    #[error("skill '{name}' has an empty description")]
228    MissingDescription { name: String },
229    #[error("skill '{name}' must have at least one of: user-invocable, agent-invocable, triggers, globs, or paths")]
230    NoActivationSurface { name: String },
231    #[error("invalid trigger glob '{pattern}': {error}")]
232    InvalidTriggerGlob { pattern: String, error: String },
233    #[error("skill not found: {0}")]
234    NotFound(String),
235    #[error("skill '{0}' is not agent-authored and cannot be modified")]
236    NotAgentAuthored(String),
237}
238
239fn split_frontmatter(content: &str) -> Option<(&str, &str)> {
240    let content = content.trim();
241    let rest = content.strip_prefix("---")?;
242    let end_pos = rest.find("\n---")?;
243    Some((&rest[..end_pos], rest[end_pos + 4..].trim()))
244}
245
246fn normalize_frontmatter_yaml(yaml: &str) -> &str {
247    let yaml = yaml.trim();
248    let yaml = yaml.strip_prefix("---\n").unwrap_or(yaml);
249    yaml.strip_suffix("\n...").unwrap_or(yaml).trim()
250}
251
252#[expect(clippy::trivially_copy_pass_by_ref)]
253fn zero(n: &u32) -> bool {
254    *n == 0
255}
256
257#[cfg(test)]
258mod tests {
259    use super::*;
260    use crate::testing::{project, prompt_file};
261
262    #[test]
263    fn parse_skill_frontmatter_and_body() {
264        let dir = project().file(
265            "my-skill/SKILL.md",
266            "---\ndescription: Test skill\ntags:\n  - rust\nagent_authored: true\nhelpful: 3\nharmful: 1\n---\n# My Skill\n\nSome content here.",
267        );
268
269        let parsed = PromptFile::parse(&dir.root().join("my-skill").join(SKILL_FILENAME)).unwrap();
270        assert_eq!(parsed.name, "my-skill");
271        assert_eq!(parsed.description, "Test skill");
272        assert_eq!(parsed.tags, vec!["rust"]);
273        assert!(parsed.agent_authored);
274        assert_eq!(parsed.helpful, 3);
275        assert_eq!(parsed.harmful, 1);
276        assert!(parsed.body.contains("# My Skill"));
277        assert!(parsed.body.contains("Some content here."));
278    }
279
280    #[test]
281    fn backward_compat_old_frontmatter() {
282        let dir = project().file("old-skill/SKILL.md", "---\ndescription: An old skill\n---\nBody.");
283
284        let parsed = PromptFile::parse(&dir.root().join("old-skill").join(SKILL_FILENAME)).unwrap();
285        assert_eq!(parsed.description, "An old skill");
286        assert_eq!(parsed.tags.len(), 0);
287        assert!(!parsed.agent_authored);
288        assert_eq!(parsed.helpful, 0);
289        assert_eq!(parsed.harmful, 0);
290    }
291
292    #[test]
293    fn confidence() {
294        let prompt = |helpful, harmful| prompt_file("test").ratings(helpful, harmful).build();
295
296        assert!((prompt(0, 0).confidence() - 0.0).abs() < f64::EPSILON);
297        assert!((prompt(7, 1).confidence() - 7.0 / 9.0).abs() < f64::EPSILON);
298        assert!((prompt(0, 5).confidence() - 0.0).abs() < f64::EPSILON);
299        assert!((prompt(3, 0).confidence() - 3.0 / 4.0).abs() < f64::EPSILON);
300    }
301
302    #[test]
303    fn write_and_parse_roundtrip() {
304        let project = project();
305        let skill_path = project.root().join("my-skill").join(SKILL_FILENAME);
306
307        prompt_file("my-skill")
308            .description("Test skill")
309            .body("# My Skill\n\nSome content here.")
310            .tags(&["convention"])
311            .agent_authored(true)
312            .ratings(2, 1)
313            .build()
314            .write(&skill_path)
315            .unwrap();
316
317        let parsed = PromptFile::parse(&skill_path).unwrap();
318        assert_eq!(parsed.description, "Test skill");
319        assert_eq!(parsed.tags, vec!["convention"]);
320        assert!(parsed.agent_authored);
321        assert_eq!(parsed.helpful, 2);
322        assert_eq!(parsed.harmful, 1);
323        assert!(parsed.body.contains("# My Skill"));
324        assert!(parsed.body.contains("Some content here."));
325    }
326
327    #[test]
328    fn write_empty_body() {
329        let project = project();
330        let skill_path = project.root().join("empty-body").join(SKILL_FILENAME);
331
332        prompt_file("empty-body").description("Empty").body("").build().write(&skill_path).unwrap();
333
334        let raw = std::fs::read_to_string(&skill_path).unwrap();
335        assert!(raw.starts_with("---\n"));
336        assert!(raw.contains("description: Empty"));
337    }
338
339    #[test]
340    fn write_and_parse_roundtrip_with_triggers() {
341        let project = project();
342        let skill_path = project.root().join("rust-rules").join(SKILL_FILENAME);
343
344        prompt_file("rust-rules")
345            .description("Rust conventions")
346            .body("Follow Rust conventions.")
347            .user_invocable(false)
348            .agent_invocable(false)
349            .read_triggers(&["src/**/*.rs", "tests/**/*.rs"])
350            .build()
351            .write(&skill_path)
352            .unwrap();
353
354        let parsed = PromptFile::parse(&skill_path).unwrap();
355        assert_eq!(parsed.description, "Rust conventions");
356        assert!(!parsed.triggers.is_empty());
357        assert!(parsed.triggers.matches_read("src/main.rs"));
358        assert!(parsed.triggers.matches_read("tests/integration.rs"));
359        assert!(!parsed.triggers.matches_read("README.md"));
360        assert_eq!(parsed.triggers.patterns(), ["src/**/*.rs", "tests/**/*.rs"]);
361    }
362
363    #[test]
364    fn write_rejects_empty_description() {
365        let project = project();
366        let skill_path = project.root().join("bad").join(SKILL_FILENAME);
367
368        let result = prompt_file("bad").description("").build().write(&skill_path);
369        assert!(matches!(result, Err(PromptFileError::MissingDescription { .. })));
370    }
371
372    #[test]
373    fn write_rejects_no_activation_surface() {
374        let project = project();
375        let skill_path = project.root().join("noop").join(SKILL_FILENAME);
376
377        let result = prompt_file("noop").user_invocable(false).agent_invocable(false).build().write(&skill_path);
378        assert!(matches!(result, Err(PromptFileError::NoActivationSurface { .. })));
379    }
380
381    #[test]
382    fn write_skips_default_frontmatter_fields() {
383        let project = project();
384        let skill_path = project.root().join("minimal").join(SKILL_FILENAME);
385
386        prompt_file("minimal").build().write(&skill_path).unwrap();
387
388        let raw = std::fs::read_to_string(&skill_path).unwrap();
389        assert!(raw.contains("description: minimal skill"));
390        assert!(!raw.contains("tags"));
391        assert!(!raw.contains("agent_authored"));
392        assert!(!raw.contains("agent-invocable"));
393        assert!(!raw.contains("argument-hint"));
394        assert!(!raw.contains("helpful"));
395        assert!(!raw.contains("harmful"));
396        assert!(!raw.contains("triggers"));
397    }
398
399    #[test]
400    fn parse_globs_key() {
401        let dir = project().file(
402            "ts-conventions/SKILL.md",
403            "---\ndescription: TS conventions\nglobs:\n  - \"src/**/*.ts\"\n  - \"src/**/*.tsx\"\n---\nUse strict TypeScript.",
404        );
405
406        let parsed = PromptFile::parse(&dir.root().join("ts-conventions").join(SKILL_FILENAME)).unwrap();
407        assert_eq!(parsed.triggers.patterns(), ["src/**/*.ts", "src/**/*.tsx"]);
408        assert!(parsed.triggers.matches_read("src/main.ts"));
409        assert!(parsed.body.contains("Use strict TypeScript."));
410    }
411
412    #[test]
413    fn parse_paths_key() {
414        let dir = project().file(
415            "rust-rules/SKILL.md",
416            "---\ndescription: Rust rules\npaths:\n  - \"**/*.rs\"\n---\nFollow Rust conventions.",
417        );
418
419        let parsed = PromptFile::parse(&dir.root().join("rust-rules").join(SKILL_FILENAME)).unwrap();
420        assert_eq!(parsed.triggers.patterns(), ["**/*.rs"]);
421        assert!(parsed.triggers.matches_read("src/lib.rs"));
422    }
423
424    #[test]
425    fn parse_merges_all_glob_sources() {
426        let dir = project().file(
427            "merged-rules/SKILL.md",
428            "---\ndescription: Merged\ntriggers:\n  read:\n    - \"src/**/*.rs\"\nglobs:\n  - \"lib/**/*.ts\"\npaths:\n  - \"app/**/*.py\"\n---\nMerged rules.",
429        );
430
431        let parsed = PromptFile::parse(&dir.root().join("merged-rules").join(SKILL_FILENAME)).unwrap();
432        assert!(parsed.triggers.matches_read("src/main.rs"));
433        assert!(parsed.triggers.matches_read("lib/index.ts"));
434        assert!(parsed.triggers.matches_read("app/main.py"));
435    }
436
437    #[test]
438    fn parse_globs_as_activation_surface() {
439        let dir = project()
440            .file("globs-only.md", "---\ndescription: TS rules\nglobs:\n  - \"**/*.ts\"\n---\nTypeScript rules.");
441
442        let parsed = PromptFile::parse(&dir.root().join("globs-only.md")).unwrap();
443        assert_eq!(parsed.name, "globs-only");
444        assert!(parsed.triggers.matches_read("src/index.ts"));
445    }
446
447    #[test]
448    fn name_from_file_stem_for_non_skill_md() {
449        let dir = project().file(
450            "rust-conventions.md",
451            "---\ndescription: Rust conventions\nglobs:\n  - \"**/*.rs\"\n---\nFollow Rust conventions.",
452        );
453
454        let parsed = PromptFile::parse(&dir.root().join("rust-conventions.md")).unwrap();
455        assert_eq!(parsed.name, "rust-conventions");
456    }
457
458    #[test]
459    fn empty_description_defaults_to_name() {
460        let dir = project().file("my-rule.md", "---\nglobs:\n  - \"**/*.rs\"\n---\nRule body.");
461
462        let parsed = PromptFile::parse(&dir.root().join("my-rule.md")).unwrap();
463        assert_eq!(parsed.name, "my-rule");
464        assert_eq!(parsed.description, "my-rule");
465    }
466
467    #[test]
468    fn skill_file_defaults_user_invocable_true_when_missing() {
469        let dir = project().file("compat-skill/SKILL.md", "---\ndescription: Claude-style skill\n---\nSkill body.");
470
471        let parsed = PromptFile::parse(&dir.root().join("compat-skill").join(SKILL_FILENAME)).unwrap();
472        assert!(parsed.user_invocable);
473        assert!(parsed.agent_invocable);
474    }
475
476    #[test]
477    fn non_skill_md_without_activation_surface_still_rejected() {
478        let dir = project().file("noop.md", "---\ndescription: No activation\nagent-invocable: false\n---\nRule body.");
479
480        let result = PromptFile::parse(&dir.root().join("noop.md"));
481        assert!(matches!(result, Err(PromptFileError::NoActivationSurface { .. })));
482    }
483}