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 = "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 = serde_yml::to_string(&frontmatter).map_err(|e| PromptFileError::Yaml(e.to_string()))?;
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) =
170            utils::markdown_file::split_frontmatter(content).ok_or(PromptFileError::MissingFrontmatter)?;
171
172        let frontmatter: PromptFrontmatter =
173            serde_yml::from_str(yaml_str).map_err(|e| PromptFileError::Yaml(e.to_string()))?;
174
175        Ok((frontmatter, body.to_string()))
176    }
177}
178
179/// Trigger configuration for automatic prompt activation.
180#[derive(Debug, Clone, Default)]
181pub struct PromptTriggers {
182    patterns: Vec<String>,
183    globs: Option<GlobSet>,
184}
185
186impl PromptTriggers {
187    fn new(glob_patterns: Vec<String>) -> Result<Self, PromptFileError> {
188        if glob_patterns.is_empty() {
189            return Ok(Self { patterns: Vec::new(), globs: None });
190        }
191
192        let mut builder = GlobSetBuilder::new();
193        for pattern in &glob_patterns {
194            let glob = Glob::new(pattern)
195                .map_err(|e| PromptFileError::InvalidTriggerGlob { pattern: pattern.clone(), error: e.to_string() })?;
196            builder.add(glob);
197        }
198
199        let globs = builder.build().map_err(|e| PromptFileError::InvalidTriggerGlob {
200            pattern: glob_patterns.join(", "),
201            error: e.to_string(),
202        })?;
203
204        Ok(Self { patterns: glob_patterns, globs: Some(globs) })
205    }
206
207    pub fn patterns(&self) -> &[String] {
208        &self.patterns
209    }
210
211    pub fn is_empty(&self) -> bool {
212        self.globs.is_none()
213    }
214
215    /// Check if a project-relative path matches any read trigger glob.
216    pub fn matches_read(&self, relative_path: &str) -> bool {
217        self.globs.as_ref().is_some_and(|gs| gs.is_match(relative_path))
218    }
219}
220
221#[derive(Debug, thiserror::Error)]
222pub enum PromptFileError {
223    #[error("IO error: {0}")]
224    Io(#[from] std::io::Error),
225    #[error("YAML error: {0}")]
226    Yaml(String),
227    #[error("missing YAML frontmatter")]
228    MissingFrontmatter,
229    #[error("skill '{name}' has an empty description")]
230    MissingDescription { name: String },
231    #[error("skill '{name}' must have at least one of: user-invocable, agent-invocable, triggers, globs, or paths")]
232    NoActivationSurface { name: String },
233    #[error("invalid trigger glob '{pattern}': {error}")]
234    InvalidTriggerGlob { pattern: String, error: String },
235    #[error("skill not found: {0}")]
236    NotFound(String),
237    #[error("skill '{0}' is not agent-authored and cannot be modified")]
238    NotAgentAuthored(String),
239}
240
241fn normalize_frontmatter_yaml(yaml: &str) -> &str {
242    let yaml = yaml.trim();
243    let yaml = yaml.strip_prefix("---\n").unwrap_or(yaml);
244    yaml.strip_suffix("\n...").unwrap_or(yaml).trim()
245}
246
247#[expect(clippy::trivially_copy_pass_by_ref)]
248fn not(b: &bool) -> bool {
249    !b
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 tempfile::TempDir;
261
262    fn minimal_frontmatter(description: &str) -> PromptFrontmatter {
263        PromptFrontmatter {
264            description: description.to_string(),
265            name: None,
266            user_invocable: None,
267            agent_invocable: None,
268            argument_hint: None,
269            tags: vec![],
270            triggers: None,
271            globs: vec![],
272            paths: vec![],
273            agent_authored: false,
274            helpful: 0,
275            harmful: 0,
276        }
277    }
278
279    #[test]
280    fn frontmatter_serde_roundtrip() {
281        let fm = minimal_frontmatter("A simple skill");
282
283        let yaml = serde_yml::to_string(&fm).unwrap();
284        let parsed: PromptFrontmatter = serde_yml::from_str(&yaml).unwrap();
285        assert_eq!(parsed.description, "A simple skill");
286        assert!(parsed.tags.is_empty());
287        assert!(!parsed.agent_authored);
288    }
289
290    #[test]
291    fn frontmatter_serde_with_all_fields() {
292        let mut fm = minimal_frontmatter("A full skill");
293        fm.tags = vec!["convention".to_string(), "testing".to_string()];
294        fm.agent_authored = true;
295        fm.helpful = 5;
296        fm.harmful = 2;
297
298        let yaml = serde_yml::to_string(&fm).unwrap();
299        let parsed: PromptFrontmatter = serde_yml::from_str(&yaml).unwrap();
300        assert_eq!(parsed.description, "A full skill");
301        assert_eq!(parsed.tags, vec!["convention", "testing"]);
302        assert!(parsed.agent_authored);
303        assert_eq!(parsed.helpful, 5);
304        assert_eq!(parsed.harmful, 2);
305    }
306
307    #[test]
308    fn backward_compat_old_frontmatter() {
309        let yaml = "description: An old skill\n";
310        let parsed: PromptFrontmatter = serde_yml::from_str(yaml).unwrap();
311        assert_eq!(parsed.description, "An old skill");
312        assert!(parsed.tags.is_empty());
313        assert!(!parsed.agent_authored);
314        assert_eq!(parsed.helpful, 0);
315        assert_eq!(parsed.harmful, 0);
316    }
317
318    #[test]
319    fn confidence() {
320        let pf = |helpful, harmful| PromptFile {
321            name: String::new(),
322            description: "test".to_string(),
323            body: String::new(),
324            path: PathBuf::new(),
325            user_invocable: false,
326            agent_invocable: false,
327            argument_hint: None,
328            tags: vec![],
329            triggers: PromptTriggers::default(),
330            agent_authored: true,
331            helpful,
332            harmful,
333        };
334
335        assert!((pf(0, 0).confidence() - 0.0).abs() < f64::EPSILON);
336        assert!((pf(7, 1).confidence() - 7.0 / 9.0).abs() < f64::EPSILON);
337        assert!((pf(0, 5).confidence() - 0.0).abs() < f64::EPSILON);
338        assert!((pf(3, 0).confidence() - 3.0 / 4.0).abs() < f64::EPSILON);
339    }
340
341    #[test]
342    fn parse_frontmatter_from_string() {
343        let content = "---\ndescription: Test skill\ntags:\n  - rust\nagent_authored: true\nhelpful: 3\nharmful: 1\n---\n# My Skill\n\nSome content here.";
344        let (fm, body) = PromptFile::parse_frontmatter(content).unwrap();
345        assert_eq!(fm.description, "Test skill");
346        assert_eq!(fm.tags, vec!["rust"]);
347        assert!(fm.agent_authored);
348        assert_eq!(fm.helpful, 3);
349        assert_eq!(fm.harmful, 1);
350        assert!(body.contains("# My Skill"));
351        assert!(body.contains("Some content here."));
352    }
353
354    #[test]
355    fn write_and_parse_roundtrip() {
356        let temp_dir = TempDir::new().unwrap();
357        let skill_path = temp_dir.path().join("my-skill").join(SKILL_FILENAME);
358
359        let prompt = PromptFile {
360            name: "my-skill".to_string(),
361            description: "Test skill".to_string(),
362            body: "# My Skill\n\nSome content here.".to_string(),
363            path: skill_path.clone(),
364            user_invocable: false,
365            agent_invocable: true,
366            argument_hint: None,
367            tags: vec!["convention".to_string()],
368            triggers: PromptTriggers::default(),
369            agent_authored: true,
370            helpful: 2,
371            harmful: 1,
372        };
373        prompt.write(&skill_path).unwrap();
374
375        let parsed = PromptFile::parse(&skill_path).unwrap();
376        assert_eq!(parsed.description, "Test skill");
377        assert_eq!(parsed.tags, vec!["convention"]);
378        assert!(parsed.agent_authored);
379        assert_eq!(parsed.helpful, 2);
380        assert_eq!(parsed.harmful, 1);
381        assert!(parsed.body.contains("# My Skill"));
382        assert!(parsed.body.contains("Some content here."));
383    }
384
385    #[test]
386    fn write_empty_body() {
387        let temp_dir = TempDir::new().unwrap();
388        let skill_path = temp_dir.path().join("empty-body").join(SKILL_FILENAME);
389
390        let prompt = PromptFile {
391            name: "empty-body".to_string(),
392            description: "Empty".to_string(),
393            body: String::new(),
394            path: skill_path.clone(),
395            user_invocable: false,
396            agent_invocable: true,
397            argument_hint: None,
398            tags: vec![],
399            triggers: PromptTriggers::default(),
400            agent_authored: true,
401            helpful: 0,
402            harmful: 0,
403        };
404        prompt.write(&skill_path).unwrap();
405
406        let raw = std::fs::read_to_string(&skill_path).unwrap();
407        assert!(raw.starts_with("---\n"));
408        assert!(raw.contains("description: Empty"));
409    }
410
411    #[test]
412    fn write_and_parse_roundtrip_with_triggers() {
413        let temp_dir = TempDir::new().unwrap();
414        let skill_path = temp_dir.path().join("rust-rules").join(SKILL_FILENAME);
415
416        let triggers = PromptTriggers::new(vec!["src/**/*.rs".to_string(), "tests/**/*.rs".to_string()]).unwrap();
417
418        let prompt = PromptFile {
419            name: "rust-rules".to_string(),
420            description: "Rust conventions".to_string(),
421            body: "Follow Rust conventions.".to_string(),
422            path: skill_path.clone(),
423            user_invocable: false,
424            agent_invocable: false,
425            argument_hint: None,
426            tags: vec![],
427            triggers,
428            agent_authored: false,
429            helpful: 0,
430            harmful: 0,
431        };
432        prompt.write(&skill_path).unwrap();
433
434        let parsed = PromptFile::parse(&skill_path).unwrap();
435        assert_eq!(parsed.description, "Rust conventions");
436        assert!(!parsed.triggers.is_empty());
437        assert!(parsed.triggers.matches_read("src/main.rs"));
438        assert!(parsed.triggers.matches_read("tests/integration.rs"));
439        assert!(!parsed.triggers.matches_read("README.md"));
440        assert_eq!(parsed.triggers.patterns(), &["src/**/*.rs", "tests/**/*.rs"]);
441    }
442
443    #[test]
444    fn write_rejects_empty_description() {
445        let temp_dir = TempDir::new().unwrap();
446        let skill_path = temp_dir.path().join("bad").join(SKILL_FILENAME);
447
448        let prompt = PromptFile {
449            name: "bad".to_string(),
450            description: String::new(),
451            body: "content".to_string(),
452            path: skill_path.clone(),
453            user_invocable: true,
454            agent_invocable: false,
455            argument_hint: None,
456            tags: vec![],
457            triggers: PromptTriggers::default(),
458            agent_authored: true,
459            helpful: 0,
460            harmful: 0,
461        };
462        let result = prompt.write(&skill_path);
463        assert!(matches!(result, Err(PromptFileError::MissingDescription { .. })));
464    }
465
466    #[test]
467    fn write_rejects_no_activation_surface() {
468        let temp_dir = TempDir::new().unwrap();
469        let skill_path = temp_dir.path().join("noop").join(SKILL_FILENAME);
470
471        let prompt = PromptFile {
472            name: "noop".to_string(),
473            description: "Does nothing".to_string(),
474            body: "content".to_string(),
475            path: skill_path.clone(),
476            user_invocable: false,
477            agent_invocable: false,
478            argument_hint: None,
479            tags: vec![],
480            triggers: PromptTriggers::default(),
481            agent_authored: true,
482            helpful: 0,
483            harmful: 0,
484        };
485        let result = prompt.write(&skill_path);
486        assert!(matches!(result, Err(PromptFileError::NoActivationSurface { .. })));
487    }
488
489    #[test]
490    fn skip_serializing_defaults() {
491        let fm = minimal_frontmatter("Minimal");
492
493        let yaml = serde_yml::to_string(&fm).unwrap();
494        assert!(!yaml.contains("tags"));
495        assert!(!yaml.contains("agent_authored"));
496        assert!(!yaml.contains("helpful"));
497        assert!(!yaml.contains("harmful"));
498    }
499
500    #[test]
501    fn parse_globs_key() {
502        let content = r#"---
503description: TS conventions
504globs:
505  - "src/**/*.ts"
506  - "src/**/*.tsx"
507---
508Use strict TypeScript."#;
509        let (fm, body) = PromptFile::parse_frontmatter(content).unwrap();
510        assert_eq!(fm.globs, vec!["src/**/*.ts", "src/**/*.tsx"]);
511        assert!(fm.triggers.is_none());
512        assert!(body.contains("Use strict TypeScript."));
513    }
514
515    #[test]
516    fn parse_paths_key() {
517        let content = r#"---
518description: Rust rules
519paths:
520  - "**/*.rs"
521---
522Follow Rust conventions."#;
523        let (fm, _) = PromptFile::parse_frontmatter(content).unwrap();
524        assert_eq!(fm.paths, vec!["**/*.rs"]);
525        assert!(fm.triggers.is_none());
526    }
527
528    #[test]
529    fn parse_merges_all_glob_sources() {
530        let temp_dir = TempDir::new().unwrap();
531        let path = temp_dir.path().join("merged-rules").join(SKILL_FILENAME);
532        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
533        std::fs::write(
534            &path,
535            r#"---
536description: Merged
537triggers:
538  read:
539    - "src/**/*.rs"
540globs:
541  - "lib/**/*.ts"
542paths:
543  - "app/**/*.py"
544---
545Merged rules."#,
546        )
547        .unwrap();
548
549        let parsed = PromptFile::parse(&path).unwrap();
550        assert!(parsed.triggers.matches_read("src/main.rs"));
551        assert!(parsed.triggers.matches_read("lib/index.ts"));
552        assert!(parsed.triggers.matches_read("app/main.py"));
553    }
554
555    #[test]
556    fn parse_globs_as_activation_surface() {
557        let temp_dir = TempDir::new().unwrap();
558        let path = temp_dir.path().join("globs-only.md");
559        std::fs::write(
560            &path,
561            r#"---
562description: TS rules
563globs:
564  - "**/*.ts"
565---
566TypeScript rules."#,
567        )
568        .unwrap();
569
570        let parsed = PromptFile::parse(&path).unwrap();
571        assert_eq!(parsed.name, "globs-only");
572        assert!(parsed.triggers.matches_read("src/index.ts"));
573    }
574
575    #[test]
576    fn name_from_file_stem_for_non_skill_md() {
577        let temp_dir = TempDir::new().unwrap();
578        let path = temp_dir.path().join("rust-conventions.md");
579        std::fs::write(
580            &path,
581            r#"---
582description: Rust conventions
583globs:
584  - "**/*.rs"
585---
586Follow Rust conventions."#,
587        )
588        .unwrap();
589
590        let parsed = PromptFile::parse(&path).unwrap();
591        assert_eq!(parsed.name, "rust-conventions");
592    }
593
594    #[test]
595    fn empty_description_defaults_to_name() {
596        let temp_dir = TempDir::new().unwrap();
597        let path = temp_dir.path().join("my-rule.md");
598        std::fs::write(
599            &path,
600            r#"---
601globs:
602  - "**/*.rs"
603---
604Rule body."#,
605        )
606        .unwrap();
607
608        let parsed = PromptFile::parse(&path).unwrap();
609        assert_eq!(parsed.name, "my-rule");
610        assert_eq!(parsed.description, "my-rule");
611    }
612
613    #[test]
614    fn skill_file_defaults_user_invocable_true_when_missing() {
615        let temp_dir = TempDir::new().unwrap();
616        let path = temp_dir.path().join("compat-skill").join(SKILL_FILENAME);
617        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
618        std::fs::write(
619            &path,
620            r"---
621description: Claude-style skill
622---
623Skill body.",
624        )
625        .unwrap();
626
627        let parsed = PromptFile::parse(&path).unwrap();
628        assert!(parsed.user_invocable);
629        assert!(parsed.agent_invocable);
630    }
631
632    #[test]
633    fn non_skill_md_without_activation_surface_still_rejected() {
634        let temp_dir = TempDir::new().unwrap();
635        let path = temp_dir.path().join("noop.md");
636        std::fs::write(
637            &path,
638            r"---
639description: No activation
640agent-invocable: false
641---
642Rule body.",
643        )
644        .unwrap();
645
646        let result = PromptFile::parse(&path);
647        assert!(matches!(result, Err(PromptFileError::NoActivationSurface { .. })));
648    }
649}