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 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 = "not")]
16 pub user_invocable: bool,
17 #[serde(default, rename = "agent-invocable", skip_serializing_if = "not")]
18 pub agent_invocable: bool,
19 #[serde(
20 default,
21 rename = "argument-hint",
22 skip_serializing_if = "Option::is_none"
23 )]
24 pub argument_hint: Option<String>,
25 #[serde(default, skip_serializing_if = "Vec::is_empty")]
26 pub tags: Vec<String>,
27 #[serde(default, skip_serializing_if = "Option::is_none")]
28 pub triggers: Option<Triggers>,
29 #[serde(default, skip_serializing_if = "not")]
30 pub agent_authored: bool,
31 #[serde(default, skip_serializing_if = "zero")]
32 pub helpful: u32,
33 #[serde(default, skip_serializing_if = "zero")]
34 pub harmful: u32,
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
38pub struct Triggers {
39 #[serde(default, skip_serializing_if = "Vec::is_empty")]
40 pub read: Vec<String>,
41}
42
43#[derive(Debug, Clone)]
45pub struct PromptFile {
46 pub name: String,
47 pub description: String,
48 pub body: String,
49 pub path: PathBuf,
50 pub user_invocable: bool,
51 pub agent_invocable: bool,
52 pub argument_hint: Option<String>,
53 pub tags: Vec<String>,
54 pub triggers: PromptTriggers,
55 pub agent_authored: bool,
56 pub helpful: u32,
57 pub harmful: u32,
58}
59
60impl PromptFile {
61 pub fn parse(path: &Path) -> Result<Self, PromptFileError> {
65 let raw = fs::read_to_string(path)?;
66
67 let (frontmatter, body) = Self::parse_frontmatter(raw.trim())?;
68
69 let dir_name = path
70 .parent()
71 .and_then(|p| p.file_name())
72 .map(|n| n.to_string_lossy().to_string())
73 .unwrap_or_default();
74
75 let name = frontmatter.name.unwrap_or(dir_name);
76 let description = frontmatter.description.trim().to_string();
77
78 if description.is_empty() {
79 return Err(PromptFileError::MissingDescription { name });
80 }
81
82 let has_read_triggers = frontmatter
83 .triggers
84 .as_ref()
85 .is_some_and(|t| !t.read.is_empty());
86
87 if !frontmatter.user_invocable && !frontmatter.agent_invocable && !has_read_triggers {
88 return Err(PromptFileError::NoActivationSurface { name });
89 }
90
91 let read_globs = frontmatter.triggers.map(|t| t.read).unwrap_or_default();
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: frontmatter.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 {
114 name: self.name.clone(),
115 });
116 }
117
118 let has_read_triggers = !self.triggers.is_empty();
119 if !self.user_invocable && !self.agent_invocable && !has_read_triggers {
120 return Err(PromptFileError::NoActivationSurface {
121 name: self.name.clone(),
122 });
123 }
124
125 Ok(())
126 }
127
128 pub fn write(&self, path: &Path) -> Result<(), PromptFileError> {
130 self.validate()?;
131
132 if let Some(parent) = path.parent() {
133 fs::create_dir_all(parent)?;
134 }
135
136 let triggers = if self.triggers.is_empty() {
137 None
138 } else {
139 Some(Triggers {
140 read: self.triggers.patterns().to_vec(),
141 })
142 };
143
144 let frontmatter = PromptFrontmatter {
145 description: self.description.clone(),
146 name: Some(self.name.clone()),
147 user_invocable: self.user_invocable,
148 agent_invocable: self.agent_invocable,
149 argument_hint: self.argument_hint.clone(),
150 tags: self.tags.clone(),
151 triggers,
152 agent_authored: self.agent_authored,
153 helpful: self.helpful,
154 harmful: self.harmful,
155 };
156
157 let yaml =
158 serde_yml::to_string(&frontmatter).map_err(|e| PromptFileError::Yaml(e.to_string()))?;
159
160 let file_content = if self.body.is_empty() {
161 format!("---\n{yaml}---\n")
162 } else {
163 format!("---\n{yaml}---\n{}\n", self.body)
164 };
165 fs::write(path, file_content)?;
166 Ok(())
167 }
168
169 pub fn confidence(&self) -> f64 {
171 f64::from(self.helpful) / (f64::from(self.helpful) + f64::from(self.harmful) + 1.0)
172 }
173
174 fn parse_frontmatter(content: &str) -> Result<(PromptFrontmatter, String), PromptFileError> {
176 let (yaml_str, body) = utils::markdown_file::split_frontmatter(content)
177 .ok_or(PromptFileError::MissingFrontmatter)?;
178
179 let frontmatter: PromptFrontmatter =
180 serde_yml::from_str(yaml_str).map_err(|e| PromptFileError::Yaml(e.to_string()))?;
181
182 Ok((frontmatter, body.to_string()))
183 }
184}
185
186#[derive(Debug, Clone, Default)]
188pub struct PromptTriggers {
189 patterns: Vec<String>,
190 globs: Option<GlobSet>,
191}
192
193impl PromptTriggers {
194 fn new(glob_patterns: Vec<String>) -> Result<Self, PromptFileError> {
195 if glob_patterns.is_empty() {
196 return Ok(Self {
197 patterns: Vec::new(),
198 globs: None,
199 });
200 }
201
202 let mut builder = GlobSetBuilder::new();
203 for pattern in &glob_patterns {
204 let glob = Glob::new(pattern).map_err(|e| PromptFileError::InvalidTriggerGlob {
205 pattern: pattern.clone(),
206 error: e.to_string(),
207 })?;
208 builder.add(glob);
209 }
210
211 let globs = builder
212 .build()
213 .map_err(|e| PromptFileError::InvalidTriggerGlob {
214 pattern: glob_patterns.join(", "),
215 error: e.to_string(),
216 })?;
217
218 Ok(Self {
219 patterns: glob_patterns,
220 globs: Some(globs),
221 })
222 }
223
224 pub fn patterns(&self) -> &[String] {
225 &self.patterns
226 }
227
228 pub fn is_empty(&self) -> bool {
229 self.globs.is_none()
230 }
231
232 pub fn matches_read(&self, relative_path: &str) -> bool {
234 self.globs
235 .as_ref()
236 .is_some_and(|gs| gs.is_match(relative_path))
237 }
238}
239
240#[derive(Debug)]
241pub enum PromptFileError {
242 Io(std::io::Error),
243 Yaml(String),
244 MissingFrontmatter,
245 MissingDescription { name: String },
246 NoActivationSurface { name: String },
247 InvalidTriggerGlob { pattern: String, error: String },
248 NotFound(String),
249 NotAgentAuthored(String),
250}
251
252impl Display for PromptFileError {
253 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
254 match self {
255 PromptFileError::Io(e) => write!(f, "IO error: {e}"),
256 PromptFileError::Yaml(e) => write!(f, "YAML error: {e}"),
257 PromptFileError::MissingFrontmatter => write!(f, "missing YAML frontmatter"),
258 PromptFileError::MissingDescription { name } => {
259 write!(f, "skill '{name}' has an empty description")
260 }
261 PromptFileError::NoActivationSurface { name } => {
262 write!(
263 f,
264 "skill '{name}' must have at least one of: user-invocable, agent-invocable, or triggers.read"
265 )
266 }
267 PromptFileError::InvalidTriggerGlob { pattern, error } => {
268 write!(f, "invalid trigger glob '{pattern}': {error}")
269 }
270 PromptFileError::NotFound(name) => write!(f, "skill not found: {name}"),
271 PromptFileError::NotAgentAuthored(name) => {
272 write!(
273 f,
274 "skill '{name}' is not agent-authored and cannot be modified"
275 )
276 }
277 }
278 }
279}
280
281impl std::error::Error for PromptFileError {
282 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
283 match self {
284 PromptFileError::Io(e) => Some(e),
285 _ => None,
286 }
287 }
288}
289
290impl From<std::io::Error> for PromptFileError {
291 fn from(e: std::io::Error) -> Self {
292 PromptFileError::Io(e)
293 }
294}
295
296#[expect(clippy::trivially_copy_pass_by_ref)]
297fn not(b: &bool) -> bool {
298 !b
299}
300
301#[expect(clippy::trivially_copy_pass_by_ref)]
302fn zero(n: &u32) -> bool {
303 *n == 0
304}
305
306#[cfg(test)]
307mod tests {
308 use super::*;
309 use tempfile::TempDir;
310
311 fn minimal_frontmatter(description: &str) -> PromptFrontmatter {
312 PromptFrontmatter {
313 description: description.to_string(),
314 name: None,
315 user_invocable: false,
316 agent_invocable: false,
317 argument_hint: None,
318 tags: vec![],
319 triggers: None,
320 agent_authored: false,
321 helpful: 0,
322 harmful: 0,
323 }
324 }
325
326 #[test]
327 fn frontmatter_serde_roundtrip() {
328 let fm = minimal_frontmatter("A simple skill");
329
330 let yaml = serde_yml::to_string(&fm).unwrap();
331 let parsed: PromptFrontmatter = serde_yml::from_str(&yaml).unwrap();
332 assert_eq!(parsed.description, "A simple skill");
333 assert!(parsed.tags.is_empty());
334 assert!(!parsed.agent_authored);
335 }
336
337 #[test]
338 fn frontmatter_serde_with_all_fields() {
339 let mut fm = minimal_frontmatter("A full skill");
340 fm.tags = vec!["convention".to_string(), "testing".to_string()];
341 fm.agent_authored = true;
342 fm.helpful = 5;
343 fm.harmful = 2;
344
345 let yaml = serde_yml::to_string(&fm).unwrap();
346 let parsed: PromptFrontmatter = serde_yml::from_str(&yaml).unwrap();
347 assert_eq!(parsed.description, "A full skill");
348 assert_eq!(parsed.tags, vec!["convention", "testing"]);
349 assert!(parsed.agent_authored);
350 assert_eq!(parsed.helpful, 5);
351 assert_eq!(parsed.harmful, 2);
352 }
353
354 #[test]
355 fn backward_compat_old_frontmatter() {
356 let yaml = "description: An old skill\n";
357 let parsed: PromptFrontmatter = serde_yml::from_str(yaml).unwrap();
358 assert_eq!(parsed.description, "An old skill");
359 assert!(parsed.tags.is_empty());
360 assert!(!parsed.agent_authored);
361 assert_eq!(parsed.helpful, 0);
362 assert_eq!(parsed.harmful, 0);
363 }
364
365 #[test]
366 fn confidence() {
367 let pf = |helpful, harmful| PromptFile {
368 name: String::new(),
369 description: "test".to_string(),
370 body: String::new(),
371 path: PathBuf::new(),
372 user_invocable: false,
373 agent_invocable: false,
374 argument_hint: None,
375 tags: vec![],
376 triggers: PromptTriggers::default(),
377 agent_authored: true,
378 helpful,
379 harmful,
380 };
381
382 assert!((pf(0, 0).confidence() - 0.0).abs() < f64::EPSILON);
383 assert!((pf(7, 1).confidence() - 7.0 / 9.0).abs() < f64::EPSILON);
384 assert!((pf(0, 5).confidence() - 0.0).abs() < f64::EPSILON);
385 assert!((pf(3, 0).confidence() - 3.0 / 4.0).abs() < f64::EPSILON);
386 }
387
388 #[test]
389 fn parse_frontmatter_from_string() {
390 let content = "---\ndescription: Test skill\ntags:\n - rust\nagent_authored: true\nhelpful: 3\nharmful: 1\n---\n# My Skill\n\nSome content here.";
391 let (fm, body) = PromptFile::parse_frontmatter(content).unwrap();
392 assert_eq!(fm.description, "Test skill");
393 assert_eq!(fm.tags, vec!["rust"]);
394 assert!(fm.agent_authored);
395 assert_eq!(fm.helpful, 3);
396 assert_eq!(fm.harmful, 1);
397 assert!(body.contains("# My Skill"));
398 assert!(body.contains("Some content here."));
399 }
400
401 #[test]
402 fn write_and_parse_roundtrip() {
403 let temp_dir = TempDir::new().unwrap();
404 let skill_path = temp_dir.path().join("my-skill").join(SKILL_FILENAME);
405
406 let prompt = PromptFile {
407 name: "my-skill".to_string(),
408 description: "Test skill".to_string(),
409 body: "# My Skill\n\nSome content here.".to_string(),
410 path: skill_path.clone(),
411 user_invocable: false,
412 agent_invocable: true,
413 argument_hint: None,
414 tags: vec!["convention".to_string()],
415 triggers: PromptTriggers::default(),
416 agent_authored: true,
417 helpful: 2,
418 harmful: 1,
419 };
420 prompt.write(&skill_path).unwrap();
421
422 let parsed = PromptFile::parse(&skill_path).unwrap();
423 assert_eq!(parsed.description, "Test skill");
424 assert_eq!(parsed.tags, vec!["convention"]);
425 assert!(parsed.agent_authored);
426 assert_eq!(parsed.helpful, 2);
427 assert_eq!(parsed.harmful, 1);
428 assert!(parsed.body.contains("# My Skill"));
429 assert!(parsed.body.contains("Some content here."));
430 }
431
432 #[test]
433 fn write_empty_body() {
434 let temp_dir = TempDir::new().unwrap();
435 let skill_path = temp_dir.path().join("empty-body").join(SKILL_FILENAME);
436
437 let prompt = PromptFile {
438 name: "empty-body".to_string(),
439 description: "Empty".to_string(),
440 body: String::new(),
441 path: skill_path.clone(),
442 user_invocable: false,
443 agent_invocable: true,
444 argument_hint: None,
445 tags: vec![],
446 triggers: PromptTriggers::default(),
447 agent_authored: true,
448 helpful: 0,
449 harmful: 0,
450 };
451 prompt.write(&skill_path).unwrap();
452
453 let raw = std::fs::read_to_string(&skill_path).unwrap();
454 assert!(raw.starts_with("---\n"));
455 assert!(raw.contains("description: Empty"));
456 }
457
458 #[test]
459 fn write_and_parse_roundtrip_with_triggers() {
460 let temp_dir = TempDir::new().unwrap();
461 let skill_path = temp_dir.path().join("rust-rules").join(SKILL_FILENAME);
462
463 let triggers =
464 PromptTriggers::new(vec!["src/**/*.rs".to_string(), "tests/**/*.rs".to_string()])
465 .unwrap();
466
467 let prompt = PromptFile {
468 name: "rust-rules".to_string(),
469 description: "Rust conventions".to_string(),
470 body: "Follow Rust conventions.".to_string(),
471 path: skill_path.clone(),
472 user_invocable: false,
473 agent_invocable: false,
474 argument_hint: None,
475 tags: vec![],
476 triggers,
477 agent_authored: false,
478 helpful: 0,
479 harmful: 0,
480 };
481 prompt.write(&skill_path).unwrap();
482
483 let parsed = PromptFile::parse(&skill_path).unwrap();
484 assert_eq!(parsed.description, "Rust conventions");
485 assert!(!parsed.triggers.is_empty());
486 assert!(parsed.triggers.matches_read("src/main.rs"));
487 assert!(parsed.triggers.matches_read("tests/integration.rs"));
488 assert!(!parsed.triggers.matches_read("README.md"));
489 assert_eq!(
490 parsed.triggers.patterns(),
491 &["src/**/*.rs", "tests/**/*.rs"]
492 );
493 }
494
495 #[test]
496 fn write_rejects_empty_description() {
497 let temp_dir = TempDir::new().unwrap();
498 let skill_path = temp_dir.path().join("bad").join(SKILL_FILENAME);
499
500 let prompt = PromptFile {
501 name: "bad".to_string(),
502 description: String::new(),
503 body: "content".to_string(),
504 path: skill_path.clone(),
505 user_invocable: true,
506 agent_invocable: false,
507 argument_hint: None,
508 tags: vec![],
509 triggers: PromptTriggers::default(),
510 agent_authored: true,
511 helpful: 0,
512 harmful: 0,
513 };
514 let result = prompt.write(&skill_path);
515 assert!(matches!(
516 result,
517 Err(PromptFileError::MissingDescription { .. })
518 ));
519 }
520
521 #[test]
522 fn write_rejects_no_activation_surface() {
523 let temp_dir = TempDir::new().unwrap();
524 let skill_path = temp_dir.path().join("noop").join(SKILL_FILENAME);
525
526 let prompt = PromptFile {
527 name: "noop".to_string(),
528 description: "Does nothing".to_string(),
529 body: "content".to_string(),
530 path: skill_path.clone(),
531 user_invocable: false,
532 agent_invocable: false,
533 argument_hint: None,
534 tags: vec![],
535 triggers: PromptTriggers::default(),
536 agent_authored: true,
537 helpful: 0,
538 harmful: 0,
539 };
540 let result = prompt.write(&skill_path);
541 assert!(matches!(
542 result,
543 Err(PromptFileError::NoActivationSurface { .. })
544 ));
545 }
546
547 #[test]
548 fn skip_serializing_defaults() {
549 let fm = minimal_frontmatter("Minimal");
550
551 let yaml = serde_yml::to_string(&fm).unwrap();
552 assert!(!yaml.contains("tags"));
553 assert!(!yaml.contains("agent_authored"));
554 assert!(!yaml.contains("helpful"));
555 assert!(!yaml.contains("harmful"));
556 }
557}