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 #[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
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 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 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 pub fn confidence(&self) -> f64 {
160 f64::from(self.helpful) / (f64::from(self.helpful) + f64::from(self.harmful) + 1.0)
161 }
162
163 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#[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 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}