use std::collections::HashSet;
use std::fs;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result, bail};
use serde::Serialize;
use tempfile::NamedTempFile;
const SKILL_BODY_FILENAME: &str = "SKILL.md";
const PREAMBLE: &str = "Additional skill context follows. Treat each skill section as additive instructions for this invocation only.";
const BYTES_PER_TOKEN_X10: usize = 33;
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct SkillSummary {
pub name: String,
pub description: String,
pub path: PathBuf,
pub bytes: usize,
pub estimated_tokens: usize,
pub support_files: Vec<PathBuf>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct SkillDocument {
pub summary: SkillSummary,
pub body: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InjectFormat {
Plain,
Claude,
Codex,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct InjectedSkills {
pub names: Vec<String>,
pub bytes: usize,
pub estimated_tokens: usize,
pub text: String,
}
#[derive(Debug)]
pub struct InjectedSkillFile {
_file: NamedTempFile,
pub path: PathBuf,
pub names: Vec<String>,
pub bytes: usize,
pub estimated_tokens: usize,
}
impl InjectedSkillFile {
pub fn path(&self) -> &Path {
&self.path
}
}
#[derive(Debug, Default)]
struct Frontmatter {
name: Option<String>,
description: Option<String>,
}
pub fn skills_root(cwd: &Path) -> PathBuf {
find_netsky_root(cwd)
.unwrap_or_else(|| cwd.to_path_buf())
.join(".agents")
.join("skills")
}
pub fn list_skills(cwd: &Path) -> Result<Vec<SkillSummary>> {
let root = skills_root(cwd);
let entries = match fs::read_dir(&root) {
Ok(entries) => entries,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(err) => return Err(err).with_context(|| format!("read {}", root.display())),
};
let mut skills = Vec::new();
for entry in entries {
let entry = entry.with_context(|| format!("read {}", root.display()))?;
if !entry
.file_type()
.with_context(|| format!("inspect {}", entry.path().display()))?
.is_dir()
{
continue;
}
let body_path = entry.path().join(SKILL_BODY_FILENAME);
if !body_path.is_file() {
continue;
}
skills.push(read_skill_summary(&body_path)?);
}
skills.sort_by(|left, right| left.name.cmp(&right.name));
Ok(skills)
}
pub fn read_skill(cwd: &Path, name: &str) -> Result<SkillDocument> {
let body_path = skill_body_path(cwd, name);
if !body_path.is_file() {
bail!("unknown skill `{name}` at {}", body_path.display());
}
let body =
fs::read_to_string(&body_path).with_context(|| format!("read {}", body_path.display()))?;
let summary = summarize_skill(name, &body_path, &body)?;
Ok(SkillDocument { summary, body })
}
pub fn inject_skills(cwd: &Path, names: &[String], format: InjectFormat) -> Result<InjectedSkills> {
let ordered_names = dedupe_names(names)?;
let mut out = String::from(PREAMBLE);
for name in &ordered_names {
let doc = read_skill(cwd, name)?;
out.push_str("\n\n## Skill: ");
out.push_str(&doc.summary.name);
out.push_str("\n\n");
out.push_str(&doc.body);
if !doc.body.ends_with('\n') {
out.push('\n');
}
}
let text = match format {
InjectFormat::Plain | InjectFormat::Claude | InjectFormat::Codex => out,
};
let bytes = text.len();
Ok(InjectedSkills {
names: ordered_names,
bytes,
estimated_tokens: estimate_tokens(bytes),
text,
})
}
pub fn write_injected_skills_tempfile(cwd: &Path, names: &[String]) -> Result<InjectedSkillFile> {
let injected = inject_skills(cwd, names, InjectFormat::Claude)?;
let mut file = NamedTempFile::new().context("create injected skills tempfile")?;
use std::io::Write as _;
file.write_all(injected.text.as_bytes())
.context("write injected skills tempfile")?;
let path = file.path().to_path_buf();
Ok(InjectedSkillFile {
_file: file,
path,
names: injected.names,
bytes: injected.bytes,
estimated_tokens: injected.estimated_tokens,
})
}
fn find_netsky_root(cwd: &Path) -> Option<PathBuf> {
cwd.ancestors().find_map(|ancestor| {
ancestor
.join(".agents")
.join("skills")
.is_dir()
.then(|| ancestor.to_path_buf())
})
}
fn skill_body_path(cwd: &Path, name: &str) -> PathBuf {
skills_root(cwd).join(name).join(SKILL_BODY_FILENAME)
}
fn read_skill_summary(body_path: &Path) -> Result<SkillSummary> {
let body =
fs::read_to_string(body_path).with_context(|| format!("read {}", body_path.display()))?;
let name = body_path
.parent()
.and_then(Path::file_name)
.map(|part| part.to_string_lossy().into_owned())
.context("skill path missing directory name")?;
summarize_skill(&name, body_path, &body)
}
fn summarize_skill(name: &str, body_path: &Path, body: &str) -> Result<SkillSummary> {
let frontmatter = parse_frontmatter(body);
let description = frontmatter
.description
.or_else(|| fallback_description(body))
.unwrap_or_default();
let bytes = body.len();
Ok(SkillSummary {
name: frontmatter.name.unwrap_or_else(|| name.to_string()),
description,
path: body_path.to_path_buf(),
bytes,
estimated_tokens: estimate_tokens(bytes),
support_files: support_files(body_path)?,
})
}
fn parse_frontmatter(body: &str) -> Frontmatter {
let Some(rest) = body.strip_prefix("---\n") else {
return Frontmatter::default();
};
let Some((raw, _)) = rest.split_once("\n---\n") else {
return Frontmatter::default();
};
let mut frontmatter = Frontmatter::default();
for line in raw.lines() {
let trimmed = line.trim();
let Some((key, value)) = trimmed.split_once(':') else {
continue;
};
let value = unquote(value.trim());
match key.trim() {
"name" if !value.is_empty() => frontmatter.name = Some(value.to_string()),
"description" if !value.is_empty() => {
frontmatter.description = Some(value.to_string());
}
_ => {}
}
}
frontmatter
}
fn fallback_description(body: &str) -> Option<String> {
let content = strip_frontmatter(body);
let mut saw_title = false;
for line in content.lines() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
if !saw_title && trimmed.starts_with('#') {
saw_title = true;
continue;
}
return Some(trimmed.to_string());
}
None
}
fn strip_frontmatter(body: &str) -> &str {
let Some(rest) = body.strip_prefix("---\n") else {
return body;
};
let Some((_, remainder)) = rest.split_once("\n---\n") else {
return body;
};
remainder
}
fn unquote(value: &str) -> &str {
if value.len() >= 2 {
let quoted = (value.starts_with('"') && value.ends_with('"'))
|| (value.starts_with('\'') && value.ends_with('\''));
if quoted {
return &value[1..value.len() - 1];
}
}
value
}
fn support_files(body_path: &Path) -> Result<Vec<PathBuf>> {
let Some(skill_dir) = body_path.parent() else {
return Ok(Vec::new());
};
let mut files = Vec::new();
for entry in fs::read_dir(skill_dir).with_context(|| format!("read {}", skill_dir.display()))? {
let entry = entry.with_context(|| format!("read {}", skill_dir.display()))?;
let path = entry.path();
if path == body_path {
continue;
}
if entry
.file_type()
.with_context(|| format!("inspect {}", path.display()))?
.is_file()
{
files.push(path);
}
}
files.sort();
Ok(files)
}
fn dedupe_names(names: &[String]) -> Result<Vec<String>> {
if names.is_empty() {
bail!("expected at least one skill name");
}
let mut seen = HashSet::new();
let mut ordered = Vec::new();
for name in names {
if seen.insert(name.clone()) {
ordered.push(name.clone());
}
}
Ok(ordered)
}
fn estimate_tokens(bytes: usize) -> usize {
(bytes * 10).div_ceil(BYTES_PER_TOKEN_X10)
}
#[cfg(test)]
mod tests {
use super::*;
fn write_skill(root: &Path, name: &str, body: &str) {
let skill_dir = root.join(".agents").join("skills").join(name);
fs::create_dir_all(&skill_dir).expect("create skill dir");
fs::write(skill_dir.join(SKILL_BODY_FILENAME), body).expect("write skill");
}
#[test]
fn skills_root_walks_up_to_repo_root() {
let tmp = tempfile::tempdir().expect("tempdir");
let nested = tmp.path().join("a").join("b");
fs::create_dir_all(tmp.path().join(".agents").join("skills")).expect("skills root");
fs::create_dir_all(&nested).expect("nested");
assert_eq!(
skills_root(&nested),
tmp.path().join(".agents").join("skills")
);
}
#[test]
fn list_skills_reads_frontmatter_and_support_files() {
let tmp = tempfile::tempdir().expect("tempdir");
write_skill(
tmp.path(),
"docker",
"---\nname: docker\ndescription: Container checks\n---\n# Docker\n\nBody.\n",
);
fs::write(
tmp.path()
.join(".agents")
.join("skills")
.join("docker")
.join("notes.txt"),
"sidecar",
)
.expect("support file");
let listed = list_skills(tmp.path()).expect("list");
assert_eq!(listed.len(), 1);
assert_eq!(listed[0].name, "docker");
assert_eq!(listed[0].description, "Container checks");
assert_eq!(listed[0].support_files.len(), 1);
}
#[test]
fn read_skill_falls_back_to_first_body_line() {
let tmp = tempfile::tempdir().expect("tempdir");
write_skill(
tmp.path(),
"duckdb",
"---\nname: duckdb\n---\n# DuckDB\n\nQuery local and remote files fast.\n\nMore.\n",
);
let doc = read_skill(tmp.path(), "duckdb").expect("read");
assert_eq!(
doc.summary.description,
"Query local and remote files fast."
);
}
#[test]
fn inject_preserves_order_and_dedupes_exact_names() {
let tmp = tempfile::tempdir().expect("tempdir");
write_skill(tmp.path(), "b", "# B\n\nBody b.\n");
write_skill(tmp.path(), "a", "# A\n\nBody a.\n");
let injected = inject_skills(
tmp.path(),
&["b".to_string(), "a".to_string(), "b".to_string()],
InjectFormat::Plain,
)
.expect("inject");
assert_eq!(injected.names, vec!["b", "a"]);
let b_idx = injected.text.find("## Skill: b").expect("b section");
let a_idx = injected.text.find("## Skill: a").expect("a section");
assert!(b_idx < a_idx, "caller order should win");
assert_eq!(injected.text.matches("## Skill: b").count(), 1);
}
#[test]
fn inject_errors_on_missing_skill() {
let tmp = tempfile::tempdir().expect("tempdir");
let err = inject_skills(tmp.path(), &["missing".to_string()], InjectFormat::Plain)
.expect_err("missing skill should fail");
assert!(err.to_string().contains("unknown skill `missing`"));
}
#[test]
fn token_estimation_uses_ceil_bytes_over_3_3() {
assert_eq!(estimate_tokens(0), 0);
assert_eq!(estimate_tokens(33), 10);
assert_eq!(estimate_tokens(34), 11);
}
#[test]
fn write_injected_skills_tempfile_keeps_content_on_disk() {
let tmp = tempfile::tempdir().expect("tempdir");
write_skill(tmp.path(), "docker", "# Docker\n\nBody.\n");
let file =
write_injected_skills_tempfile(tmp.path(), &["docker".to_string()]).expect("file");
let on_disk = fs::read_to_string(file.path()).expect("read tempfile");
assert_eq!(file.names, vec!["docker"]);
assert_eq!(file.bytes, on_disk.len());
assert!(on_disk.contains("## Skill: docker"));
}
}