use std::path::PathBuf;
use crate::error::AgentConfigError;
use super::validate::{validate_identifier, IdentifierKind};
fn validate_frontmatter_scalar(value: &str) -> Result<(), AgentConfigError> {
for c in value.chars() {
if c == '\n' || c == '\r' || c == '\t' {
return Err(AgentConfigError::InvalidTag {
tag: value.to_string(),
reason: "skill frontmatter must not contain newlines or tabs",
});
}
if (c as u32) < 0x20 && c != ' ' {
return Err(AgentConfigError::InvalidTag {
tag: value.to_string(),
reason: "skill frontmatter must not contain control characters",
});
}
if c == '\u{007F}' {
return Err(AgentConfigError::InvalidTag {
tag: value.to_string(),
reason: "skill frontmatter must not contain DEL (0x7F)",
});
}
}
Ok(())
}
#[derive(Debug, Clone)]
pub struct SkillSpec {
pub name: String,
pub owner_tag: String,
pub frontmatter: SkillFrontmatter,
pub body: String,
pub assets: Vec<SkillAsset>,
pub adopt_unowned: bool,
}
impl SkillSpec {
pub fn builder(name: impl Into<String>) -> SkillSpecBuilder {
let name = name.into();
SkillSpecBuilder {
name: name.clone(),
owner_tag: None,
frontmatter: SkillFrontmatter {
name,
description: String::new(),
allowed_tools: None,
},
body: String::new(),
assets: Vec::new(),
adopt_unowned: false,
}
}
pub(crate) fn validate(&self) -> Result<(), AgentConfigError> {
Self::validate_name(&self.name)?;
if self.frontmatter.description.trim().is_empty() {
return Err(AgentConfigError::MissingSpecField {
id: "<skill spec>",
field: "frontmatter.description",
});
}
if self.body.trim().is_empty() {
return Err(AgentConfigError::MissingSpecField {
id: "<skill spec>",
field: "body",
});
}
validate_frontmatter_scalar(&self.frontmatter.name)?;
validate_frontmatter_scalar(&self.frontmatter.description)?;
if let Some(tools) = &self.frontmatter.allowed_tools {
for t in tools {
validate_frontmatter_scalar(t)?;
}
}
validate_identifier(&self.owner_tag, IdentifierKind::OwnerTag)
}
pub(crate) fn validate_name(name: &str) -> Result<(), AgentConfigError> {
validate_identifier(name, IdentifierKind::SkillName)
}
}
#[derive(Debug, Clone)]
pub struct SkillFrontmatter {
pub name: String,
pub description: String,
pub allowed_tools: Option<Vec<String>>,
}
#[derive(Debug, Clone)]
pub struct SkillAsset {
pub relative_path: PathBuf,
pub bytes: Vec<u8>,
pub executable: bool,
}
#[derive(Debug, Clone)]
pub struct SkillSpecBuilder {
name: String,
owner_tag: Option<String>,
frontmatter: SkillFrontmatter,
body: String,
assets: Vec<SkillAsset>,
adopt_unowned: bool,
}
impl SkillSpecBuilder {
pub fn owner(mut self, tag: impl Into<String>) -> Self {
self.owner_tag = Some(tag.into());
self
}
pub fn adopt_unowned(mut self, adopt: bool) -> Self {
self.adopt_unowned = adopt;
self
}
pub fn description(mut self, d: impl Into<String>) -> Self {
self.frontmatter.description = d.into();
self
}
pub fn allowed_tools<I, S>(mut self, tools: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.frontmatter.allowed_tools = Some(tools.into_iter().map(Into::into).collect());
self
}
pub fn body(mut self, body: impl Into<String>) -> Self {
self.body = body.into();
self
}
pub fn asset(mut self, asset: SkillAsset) -> Self {
self.assets.push(asset);
self
}
pub fn build(self) -> SkillSpec {
self.try_build().expect("SkillSpec missing required field")
}
pub fn try_build(self) -> Result<SkillSpec, AgentConfigError> {
let owner_tag = self.owner_tag.ok_or(AgentConfigError::MissingSpecField {
id: "<skill builder>",
field: "owner",
})?;
let spec = SkillSpec {
name: self.name,
owner_tag,
frontmatter: self.frontmatter,
body: self.body,
assets: self.assets,
adopt_unowned: self.adopt_unowned,
};
spec.validate()?;
Ok(spec)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validate_rejects_newline_in_description() {
let err = SkillSpec::builder("alpha")
.owner("appA")
.description("line1\nline2")
.body("body")
.try_build()
.unwrap_err();
assert!(matches!(err, AgentConfigError::InvalidTag { .. }));
}
#[test]
fn validate_rejects_tab_in_name() {
let mut spec = SkillSpec::builder("alpha")
.owner("appA")
.description("ok")
.body("body")
.try_build()
.expect("base spec valid");
spec.frontmatter.name = "bad\tname".into();
let err = spec.validate().unwrap_err();
assert!(matches!(err, AgentConfigError::InvalidTag { .. }));
}
#[test]
fn validate_rejects_control_char_in_allowed_tools() {
let err = SkillSpec::builder("alpha")
.owner("appA")
.description("ok")
.body("body")
.allowed_tools(["ed\u{0001}it"])
.try_build()
.unwrap_err();
assert!(matches!(err, AgentConfigError::InvalidTag { .. }));
}
#[test]
fn validate_rejects_del_in_description() {
let err = SkillSpec::builder("alpha")
.owner("appA")
.description("evil\u{007F}")
.body("body")
.try_build()
.unwrap_err();
assert!(matches!(err, AgentConfigError::InvalidTag { .. }));
}
#[test]
fn validate_accepts_normal_description_with_punctuation() {
SkillSpec::builder("alpha")
.owner("appA")
.description("Format Git commit messages: subject + body.")
.body("body")
.try_build()
.expect("valid");
}
#[test]
fn validate_rejects_empty_body() {
let err = SkillSpec::builder("alpha")
.owner("appA")
.description("Use this skill")
.try_build()
.unwrap_err();
assert!(matches!(
err,
AgentConfigError::MissingSpecField { field: "body", .. }
));
}
#[test]
fn validate_rejects_whitespace_only_body() {
let err = SkillSpec::builder("alpha")
.owner("appA")
.description("Use this skill")
.body(" \n\t \n")
.try_build()
.unwrap_err();
assert!(matches!(
err,
AgentConfigError::MissingSpecField { field: "body", .. }
));
}
#[test]
fn adopt_unowned_defaults_false_and_round_trips() {
let default_spec = SkillSpec::builder("alpha")
.owner("appA")
.description("Use this skill")
.body("body")
.build();
assert!(!default_spec.adopt_unowned);
let opted = SkillSpec::builder("alpha")
.owner("appA")
.description("Use this skill")
.body("body")
.adopt_unowned(true)
.build();
assert!(opted.adopt_unowned);
}
}