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