1use std::collections::HashMap;
13use std::io::Write;
14use std::path::{Path, PathBuf};
15
16use serde::Serialize;
17use serde_json::Value;
18
19use crate::error::{SkillError, SkillResult};
20
21const MAX_NAME_LEN: usize = 64;
23
24const SKILLS_DIR: &str = ".skills";
26
27pub fn validate_skill_name(name: &str) -> SkillResult<()> {
49 if name.is_empty() {
50 return Err(SkillError::Validation("skill name must not be empty".to_string()));
51 }
52
53 if name.chars().count() > MAX_NAME_LEN {
54 return Err(SkillError::Validation(format!(
55 "skill name must be at most {MAX_NAME_LEN} characters, got {}",
56 name.chars().count()
57 )));
58 }
59
60 if let Some(bad) = name.chars().find(|c| !matches!(c, 'a'..='z' | '0'..='9' | '-')) {
61 return Err(SkillError::Validation(format!(
62 "skill name must contain only lowercase letters, digits, and hyphens; found {bad:?} \
63 in {name:?}"
64 )));
65 }
66
67 if name.starts_with('-') || name.ends_with('-') {
68 return Err(SkillError::Validation(format!(
69 "skill name must not begin or end with a hyphen: {name:?}"
70 )));
71 }
72
73 Ok(())
74}
75
76#[derive(Debug, Serialize)]
81struct FrontmatterOut<'a> {
82 name: &'a str,
83 description: &'a str,
84 #[serde(skip_serializing_if = "Option::is_none")]
85 version: &'a Option<String>,
86 #[serde(skip_serializing_if = "Option::is_none")]
87 license: &'a Option<String>,
88 #[serde(skip_serializing_if = "Option::is_none")]
89 compatibility: &'a Option<String>,
90 #[serde(skip_serializing_if = "Vec::is_empty")]
91 tags: &'a Vec<String>,
92 #[serde(rename = "allowed-tools", skip_serializing_if = "Vec::is_empty")]
93 allowed_tools: &'a Vec<String>,
94 #[serde(skip_serializing_if = "Vec::is_empty")]
95 references: &'a Vec<String>,
96 #[serde(skip_serializing_if = "is_false")]
97 trigger: bool,
98 #[serde(skip_serializing_if = "Option::is_none")]
99 hint: &'a Option<String>,
100 #[serde(skip_serializing_if = "HashMap::is_empty")]
101 metadata: &'a HashMap<String, Value>,
102 #[serde(skip_serializing_if = "Vec::is_empty")]
103 triggers: &'a Vec<String>,
104}
105
106fn is_false(value: &bool) -> bool {
107 !*value
108}
109
110#[derive(Debug, Clone, Default)]
128pub struct SkillDraft {
129 name: String,
130 description: String,
131 body: String,
132 version: Option<String>,
133 license: Option<String>,
134 compatibility: Option<String>,
135 tags: Vec<String>,
136 allowed_tools: Vec<String>,
137 references: Vec<String>,
138 trigger: bool,
139 hint: Option<String>,
140 metadata: HashMap<String, Value>,
141 triggers: Vec<String>,
142}
143
144impl SkillDraft {
145 pub fn new(name: impl Into<String>, description: impl Into<String>) -> Self {
147 Self { name: name.into(), description: description.into(), ..Default::default() }
148 }
149
150 pub fn with_body(mut self, body: impl Into<String>) -> Self {
152 self.body = body.into();
153 self
154 }
155
156 pub fn with_version(mut self, version: impl Into<String>) -> Self {
158 self.version = Some(version.into());
159 self
160 }
161
162 pub fn with_license(mut self, license: impl Into<String>) -> Self {
164 self.license = Some(license.into());
165 self
166 }
167
168 pub fn with_compatibility(mut self, compatibility: impl Into<String>) -> Self {
170 self.compatibility = Some(compatibility.into());
171 self
172 }
173
174 pub fn with_tags<I, S>(mut self, tags: I) -> Self
176 where
177 I: IntoIterator<Item = S>,
178 S: Into<String>,
179 {
180 self.tags = tags.into_iter().map(Into::into).collect();
181 self
182 }
183
184 pub fn with_allowed_tools<I, S>(mut self, tools: I) -> Self
186 where
187 I: IntoIterator<Item = S>,
188 S: Into<String>,
189 {
190 self.allowed_tools = tools.into_iter().map(Into::into).collect();
191 self
192 }
193
194 pub fn with_references<I, S>(mut self, references: I) -> Self
196 where
197 I: IntoIterator<Item = S>,
198 S: Into<String>,
199 {
200 self.references = references.into_iter().map(Into::into).collect();
201 self
202 }
203
204 pub fn with_trigger(mut self, trigger: bool) -> Self {
206 self.trigger = trigger;
207 self
208 }
209
210 pub fn with_hint(mut self, hint: impl Into<String>) -> Self {
212 self.hint = Some(hint.into());
213 self
214 }
215
216 pub fn with_metadata(mut self, metadata: HashMap<String, Value>) -> Self {
221 self.metadata = metadata;
222 self
223 }
224
225 pub fn with_metadata_entry(mut self, key: impl Into<String>, value: Value) -> Self {
227 self.metadata.insert(key.into(), value);
228 self
229 }
230
231 pub fn with_triggers<I, S>(mut self, triggers: I) -> Self
233 where
234 I: IntoIterator<Item = S>,
235 S: Into<String>,
236 {
237 self.triggers = triggers.into_iter().map(Into::into).collect();
238 self
239 }
240
241 pub fn name(&self) -> &str {
243 &self.name
244 }
245
246 pub fn validate(&self) -> SkillResult<()> {
252 validate_skill_name(&self.name)?;
253
254 if self.description.trim().is_empty() {
255 return Err(SkillError::Validation(format!(
256 "skill {:?} must have a non-empty description; it is what an agent matches on",
257 self.name
258 )));
259 }
260
261 Ok(())
262 }
263
264 pub fn to_markdown(&self) -> SkillResult<String> {
274 self.validate()?;
275
276 let frontmatter = serde_yaml::to_string(&FrontmatterOut {
277 name: &self.name,
278 description: &self.description,
279 version: &self.version,
280 license: &self.license,
281 compatibility: &self.compatibility,
282 tags: &self.tags,
283 allowed_tools: &self.allowed_tools,
284 references: &self.references,
285 trigger: self.trigger,
286 hint: &self.hint,
287 metadata: &self.metadata,
288 triggers: &self.triggers,
289 })?;
290
291 Ok(format!("---\n{}---\n\n{}\n", frontmatter, self.body.trim()))
292 }
293}
294
295#[derive(Debug, Clone)]
318pub struct SkillWriter {
319 root: PathBuf,
320}
321
322impl SkillWriter {
323 pub fn new(root: impl Into<PathBuf>) -> Self {
327 Self { root: root.into() }
328 }
329
330 pub fn skills_dir(&self) -> PathBuf {
332 self.root.join(SKILLS_DIR)
333 }
334
335 pub fn path_for(&self, name: &str) -> SkillResult<PathBuf> {
341 validate_skill_name(name)?;
342 Ok(self.skills_dir().join(format!("{name}.md")))
343 }
344
345 pub fn write(&self, draft: &SkillDraft) -> SkillResult<PathBuf> {
356 let rendered = draft.to_markdown()?;
357 let path = self.path_for(&draft.name)?;
358
359 let dir = self.skills_dir();
360 std::fs::create_dir_all(&dir)?;
361
362 let mut temporary = tempfile::NamedTempFile::new_in(&dir)?;
365 temporary.write_all(rendered.as_bytes())?;
366 temporary.as_file().sync_all()?;
367 temporary.persist(&path).map_err(|error| SkillError::Io(error.error))?;
368
369 #[cfg(unix)]
373 std::fs::File::open(&dir)?.sync_all()?;
374
375 tracing::debug!(skill = %draft.name, path = %path.display(), "wrote skill");
376 Ok(path)
377 }
378
379 pub fn remove(&self, name: &str) -> SkillResult<bool> {
386 let path = self.path_for(name)?;
387
388 match std::fs::remove_file(&path) {
389 Ok(()) => {
390 tracing::debug!(skill = %name, "removed skill");
391 Ok(true)
392 }
393 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(false),
394 Err(err) => Err(SkillError::Io(err)),
395 }
396 }
397
398 pub fn exists(&self, name: &str) -> SkillResult<bool> {
404 Ok(self.path_for(name)?.is_file())
405 }
406
407 pub fn root(&self) -> &Path {
409 &self.root
410 }
411}
412
413#[cfg(test)]
414mod tests {
415 use super::*;
416 use crate::index::load_skill_index;
417 use crate::parser::parse_skill_markdown;
418
419 #[test]
420 fn valid_names_are_accepted() {
421 for name in ["a", "disk-triage", "sweep-2", "0", &"x".repeat(MAX_NAME_LEN)] {
422 assert!(validate_skill_name(name).is_ok(), "should accept {name:?}");
423 }
424 }
425
426 #[test]
427 fn invalid_names_are_rejected() {
428 for name in [
429 "",
430 "Disk-Triage",
431 "disk triage",
432 "disk_triage",
433 "-leading",
434 "trailing-",
435 "../escape",
436 "nested/name",
437 "dot.name",
438 &"x".repeat(MAX_NAME_LEN + 1),
439 ] {
440 assert!(validate_skill_name(name).is_err(), "should reject {name:?}");
441 }
442 }
443
444 #[test]
445 fn a_draft_round_trips_through_the_parser() {
446 let draft = SkillDraft::new("disk-triage", "Diagnose low disk space")
447 .with_body("1. Check largest directories.\n2. Report growth rate.")
448 .with_version("1.2.3")
449 .with_license("Apache-2.0")
450 .with_compatibility("Requires read access to the filesystem")
451 .with_tags(["ops", "storage"])
452 .with_allowed_tools(["read_file", "run_command"])
453 .with_references(["references/thresholds.md"])
454 .with_trigger(true)
455 .with_hint("name a mount point")
456 .with_triggers(["*.log"])
457 .with_metadata_entry("incident", Value::from("INC-42"));
458
459 let rendered = draft.to_markdown().expect("renders");
460 let parsed = parse_skill_markdown(Path::new("disk-triage.md"), &rendered).expect("parses");
461
462 assert_eq!(parsed.name, "disk-triage");
463 assert_eq!(parsed.description, "Diagnose low disk space");
464 assert_eq!(parsed.version.as_deref(), Some("1.2.3"));
465 assert_eq!(parsed.license.as_deref(), Some("Apache-2.0"));
466 assert_eq!(parsed.compatibility.as_deref(), Some("Requires read access to the filesystem"));
467 assert_eq!(parsed.tags, vec!["ops", "storage"]);
468 assert_eq!(parsed.allowed_tools, vec!["read_file", "run_command"]);
469 assert_eq!(parsed.references, vec!["references/thresholds.md"]);
470 assert!(parsed.trigger);
471 assert_eq!(parsed.hint.as_deref(), Some("name a mount point"));
472 assert_eq!(parsed.triggers, vec!["*.log"]);
473 assert_eq!(parsed.metadata.get("incident"), Some(&Value::from("INC-42")));
474 assert_eq!(parsed.body, "1. Check largest directories.\n2. Report growth rate.");
475 }
476
477 #[test]
478 fn a_minimal_draft_omits_unset_fields() {
479 let rendered = SkillDraft::new("minimal", "Only the required fields")
480 .with_body("Body.")
481 .to_markdown()
482 .expect("renders");
483
484 for absent in ["version:", "license:", "tags:", "allowed-tools:", "trigger:", "metadata:"] {
485 assert!(!rendered.contains(absent), "{absent} should be omitted from:\n{rendered}");
486 }
487 assert!(parse_skill_markdown(Path::new("minimal.md"), &rendered).is_ok());
488 }
489
490 #[test]
491 fn an_empty_description_is_rejected() {
492 let error = SkillDraft::new("named", " ")
493 .with_body("Body.")
494 .to_markdown()
495 .expect_err("an empty description must not be written");
496
497 assert!(error.to_string().contains("description"), "got {error}");
498 }
499
500 #[test]
501 fn an_invalid_name_is_rejected_before_any_file_is_touched() {
502 let root = tempfile::tempdir().expect("tempdir");
503 let writer = SkillWriter::new(root.path());
504
505 assert!(writer.write(&SkillDraft::new("../escape", "Traversal attempt")).is_err());
506 assert!(
507 !root.path().join(SKILLS_DIR).exists(),
508 "a rejected name must not create the skills directory"
509 );
510 }
511
512 #[test]
513 fn a_written_skill_is_discovered_by_the_index() {
514 let root = tempfile::tempdir().expect("tempdir");
515 let writer = SkillWriter::new(root.path());
516
517 let path = writer
518 .write(
519 &SkillDraft::new("disk-triage", "Diagnose low disk space")
520 .with_body("Check the largest directories."),
521 )
522 .expect("writes");
523
524 assert!(path.is_file());
525 let index = load_skill_index(root.path()).expect("index loads");
526 let found = index.find_by_name("disk-triage").expect("skill is indexed");
527 assert_eq!(found.description, "Diagnose low disk space");
528 assert_eq!(found.body, "Check the largest directories.");
529 }
530
531 #[test]
532 fn writing_the_same_name_replaces_the_previous_skill() {
533 let root = tempfile::tempdir().expect("tempdir");
534 let writer = SkillWriter::new(root.path());
535
536 writer.write(&SkillDraft::new("sweep", "First").with_body("One.")).expect("first write");
537 writer.write(&SkillDraft::new("sweep", "Second").with_body("Two.")).expect("second write");
538
539 let index = load_skill_index(root.path()).expect("index loads");
540 assert_eq!(index.len(), 1, "replacing must not leave a duplicate");
541 assert_eq!(index.find_by_name("sweep").expect("present").description, "Second");
542 }
543
544 #[test]
545 fn concurrent_writers_do_not_share_a_temporary_path() {
546 let root = tempfile::tempdir().expect("tempdir");
547 let writer = SkillWriter::new(root.path());
548
549 std::thread::scope(|scope| {
550 let first = writer.clone();
551 scope.spawn(move || {
552 first
553 .write(&SkillDraft::new("first", "First skill").with_body("One."))
554 .expect("first write");
555 });
556 let second = writer.clone();
557 scope.spawn(move || {
558 second
559 .write(&SkillDraft::new("second", "Second skill").with_body("Two."))
560 .expect("second write");
561 });
562 });
563
564 let index = load_skill_index(root.path()).expect("index loads");
565 assert!(index.find_by_name("first").is_some());
566 assert!(index.find_by_name("second").is_some());
567 }
568
569 #[test]
570 fn writing_leaves_no_temporary_file_behind() {
571 let root = tempfile::tempdir().expect("tempdir");
572 let writer = SkillWriter::new(root.path());
573 writer.write(&SkillDraft::new("sweep", "Description").with_body("Body.")).expect("writes");
574
575 let leftovers: Vec<_> = std::fs::read_dir(writer.skills_dir())
576 .expect("read dir")
577 .filter_map(|entry| entry.ok())
578 .map(|entry| entry.file_name().to_string_lossy().to_string())
579 .filter(|name| name.ends_with(".tmp"))
580 .collect();
581
582 assert!(leftovers.is_empty(), "found temporary files: {leftovers:?}");
583 }
584
585 #[test]
586 fn remove_reports_whether_a_skill_was_present() {
587 let root = tempfile::tempdir().expect("tempdir");
588 let writer = SkillWriter::new(root.path());
589 writer.write(&SkillDraft::new("sweep", "Description").with_body("Body.")).expect("writes");
590
591 assert!(writer.exists("sweep").expect("exists"));
592 assert!(writer.remove("sweep").expect("removes"), "the skill was present");
593 assert!(!writer.remove("sweep").expect("second remove"), "already gone");
594 assert!(!writer.exists("sweep").expect("exists"));
595 }
596
597 #[test]
598 fn remove_rejects_an_invalid_name() {
599 let root = tempfile::tempdir().expect("tempdir");
600 let writer = SkillWriter::new(root.path());
601
602 assert!(writer.remove("../escape").is_err());
603 }
604}