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