use std::path::{Path, PathBuf};
use crate::error::{Error, Result};
pub const MAX_SKILLS: usize = 64;
const DESCRIPTION_CAP: usize = 240;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Skill {
pub name: String,
pub description: String,
pub path: PathBuf,
}
#[derive(Debug, Clone, Default)]
pub struct Skills {
skills: Vec<Skill>,
}
impl Skills {
pub fn none() -> Self {
Self::default()
}
pub fn discover(dir: impl AsRef<Path>) -> Result<Self> {
let dir = dir.as_ref();
if !dir.exists() {
return Err(Error::Config(format!(
"skills directory {} does not exist",
dir.display()
)));
}
if !dir.is_dir() {
return Err(Error::Config(format!(
"skills path {} is not a directory; point with_skills at a directory of markdown \
files, not at one file",
dir.display()
)));
}
let mut entries: Vec<PathBuf> = std::fs::read_dir(dir)?
.filter_map(|e| e.ok())
.map(|e| e.path())
.collect();
entries.sort();
let mut skills: Vec<Skill> = Vec::new();
for path in entries {
let file = if path.is_dir() {
let candidate = path.join("SKILL.md");
if !candidate.is_file() {
continue;
}
candidate
} else if path
.extension()
.is_some_and(|e| e.eq_ignore_ascii_case("md"))
{
path.clone()
} else {
continue;
};
let text = std::fs::read_to_string(&file)?;
let (front_name, front_desc, body) = split_front_matter(&text);
let fallback_name = default_name(&file);
let name = front_name.unwrap_or(fallback_name);
let description = front_desc
.or_else(|| first_prose_line(body))
.unwrap_or_else(|| "(no description)".to_string());
skills.push(Skill {
name,
description: clamp(&description, DESCRIPTION_CAP),
path: std::fs::canonicalize(&file).unwrap_or(file),
});
if skills.len() > MAX_SKILLS {
return Err(Error::Config(format!(
"skills directory {} holds more than {MAX_SKILLS} skills. Every skill's name \
and description is sent on every turn, so the set is rejected rather than \
silently reduced — split the directory or point at a smaller one",
dir.display()
)));
}
}
skills.sort_by(|a, b| a.name.cmp(&b.name));
for pair in skills.windows(2) {
if pair[0].name == pair[1].name {
return Err(Error::Config(format!(
"two skills are both named {:?} ({} and {}); the agent addresses a skill by \
name, so the set is ambiguous",
pair[0].name,
pair[0].path.display(),
pair[1].path.display()
)));
}
}
Ok(Self { skills })
}
pub fn is_empty(&self) -> bool {
self.skills.is_empty()
}
pub fn len(&self) -> usize {
self.skills.len()
}
pub fn get(&self, name: &str) -> Option<&Skill> {
self.skills.iter().find(|s| s.name == name)
}
pub fn iter(&self) -> impl Iterator<Item = &Skill> {
self.skills.iter()
}
pub fn names(&self) -> Vec<&str> {
self.skills.iter().map(|s| s.name.as_str()).collect()
}
pub fn catalog(&self) -> String {
self.skills
.iter()
.map(|s| format!("- {}: {}", s.name, s.description))
.collect::<Vec<_>>()
.join("\n")
}
}
fn default_name(file: &Path) -> String {
let stem = file
.file_stem()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_default();
if stem.eq_ignore_ascii_case("SKILL") {
if let Some(parent) = file.parent().and_then(|p| p.file_name()) {
return parent.to_string_lossy().to_string();
}
}
stem
}
fn split_front_matter(text: &str) -> (Option<String>, Option<String>, &str) {
let stripped = text
.strip_prefix("---\n")
.or_else(|| text.strip_prefix("---\r\n"));
let Some(rest) = stripped else {
return (None, None, text);
};
let mut offset = 0usize;
let mut front_end = None;
for line in rest.split_inclusive('\n') {
if line.trim_end_matches(['\r', '\n']).trim() == "---" {
front_end = Some((offset, offset + line.len()));
break;
}
offset += line.len();
}
let Some((front_len, body_start)) = front_end else {
return (None, None, text);
};
let front = &rest[..front_len];
let body = &rest[body_start..];
let mut name = None;
let mut description = None;
let mut open: Option<&str> = None;
let mut buffer = String::new();
let flush = |key: Option<&str>,
buffer: &mut String,
name: &mut Option<String>,
description: &mut Option<String>| {
let value = buffer.trim().to_string();
buffer.clear();
if value.is_empty() {
return;
}
match key {
Some("name") => *name = Some(value),
Some("description") => *description = Some(value),
_ => {}
}
};
for raw in front.lines() {
let line = raw.trim_end();
if line.trim().is_empty() {
continue;
}
let indented = line.starts_with(' ') || line.starts_with('\t');
let starts_key = line
.find(':')
.is_some_and(|i| !line[..i].trim().is_empty() && !line[..i].contains(' '));
if indented || !starts_key {
if open.is_some() {
if !buffer.is_empty() {
buffer.push(' ');
}
buffer.push_str(line.trim());
}
continue;
}
flush(open, &mut buffer, &mut name, &mut description);
let (key, value) = line.split_once(':').expect("starts_key found one");
let key = key.trim();
let value = value.trim();
open = match key {
"name" => Some("name"),
"description" => Some("description"),
_ => Some("other"),
};
if value != ">" && value != "|" && value != ">-" && value != "|-" {
buffer.push_str(value);
}
}
flush(open, &mut buffer, &mut name, &mut description);
(name, description, body)
}
fn first_prose_line(body: &str) -> Option<String> {
body.lines()
.map(|l| l.trim().trim_start_matches(['#', '>']).trim())
.find(|l| !l.is_empty())
.map(str::to_string)
}
fn clamp(s: &str, cap: usize) -> String {
let one_line = s.split_whitespace().collect::<Vec<_>>().join(" ");
if one_line.len() <= cap {
return one_line;
}
let mut end = cap;
while end > 0 && !one_line.is_char_boundary(end) {
end -= 1;
}
format!("{}…", &one_line[..end])
}
#[cfg(test)]
mod tests {
use super::*;
fn front(text: &str) -> (Option<String>, Option<String>, String) {
let (n, d, b) = split_front_matter(text);
(n, d, b.to_string())
}
#[test]
fn plain_frontmatter_is_read_and_the_body_follows() {
let (name, desc, body) = front("---\nname: alpha\ndescription: does a thing\n---\nBODY\n");
assert_eq!(name.as_deref(), Some("alpha"));
assert_eq!(desc.as_deref(), Some("does a thing"));
assert_eq!(body, "BODY\n");
}
#[test]
fn crlf_line_endings_parse() {
let (name, desc, body) =
front("---\r\nname: alpha\r\ndescription: does a thing\r\n---\r\nBODY\r\n");
assert_eq!(name.as_deref(), Some("alpha"));
assert_eq!(desc.as_deref(), Some("does a thing"));
assert_eq!(body, "BODY\r\n");
}
#[test]
fn a_description_spanning_lines_is_joined() {
let (_, desc, _) =
front("---\nname: alpha\ndescription: >\n first part\n second part\n---\nBODY\n");
assert_eq!(desc.as_deref(), Some("first part second part"));
}
#[test]
fn a_plain_continuation_line_is_joined_too() {
let (_, desc, _) = front("---\ndescription: first part\n second part\n---\nBODY\n");
assert_eq!(desc.as_deref(), Some("first part second part"));
}
#[test]
fn a_later_key_does_not_absorb_the_description() {
let (name, desc, _) =
front("---\nname: alpha\ndescription: the real one\nmetadata:\n type: x\n---\nBODY\n");
assert_eq!(name.as_deref(), Some("alpha"));
assert_eq!(desc.as_deref(), Some("the real one"));
}
#[test]
fn absent_frontmatter_leaves_the_whole_file_as_body() {
let (name, desc, body) = front("# Heading\n\nprose\n");
assert!(name.is_none());
assert!(desc.is_none());
assert_eq!(body, "# Heading\n\nprose\n");
}
#[test]
fn unterminated_frontmatter_is_treated_as_no_frontmatter() {
let text = "---\nname: alpha\ndescription: never closed\n\nBODY\n";
let (name, desc, body) = front(text);
assert!(name.is_none(), "an unclosed fence must not be trusted");
assert!(desc.is_none());
assert_eq!(body, text, "the whole file is the body");
}
#[test]
fn a_heading_becomes_the_fallback_description() {
assert_eq!(
first_prose_line("# Migrations\n\nbody").as_deref(),
Some("Migrations")
);
assert_eq!(
first_prose_line("\n\nplain line\n").as_deref(),
Some("plain line")
);
assert_eq!(first_prose_line(" \n"), None);
}
#[test]
fn a_non_ascii_body_survives_and_a_long_description_clamps_at_a_char_boundary() {
let (_, _, body) = front("---\nname: a\n---\n中文の本文 — é\n");
assert_eq!(body, "中文の本文 — é\n");
let long = "é".repeat(400);
let clamped = clamp(&long, DESCRIPTION_CAP);
assert!(clamped.ends_with('…'));
assert!(clamped.len() <= DESCRIPTION_CAP + 4);
}
#[test]
fn skill_md_takes_its_directory_name_and_a_plain_file_its_stem() {
assert_eq!(
default_name(Path::new("/s/api-style/SKILL.md")),
"api-style"
);
assert_eq!(default_name(Path::new("/s/migrations.md")), "migrations");
}
}