Skip to main content

aether_project/
prompt_file.rs

1use std::fmt::{Display, Formatter};
2use std::fs;
3use std::path::{Path, PathBuf};
4
5use globset::{Glob, GlobSet, GlobSetBuilder};
6use serde::{Deserialize, Serialize};
7
8pub const SKILL_FILENAME: &str = "SKILL.md";
9
10#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
11pub(crate) struct PromptFrontmatter {
12    #[serde(default)]
13    pub description: String,
14    #[serde(default, skip_serializing_if = "Option::is_none")]
15    pub name: Option<String>,
16    #[serde(default, rename = "user-invocable", skip_serializing_if = "Option::is_none")]
17    pub user_invocable: Option<bool>,
18    #[serde(default, rename = "agent-invocable", skip_serializing_if = "Option::is_none")]
19    pub agent_invocable: Option<bool>,
20    #[serde(default, rename = "argument-hint", skip_serializing_if = "Option::is_none")]
21    pub argument_hint: Option<String>,
22    #[serde(default, skip_serializing_if = "Vec::is_empty")]
23    pub tags: Vec<String>,
24    #[serde(default, skip_serializing_if = "Option::is_none")]
25    pub triggers: Option<Triggers>,
26    /// Claude Code compatibility: top-level glob patterns (alias for `triggers.read`).
27    #[serde(default, skip_serializing_if = "Vec::is_empty")]
28    pub globs: Vec<String>,
29    /// Cursor compatibility: top-level path patterns (alias for `triggers.read`).
30    #[serde(default, skip_serializing_if = "Vec::is_empty")]
31    pub paths: Vec<String>,
32    #[serde(default, skip_serializing_if = "not")]
33    pub agent_authored: bool,
34    #[serde(default, skip_serializing_if = "zero")]
35    pub helpful: u32,
36    #[serde(default, skip_serializing_if = "zero")]
37    pub harmful: u32,
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
41pub struct Triggers {
42    #[serde(default, skip_serializing_if = "Vec::is_empty")]
43    pub read: Vec<String>,
44}
45
46/// A resolved skill artifact discovered from a `SKILL.md` file.
47#[derive(Debug, Clone)]
48pub struct PromptFile {
49    pub name: String,
50    pub description: String,
51    pub body: String,
52    pub path: PathBuf,
53    pub user_invocable: bool,
54    pub agent_invocable: bool,
55    pub argument_hint: Option<String>,
56    pub tags: Vec<String>,
57    pub triggers: PromptTriggers,
58    pub agent_authored: bool,
59    pub helpful: u32,
60    pub harmful: u32,
61}
62
63impl PromptFile {
64    /// Parse a prompt file at the given path into a `PromptFile`.
65    ///
66    /// The name defaults to the parent directory name unless overridden in frontmatter.
67    pub fn parse(path: &Path) -> Result<Self, PromptFileError> {
68        let raw = fs::read_to_string(path)?;
69        let is_skill_file = path.file_name().is_some_and(|n| n == SKILL_FILENAME);
70
71        let (frontmatter, body) = Self::parse_frontmatter(raw.trim())?;
72
73        let default_name = if is_skill_file {
74            path.parent().and_then(|p| p.file_name()).map(|n| n.to_string_lossy().to_string()).unwrap_or_default()
75        } else {
76            path.file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_default()
77        };
78
79        let name = frontmatter.name.unwrap_or(default_name);
80        let description = frontmatter.description.trim().to_string();
81        let description = if description.is_empty() { name.clone() } else { description };
82        let user_invocable = frontmatter.user_invocable.unwrap_or(is_skill_file);
83        let agent_invocable = frontmatter.agent_invocable.unwrap_or(true);
84
85        let mut read_globs = frontmatter.triggers.map(|t| t.read).unwrap_or_default();
86        read_globs.extend(frontmatter.globs);
87        read_globs.extend(frontmatter.paths);
88
89        if !user_invocable && !agent_invocable && read_globs.is_empty() {
90            return Err(PromptFileError::NoActivationSurface { name });
91        }
92
93        let triggers = PromptTriggers::new(read_globs)?;
94
95        Ok(Self {
96            name,
97            description,
98            body,
99            path: path.to_path_buf(),
100            user_invocable,
101            agent_invocable,
102            argument_hint: frontmatter.argument_hint,
103            tags: frontmatter.tags,
104            triggers,
105            agent_authored: frontmatter.agent_authored,
106            helpful: frontmatter.helpful,
107            harmful: frontmatter.harmful,
108        })
109    }
110
111    /// Validate this prompt file has a non-empty description and at least one activation surface.
112    pub fn validate(&self) -> Result<(), PromptFileError> {
113        if self.description.trim().is_empty() {
114            return Err(PromptFileError::MissingDescription { name: self.name.clone() });
115        }
116
117        let has_read_triggers = !self.triggers.is_empty();
118        if !self.user_invocable && !self.agent_invocable && !has_read_triggers {
119            return Err(PromptFileError::NoActivationSurface { name: self.name.clone() });
120        }
121
122        Ok(())
123    }
124
125    /// Write this prompt file to the given path, creating parent directories as needed.
126    pub fn write(&self, path: &Path) -> Result<(), PromptFileError> {
127        self.validate()?;
128
129        if let Some(parent) = path.parent() {
130            fs::create_dir_all(parent)?;
131        }
132
133        let triggers =
134            if self.triggers.is_empty() { None } else { Some(Triggers { read: self.triggers.patterns().to_vec() }) };
135
136        let frontmatter = PromptFrontmatter {
137            description: self.description.clone(),
138            name: Some(self.name.clone()),
139            user_invocable: self.user_invocable.then_some(true),
140            agent_invocable: (!self.agent_invocable).then_some(false),
141            argument_hint: self.argument_hint.clone(),
142            tags: self.tags.clone(),
143            triggers,
144            globs: vec![],
145            paths: vec![],
146            agent_authored: self.agent_authored,
147            helpful: self.helpful,
148            harmful: self.harmful,
149        };
150
151        let yaml = serde_yml::to_string(&frontmatter).map_err(|e| PromptFileError::Yaml(e.to_string()))?;
152        let yaml = normalize_frontmatter_yaml(&yaml);
153
154        let file_content = if self.body.is_empty() {
155            format!("---\n{yaml}\n---\n")
156        } else {
157            format!("---\n{yaml}\n---\n{}\n", self.body)
158        };
159        fs::write(path, file_content)?;
160        Ok(())
161    }
162
163    /// Confidence score based on helpful/harmful ratings.
164    pub fn confidence(&self) -> f64 {
165        f64::from(self.helpful) / (f64::from(self.helpful) + f64::from(self.harmful) + 1.0)
166    }
167
168    /// Parse YAML frontmatter and body from a SKILL.md content string (no I/O).
169    fn parse_frontmatter(content: &str) -> Result<(PromptFrontmatter, String), PromptFileError> {
170        let (yaml_str, body) =
171            utils::markdown_file::split_frontmatter(content).ok_or(PromptFileError::MissingFrontmatter)?;
172
173        let frontmatter: PromptFrontmatter =
174            serde_yml::from_str(yaml_str).map_err(|e| PromptFileError::Yaml(e.to_string()))?;
175
176        Ok((frontmatter, body.to_string()))
177    }
178}
179
180/// Trigger configuration for automatic prompt activation.
181#[derive(Debug, Clone, Default)]
182pub struct PromptTriggers {
183    patterns: Vec<String>,
184    globs: Option<GlobSet>,
185}
186
187impl PromptTriggers {
188    fn new(glob_patterns: Vec<String>) -> Result<Self, PromptFileError> {
189        if glob_patterns.is_empty() {
190            return Ok(Self { patterns: Vec::new(), globs: None });
191        }
192
193        let mut builder = GlobSetBuilder::new();
194        for pattern in &glob_patterns {
195            let glob = Glob::new(pattern)
196                .map_err(|e| PromptFileError::InvalidTriggerGlob { pattern: pattern.clone(), error: e.to_string() })?;
197            builder.add(glob);
198        }
199
200        let globs = builder.build().map_err(|e| PromptFileError::InvalidTriggerGlob {
201            pattern: glob_patterns.join(", "),
202            error: e.to_string(),
203        })?;
204
205        Ok(Self { patterns: glob_patterns, globs: Some(globs) })
206    }
207
208    pub fn patterns(&self) -> &[String] {
209        &self.patterns
210    }
211
212    pub fn is_empty(&self) -> bool {
213        self.globs.is_none()
214    }
215
216    /// Check if a project-relative path matches any read trigger glob.
217    pub fn matches_read(&self, relative_path: &str) -> bool {
218        self.globs.as_ref().is_some_and(|gs| gs.is_match(relative_path))
219    }
220}
221
222#[derive(Debug)]
223pub enum PromptFileError {
224    Io(std::io::Error),
225    Yaml(String),
226    MissingFrontmatter,
227    MissingDescription { name: String },
228    NoActivationSurface { name: String },
229    InvalidTriggerGlob { pattern: String, error: String },
230    NotFound(String),
231    NotAgentAuthored(String),
232}
233
234impl Display for PromptFileError {
235    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
236        match self {
237            PromptFileError::Io(e) => write!(f, "IO error: {e}"),
238            PromptFileError::Yaml(e) => write!(f, "YAML error: {e}"),
239            PromptFileError::MissingFrontmatter => write!(f, "missing YAML frontmatter"),
240            PromptFileError::MissingDescription { name } => {
241                write!(f, "skill '{name}' has an empty description")
242            }
243            PromptFileError::NoActivationSurface { name } => {
244                write!(
245                    f,
246                    "skill '{name}' must have at least one of: user-invocable, agent-invocable, triggers, globs, or paths"
247                )
248            }
249            PromptFileError::InvalidTriggerGlob { pattern, error } => {
250                write!(f, "invalid trigger glob '{pattern}': {error}")
251            }
252            PromptFileError::NotFound(name) => write!(f, "skill not found: {name}"),
253            PromptFileError::NotAgentAuthored(name) => {
254                write!(f, "skill '{name}' is not agent-authored and cannot be modified")
255            }
256        }
257    }
258}
259
260impl std::error::Error for PromptFileError {
261    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
262        match self {
263            PromptFileError::Io(e) => Some(e),
264            _ => None,
265        }
266    }
267}
268
269impl From<std::io::Error> for PromptFileError {
270    fn from(e: std::io::Error) -> Self {
271        PromptFileError::Io(e)
272    }
273}
274
275fn normalize_frontmatter_yaml(yaml: &str) -> &str {
276    let yaml = yaml.trim();
277    let yaml = yaml.strip_prefix("---\n").unwrap_or(yaml);
278    yaml.strip_suffix("\n...").unwrap_or(yaml).trim()
279}
280
281#[expect(clippy::trivially_copy_pass_by_ref)]
282fn not(b: &bool) -> bool {
283    !b
284}
285
286#[expect(clippy::trivially_copy_pass_by_ref)]
287fn zero(n: &u32) -> bool {
288    *n == 0
289}
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294    use tempfile::TempDir;
295
296    fn minimal_frontmatter(description: &str) -> PromptFrontmatter {
297        PromptFrontmatter {
298            description: description.to_string(),
299            name: None,
300            user_invocable: None,
301            agent_invocable: None,
302            argument_hint: None,
303            tags: vec![],
304            triggers: None,
305            globs: vec![],
306            paths: vec![],
307            agent_authored: false,
308            helpful: 0,
309            harmful: 0,
310        }
311    }
312
313    #[test]
314    fn frontmatter_serde_roundtrip() {
315        let fm = minimal_frontmatter("A simple skill");
316
317        let yaml = serde_yml::to_string(&fm).unwrap();
318        let parsed: PromptFrontmatter = serde_yml::from_str(&yaml).unwrap();
319        assert_eq!(parsed.description, "A simple skill");
320        assert!(parsed.tags.is_empty());
321        assert!(!parsed.agent_authored);
322    }
323
324    #[test]
325    fn frontmatter_serde_with_all_fields() {
326        let mut fm = minimal_frontmatter("A full skill");
327        fm.tags = vec!["convention".to_string(), "testing".to_string()];
328        fm.agent_authored = true;
329        fm.helpful = 5;
330        fm.harmful = 2;
331
332        let yaml = serde_yml::to_string(&fm).unwrap();
333        let parsed: PromptFrontmatter = serde_yml::from_str(&yaml).unwrap();
334        assert_eq!(parsed.description, "A full skill");
335        assert_eq!(parsed.tags, vec!["convention", "testing"]);
336        assert!(parsed.agent_authored);
337        assert_eq!(parsed.helpful, 5);
338        assert_eq!(parsed.harmful, 2);
339    }
340
341    #[test]
342    fn backward_compat_old_frontmatter() {
343        let yaml = "description: An old skill\n";
344        let parsed: PromptFrontmatter = serde_yml::from_str(yaml).unwrap();
345        assert_eq!(parsed.description, "An old skill");
346        assert!(parsed.tags.is_empty());
347        assert!(!parsed.agent_authored);
348        assert_eq!(parsed.helpful, 0);
349        assert_eq!(parsed.harmful, 0);
350    }
351
352    #[test]
353    fn confidence() {
354        let pf = |helpful, harmful| PromptFile {
355            name: String::new(),
356            description: "test".to_string(),
357            body: String::new(),
358            path: PathBuf::new(),
359            user_invocable: false,
360            agent_invocable: false,
361            argument_hint: None,
362            tags: vec![],
363            triggers: PromptTriggers::default(),
364            agent_authored: true,
365            helpful,
366            harmful,
367        };
368
369        assert!((pf(0, 0).confidence() - 0.0).abs() < f64::EPSILON);
370        assert!((pf(7, 1).confidence() - 7.0 / 9.0).abs() < f64::EPSILON);
371        assert!((pf(0, 5).confidence() - 0.0).abs() < f64::EPSILON);
372        assert!((pf(3, 0).confidence() - 3.0 / 4.0).abs() < f64::EPSILON);
373    }
374
375    #[test]
376    fn parse_frontmatter_from_string() {
377        let content = "---\ndescription: Test skill\ntags:\n  - rust\nagent_authored: true\nhelpful: 3\nharmful: 1\n---\n# My Skill\n\nSome content here.";
378        let (fm, body) = PromptFile::parse_frontmatter(content).unwrap();
379        assert_eq!(fm.description, "Test skill");
380        assert_eq!(fm.tags, vec!["rust"]);
381        assert!(fm.agent_authored);
382        assert_eq!(fm.helpful, 3);
383        assert_eq!(fm.harmful, 1);
384        assert!(body.contains("# My Skill"));
385        assert!(body.contains("Some content here."));
386    }
387
388    #[test]
389    fn write_and_parse_roundtrip() {
390        let temp_dir = TempDir::new().unwrap();
391        let skill_path = temp_dir.path().join("my-skill").join(SKILL_FILENAME);
392
393        let prompt = PromptFile {
394            name: "my-skill".to_string(),
395            description: "Test skill".to_string(),
396            body: "# My Skill\n\nSome content here.".to_string(),
397            path: skill_path.clone(),
398            user_invocable: false,
399            agent_invocable: true,
400            argument_hint: None,
401            tags: vec!["convention".to_string()],
402            triggers: PromptTriggers::default(),
403            agent_authored: true,
404            helpful: 2,
405            harmful: 1,
406        };
407        prompt.write(&skill_path).unwrap();
408
409        let parsed = PromptFile::parse(&skill_path).unwrap();
410        assert_eq!(parsed.description, "Test skill");
411        assert_eq!(parsed.tags, vec!["convention"]);
412        assert!(parsed.agent_authored);
413        assert_eq!(parsed.helpful, 2);
414        assert_eq!(parsed.harmful, 1);
415        assert!(parsed.body.contains("# My Skill"));
416        assert!(parsed.body.contains("Some content here."));
417    }
418
419    #[test]
420    fn write_empty_body() {
421        let temp_dir = TempDir::new().unwrap();
422        let skill_path = temp_dir.path().join("empty-body").join(SKILL_FILENAME);
423
424        let prompt = PromptFile {
425            name: "empty-body".to_string(),
426            description: "Empty".to_string(),
427            body: String::new(),
428            path: skill_path.clone(),
429            user_invocable: false,
430            agent_invocable: true,
431            argument_hint: None,
432            tags: vec![],
433            triggers: PromptTriggers::default(),
434            agent_authored: true,
435            helpful: 0,
436            harmful: 0,
437        };
438        prompt.write(&skill_path).unwrap();
439
440        let raw = std::fs::read_to_string(&skill_path).unwrap();
441        assert!(raw.starts_with("---\n"));
442        assert!(raw.contains("description: Empty"));
443    }
444
445    #[test]
446    fn write_and_parse_roundtrip_with_triggers() {
447        let temp_dir = TempDir::new().unwrap();
448        let skill_path = temp_dir.path().join("rust-rules").join(SKILL_FILENAME);
449
450        let triggers = PromptTriggers::new(vec!["src/**/*.rs".to_string(), "tests/**/*.rs".to_string()]).unwrap();
451
452        let prompt = PromptFile {
453            name: "rust-rules".to_string(),
454            description: "Rust conventions".to_string(),
455            body: "Follow Rust conventions.".to_string(),
456            path: skill_path.clone(),
457            user_invocable: false,
458            agent_invocable: false,
459            argument_hint: None,
460            tags: vec![],
461            triggers,
462            agent_authored: false,
463            helpful: 0,
464            harmful: 0,
465        };
466        prompt.write(&skill_path).unwrap();
467
468        let parsed = PromptFile::parse(&skill_path).unwrap();
469        assert_eq!(parsed.description, "Rust conventions");
470        assert!(!parsed.triggers.is_empty());
471        assert!(parsed.triggers.matches_read("src/main.rs"));
472        assert!(parsed.triggers.matches_read("tests/integration.rs"));
473        assert!(!parsed.triggers.matches_read("README.md"));
474        assert_eq!(parsed.triggers.patterns(), &["src/**/*.rs", "tests/**/*.rs"]);
475    }
476
477    #[test]
478    fn write_rejects_empty_description() {
479        let temp_dir = TempDir::new().unwrap();
480        let skill_path = temp_dir.path().join("bad").join(SKILL_FILENAME);
481
482        let prompt = PromptFile {
483            name: "bad".to_string(),
484            description: String::new(),
485            body: "content".to_string(),
486            path: skill_path.clone(),
487            user_invocable: true,
488            agent_invocable: false,
489            argument_hint: None,
490            tags: vec![],
491            triggers: PromptTriggers::default(),
492            agent_authored: true,
493            helpful: 0,
494            harmful: 0,
495        };
496        let result = prompt.write(&skill_path);
497        assert!(matches!(result, Err(PromptFileError::MissingDescription { .. })));
498    }
499
500    #[test]
501    fn write_rejects_no_activation_surface() {
502        let temp_dir = TempDir::new().unwrap();
503        let skill_path = temp_dir.path().join("noop").join(SKILL_FILENAME);
504
505        let prompt = PromptFile {
506            name: "noop".to_string(),
507            description: "Does nothing".to_string(),
508            body: "content".to_string(),
509            path: skill_path.clone(),
510            user_invocable: false,
511            agent_invocable: false,
512            argument_hint: None,
513            tags: vec![],
514            triggers: PromptTriggers::default(),
515            agent_authored: true,
516            helpful: 0,
517            harmful: 0,
518        };
519        let result = prompt.write(&skill_path);
520        assert!(matches!(result, Err(PromptFileError::NoActivationSurface { .. })));
521    }
522
523    #[test]
524    fn skip_serializing_defaults() {
525        let fm = minimal_frontmatter("Minimal");
526
527        let yaml = serde_yml::to_string(&fm).unwrap();
528        assert!(!yaml.contains("tags"));
529        assert!(!yaml.contains("agent_authored"));
530        assert!(!yaml.contains("helpful"));
531        assert!(!yaml.contains("harmful"));
532    }
533
534    #[test]
535    fn parse_globs_key() {
536        let content = r#"---
537description: TS conventions
538globs:
539  - "src/**/*.ts"
540  - "src/**/*.tsx"
541---
542Use strict TypeScript."#;
543        let (fm, body) = PromptFile::parse_frontmatter(content).unwrap();
544        assert_eq!(fm.globs, vec!["src/**/*.ts", "src/**/*.tsx"]);
545        assert!(fm.triggers.is_none());
546        assert!(body.contains("Use strict TypeScript."));
547    }
548
549    #[test]
550    fn parse_paths_key() {
551        let content = r#"---
552description: Rust rules
553paths:
554  - "**/*.rs"
555---
556Follow Rust conventions."#;
557        let (fm, _) = PromptFile::parse_frontmatter(content).unwrap();
558        assert_eq!(fm.paths, vec!["**/*.rs"]);
559        assert!(fm.triggers.is_none());
560    }
561
562    #[test]
563    fn parse_merges_all_glob_sources() {
564        let temp_dir = TempDir::new().unwrap();
565        let path = temp_dir.path().join("merged-rules").join(SKILL_FILENAME);
566        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
567        std::fs::write(
568            &path,
569            r#"---
570description: Merged
571triggers:
572  read:
573    - "src/**/*.rs"
574globs:
575  - "lib/**/*.ts"
576paths:
577  - "app/**/*.py"
578---
579Merged rules."#,
580        )
581        .unwrap();
582
583        let parsed = PromptFile::parse(&path).unwrap();
584        assert!(parsed.triggers.matches_read("src/main.rs"));
585        assert!(parsed.triggers.matches_read("lib/index.ts"));
586        assert!(parsed.triggers.matches_read("app/main.py"));
587    }
588
589    #[test]
590    fn parse_globs_as_activation_surface() {
591        let temp_dir = TempDir::new().unwrap();
592        let path = temp_dir.path().join("globs-only.md");
593        std::fs::write(
594            &path,
595            r#"---
596description: TS rules
597globs:
598  - "**/*.ts"
599---
600TypeScript rules."#,
601        )
602        .unwrap();
603
604        let parsed = PromptFile::parse(&path).unwrap();
605        assert_eq!(parsed.name, "globs-only");
606        assert!(parsed.triggers.matches_read("src/index.ts"));
607    }
608
609    #[test]
610    fn name_from_file_stem_for_non_skill_md() {
611        let temp_dir = TempDir::new().unwrap();
612        let path = temp_dir.path().join("rust-conventions.md");
613        std::fs::write(
614            &path,
615            r#"---
616description: Rust conventions
617globs:
618  - "**/*.rs"
619---
620Follow Rust conventions."#,
621        )
622        .unwrap();
623
624        let parsed = PromptFile::parse(&path).unwrap();
625        assert_eq!(parsed.name, "rust-conventions");
626    }
627
628    #[test]
629    fn empty_description_defaults_to_name() {
630        let temp_dir = TempDir::new().unwrap();
631        let path = temp_dir.path().join("my-rule.md");
632        std::fs::write(
633            &path,
634            r#"---
635globs:
636  - "**/*.rs"
637---
638Rule body."#,
639        )
640        .unwrap();
641
642        let parsed = PromptFile::parse(&path).unwrap();
643        assert_eq!(parsed.name, "my-rule");
644        assert_eq!(parsed.description, "my-rule");
645    }
646
647    #[test]
648    fn skill_file_defaults_user_invocable_true_when_missing() {
649        let temp_dir = TempDir::new().unwrap();
650        let path = temp_dir.path().join("compat-skill").join(SKILL_FILENAME);
651        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
652        std::fs::write(
653            &path,
654            r"---
655description: Claude-style skill
656---
657Skill body.",
658        )
659        .unwrap();
660
661        let parsed = PromptFile::parse(&path).unwrap();
662        assert!(parsed.user_invocable);
663        assert!(parsed.agent_invocable);
664    }
665
666    #[test]
667    fn non_skill_md_without_activation_surface_still_rejected() {
668        let temp_dir = TempDir::new().unwrap();
669        let path = temp_dir.path().join("noop.md");
670        std::fs::write(
671            &path,
672            r"---
673description: No activation
674agent-invocable: false
675---
676Rule body.",
677        )
678        .unwrap();
679
680        let result = PromptFile::parse(&path);
681        assert!(matches!(result, Err(PromptFileError::NoActivationSurface { .. })));
682    }
683}