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 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 pub fn confidence(&self) -> f64 {
165 f64::from(self.helpful) / (f64::from(self.helpful) + f64::from(self.harmful) + 1.0)
166 }
167
168 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#[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 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}