use std::collections::HashMap;
use std::io::Write;
use std::path::{Path, PathBuf};
use serde::Serialize;
use serde_json::Value;
use crate::error::{SkillError, SkillResult};
const MAX_NAME_LEN: usize = 64;
const SKILLS_DIR: &str = ".skills";
pub fn validate_skill_name(name: &str) -> SkillResult<()> {
if name.is_empty() {
return Err(SkillError::Validation("skill name must not be empty".to_string()));
}
if name.chars().count() > MAX_NAME_LEN {
return Err(SkillError::Validation(format!(
"skill name must be at most {MAX_NAME_LEN} characters, got {}",
name.chars().count()
)));
}
if let Some(bad) = name.chars().find(|c| !matches!(c, 'a'..='z' | '0'..='9' | '-')) {
return Err(SkillError::Validation(format!(
"skill name must contain only lowercase letters, digits, and hyphens; found {bad:?} \
in {name:?}"
)));
}
if name.starts_with('-') || name.ends_with('-') {
return Err(SkillError::Validation(format!(
"skill name must not begin or end with a hyphen: {name:?}"
)));
}
Ok(())
}
#[derive(Debug, Serialize)]
struct FrontmatterOut<'a> {
name: &'a str,
description: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
version: &'a Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
license: &'a Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
compatibility: &'a Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
tags: &'a Vec<String>,
#[serde(rename = "allowed-tools", skip_serializing_if = "Vec::is_empty")]
allowed_tools: &'a Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
references: &'a Vec<String>,
#[serde(skip_serializing_if = "is_false")]
trigger: bool,
#[serde(skip_serializing_if = "Option::is_none")]
hint: &'a Option<String>,
#[serde(skip_serializing_if = "HashMap::is_empty")]
metadata: &'a HashMap<String, Value>,
#[serde(skip_serializing_if = "Vec::is_empty")]
triggers: &'a Vec<String>,
}
fn is_false(value: &bool) -> bool {
!*value
}
#[derive(Debug, Clone, Default)]
pub struct SkillDraft {
name: String,
description: String,
body: String,
version: Option<String>,
license: Option<String>,
compatibility: Option<String>,
tags: Vec<String>,
allowed_tools: Vec<String>,
references: Vec<String>,
trigger: bool,
hint: Option<String>,
metadata: HashMap<String, Value>,
triggers: Vec<String>,
}
impl SkillDraft {
pub fn new(name: impl Into<String>, description: impl Into<String>) -> Self {
Self { name: name.into(), description: description.into(), ..Default::default() }
}
pub fn with_body(mut self, body: impl Into<String>) -> Self {
self.body = body.into();
self
}
pub fn with_version(mut self, version: impl Into<String>) -> Self {
self.version = Some(version.into());
self
}
pub fn with_license(mut self, license: impl Into<String>) -> Self {
self.license = Some(license.into());
self
}
pub fn with_compatibility(mut self, compatibility: impl Into<String>) -> Self {
self.compatibility = Some(compatibility.into());
self
}
pub fn with_tags<I, S>(mut self, tags: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.tags = tags.into_iter().map(Into::into).collect();
self
}
pub fn with_allowed_tools<I, S>(mut self, tools: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.allowed_tools = tools.into_iter().map(Into::into).collect();
self
}
pub fn with_references<I, S>(mut self, references: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.references = references.into_iter().map(Into::into).collect();
self
}
pub fn with_trigger(mut self, trigger: bool) -> Self {
self.trigger = trigger;
self
}
pub fn with_hint(mut self, hint: impl Into<String>) -> Self {
self.hint = Some(hint.into());
self
}
pub fn with_metadata(mut self, metadata: HashMap<String, Value>) -> Self {
self.metadata = metadata;
self
}
pub fn with_metadata_entry(mut self, key: impl Into<String>, value: Value) -> Self {
self.metadata.insert(key.into(), value);
self
}
pub fn with_triggers<I, S>(mut self, triggers: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.triggers = triggers.into_iter().map(Into::into).collect();
self
}
pub fn name(&self) -> &str {
&self.name
}
pub fn validate(&self) -> SkillResult<()> {
validate_skill_name(&self.name)?;
if self.description.trim().is_empty() {
return Err(SkillError::Validation(format!(
"skill {:?} must have a non-empty description; it is what an agent matches on",
self.name
)));
}
Ok(())
}
pub fn to_markdown(&self) -> SkillResult<String> {
self.validate()?;
let frontmatter = serde_yaml::to_string(&FrontmatterOut {
name: &self.name,
description: &self.description,
version: &self.version,
license: &self.license,
compatibility: &self.compatibility,
tags: &self.tags,
allowed_tools: &self.allowed_tools,
references: &self.references,
trigger: self.trigger,
hint: &self.hint,
metadata: &self.metadata,
triggers: &self.triggers,
})?;
Ok(format!("---\n{}---\n\n{}\n", frontmatter, self.body.trim()))
}
}
#[derive(Debug, Clone)]
pub struct SkillWriter {
root: PathBuf,
}
impl SkillWriter {
pub fn new(root: impl Into<PathBuf>) -> Self {
Self { root: root.into() }
}
pub fn skills_dir(&self) -> PathBuf {
self.root.join(SKILLS_DIR)
}
pub fn path_for(&self, name: &str) -> SkillResult<PathBuf> {
validate_skill_name(name)?;
Ok(self.skills_dir().join(format!("{name}.md")))
}
pub fn write(&self, draft: &SkillDraft) -> SkillResult<PathBuf> {
let rendered = draft.to_markdown()?;
let path = self.path_for(&draft.name)?;
let dir = self.skills_dir();
std::fs::create_dir_all(&dir)?;
let mut temporary = tempfile::NamedTempFile::new_in(&dir)?;
temporary.write_all(rendered.as_bytes())?;
temporary.as_file().sync_all()?;
temporary.persist(&path).map_err(|error| SkillError::Io(error.error))?;
#[cfg(unix)]
std::fs::File::open(&dir)?.sync_all()?;
tracing::debug!(skill = %draft.name, path = %path.display(), "wrote skill");
Ok(path)
}
pub fn remove(&self, name: &str) -> SkillResult<bool> {
let path = self.path_for(name)?;
match std::fs::remove_file(&path) {
Ok(()) => {
tracing::debug!(skill = %name, "removed skill");
Ok(true)
}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(false),
Err(err) => Err(SkillError::Io(err)),
}
}
pub fn exists(&self, name: &str) -> SkillResult<bool> {
Ok(self.path_for(name)?.is_file())
}
pub fn root(&self) -> &Path {
&self.root
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::index::load_skill_index;
use crate::parser::parse_skill_markdown;
#[test]
fn valid_names_are_accepted() {
for name in ["a", "disk-triage", "sweep-2", "0", &"x".repeat(MAX_NAME_LEN)] {
assert!(validate_skill_name(name).is_ok(), "should accept {name:?}");
}
}
#[test]
fn invalid_names_are_rejected() {
for name in [
"",
"Disk-Triage",
"disk triage",
"disk_triage",
"-leading",
"trailing-",
"../escape",
"nested/name",
"dot.name",
&"x".repeat(MAX_NAME_LEN + 1),
] {
assert!(validate_skill_name(name).is_err(), "should reject {name:?}");
}
}
#[test]
fn a_draft_round_trips_through_the_parser() {
let draft = SkillDraft::new("disk-triage", "Diagnose low disk space")
.with_body("1. Check largest directories.\n2. Report growth rate.")
.with_version("1.2.3")
.with_license("Apache-2.0")
.with_compatibility("Requires read access to the filesystem")
.with_tags(["ops", "storage"])
.with_allowed_tools(["read_file", "run_command"])
.with_references(["references/thresholds.md"])
.with_trigger(true)
.with_hint("name a mount point")
.with_triggers(["*.log"])
.with_metadata_entry("incident", Value::from("INC-42"));
let rendered = draft.to_markdown().expect("renders");
let parsed = parse_skill_markdown(Path::new("disk-triage.md"), &rendered).expect("parses");
assert_eq!(parsed.name, "disk-triage");
assert_eq!(parsed.description, "Diagnose low disk space");
assert_eq!(parsed.version.as_deref(), Some("1.2.3"));
assert_eq!(parsed.license.as_deref(), Some("Apache-2.0"));
assert_eq!(parsed.compatibility.as_deref(), Some("Requires read access to the filesystem"));
assert_eq!(parsed.tags, vec!["ops", "storage"]);
assert_eq!(parsed.allowed_tools, vec!["read_file", "run_command"]);
assert_eq!(parsed.references, vec!["references/thresholds.md"]);
assert!(parsed.trigger);
assert_eq!(parsed.hint.as_deref(), Some("name a mount point"));
assert_eq!(parsed.triggers, vec!["*.log"]);
assert_eq!(parsed.metadata.get("incident"), Some(&Value::from("INC-42")));
assert_eq!(parsed.body, "1. Check largest directories.\n2. Report growth rate.");
}
#[test]
fn a_minimal_draft_omits_unset_fields() {
let rendered = SkillDraft::new("minimal", "Only the required fields")
.with_body("Body.")
.to_markdown()
.expect("renders");
for absent in ["version:", "license:", "tags:", "allowed-tools:", "trigger:", "metadata:"] {
assert!(!rendered.contains(absent), "{absent} should be omitted from:\n{rendered}");
}
assert!(parse_skill_markdown(Path::new("minimal.md"), &rendered).is_ok());
}
#[test]
fn an_empty_description_is_rejected() {
let error = SkillDraft::new("named", " ")
.with_body("Body.")
.to_markdown()
.expect_err("an empty description must not be written");
assert!(error.to_string().contains("description"), "got {error}");
}
#[test]
fn an_invalid_name_is_rejected_before_any_file_is_touched() {
let root = tempfile::tempdir().expect("tempdir");
let writer = SkillWriter::new(root.path());
assert!(writer.write(&SkillDraft::new("../escape", "Traversal attempt")).is_err());
assert!(
!root.path().join(SKILLS_DIR).exists(),
"a rejected name must not create the skills directory"
);
}
#[test]
fn a_written_skill_is_discovered_by_the_index() {
let root = tempfile::tempdir().expect("tempdir");
let writer = SkillWriter::new(root.path());
let path = writer
.write(
&SkillDraft::new("disk-triage", "Diagnose low disk space")
.with_body("Check the largest directories."),
)
.expect("writes");
assert!(path.is_file());
let index = load_skill_index(root.path()).expect("index loads");
let found = index.find_by_name("disk-triage").expect("skill is indexed");
assert_eq!(found.description, "Diagnose low disk space");
assert_eq!(found.body, "Check the largest directories.");
}
#[test]
fn writing_the_same_name_replaces_the_previous_skill() {
let root = tempfile::tempdir().expect("tempdir");
let writer = SkillWriter::new(root.path());
writer.write(&SkillDraft::new("sweep", "First").with_body("One.")).expect("first write");
writer.write(&SkillDraft::new("sweep", "Second").with_body("Two.")).expect("second write");
let index = load_skill_index(root.path()).expect("index loads");
assert_eq!(index.len(), 1, "replacing must not leave a duplicate");
assert_eq!(index.find_by_name("sweep").expect("present").description, "Second");
}
#[test]
fn concurrent_writers_do_not_share_a_temporary_path() {
let root = tempfile::tempdir().expect("tempdir");
let writer = SkillWriter::new(root.path());
std::thread::scope(|scope| {
let first = writer.clone();
scope.spawn(move || {
first
.write(&SkillDraft::new("first", "First skill").with_body("One."))
.expect("first write");
});
let second = writer.clone();
scope.spawn(move || {
second
.write(&SkillDraft::new("second", "Second skill").with_body("Two."))
.expect("second write");
});
});
let index = load_skill_index(root.path()).expect("index loads");
assert!(index.find_by_name("first").is_some());
assert!(index.find_by_name("second").is_some());
}
#[test]
fn writing_leaves_no_temporary_file_behind() {
let root = tempfile::tempdir().expect("tempdir");
let writer = SkillWriter::new(root.path());
writer.write(&SkillDraft::new("sweep", "Description").with_body("Body.")).expect("writes");
let leftovers: Vec<_> = std::fs::read_dir(writer.skills_dir())
.expect("read dir")
.filter_map(|entry| entry.ok())
.map(|entry| entry.file_name().to_string_lossy().to_string())
.filter(|name| name.ends_with(".tmp"))
.collect();
assert!(leftovers.is_empty(), "found temporary files: {leftovers:?}");
}
#[test]
fn remove_reports_whether_a_skill_was_present() {
let root = tempfile::tempdir().expect("tempdir");
let writer = SkillWriter::new(root.path());
writer.write(&SkillDraft::new("sweep", "Description").with_body("Body.")).expect("writes");
assert!(writer.exists("sweep").expect("exists"));
assert!(writer.remove("sweep").expect("removes"), "the skill was present");
assert!(!writer.remove("sweep").expect("second remove"), "already gone");
assert!(!writer.exists("sweep").expect("exists"));
}
#[test]
fn remove_rejects_an_invalid_name() {
let root = tempfile::tempdir().expect("tempdir");
let writer = SkillWriter::new(root.path());
assert!(writer.remove("../escape").is_err());
}
}