use std::{
fs,
path::{Path, PathBuf},
};
use serde::Deserialize;
use crate::{CompileInput, Portability, RoleInput, SkillInput, SkillResource};
const MAX_RESOURCE_BYTES: usize = 64 * 1024;
const MAX_SKILL_RESOURCE_BYTES: usize = 256 * 1024;
const RESOURCE_DIRECTORIES: [&str; 3] = ["assets", "references", "scripts"];
mod embedded {
include!(concat!(env!("OUT_DIR"), "/embedded_content.rs"));
}
type EmbeddedSource = (&'static str, &'static str);
type EmbeddedSources = &'static [EmbeddedSource];
#[derive(Debug)]
pub enum ContentError {
Io { path: PathBuf, message: String },
InvalidFrontmatter { path: String },
InvalidMetadata { path: String, message: String },
InvalidSource { path: String, message: String },
}
impl std::fmt::Display for ContentError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Io { path, message } => write!(formatter, "{}: {message}", path.display()),
Self::InvalidFrontmatter { path } => {
let kind = if path.contains("/roles/") {
"role"
} else if path.contains("/skills/") {
"skill"
} else {
"content"
};
write!(formatter, "{path}: invalid {kind} frontmatter")
}
Self::InvalidMetadata { path, message } => write!(formatter, "{path}: {message}"),
Self::InvalidSource { path, message } => write!(formatter, "{path}: {message}"),
}
}
}
impl std::error::Error for ContentError {}
pub fn load_compile_input(content_dir: &Path) -> Result<CompileInput, ContentError> {
let roles_dir = content_dir.join("roles");
let skills_dir = content_dir.join("skills");
let mut role_paths = regular_children(&roles_dir, ChildKind::MarkdownFile)?;
let mut skill_paths = regular_children(&skills_dir, ChildKind::Directory)?;
role_paths.sort();
skill_paths.sort();
if role_paths.is_empty() {
return Err(ContentError::InvalidSource {
path: roles_dir.display().to_string(),
message: "zero role files".into(),
});
}
if skill_paths.is_empty() {
return Err(ContentError::InvalidSource {
path: skills_dir.display().to_string(),
message: "zero skill directories".into(),
});
}
let roles = role_paths
.into_iter()
.map(|path| load_role(content_dir, &path))
.collect::<Result<Vec<_>, _>>()?;
let skills = skill_paths
.into_iter()
.map(|path| load_skill(content_dir, &path))
.collect::<Result<Vec<_>, _>>()?;
Ok(CompileInput { roles, skills })
}
pub fn embedded_compile_input() -> Result<CompileInput, ContentError> {
if embedded::EMBEDDED_ROLES.is_empty() || embedded::EMBEDDED_SKILLS.is_empty() {
return Err(ContentError::InvalidSource {
path: "content".into(),
message: "embedded canonical content must contain roles and skills".into(),
});
}
let roles = embedded::EMBEDDED_ROLES
.iter()
.map(|(source, raw)| parse_role(source, raw))
.collect::<Result<Vec<_>, _>>()?;
let skills = embedded::EMBEDDED_SKILLS
.iter()
.map(|(source, raw)| {
let mut skill = parse_skill(source, raw)?;
let prefix = source
.strip_suffix("SKILL.md")
.expect("embedded skill source ends with SKILL.md");
skill.resources = embedded::EMBEDDED_SKILL_RESOURCES
.iter()
.filter(|(resource_source, _, _)| resource_source.starts_with(prefix))
.map(|(resource_source, content, executable)| SkillResource {
relative_path: resource_source[prefix.len()..].to_owned(),
content: content.as_bytes().to_vec(),
executable: *executable,
source_path: (*resource_source).to_owned(),
})
.collect();
Ok(skill)
})
.collect::<Result<Vec<_>, ContentError>>()?;
Ok(CompileInput { roles, skills })
}
pub fn embedded_guard_sources() -> (EmbeddedSources, EmbeddedSources) {
(embedded::EMBEDDED_PREDICATES, embedded::EMBEDDED_ROLES)
}
#[must_use]
pub fn embedded_handoff_template() -> &'static str {
embedded::EMBEDDED_TEMPLATES
.iter()
.find_map(|(path, raw)| (*path == "content/templates/handoff.md").then_some(*raw))
.expect("generated package content must contain content/templates/handoff.md")
}
#[derive(
Clone,
Copy,
strum::AsRefStr,
strum::Display,
strum::EnumCount,
strum::EnumIs,
strum::EnumString,
strum::IntoStaticStr,
strum::VariantNames,
)]
#[strum(ascii_case_insensitive, serialize_all = "snake_case")]
enum ChildKind {
MarkdownFile,
Directory,
}
fn regular_children(directory: &Path, kind: ChildKind) -> Result<Vec<PathBuf>, ContentError> {
let entries = fs::read_dir(directory).map_err(|error| ContentError::Io {
path: directory.to_owned(),
message: format!("cannot read directory: {error}"),
})?;
let mut paths = Vec::new();
for entry in entries {
let entry = entry.map_err(|error| ContentError::Io {
path: directory.to_owned(),
message: format!("cannot read entry: {error}"),
})?;
let file_type = entry.file_type().map_err(|error| ContentError::Io {
path: entry.path(),
message: format!("cannot inspect entry: {error}"),
})?;
let path = entry.path();
if file_type.is_symlink() {
return Err(ContentError::InvalidSource {
path: path.display().to_string(),
message: "symlinks are not valid authored content".into(),
});
}
match kind {
ChildKind::MarkdownFile if file_type.is_file() => {
if path.extension().and_then(|value| value.to_str()) != Some("md") {
return Err(ContentError::InvalidSource {
path: path.display().to_string(),
message: "expected a .md role file".into(),
});
}
paths.push(path);
}
ChildKind::Directory if file_type.is_dir() => paths.push(path),
_ => {
return Err(ContentError::InvalidSource {
path: path.display().to_string(),
message: "unexpected authored content entry".into(),
});
}
}
}
Ok(paths)
}
fn load_role(content_dir: &Path, path: &Path) -> Result<RoleInput, ContentError> {
let raw = read_regular_utf8(path)?;
let source = relative_source(content_dir, path)?;
parse_role(&source, &raw)
}
fn parse_role(source: &str, raw: &str) -> Result<RoleInput, ContentError> {
let path = Path::new(source);
let (frontmatter, body) = split_frontmatter(path, raw)?;
let metadata: RoleFrontmatter =
serde_saphyr::from_str(frontmatter).map_err(|_| ContentError::InvalidFrontmatter {
path: path.display().to_string(),
})?;
let filename = path
.file_stem()
.and_then(|value| value.to_str())
.ok_or_else(|| ContentError::InvalidSource {
path: path.display().to_string(),
message: "role filename is not UTF-8".into(),
})?;
if metadata.role != filename {
return Err(ContentError::InvalidMetadata {
path: path.display().to_string(),
message: format!(
"role `{}` does not match filename `{filename}`",
metadata.role
),
});
}
if metadata.source.trim().is_empty() {
return Err(ContentError::InvalidMetadata {
path: path.display().to_string(),
message: "source must not be empty".into(),
});
}
if metadata.startup_skill.trim().is_empty() {
return Err(ContentError::InvalidMetadata {
path: path.display().to_string(),
message: "startup skill must not be empty".into(),
});
}
Ok(RoleInput {
role: metadata.role,
description: metadata.description,
model_hint: metadata.model_hint,
write_eligible: metadata.write_eligible,
dispatchable: metadata.dispatchable,
capabilities: metadata.capabilities,
startup_skill: metadata.startup_skill,
write_scope: metadata.write_scope,
body: body.into(),
source_path: source.to_owned(),
source_content: raw.to_owned(),
})
}
fn load_skill(content_dir: &Path, directory: &Path) -> Result<SkillInput, ContentError> {
let path = directory.join("SKILL.md");
let raw = read_regular_utf8(&path)?;
let source = relative_source(content_dir, &path)?;
let mut skill = parse_skill(&source, &raw)?;
skill.resources = load_skill_resources(content_dir, directory)?;
Ok(skill)
}
fn load_skill_resources(
content_dir: &Path,
directory: &Path,
) -> Result<Vec<SkillResource>, ContentError> {
let mut resources = Vec::new();
let mut total_bytes = 0usize;
for entry in fs::read_dir(directory).map_err(|error| ContentError::Io {
path: directory.to_owned(),
message: format!("cannot read skill directory: {error}"),
})? {
let entry = entry.map_err(|error| ContentError::Io {
path: directory.to_owned(),
message: format!("cannot read skill entry: {error}"),
})?;
let path = entry.path();
let name = entry.file_name();
let name = name.to_str().ok_or_else(|| ContentError::InvalidSource {
path: path.display().to_string(),
message: "skill entry name is not UTF-8".into(),
})?;
let file_type = entry.file_type().map_err(|error| ContentError::Io {
path: path.clone(),
message: format!("cannot inspect skill entry: {error}"),
})?;
if file_type.is_symlink() {
return Err(ContentError::InvalidSource {
path: path.display().to_string(),
message: "symlink skill resource is not allowed".into(),
});
}
if name == "SKILL.md" && file_type.is_file() {
continue;
}
if !RESOURCE_DIRECTORIES.contains(&name) || !file_type.is_dir() {
return Err(ContentError::InvalidSource {
path: path.display().to_string(),
message: "unexpected skill entry; expected SKILL.md or a resource category".into(),
});
}
for resource_entry in fs::read_dir(&path).map_err(|error| ContentError::Io {
path: path.clone(),
message: format!("cannot read resource category: {error}"),
})? {
let resource_entry = resource_entry.map_err(|error| ContentError::Io {
path: path.clone(),
message: format!("cannot read resource entry: {error}"),
})?;
let resource_path = resource_entry.path();
let resource_type = resource_entry
.file_type()
.map_err(|error| ContentError::Io {
path: resource_path.clone(),
message: format!("cannot inspect resource entry: {error}"),
})?;
if resource_type.is_symlink() {
return Err(ContentError::InvalidSource {
path: resource_path.display().to_string(),
message: "symlink skill resource is not allowed".into(),
});
}
if !resource_type.is_file() {
return Err(ContentError::InvalidSource {
path: resource_path.display().to_string(),
message: "skill resources must be one file below their category".into(),
});
}
let resource_name = resource_entry.file_name();
let resource_name =
resource_name
.to_str()
.ok_or_else(|| ContentError::InvalidSource {
path: resource_path.display().to_string(),
message: "resource filename is not UTF-8".into(),
})?;
let content = read_resource(&resource_path)?;
total_bytes = total_bytes.checked_add(content.len()).ok_or_else(|| {
ContentError::InvalidSource {
path: directory.display().to_string(),
message: "skill resource byte count overflow".into(),
}
})?;
if total_bytes > MAX_SKILL_RESOURCE_BYTES {
return Err(ContentError::InvalidSource {
path: directory.display().to_string(),
message: format!("skill resources exceed {MAX_SKILL_RESOURCE_BYTES} bytes"),
});
}
resources.push(SkillResource {
relative_path: format!("{name}/{resource_name}"),
content,
executable: name == "scripts",
source_path: relative_source(content_dir, &resource_path)?,
});
}
}
resources.sort_by(|left, right| left.relative_path.cmp(&right.relative_path));
Ok(resources)
}
fn read_resource(path: &Path) -> Result<Vec<u8>, ContentError> {
let metadata = fs::symlink_metadata(path).map_err(|error| ContentError::Io {
path: path.to_owned(),
message: format!("cannot inspect resource: {error}"),
})?;
let bytes = usize::try_from(metadata.len()).unwrap_or(usize::MAX);
if bytes > MAX_RESOURCE_BYTES {
return Err(ContentError::InvalidSource {
path: path.display().to_string(),
message: format!("skill resource exceeds {MAX_RESOURCE_BYTES} bytes"),
});
}
let content = fs::read(path).map_err(|error| ContentError::Io {
path: path.to_owned(),
message: format!("cannot read skill resource: {error}"),
})?;
if content.len() > MAX_RESOURCE_BYTES {
return Err(ContentError::InvalidSource {
path: path.display().to_string(),
message: format!("skill resource exceeds {MAX_RESOURCE_BYTES} bytes"),
});
}
core::str::from_utf8(&content).map_err(|_| ContentError::InvalidSource {
path: path.display().to_string(),
message: "skill resource must be UTF-8".into(),
})?;
Ok(content)
}
fn parse_skill(source: &str, raw: &str) -> Result<SkillInput, ContentError> {
let path = Path::new(source);
let directory = path.parent().ok_or_else(|| ContentError::InvalidSource {
path: path.display().to_string(),
message: "skill source has no directory".into(),
})?;
let (frontmatter, body) = split_frontmatter(path, raw)?;
let metadata: SkillFrontmatter =
serde_saphyr::from_str(frontmatter).map_err(|_| ContentError::InvalidFrontmatter {
path: path.display().to_string(),
})?;
let directory_name = directory
.file_name()
.and_then(|value| value.to_str())
.ok_or_else(|| ContentError::InvalidSource {
path: path.display().to_string(),
message: "skill directory is not UTF-8".into(),
})?;
if metadata.name != directory_name {
return Err(ContentError::InvalidMetadata {
path: path.display().to_string(),
message: format!(
"skill `{}` does not match directory `{directory_name}`",
metadata.name
),
});
}
if metadata.source.trim().is_empty() {
return Err(ContentError::InvalidMetadata {
path: path.display().to_string(),
message: "source must not be empty".into(),
});
}
let portability = match metadata.portability.as_str() {
"cross-harness" => Portability::CrossHarness,
"claude-only" => Portability::ClaudeOnly,
"unverified" => Portability::Unverified,
value => {
return Err(ContentError::InvalidMetadata {
path: path.display().to_string(),
message: format!("unsupported portability `{value}`"),
});
}
};
Ok(SkillInput {
name: metadata.name,
description: metadata.description,
portability,
resources: Vec::new(),
body: body.into(),
source_path: source.to_owned(),
source_content: raw.to_owned(),
})
}
fn read_regular_utf8(path: &Path) -> Result<String, ContentError> {
let metadata = fs::symlink_metadata(path).map_err(|error| ContentError::Io {
path: path.to_owned(),
message: format!("cannot inspect file: {error}"),
})?;
if !metadata.file_type().is_file() {
return Err(ContentError::InvalidSource {
path: path.display().to_string(),
message: "expected a regular file".into(),
});
}
fs::read_to_string(path).map_err(|error| ContentError::Io {
path: path.to_owned(),
message: format!("cannot read UTF-8 content: {error}"),
})
}
fn split_frontmatter<'a>(path: &Path, raw: &'a str) -> Result<(&'a str, &'a str), ContentError> {
let raw = raw
.strip_prefix("---\n")
.or_else(|| raw.strip_prefix("---\r\n"))
.ok_or_else(|| ContentError::InvalidFrontmatter {
path: path.display().to_string(),
})?;
raw.split_once("\n---\n")
.or_else(|| raw.split_once("\r\n---\r\n"))
.ok_or_else(|| ContentError::InvalidFrontmatter {
path: path.display().to_string(),
})
}
fn relative_source(content_dir: &Path, path: &Path) -> Result<String, ContentError> {
let parent = content_dir
.parent()
.ok_or_else(|| ContentError::InvalidSource {
path: content_dir.display().to_string(),
message: "content directory has no parent".into(),
})?;
path.strip_prefix(parent)
.map_err(|_| ContentError::InvalidSource {
path: path.display().to_string(),
message: "source escaped content root".into(),
})?
.to_str()
.map(|source| source.replace('\\', "/"))
.ok_or_else(|| ContentError::InvalidSource {
path: path.display().to_string(),
message: "source path is not UTF-8".into(),
})
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct RoleFrontmatter {
role: String,
description: String,
source: String,
model_hint: String,
write_eligible: bool,
dispatchable: bool,
capabilities: Vec<String>,
#[serde(rename = "skill")]
startup_skill: String,
write_scope: String,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct SkillFrontmatter {
name: String,
description: String,
source: String,
portability: String,
}