//! Agent Skills (`SKILL.md`) parser. //! //! Skills follow the [agentskills.io](https://agentskills.io/specification) //! spec: a directory `//` containing `SKILL.md` (YAML frontmatter //! + Markdown body) and optional `scripts/` / `references/` / `assets/` //! subdirectories. The body is procedural agent guidance; insomnia ingests //! it as a Workflow so `/` 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 (`.insomnia/workflow/.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 insomnia 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, #[serde(default)] pub compatibility: Option, #[serde(default)] pub metadata: Option, #[serde(default, rename = "allowed-tools")] pub allowed_tools: Option, } /// 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 `/`, 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 { 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 `//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 { 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 = 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::(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"]); } }