454 lines
15 KiB
Rust
454 lines
15 KiB
Rust
//! Agent Skills (`SKILL.md`) parser.
|
|
//!
|
|
//! Skills follow the [agentskills.io](https://agentskills.io/specification)
|
|
//! spec: a directory `<root>/<name>/` containing `SKILL.md` (YAML frontmatter
|
|
//! + Markdown body) and optional `scripts/` / `references/` / `assets/`
|
|
//! subdirectories. The body is procedural agent guidance; yoi ingests
|
|
//! it as a Workflow so `/<name>` resolves to it just like an internal
|
|
//! Workflow.
|
|
//!
|
|
//! Parsing is intentionally lenient at the directory-scan level — one
|
|
//! malformed SKILL.md emits `tracing::warn!` and is skipped, leaving sibling
|
|
//! skills loadable. Internal Workflows (`.yoi/workflow/<slug>.md`) keep
|
|
//! their hard-error semantics.
|
|
|
|
use std::io;
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use lint_common::RecordLintError;
|
|
use serde::Deserialize;
|
|
use thiserror::Error;
|
|
use tracing::warn;
|
|
|
|
use crate::schema::split_frontmatter;
|
|
use crate::workflow::{WORKFLOW_DESCRIPTION_HARD_CAP, WorkflowRecord, WorkflowSource};
|
|
use crate::{Slug, WorkflowLintError};
|
|
|
|
/// Filename within a skill directory carrying the frontmatter + body.
|
|
pub const SKILL_FILENAME: &str = "SKILL.md";
|
|
|
|
/// SKILL.md frontmatter as defined by the agent-skills spec.
|
|
///
|
|
/// Fields beyond `name` / `description` are accepted to be spec-compatible
|
|
/// but not used by yoi today: `license`, `compatibility`, and
|
|
/// `metadata` are documentary, while `allowed-tools` is recognised and
|
|
/// emits a warning until [`permission-extension-point.md`] lands.
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
#[allow(dead_code)]
|
|
pub struct SkillFrontmatter {
|
|
pub name: String,
|
|
pub description: String,
|
|
#[serde(default)]
|
|
pub license: Option<String>,
|
|
#[serde(default)]
|
|
pub compatibility: Option<String>,
|
|
#[serde(default)]
|
|
pub metadata: Option<serde_yaml::Value>,
|
|
#[serde(default, rename = "allowed-tools")]
|
|
pub allowed_tools: Option<serde_yaml::Value>,
|
|
}
|
|
|
|
/// Validated skill record. Constructed by [`parse_skill_md`] and converted
|
|
/// to a `WorkflowRecord` by the caller via the `Skill → Workflow`
|
|
/// projection in [`crate::WorkflowRecord`].
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct SkillRecord {
|
|
pub slug: Slug,
|
|
pub description: String,
|
|
pub body: String,
|
|
/// The skill directory (parent of `SKILL.md`). Carried so callers can
|
|
/// register `scripts/` / `references/` / `assets/` against the Pod's
|
|
/// scope.
|
|
pub dir: PathBuf,
|
|
/// Path to the `SKILL.md` file itself. Used as the resolved path on
|
|
/// the resulting `WorkflowRecord`.
|
|
pub skill_md_path: PathBuf,
|
|
}
|
|
|
|
impl SkillRecord {
|
|
/// Project this skill into a [`WorkflowRecord`]. Skill-sourced
|
|
/// Workflows are advertised resident (`model_invokation: true`,
|
|
/// matching the agentskills progressive-disclosure model), are
|
|
/// invocable as `/<slug>`, and carry no `requires` since the SKILL
|
|
/// spec has no Knowledge-dependency concept.
|
|
pub fn into_workflow_record(self, source: WorkflowSource) -> WorkflowRecord {
|
|
WorkflowRecord {
|
|
slug: self.slug,
|
|
description: self.description,
|
|
model_invokation: true,
|
|
user_invocable: true,
|
|
requires: Vec::new(),
|
|
body: self.body,
|
|
path: self.skill_md_path,
|
|
source,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Error)]
|
|
pub enum SkillParseError {
|
|
#[error("skill path has no parent directory: {}", .0.display())]
|
|
NoParentDir(PathBuf),
|
|
#[error("failed to read SKILL.md at {}: {source}", .path.display())]
|
|
ReadFile { path: PathBuf, source: io::Error },
|
|
#[error("invalid frontmatter in {}: {source}", .path.display())]
|
|
Frontmatter {
|
|
path: PathBuf,
|
|
#[source]
|
|
source: WorkflowLintError,
|
|
},
|
|
#[error(
|
|
"SKILL.md `name` `{name}` does not match its directory name `{dir_name}` (at {})",
|
|
.skill_md_path.display()
|
|
)]
|
|
NameDirMismatch {
|
|
name: String,
|
|
dir_name: String,
|
|
skill_md_path: PathBuf,
|
|
},
|
|
#[error("SKILL.md `name` is not a valid slug at {}: {source}", .skill_md_path.display())]
|
|
InvalidName {
|
|
skill_md_path: PathBuf,
|
|
#[source]
|
|
source: WorkflowLintError,
|
|
},
|
|
#[error("SKILL.md `description` must be non-empty (at {})", .skill_md_path.display())]
|
|
DescriptionEmpty { skill_md_path: PathBuf },
|
|
#[error(
|
|
"SKILL.md `description` length {actual} exceeds limit {limit} (at {})",
|
|
.skill_md_path.display()
|
|
)]
|
|
DescriptionTooLong {
|
|
skill_md_path: PathBuf,
|
|
actual: usize,
|
|
limit: usize,
|
|
},
|
|
}
|
|
|
|
/// Parse a single `SKILL.md`. The directory name is taken from the parent
|
|
/// of `skill_md_path` and validated against the frontmatter `name`.
|
|
pub fn parse_skill_md(skill_md_path: &Path) -> Result<SkillRecord, SkillParseError> {
|
|
let dir = skill_md_path
|
|
.parent()
|
|
.map(|p| p.to_path_buf())
|
|
.ok_or_else(|| SkillParseError::NoParentDir(skill_md_path.to_path_buf()))?;
|
|
let dir_name = dir
|
|
.file_name()
|
|
.and_then(|s| s.to_str())
|
|
.map(|s| s.to_string())
|
|
.ok_or_else(|| SkillParseError::NoParentDir(skill_md_path.to_path_buf()))?;
|
|
|
|
let raw =
|
|
std::fs::read_to_string(skill_md_path).map_err(|source| SkillParseError::ReadFile {
|
|
path: skill_md_path.to_path_buf(),
|
|
source,
|
|
})?;
|
|
let (yaml, body) = split_frontmatter(&raw).map_err(|source| SkillParseError::Frontmatter {
|
|
path: skill_md_path.to_path_buf(),
|
|
source,
|
|
})?;
|
|
warn_unknown_skill_fields(skill_md_path, yaml);
|
|
let frontmatter: SkillFrontmatter =
|
|
serde_yaml::from_str(yaml).map_err(|err| SkillParseError::Frontmatter {
|
|
path: skill_md_path.to_path_buf(),
|
|
source: WorkflowLintError::Record(RecordLintError::MalformedFrontmatter(
|
|
err.to_string(),
|
|
)),
|
|
})?;
|
|
|
|
if frontmatter.allowed_tools.is_some() {
|
|
warn!(
|
|
path = %skill_md_path.display(),
|
|
"SKILL.md `allowed-tools` is recognised but not yet enforced; ignoring"
|
|
);
|
|
}
|
|
|
|
let desc_chars = frontmatter.description.chars().count();
|
|
if desc_chars == 0 {
|
|
return Err(SkillParseError::DescriptionEmpty {
|
|
skill_md_path: skill_md_path.to_path_buf(),
|
|
});
|
|
}
|
|
if desc_chars > WORKFLOW_DESCRIPTION_HARD_CAP {
|
|
return Err(SkillParseError::DescriptionTooLong {
|
|
skill_md_path: skill_md_path.to_path_buf(),
|
|
actual: desc_chars,
|
|
limit: WORKFLOW_DESCRIPTION_HARD_CAP,
|
|
});
|
|
}
|
|
|
|
if frontmatter.name != dir_name {
|
|
return Err(SkillParseError::NameDirMismatch {
|
|
name: frontmatter.name,
|
|
dir_name,
|
|
skill_md_path: skill_md_path.to_path_buf(),
|
|
});
|
|
}
|
|
let slug = Slug::parse(frontmatter.name).map_err(|source| SkillParseError::InvalidName {
|
|
skill_md_path: skill_md_path.to_path_buf(),
|
|
source: source.into(),
|
|
})?;
|
|
|
|
Ok(SkillRecord {
|
|
slug,
|
|
description: frontmatter.description,
|
|
body: body.to_string(),
|
|
dir,
|
|
skill_md_path: skill_md_path.to_path_buf(),
|
|
})
|
|
}
|
|
|
|
/// Scan a skills root for `<root>/<name>/SKILL.md`. Returns successfully
|
|
/// parsed skills; per-skill errors emit a `tracing::warn!` and are
|
|
/// skipped. A missing root is treated as zero skills, not an error —
|
|
/// callers can probe optional directories without pre-checking.
|
|
pub fn load_skills_from_dir(root: &Path) -> Vec<SkillRecord> {
|
|
let entries = match std::fs::read_dir(root) {
|
|
Ok(it) => it,
|
|
Err(err) if err.kind() == io::ErrorKind::NotFound => return Vec::new(),
|
|
Err(err) => {
|
|
warn!(
|
|
dir = %root.display(),
|
|
error = %err,
|
|
"failed to read skills directory; treating as empty"
|
|
);
|
|
return Vec::new();
|
|
}
|
|
};
|
|
|
|
let mut paths: Vec<PathBuf> = Vec::new();
|
|
for entry in entries {
|
|
let entry = match entry {
|
|
Ok(e) => e,
|
|
Err(err) => {
|
|
warn!(
|
|
dir = %root.display(),
|
|
error = %err,
|
|
"skill directory entry read error; skipping"
|
|
);
|
|
continue;
|
|
}
|
|
};
|
|
let path = entry.path();
|
|
if !path.is_dir() {
|
|
continue;
|
|
}
|
|
let skill_md = path.join(SKILL_FILENAME);
|
|
if skill_md.is_file() {
|
|
paths.push(skill_md);
|
|
}
|
|
}
|
|
paths.sort();
|
|
|
|
let mut out = Vec::new();
|
|
for path in paths {
|
|
match parse_skill_md(&path) {
|
|
Ok(record) => out.push(record),
|
|
Err(err) => warn!(path = %path.display(), error = %err, "SKILL.md skipped"),
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
fn warn_unknown_skill_fields(path: &Path, yaml: &str) {
|
|
let Ok(value) = serde_yaml::from_str::<serde_yaml::Value>(yaml) else {
|
|
return;
|
|
};
|
|
let Some(map) = value.as_mapping() else {
|
|
return;
|
|
};
|
|
for key in map.keys().filter_map(|k| k.as_str()) {
|
|
if !matches!(
|
|
key,
|
|
"name" | "description" | "license" | "compatibility" | "metadata" | "allowed-tools"
|
|
) {
|
|
warn!(path = %path.display(), field = key, "unknown SKILL.md frontmatter field ignored");
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use tempfile::TempDir;
|
|
|
|
fn write_skill(root: &Path, name: &str, frontmatter: &str, body: &str) -> PathBuf {
|
|
let dir = root.join(name);
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
let path = dir.join(SKILL_FILENAME);
|
|
std::fs::write(&path, format!("---\n{frontmatter}\n---\n{body}")).unwrap();
|
|
path
|
|
}
|
|
|
|
#[test]
|
|
fn parses_minimal_skill() {
|
|
let dir = TempDir::new().unwrap();
|
|
let path = write_skill(
|
|
dir.path(),
|
|
"do-thing",
|
|
"name: do-thing\ndescription: Do the thing",
|
|
"Step 1\nStep 2\n",
|
|
);
|
|
let record = parse_skill_md(&path).unwrap();
|
|
assert_eq!(record.slug.as_str(), "do-thing");
|
|
assert_eq!(record.description, "Do the thing");
|
|
assert_eq!(record.body, "Step 1\nStep 2\n");
|
|
assert_eq!(record.dir, dir.path().join("do-thing"));
|
|
assert_eq!(record.skill_md_path, path);
|
|
}
|
|
|
|
#[test]
|
|
fn name_dir_mismatch_is_error() {
|
|
let dir = TempDir::new().unwrap();
|
|
let path = write_skill(
|
|
dir.path(),
|
|
"actual-dir",
|
|
"name: declared-name\ndescription: x",
|
|
"body",
|
|
);
|
|
let err = parse_skill_md(&path).unwrap_err();
|
|
assert!(matches!(err, SkillParseError::NameDirMismatch { .. }));
|
|
}
|
|
|
|
#[test]
|
|
fn invalid_slug_name_is_error() {
|
|
let dir = TempDir::new().unwrap();
|
|
let path = write_skill(
|
|
dir.path(),
|
|
"BAD-Caps",
|
|
"name: BAD-Caps\ndescription: x",
|
|
"body",
|
|
);
|
|
// Slug::parse rejects uppercase before the dir match check fires;
|
|
// either way the parse is rejected.
|
|
let err = parse_skill_md(&path).unwrap_err();
|
|
assert!(matches!(
|
|
err,
|
|
SkillParseError::InvalidName { .. } | SkillParseError::NameDirMismatch { .. }
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn empty_description_is_error() {
|
|
let dir = TempDir::new().unwrap();
|
|
let path = write_skill(dir.path(), "x", "name: x\ndescription: \"\"", "body");
|
|
let err = parse_skill_md(&path).unwrap_err();
|
|
assert!(matches!(err, SkillParseError::DescriptionEmpty { .. }));
|
|
}
|
|
|
|
#[test]
|
|
fn description_at_cap_is_accepted() {
|
|
let dir = TempDir::new().unwrap();
|
|
let desc = "x".repeat(WORKFLOW_DESCRIPTION_HARD_CAP);
|
|
let path = write_skill(
|
|
dir.path(),
|
|
"x",
|
|
&format!("name: x\ndescription: {desc}"),
|
|
"body",
|
|
);
|
|
let record = parse_skill_md(&path).unwrap();
|
|
assert_eq!(
|
|
record.description.chars().count(),
|
|
WORKFLOW_DESCRIPTION_HARD_CAP
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn description_over_cap_is_error() {
|
|
let dir = TempDir::new().unwrap();
|
|
let desc = "x".repeat(WORKFLOW_DESCRIPTION_HARD_CAP + 1);
|
|
let path = write_skill(
|
|
dir.path(),
|
|
"x",
|
|
&format!("name: x\ndescription: {desc}"),
|
|
"body",
|
|
);
|
|
let err = parse_skill_md(&path).unwrap_err();
|
|
assert!(matches!(err, SkillParseError::DescriptionTooLong { .. }));
|
|
}
|
|
|
|
#[test]
|
|
fn missing_frontmatter_is_error() {
|
|
let dir = TempDir::new().unwrap();
|
|
let path = dir.path().join("x").join(SKILL_FILENAME);
|
|
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
|
std::fs::write(&path, "no frontmatter at all\n").unwrap();
|
|
let err = parse_skill_md(&path).unwrap_err();
|
|
assert!(matches!(err, SkillParseError::Frontmatter { .. }));
|
|
}
|
|
|
|
#[test]
|
|
fn extra_frontmatter_fields_are_kept() {
|
|
let dir = TempDir::new().unwrap();
|
|
let path = write_skill(
|
|
dir.path(),
|
|
"x",
|
|
"name: x\ndescription: ok\nlicense: MIT\ncompatibility: claude-4\n\
|
|
metadata:\n team: foo\nallowed-tools:\n - Read",
|
|
"body",
|
|
);
|
|
let record = parse_skill_md(&path).unwrap();
|
|
assert_eq!(record.slug.as_str(), "x");
|
|
// allowed-tools triggers a warn, but parse succeeds.
|
|
}
|
|
|
|
#[test]
|
|
fn load_skills_from_dir_skips_broken_and_keeps_good() {
|
|
let dir = TempDir::new().unwrap();
|
|
write_skill(dir.path(), "good", "name: good\ndescription: ok", "body");
|
|
// Mismatch — should be skipped, not abort the scan.
|
|
write_skill(
|
|
dir.path(),
|
|
"bad-dir",
|
|
"name: declared-different\ndescription: ok",
|
|
"body",
|
|
);
|
|
// A bare file at the root (not a directory) is ignored.
|
|
std::fs::write(dir.path().join("stray.md"), "not a skill").unwrap();
|
|
|
|
let records = load_skills_from_dir(dir.path());
|
|
let slugs: Vec<&str> = records.iter().map(|r| r.slug.as_str()).collect();
|
|
assert_eq!(slugs, vec!["good"]);
|
|
}
|
|
|
|
#[test]
|
|
fn load_skills_from_dir_missing_root_is_empty() {
|
|
let dir = TempDir::new().unwrap();
|
|
let records = load_skills_from_dir(&dir.path().join("does-not-exist"));
|
|
assert!(records.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn into_workflow_record_uses_skill_defaults() {
|
|
let dir = TempDir::new().unwrap();
|
|
let path = write_skill(
|
|
dir.path(),
|
|
"x",
|
|
"name: x\ndescription: Project X",
|
|
"Steps\n",
|
|
);
|
|
let record = parse_skill_md(&path).unwrap();
|
|
let wf = record.into_workflow_record(WorkflowSource::Skill {
|
|
dir: dir.path().to_path_buf(),
|
|
});
|
|
assert_eq!(wf.slug.as_str(), "x");
|
|
assert_eq!(wf.description, "Project X");
|
|
assert!(wf.model_invokation);
|
|
assert!(wf.user_invocable);
|
|
assert!(wf.requires.is_empty());
|
|
assert_eq!(wf.body, "Steps\n");
|
|
assert!(matches!(wf.source, WorkflowSource::Skill { .. }));
|
|
}
|
|
|
|
#[test]
|
|
fn load_skills_from_dir_orders_deterministically() {
|
|
let dir = TempDir::new().unwrap();
|
|
write_skill(dir.path(), "b", "name: b\ndescription: b", "");
|
|
write_skill(dir.path(), "a", "name: a\ndescription: a", "");
|
|
write_skill(dir.path(), "c", "name: c\ndescription: c", "");
|
|
let records = load_skills_from_dir(dir.path());
|
|
let slugs: Vec<&str> = records.iter().map(|r| r.slug.as_str()).collect();
|
|
assert_eq!(slugs, vec!["a", "b", "c"]);
|
|
}
|
|
}
|