use std::collections::BTreeMap;
use std::env;
use std::path::{Path, PathBuf};
use defect_agent::error::BoxError;
use defect_agent::tool::SkillTriggers;
use serde::Deserialize;
use crate::frontmatter::{parse_frontmatter, split_frontmatter};
use crate::loader::find_repo_root;
use crate::types::{ConfigError, LoadConfigOptions};
const PROJECT_SKILLS_RELATIVE: &str = ".defect/skills";
const USER_SKILLS_RELATIVE: &str = "defect/skills";
const SKILL_MANIFEST_FILE: &str = "SKILL.md";
const DESCRIPTION_SOFT_LIMIT: usize = 200;
#[derive(Debug, Clone)]
pub struct SkillSpec {
pub name: String,
pub dir: PathBuf,
pub description: String,
pub body: String,
pub always: bool,
pub triggers: SkillTriggers,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct SkillManifestToml {
name: String,
description: String,
#[serde(default)]
always: Option<bool>,
#[serde(default)]
triggers: Option<SkillTriggersToml>,
#[serde(default, alias = "allowed-tools")]
#[allow(
dead_code,
reason = "open-standard placeholder field; currently parsed but not consumed"
)]
allowed_tools: Option<Vec<String>>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct SkillTriggersToml {
#[serde(default)]
globs: Vec<String>,
#[serde(default)]
keywords: Vec<String>,
}
pub fn discover_skills(
opts: &LoadConfigOptions,
) -> Result<BTreeMap<String, SkillSpec>, ConfigError> {
let mut skills = BTreeMap::new();
if let Some(user_dir) = resolve_user_skills_dir(opts) {
scan_skills_dir(&user_dir, &mut skills)?;
}
if let Some(repo_root) = find_repo_root(&opts.cwd) {
scan_skills_dir(&repo_root.join(PROJECT_SKILLS_RELATIVE), &mut skills)?;
}
Ok(skills)
}
fn scan_skills_dir(
skills_dir: &Path,
out: &mut BTreeMap<String, SkillSpec>,
) -> Result<(), ConfigError> {
let entries = match std::fs::read_dir(skills_dir) {
Ok(entries) => entries,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(err) => {
return Err(ConfigError::Io {
path: skills_dir.to_path_buf(),
source: BoxError::new(err),
});
}
};
for entry in entries {
let entry = entry.map_err(|err| ConfigError::Io {
path: skills_dir.to_path_buf(),
source: BoxError::new(err),
})?;
let path = entry.path();
if !path.is_dir() {
continue;
}
let manifest_path = path.join(SKILL_MANIFEST_FILE);
if !manifest_path.is_file() {
continue;
}
let Some(dir_name) = path.file_name().and_then(|n| n.to_str()).map(str::to_owned) else {
continue;
};
let spec = parse_skill(&path, &manifest_path, &dir_name)?;
out.insert(dir_name, spec);
}
Ok(())
}
fn parse_skill(dir: &Path, manifest_path: &Path, dir_name: &str) -> Result<SkillSpec, ConfigError> {
let raw = std::fs::read_to_string(manifest_path).map_err(|err| ConfigError::Io {
path: manifest_path.to_path_buf(),
source: BoxError::new(err),
})?;
let (kind, frontmatter, body) =
split_frontmatter(&raw).ok_or_else(|| ConfigError::Invalid {
path: manifest_path.to_path_buf(),
message: "SKILL.md must start with frontmatter delimited by `+++` (TOML) or `---` \
(YAML)"
.into(),
})?;
let manifest: SkillManifestToml =
parse_frontmatter(kind, frontmatter).map_err(|message| ConfigError::Invalid {
path: manifest_path.to_path_buf(),
message,
})?;
if manifest.name != dir_name {
return Err(ConfigError::Invalid {
path: manifest_path.to_path_buf(),
message: format!(
"skill `name` (`{}`) must match its directory name (`{dir_name}`)",
manifest.name
),
});
}
if manifest.description.len() > DESCRIPTION_SOFT_LIMIT {
tracing::warn!(
skill = %dir_name,
len = manifest.description.len(),
limit = DESCRIPTION_SOFT_LIMIT,
"skill description exceeds the soft length limit; it inflates the L1 manifest budget",
);
}
let triggers = match manifest.triggers {
Some(t) => SkillTriggers {
globs: compile_globs(&t.globs, manifest_path)?,
keywords: t.keywords,
},
None => SkillTriggers::default(),
};
Ok(SkillSpec {
name: manifest.name,
dir: dir.to_path_buf(),
description: manifest.description,
body: body.to_string(),
always: manifest.always.unwrap_or(false),
triggers,
})
}
fn compile_globs(
globs: &[String],
manifest_path: &Path,
) -> Result<Option<globset::GlobSet>, ConfigError> {
if globs.is_empty() {
return Ok(None);
}
let mut builder = globset::GlobSetBuilder::new();
for pat in globs {
let glob = globset::Glob::new(pat).map_err(|err| ConfigError::Invalid {
path: manifest_path.to_path_buf(),
message: format!("invalid trigger glob `{pat}`: {err}"),
})?;
builder.add(glob);
}
let set = builder.build().map_err(|err| ConfigError::Invalid {
path: manifest_path.to_path_buf(),
message: format!("failed to build trigger glob set: {err}"),
})?;
Ok(Some(set))
}
fn resolve_user_skills_dir(opts: &LoadConfigOptions) -> Option<PathBuf> {
if opts.local {
return None;
}
if let Some(xdg) = &opts.xdg_config_home {
return Some(xdg.join(USER_SKILLS_RELATIVE));
}
if let Ok(xdg) = env::var("XDG_CONFIG_HOME") {
return Some(PathBuf::from(xdg).join(USER_SKILLS_RELATIVE));
}
if let Some(home) = &opts.home_dir {
return Some(home.join(".config/defect/skills"));
}
if let Ok(home) = env::var("HOME") {
return Some(PathBuf::from(home).join(".config/defect/skills"));
}
None
}
#[cfg(test)]
mod tests;