use serde::Deserialize;
use serde_json::{Map, Value};
use crate::{
Cancellation, Error,
skill::{Definition, Skill as Contract},
};
pub struct Skill {
definition: Definition,
instructions: String,
}
impl Skill {
pub fn new(definition: Definition, instructions: String) -> Result<Self, Error> {
if definition.name.trim().is_empty() || instructions.trim().is_empty() {
return Err(Error::new(
"INVALID_ARGUMENTS",
"skill name and instructions are required",
));
}
Ok(Self {
definition,
instructions,
})
}
pub fn from_markdown(markdown: &str) -> Result<Self, Error> {
if markdown.len() > 4 * 1024 * 1024 {
return Err(Error::new(
"INVALID_ARGUMENTS",
"skill document is too large",
));
}
let markdown = markdown.strip_prefix('\u{feff}').unwrap_or(markdown);
let mut lines = markdown.split_inclusive('\n');
if lines.next().map(str::trim_end) != Some("---") {
return Err(Error::new(
"INVALID_ARGUMENTS",
"skill requires YAML frontmatter",
));
}
let mut header = String::new();
let mut closed = false;
for line in lines.by_ref() {
if line.trim_end() == "---" {
closed = true;
break;
}
header.push_str(line);
}
if !closed {
return Err(Error::new(
"INVALID_ARGUMENTS",
"skill frontmatter is not closed",
));
}
#[derive(Deserialize)]
struct Header {
name: String,
description: String,
#[serde(default)]
metadata: Map<String, Value>,
#[serde(flatten)]
extra: Map<String, Value>,
}
let header: Header = serde_saphyr::from_str(&header)
.map_err(|_| Error::new("INVALID_ARGUMENTS", "invalid skill YAML frontmatter"))?;
let name = &header.name;
if name.is_empty()
|| name.len() > 64
|| name.starts_with('-')
|| name.ends_with('-')
|| name.contains("--")
|| !name
.bytes()
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
|| header.description.trim().is_empty()
|| header.description.chars().count() > 1024
{
return Err(Error::new(
"INVALID_ARGUMENTS",
"invalid skill name or description",
));
}
let mut metadata = header.metadata;
for (key, value) in header.extra {
metadata.entry(key).or_insert(value);
}
Self::new(
Definition {
name: header.name,
description: Some(header.description),
metadata,
},
lines.collect(),
)
}
pub async fn load(
workspace: &super::workspace::Local,
path: &str,
cancellation: &Cancellation,
) -> Result<Self, Error> {
super::operation::check(cancellation)?;
let data =
super::operation::cancellable(workspace.read(path, cancellation), cancellation).await?;
let markdown = std::str::from_utf8(&data)
.map_err(|_| Error::new("INVALID_ARGUMENTS", "skill document must be UTF-8"))?;
let skill = Self::from_markdown(markdown)?;
if path.ends_with("/SKILL.md") {
let parent = path.rsplit('/').nth(1).unwrap_or("");
if parent != skill.definition.name {
return Err(Error::new(
"INVALID_ARGUMENTS",
"skill name must match its directory",
));
}
}
super::operation::check(cancellation)?;
Ok(skill)
}
}
impl Contract for Skill {
fn definition(&self) -> &Definition {
&self.definition
}
async fn read(&self, cancellation: &Cancellation) -> Result<String, Error> {
super::operation::check(cancellation)?;
let instructions = self.instructions.clone();
super::operation::check(cancellation)?;
Ok(instructions)
}
}