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