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 #[serde(default, skip_serializing_if = "Vec::is_empty")]
28 pub globs: Vec<String>,
29 #[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#[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 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 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 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 pub fn confidence(&self) -> f64 {
161 f64::from(self.helpful) / (f64::from(self.helpful) + f64::from(self.harmful) + 1.0)
162 }
163
164 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#[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 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}