use anyhow::Result;
use serde::Serialize;
use std::fs;
use std::ops::Range;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Serialize)]
pub struct Skill {
pub name: String,
pub description: String,
pub path: PathBuf,
pub body: String,
}
pub fn discover(cwd: &Path) -> Vec<Skill> {
let mut out = Vec::new();
let mut roots = vec![
cwd.join(".varynth").join("skills"),
cwd.join(".claude").join("skills"),
crate::config::Config::home_dir().join("skills"),
];
if let Some(home) = dirs::home_dir() {
roots.push(home.join(".claude").join("skills"));
}
for root in roots {
walk_skills(&root, &mut out);
}
out.sort_by(|a, b| a.name.cmp(&b.name));
out.dedup_by(|a, b| a.name == b.name);
out
}
fn walk_skills(root: &Path, out: &mut Vec<Skill>) {
if !root.exists() {
return;
}
let walker = walkdir::WalkDir::new(root).max_depth(4);
for e in walker.into_iter().flatten() {
if e.file_name() != "SKILL.md" {
continue;
}
if let Ok(skill) = parse_skill(e.path()) {
out.push(skill);
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Frontmatter {
pub raw: Option<String>,
pub name: Option<String>,
pub description: Option<String>,
pub env: Vec<String>,
}
pub fn frontmatter_span(md: &str) -> Option<Range<usize>> {
let lead = md.len() - md.trim_start().len();
if !md[lead..].starts_with("---") {
return None;
}
let after_open = lead + 3;
let close_rel = md[after_open..].find("\n---")?;
let close = after_open + close_rel + 1;
let end = md[close..].find('\n').map_or(md.len(), |n| close + n + 1);
Some(lead..end)
}
pub fn parse_frontmatter(md: &str) -> Frontmatter {
let mut fm = Frontmatter::default();
let Some(span) = frontmatter_span(md) else {
return fm;
};
fm.raw = Some(md[span.start..span.end].trim_end().to_string());
let inner = &md[span.start + 3..span.end];
let mut lines = inner.lines().peekable();
while let Some(line) = lines.next() {
let line = line.trim_end();
if line.starts_with("---") {
break; }
if let Some(v) = line.strip_prefix("name:") {
fm.name = yaml_scalar(v);
} else if let Some(v) = line.strip_prefix("description:") {
fm.description = yaml_scalar(v);
} else if let Some(v) = line.strip_prefix("env:") {
let value = v.trim();
if value.starts_with('[') {
let list = value.trim_start_matches('[').trim_end_matches(']');
for item in list.split(',') {
if let Some(s) = yaml_scalar(item) {
fm.env.push(s);
}
}
} else if !value.is_empty() {
if let Some(s) = yaml_scalar(value) {
fm.env.push(s);
}
} else {
while let Some(next) = lines.peek() {
let Some(item) = next.trim().strip_prefix("- ") else {
break;
};
if let Some(s) = yaml_scalar(item) {
fm.env.push(s);
}
lines.next();
}
}
}
}
fm
}
fn yaml_scalar(value: &str) -> Option<String> {
let trimmed = value.trim();
if trimmed.is_empty() {
return None;
}
let unquoted = trimmed
.strip_prefix('"')
.and_then(|s| s.strip_suffix('"'))
.or_else(|| {
trimmed
.strip_prefix('\'')
.and_then(|s| s.strip_suffix('\''))
})
.unwrap_or(trimmed)
.trim();
if unquoted.is_empty() {
None
} else {
Some(unquoted.to_string())
}
}
fn parse_skill(path: &Path) -> Result<Skill> {
let body = fs::read_to_string(path)?;
let fm = parse_frontmatter(&body);
let mut name = path
.parent()
.and_then(|p| p.file_name())
.and_then(|s| s.to_str())
.unwrap_or("skill")
.to_string();
if let Some(n) = fm.name {
name = n;
}
let mut description = fm.description.unwrap_or_default();
if description.is_empty() {
description = body
.lines()
.find(|l| !l.trim().is_empty() && !l.starts_with("---") && !l.starts_with('#'))
.unwrap_or("skill")
.trim()
.chars()
.take(160)
.collect();
}
Ok(Skill {
name,
description,
path: path.to_path_buf(),
body,
})
}
pub fn parse_skill_file(path: &Path) -> Result<Skill> {
parse_skill(path)
}
pub fn render_for_prompt(skills: &[Skill]) -> String {
if skills.is_empty() {
return String::new();
}
let mut s =
String::from("\n\n# Available skills\nInvoke with /name. Bodies are loaded on demand.\n");
for sk in skills {
s.push_str(&format!("- /{} — {}\n", sk.name, sk.description));
}
s
}
pub fn load_named<'a>(skills: &'a [Skill], name: &str) -> Option<&'a Skill> {
let n = name.trim_start_matches('/');
skills.iter().find(|s| s.name.eq_ignore_ascii_case(n))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn frontmatter_parses_name_description_and_env_block_list() {
let md = "---\nname: find-skills\ndescription: Find skills on skills.sh\nenv:\n - GITHUB_TOKEN\n - OPENAI_API_KEY\n---\n\n# Body\n";
let fm = parse_frontmatter(md);
assert_eq!(fm.name.as_deref(), Some("find-skills"));
assert_eq!(fm.description.as_deref(), Some("Find skills on skills.sh"));
assert_eq!(fm.env, vec!["GITHUB_TOKEN", "OPENAI_API_KEY"]);
assert!(fm.raw.as_deref().unwrap().starts_with("---"));
}
#[test]
fn frontmatter_handles_inline_env_and_quoted_values() {
let md = "---\nname: \"quoted-name\"\ndescription: 'single quoted'\nenv: [A_KEY, 'B_KEY']\n---\nbody";
let fm = parse_frontmatter(md);
assert_eq!(fm.name.as_deref(), Some("quoted-name"));
assert_eq!(fm.description.as_deref(), Some("single quoted"));
assert_eq!(fm.env, vec!["A_KEY", "B_KEY"]);
}
#[test]
fn frontmatter_missing_block_yields_defaults() {
assert_eq!(
parse_frontmatter("# just markdown\n"),
Frontmatter::default()
);
let fm = parse_frontmatter("---\n---\nbody");
assert!(fm.raw.is_some());
assert_eq!(fm.name, None);
assert!(fm.env.is_empty());
}
#[test]
fn frontmatter_span_end_is_body_start() {
let md = "---\nname: x\n---\nbody line";
let span = frontmatter_span(md).unwrap();
assert_eq!(&md[span.end..], "body line");
assert_eq!(md[span.start..span.end].trim_end(), "---\nname: x\n---");
assert!(frontmatter_span("no frontmatter here").is_none());
assert!(frontmatter_span("---").is_none());
}
#[test]
fn parse_skill_file_prefers_frontmatter_name() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("SKILL.md");
std::fs::write(
&path,
"---\nname: my-skill\ndescription: does things\n---\n# My Skill\n",
)
.unwrap();
let skill = parse_skill_file(&path).unwrap();
assert_eq!(skill.name, "my-skill");
assert_eq!(skill.description, "does things");
}
}