use std::collections::HashMap;
use std::path::{Path, PathBuf};
use locode_host::{SkillsExtraEntry, find_root_from_markers, locode_home};
use crate::frontmatter;
pub const SKILL_FILE: &str = "SKILL.md";
pub const PROJECT_SKILLS_DIR: [&str; 2] = [".agents", "skills"];
const MAX_NAME_LEN: usize = 64;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum SkillScope {
Project,
User,
Extra,
}
impl SkillScope {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
SkillScope::Project => "project",
SkillScope::User => "user",
SkillScope::Extra => "extra",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Skill {
pub name: String,
pub scope: SkillScope,
pub description: String,
pub when_to_use: Option<String>,
pub path: PathBuf,
pub disable_model_invocation: bool,
pub user_invocable: bool,
}
impl Skill {
#[must_use]
pub fn display_name(&self, ambiguous: bool) -> String {
if ambiguous {
format!("{}:{}", self.scope.as_str(), self.name)
} else {
self.name.clone()
}
}
}
#[derive(Debug, Clone, Default)]
pub struct SkillsConfig {
pub enabled: bool,
pub root_markers: Vec<String>,
pub extends_dirs: Vec<PathBuf>,
pub extra: Vec<SkillsExtraEntry>,
pub extra_roots: Vec<PathBuf>,
}
impl SkillsConfig {
#[must_use]
pub fn enabled() -> Self {
Self {
enabled: true,
root_markers: vec![".git".to_string()],
extends_dirs: Vec::new(),
extra_roots: Vec::new(),
extra: Vec::new(),
}
}
}
#[derive(Debug, Clone, Default)]
pub struct DiscoveredSkills {
pub skills: Vec<Skill>,
pub warnings: Vec<String>,
}
#[must_use]
pub fn discover(cwd: &Path, cfg: &SkillsConfig) -> DiscoveredSkills {
let user_root = cfg
.enabled
.then(|| locode_home().ok().map(|home| home.join("skills")))
.flatten();
discover_impl(cwd, cfg, user_root.as_deref())
}
fn discover_impl(cwd: &Path, cfg: &SkillsConfig, user_root: Option<&Path>) -> DiscoveredSkills {
if !cfg.enabled {
return DiscoveredSkills::default();
}
let mut warnings = Vec::new();
let mut found: Vec<Skill> = Vec::new();
let root = find_root_from_markers(cwd, &cfg.root_markers, None);
collect_root(
&root.join(PROJECT_SKILLS_DIR[0]).join(PROJECT_SKILLS_DIR[1]),
SkillScope::Project,
&mut found,
&mut warnings,
);
if let Some(user_root) = user_root {
collect_root(user_root, SkillScope::User, &mut found, &mut warnings);
}
for dir in &cfg.extends_dirs {
collect_root(
&dir.join("skills"),
SkillScope::User,
&mut found,
&mut warnings,
);
}
for dir in &cfg.extra_roots {
collect_root(
&dir.join(PROJECT_SKILLS_DIR[0]).join(PROJECT_SKILLS_DIR[1]),
SkillScope::Project,
&mut found,
&mut warnings,
);
}
for entry in &cfg.extra {
match entry {
SkillsExtraEntry::Skill(dir) => {
collect_skill_dir(dir, SkillScope::Extra, &mut found, &mut warnings);
}
SkillsExtraEntry::Folder(dir) => {
collect_root(dir, SkillScope::Extra, &mut found, &mut warnings);
}
}
}
let mut seen: Vec<(SkillScope, String)> = Vec::new();
found.retain(|s| {
let key = (s.scope, s.name.clone());
if seen.contains(&key) {
false
} else {
seen.push(key);
true
}
});
found.retain(|s| !s.disable_model_invocation);
DiscoveredSkills {
skills: found,
warnings,
}
}
#[must_use]
pub fn ambiguous_names(skills: &[Skill]) -> Vec<String> {
let mut counts: HashMap<&str, usize> = HashMap::new();
for s in skills {
*counts.entry(s.name.as_str()).or_default() += 1;
}
let mut out: Vec<String> = counts
.into_iter()
.filter(|&(_, n)| n > 1)
.map(|(n, _)| n.to_string())
.collect();
out.sort();
out
}
fn collect_root(root: &Path, scope: SkillScope, out: &mut Vec<Skill>, warnings: &mut Vec<String>) {
let Ok(read) = std::fs::read_dir(root) else {
return; };
let mut dirs: Vec<PathBuf> = read
.flatten()
.map(|e| e.path())
.filter(|p| p.is_dir())
.collect();
dirs.sort(); for dir in dirs {
collect_skill_dir(&dir, scope, out, warnings);
}
}
fn collect_skill_dir(
dir: &Path,
scope: SkillScope,
out: &mut Vec<Skill>,
warnings: &mut Vec<String>,
) {
let path = dir.join(SKILL_FILE);
if !path.is_file() {
return;
}
let Ok(source) = std::fs::read_to_string(&path) else {
warnings.push(format!("skills: cannot read {}; skipped", path.display()));
return;
};
let Some((fm, _body)) = frontmatter::parse(&source) else {
warnings.push(format!(
"skills: {} has no `---` frontmatter block; skipped",
path.display()
));
return;
};
let raw_name = fm.name.as_deref().unwrap_or_default();
let name = if raw_name.trim().is_empty() {
dir.file_name().map(|n| n.to_string_lossy().to_string())
} else {
Some(raw_name.to_string())
}
.map(|n| normalize_name(&n))
.filter(|n| !n.is_empty());
let Some(name) = name else {
warnings.push(format!(
"skills: {} has no usable name (frontmatter `name` and directory name both \
normalize to empty); skipped",
path.display()
));
return;
};
let description = fm
.description
.as_deref()
.map(|d| d.trim().to_string())
.unwrap_or_default();
if description.is_empty() {
warnings.push(format!(
"skills: {} has no `description`; skipped (the description is what the \
model matches a task against)",
path.display()
));
return;
}
out.push(Skill {
name,
scope,
description,
when_to_use: fm
.when_to_use
.as_deref()
.map(|w| w.trim().to_string())
.filter(|w| !w.is_empty()),
path,
disable_model_invocation: fm.disable_model_invocation(),
user_invocable: fm.user_invocable(),
});
}
#[must_use]
pub fn normalize_name(name: &str) -> String {
let mut out = String::with_capacity(name.len());
for c in name.trim().chars() {
let c = c.to_ascii_lowercase();
let c = if c.is_ascii_lowercase() || c.is_ascii_digit() {
c
} else {
'-'
};
if c == '-' && out.ends_with('-') {
continue;
}
out.push(c);
}
let out = out.trim_matches('-');
out.chars().take(MAX_NAME_LEN).collect::<String>()
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn tmp() -> (TempDir, PathBuf) {
let d = TempDir::new().unwrap();
let p = std::fs::canonicalize(d.path()).unwrap();
(d, p)
}
fn skill(root: &Path, name: &str, frontmatter: &str) {
let dir = root.join(name);
fs::create_dir_all(&dir).unwrap();
fs::write(
dir.join(SKILL_FILE),
format!("---\n{frontmatter}\n---\n# {name}\n"),
)
.unwrap();
}
fn cfg() -> SkillsConfig {
SkillsConfig::enabled()
}
fn discover_no_home(cwd: &Path, cfg: &SkillsConfig) -> DiscoveredSkills {
discover_impl(cwd, cfg, None)
}
fn names(d: &DiscoveredSkills) -> Vec<&str> {
d.skills.iter().map(|s| s.name.as_str()).collect()
}
#[test]
fn finds_project_skills_sorted_and_reads_the_five_keys() {
let (_g, repo) = tmp();
fs::create_dir(repo.join(".git")).unwrap();
let root = repo.join(".agents/skills");
skill(&root, "zebra", "name: zebra\ndescription: Z");
skill(
&root,
"commit",
"name: commit\ndescription: Make a commit\nwhen-to-use: on push\nuser-invocable: false",
);
let got = discover_no_home(&repo, &cfg());
assert_eq!(names(&got), vec!["commit", "zebra"], "sorted, stable");
let c = &got.skills[0];
assert_eq!(c.scope, SkillScope::Project);
assert_eq!(c.description, "Make a commit");
assert_eq!(c.when_to_use.as_deref(), Some("on push"));
assert!(!c.user_invocable, "parsed, even though it is inert in v1");
assert!(c.path.ends_with("commit/SKILL.md"));
}
#[test]
fn name_falls_back_to_the_directory_and_is_slugified() {
let (_g, repo) = tmp();
fs::create_dir(repo.join(".git")).unwrap();
let root = repo.join(".agents/skills");
skill(&root, "My_Skill", "description: D"); skill(&root, "other", "name: Review PR\ndescription: D");
let got = discover_no_home(&repo, &cfg());
let mut n = names(&got);
n.sort_unstable();
assert_eq!(n, vec!["my-skill", "review-pr"]);
}
#[test]
fn disable_model_invocation_hides_the_skill_entirely() {
let (_g, repo) = tmp();
fs::create_dir(repo.join(".git")).unwrap();
let root = repo.join(".agents/skills");
skill(&root, "shown", "description: D");
skill(
&root,
"hidden",
"description: D\ndisable-model-invocation: true",
);
let got = discover_no_home(&repo, &cfg());
assert_eq!(names(&got), vec!["shown"]);
}
#[test]
fn a_broken_skill_is_skipped_with_a_warning_and_does_not_stop_the_others() {
let (_g, repo) = tmp();
fs::create_dir(repo.join(".git")).unwrap();
let root = repo.join(".agents/skills");
skill(&root, "good", "description: D");
let bad = root.join("bad");
fs::create_dir_all(&bad).unwrap();
fs::write(bad.join(SKILL_FILE), "# just markdown\n").unwrap();
skill(&root, "nodesc", "name: nodesc");
let got = discover_no_home(&repo, &cfg());
assert_eq!(names(&got), vec!["good"]);
let w = got.warnings.join(" | ");
assert!(w.contains("frontmatter"), "{w}");
assert!(w.contains("description"), "{w}");
}
#[test]
fn precedence_within_a_scope_drops_the_loser_across_scopes_keeps_both() {
let (_g, repo) = tmp();
fs::create_dir(repo.join(".git")).unwrap();
skill(
&repo.join(".agents/skills"),
"commit",
"description: project",
);
let (_e, team) = tmp();
skill(&team.join("skills"), "commit", "description: team");
let (_x, extra) = tmp();
skill(&extra, "commit", "description: extra");
let mut c = cfg();
c.extends_dirs = vec![team];
c.extra = vec![SkillsExtraEntry::Folder(extra)];
let got = discover_no_home(&repo, &c);
let by_scope: Vec<_> = got
.skills
.iter()
.map(|s| (s.scope, &*s.description))
.collect();
assert_eq!(
by_scope,
vec![
(SkillScope::Project, "project"),
(SkillScope::User, "team"),
(SkillScope::Extra, "extra"),
],
"one per scope survives"
);
assert_eq!(ambiguous_names(&got.skills), vec!["commit"]);
assert_eq!(got.skills[1].display_name(true), "user:commit");
}
#[test]
fn a_single_skill_extra_entry_points_straight_at_the_skill_dir() {
let (_g, repo) = tmp();
fs::create_dir(repo.join(".git")).unwrap();
let (_x, dir) = tmp();
let one = dir.join("oneoff");
fs::create_dir_all(&one).unwrap();
fs::write(one.join(SKILL_FILE), "---\ndescription: D\n---\n").unwrap();
let mut c = cfg();
c.extra = vec![SkillsExtraEntry::Skill(one)];
let got = discover_no_home(&repo, &c);
assert_eq!(names(&got), vec!["oneoff"]);
assert_eq!(got.skills[0].scope, SkillScope::Extra);
}
#[test]
fn disabled_config_touches_nothing() {
let (_g, repo) = tmp();
fs::create_dir(repo.join(".git")).unwrap();
skill(&repo.join(".agents/skills"), "commit", "description: D");
let got = discover_no_home(&repo, &SkillsConfig::default());
assert!(got.skills.is_empty() && got.warnings.is_empty());
}
#[test]
fn user_root_and_extends_share_the_user_scope_with_the_home_root_winning() {
let (_g, repo) = tmp();
fs::create_dir(repo.join(".git")).unwrap();
let (_h, home) = tmp();
skill(&home.join("skills"), "commit", "description: home");
let (_e, team) = tmp();
skill(&team.join("skills"), "commit", "description: team");
let mut c = cfg();
c.extends_dirs = vec![team];
let got = discover_impl(&repo, &c, Some(&home.join("skills")));
assert_eq!(
got.skills
.iter()
.map(|s| &*s.description)
.collect::<Vec<_>>(),
vec!["home"],
"same scope ⇒ the home root shadows the extended dotfolder"
);
}
#[test]
fn project_skills_come_from_dot_agents_not_dot_locode() {
let (_g, repo) = tmp();
fs::create_dir(repo.join(".git")).unwrap();
skill(&repo.join(".agents/skills"), "shared", "description: D");
skill(&repo.join(".locode/skills"), "ours", "description: D");
let got = discover_no_home(&repo, &cfg());
assert_eq!(
names(&got),
vec!["shared"],
"`.locode/skills` is not a project skills root"
);
}
#[test]
fn slug_rules() {
assert_eq!(normalize_name("Review PR"), "review-pr");
assert_eq!(normalize_name("tool-v1.2"), "tool-v1-2");
assert_eq!(normalize_name("__weird__"), "weird");
assert_eq!(normalize_name("---"), "");
assert_eq!(normalize_name(&"a".repeat(100)).len(), 64);
}
}