fn parse_skill_meta(path: &std::path::Path) -> Option<(String, String)> {
let content = std::fs::read_to_string(path).ok()?;
let rest = content.trim_start().strip_prefix("---")?;
let end = rest.find("\n---")?;
let (mut name, mut desc) = (None, None);
for line in rest[..end].lines() {
if let Some(v) = line.strip_prefix("name:") {
name = Some(v.trim().trim_matches(['"', '\'']).to_string());
} else if let Some(v) = line.strip_prefix("description:") {
desc = Some(v.trim().trim_matches(['"', '\'']).to_string());
}
}
let name = name?;
if name.is_empty() {
return None;
}
Some((name, desc.unwrap_or_default()))
}
fn disabled_skills_path() -> Option<std::path::PathBuf> {
std::env::var_os("HOME").map(|h| std::path::Path::new(&h).join(".a3s/disabled_skills"))
}
pub(crate) fn load_disabled_skills() -> std::collections::HashSet<String> {
disabled_skills_path()
.and_then(|p| std::fs::read_to_string(p).ok())
.map(|s| {
s.lines()
.map(str::trim)
.filter(|l| !l.is_empty())
.map(String::from)
.collect()
})
.unwrap_or_default()
}
pub(crate) fn save_disabled_skills(set: &std::collections::HashSet<String>) {
if let Some(p) = disabled_skills_path() {
if let Some(parent) = p.parent() {
let _ = std::fs::create_dir_all(parent);
}
let mut names: Vec<&String> = set.iter().collect();
names.sort();
let body = names
.iter()
.map(|s| s.as_str())
.collect::<Vec<_>>()
.join("\n");
let _ = std::fs::write(p, body);
}
}
pub(crate) fn load_skills(dirs: &[std::path::PathBuf]) -> Vec<(String, String)> {
let mut out: Vec<(String, String)> = Vec::new();
for d in dirs {
let Ok(rd) = std::fs::read_dir(d) else {
continue;
};
for e in rd.flatten() {
let p = e.path();
let md = if p.is_dir() {
p.join("SKILL.md")
} else if p.extension().and_then(|x| x.to_str()) == Some("md") {
p.clone()
} else {
continue;
};
if md.is_file() {
if let Some(meta) = parse_skill_meta(&md) {
out.push(meta);
}
}
}
}
out.sort();
out.dedup();
out
}
pub(crate) fn count_skill_files(dirs: &[std::path::PathBuf]) -> usize {
let mut n = 0;
for d in dirs {
if let Ok(rd) = std::fs::read_dir(d) {
for e in rd.flatten() {
let p = e.path();
let is_skill_dir = p.is_dir() && p.join("SKILL.md").is_file();
let is_flat_md = p.extension().and_then(|x| x.to_str()) == Some("md");
if is_skill_dir || is_flat_md {
n += 1;
}
}
}
}
n
}
const OKF_SKILL: &str = include_str!("../../../skills/okf.md");
pub(crate) fn ensure_builtin_skills_dir() -> Option<std::path::PathBuf> {
let root = std::path::PathBuf::from(std::env::var_os("HOME")?)
.join(".a3s")
.join("cli-skills");
ensure_builtin_skills_dir_at(&root).ok()?;
Some(root)
}
const BUILTIN_SKILLS: &[(&str, &str)] = &[("okf", OKF_SKILL)];
fn ensure_builtin_skills_dir_at(root: &std::path::Path) -> std::io::Result<()> {
for (name, body) in BUILTIN_SKILLS {
let dir = root.join(name);
std::fs::create_dir_all(&dir)?;
std::fs::write(dir.join("SKILL.md"), body)?;
}
if let Ok(rd) = std::fs::read_dir(root) {
for entry in rd.flatten() {
let p = entry.path();
let is_builtin = p
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| BUILTIN_SKILLS.iter().any(|(name, _)| *name == n));
if p.is_dir() && !is_builtin {
let _ = std::fs::remove_dir_all(&p);
}
}
}
Ok(())
}
pub(crate) fn agent_skill_dirs(workspace: &str) -> Vec<std::path::PathBuf> {
let mut dirs: Vec<std::path::PathBuf> = Vec::new();
let project_a3s = std::path::Path::new(workspace).join(".a3s").join("skills");
if project_a3s.is_dir() {
dirs.push(project_a3s);
}
let configured_a3s = crate::config::skill_dir();
if configured_a3s.is_dir() {
dirs.push(configured_a3s);
}
for root in [".claude", ".codex"] {
let project = std::path::Path::new(workspace).join(root).join("skills");
if project.is_dir() {
dirs.push(project);
}
}
if let Some(home) = std::env::var_os("HOME") {
let home = std::path::PathBuf::from(home);
for root in [".claude", ".codex"] {
let personal = home.join(root).join("skills");
if personal.is_dir() {
dirs.push(personal);
}
}
collect_skills_dirs(&home.join(".claude/plugins"), 0, 6, &mut dirs);
collect_skills_dirs(&home.join(".codex/plugins"), 0, 6, &mut dirs);
}
dirs.sort();
dirs.dedup();
dirs
}
fn collect_skills_dirs(
dir: &std::path::Path,
depth: usize,
max: usize,
out: &mut Vec<std::path::PathBuf>,
) {
if depth > max {
return;
}
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for e in entries.flatten() {
let p = e.path();
if !p.is_dir() {
continue;
}
let name = p.file_name().and_then(|n| n.to_str()).unwrap_or("");
if name == "skills" {
out.push(p);
} else if !name.starts_with('.') && name != "node_modules" {
collect_skills_dirs(&p, depth + 1, max, out);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn okf_skill_materializes_and_parses() {
let dir = std::env::temp_dir().join(format!("a3s-okf-skill-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("kb-compile")).unwrap();
std::fs::write(dir.join("kb-compile/SKILL.md"), "stale").unwrap();
ensure_builtin_skills_dir_at(&dir).unwrap();
assert!(
!dir.join("kb-compile").exists(),
"stale kb-compile dir should be pruned so /kb-compile can't resurface"
);
let skills = load_skills(std::slice::from_ref(&dir));
assert!(
skills.iter().any(|(n, _)| n == "okf"),
"okf skill not discovered: {skills:?}"
);
assert!(
!skills.iter().any(|(n, _)| n == "kb-compile"),
"stale kb-compile must not be discovered: {skills:?}"
);
let md = std::fs::read_to_string(dir.join("okf/SKILL.md")).unwrap();
let skill = a3s_code_core::skills::Skill::parse(&md)
.expect("core skill loader must accept the bundled okf SKILL.md");
assert_eq!(skill.name, "okf");
assert!(
skill.allowed_tools.is_some(),
"allowed-tools must parse (fail-secure) so the skill is usable"
);
let _ = std::fs::remove_dir_all(&dir);
}
}