Skip to main content

defect_config/
skills.rs

1//! Skill discovery and parsing.
2//!
3//! Skills are reusable prompt fragments that users configure for an agent: a markdown
4//! body, plus optional `scripts/` / `refs/` resource files in the same directory. When
5//! needed, the model pulls a skill's body into context by name via the `skill` tool
6//! (progressive disclosure L2). See Skill configuration types for the design.
7//!
8//! ## File layout (aligned with the Anthropic / Codex Agent Skills open standard)
9//!
10//! `<agents-or-skills-dir>/skills/<name>/SKILL.md` — the skill body follows the
11//! frontmatter (`+++` ⇒ TOML, `---` ⇒ YAML). The skill name is the directory name. The
12//! directory may contain sibling `scripts/` / `refs/` subdirectories; the model reads
13//! them on demand using ordinary `bash` / `read_file` tools (L3). This module only parses
14//! `SKILL.md` and does not scan resource files.
15//!
16//! Shares frontmatter parsing ([`crate::frontmatter`]) and the layered discovery skeleton
17//! with subagent profiles ([`crate::profiles`]), but the **semantics differ**:
18//! - A profile "spawns an isolated sub-agent to execute a task" (`spawn_agent`'s `task`);
19//! - A skill "injects instructions into the current conversation" (`skill` tool's
20//!   `name`).
21//!
22//! ## Layered discovery
23//!
24//! Same structure as the main config / profiles:
25//! - User layer: `<XDG_CONFIG_HOME>/defect/skills/` (or `~/.config/defect/skills/`)
26//! - Project layer: `<repo_root>/.defect/skills/`
27//!
28//! When the same name exists in both layers, the **project layer overrides the user
29//! layer** (full replacement, not merged — the body is an indivisible markdown block, so
30//! field-level merging has no natural semantics).
31
32use std::collections::BTreeMap;
33use std::env;
34use std::path::{Path, PathBuf};
35
36use defect_agent::error::BoxError;
37use defect_agent::tool::SkillTriggers;
38use serde::Deserialize;
39
40use crate::frontmatter::{parse_frontmatter, split_frontmatter};
41use crate::loader::find_repo_root;
42use crate::types::{ConfigError, LoadConfigOptions};
43
44/// Project-level skill directory (relative to repo root). Mirrors [`crate::profiles`]'s
45/// `PROJECT_AGENTS_RELATIVE` (`.defect/agents`).
46const PROJECT_SKILLS_RELATIVE: &str = ".defect/skills";
47/// User-level skill directory (relative to `XDG_CONFIG_HOME`).
48const USER_SKILLS_RELATIVE: &str = "defect/skills";
49/// Mandatory manifest filename inside every skill directory (aligned with the Anthropic /
50/// Codex open standard).
51const SKILL_MANIFEST_FILE: &str = "SKILL.md";
52/// Soft length limit for skill `description` — exceeding it only warns, does not truncate
53/// (cost control for inclusion in the L1 manifest, following Anthropic's practice).
54const DESCRIPTION_SOFT_LIMIT: usize = 200;
55
56/// A parsed skill.
57///
58/// Produced by [`discover_skills`]; consumed by the `skill` tool — `name` / `description`
59/// go into the tool schema's manifest, `body` is returned as the tool result when the
60/// model fetches by name, and `dir` gives the model the absolute root for resource files
61/// (`scripts/` / `refs/`). `always` / `triggers` drive automatic activation (see
62/// [`SkillTriggers`]), and are projected into the agent-side `SkillEntry` during CLI
63/// assembly.
64#[derive(Debug, Clone)]
65pub struct SkillSpec {
66    /// Skill name (directory name). Value of the `name` enum in the `skill` tool.
67    pub name: String,
68    /// Absolute path to the skill directory, used by the `skill` tool to backfill
69    /// resource file paths for the model.
70    pub dir: PathBuf,
71    /// Selection-phase description – included in the L1 manifest so the model can decide
72    /// whether to load it. Required.
73    pub description: String,
74    /// The full body of `SKILL.md` after stripping the frontmatter (content loaded at
75    /// L2).
76    pub body: String,
77    /// `always: true` ⇒ body is injected directly into the system prompt at session start
78    /// (always-on).
79    pub always: bool,
80    /// Auto-activation trigger conditions (globs are compiled into `GlobSet`/keywords
81    /// during parsing). Reuses the agent-side type; CLI projection clones directly.
82    pub triggers: SkillTriggers,
83}
84
85/// Raw deserialization form of `SKILL.md` frontmatter.
86///
87/// Keeps `deny_unknown_fields` (consistent with [`crate::profiles`]) to catch
88/// misspellings of required fields (typos like `naem` / `desciption` are not silently
89/// ignored). The `always` / `triggers` fields from the Agent Skills open standard are now
90/// consumed (auto-activation), while `allowed_tools` remains an explicit
91/// placeholder reserved for tool gating.
92///
93/// Trade-off of explicit listing (vs. deny vs. fully open): deny would break the selling
94/// point that "users can drop in an existing Anthropic / Codex-format skill and it just
95/// works"; fully open loses typo protection. Explicitly listing the documented
96/// fields balances both — consumed fields go from "ignored" to "consumed", with backward
97/// compatibility for user files.
98#[derive(Debug, Deserialize)]
99#[serde(deny_unknown_fields)]
100struct SkillManifestToml {
101    /// Required, and must match the directory name — the manifest display and the `skill`
102    /// tool argument use the same name; a mismatch would cause the model to look up the
103    /// skill by the manifest name and fail to find it.
104    name: String,
105    /// Required – goes into the L1 manifest. If missing, serde reports "missing field
106    /// `description`", which [`discover_skills`] wraps into a hard error with the file
107    /// path.
108    description: String,
109    /// `true` means this skill's body is directly appended to the system prompt at
110    /// session start (always-on).
111    #[serde(default)]
112    always: Option<bool>,
113    /// Automatic activation trigger conditions (by file glob or prompt keyword).
114    #[serde(default)]
115    triggers: Option<SkillTriggersToml>,
116    /// Placeholder for ACP client tool gating (inspired by Anthropic's
117    /// `allowed-tools`, so the hyphenated form is also accepted). Currently parsed but
118    /// not consumed; reserved for tool gating.
119    #[serde(default, alias = "allowed-tools")]
120    #[allow(
121        dead_code,
122        reason = "open-standard placeholder field; currently parsed but not consumed"
123    )]
124    allowed_tools: Option<Vec<String>>,
125}
126
127/// `[triggers]` sub-table: auto-activation conditions. `globs` is compiled into a
128/// [`globset::GlobSet`] in [`parse_skill`] (bad globs fail fast).
129#[derive(Debug, Deserialize)]
130#[serde(deny_unknown_fields)]
131struct SkillTriggersToml {
132    #[serde(default)]
133    globs: Vec<String>,
134    #[serde(default)]
135    keywords: Vec<String>,
136}
137
138/// Discover and parse all available skills.
139///
140/// User-level skills are scanned first, then project-level skills; a project-level skill
141/// with the same name overrides the user-level one. For any skill, a failed `SKILL.md`
142/// parse, missing frontmatter, or a `name` that does not match the directory name is a
143/// hard error (fail loud, do not silently skip bad skills — same as the `profiles`
144/// module, unlike the old design's warn-and-skip). Non-skill items in the directory
145/// (subdirectories without `SKILL.md`, non-directory entries) are silently skipped.
146///
147/// # Errors
148/// - [`ConfigError::Io`]: reading `SKILL.md` failed
149/// - [`ConfigError::Invalid`]: `SKILL.md` missing frontmatter, parse failure, missing
150///   `name` / `description`, or `name` ≠ directory name
151pub fn discover_skills(
152    opts: &LoadConfigOptions,
153) -> Result<BTreeMap<String, SkillSpec>, ConfigError> {
154    let mut skills = BTreeMap::new();
155
156    // User-layer first, project-layer second — later writes overwrite earlier ones, so
157    // project settings override user settings.
158    if let Some(user_dir) = resolve_user_skills_dir(opts) {
159        scan_skills_dir(&user_dir, &mut skills)?;
160    }
161    if let Some(repo_root) = find_repo_root(&opts.cwd) {
162        scan_skills_dir(&repo_root.join(PROJECT_SKILLS_RELATIVE), &mut skills)?;
163    }
164
165    Ok(skills)
166}
167
168/// Scan a `skills/` directory, parse each skill into a [`SkillSpec`], and write it into
169/// `out` (when the same name appears across layers, the current layer overwrites previous
170/// ones — the caller passes directories in user→project order to implement "project
171/// overrides user"). If the directory does not exist, this is a no-op.
172fn scan_skills_dir(
173    skills_dir: &Path,
174    out: &mut BTreeMap<String, SkillSpec>,
175) -> Result<(), ConfigError> {
176    let entries = match std::fs::read_dir(skills_dir) {
177        Ok(entries) => entries,
178        // It is normal for the directory to not exist (the user has not created any
179        // skills) — this is not an error.
180        Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(()),
181        Err(err) => {
182            return Err(ConfigError::Io {
183                path: skills_dir.to_path_buf(),
184                source: BoxError::new(err),
185            });
186        }
187    };
188
189    for entry in entries {
190        let entry = entry.map_err(|err| ConfigError::Io {
191            path: skills_dir.to_path_buf(),
192            source: BoxError::new(err),
193        })?;
194        let path = entry.path();
195        if !path.is_dir() {
196            // Skills only use the dir-per-skill layout — skip non-directory entries.
197            continue;
198        }
199        let manifest_path = path.join(SKILL_MANIFEST_FILE);
200        if !manifest_path.is_file() {
201            // Subdirectories without a SKILL.md are not skills — skip silently.
202            continue;
203        }
204        let Some(dir_name) = path.file_name().and_then(|n| n.to_str()).map(str::to_owned) else {
205            continue;
206        };
207        let spec = parse_skill(&path, &manifest_path, &dir_name)?;
208        out.insert(dir_name, spec);
209    }
210
211    Ok(())
212}
213
214/// Parse a skill directory: read `SKILL.md`, split the frontmatter, verify that `name`
215/// matches the directory name, and treat the body as the content after the frontmatter.
216fn parse_skill(dir: &Path, manifest_path: &Path, dir_name: &str) -> Result<SkillSpec, ConfigError> {
217    let raw = std::fs::read_to_string(manifest_path).map_err(|err| ConfigError::Io {
218        path: manifest_path.to_path_buf(),
219        source: BoxError::new(err),
220    })?;
221    let (kind, frontmatter, body) =
222        split_frontmatter(&raw).ok_or_else(|| ConfigError::Invalid {
223            path: manifest_path.to_path_buf(),
224            message: "SKILL.md must start with frontmatter delimited by `+++` (TOML) or `---` \
225                      (YAML)"
226                .into(),
227        })?;
228
229    let manifest: SkillManifestToml =
230        parse_frontmatter(kind, frontmatter).map_err(|message| ConfigError::Invalid {
231            path: manifest_path.to_path_buf(),
232            message,
233        })?;
234
235    if manifest.name != dir_name {
236        return Err(ConfigError::Invalid {
237            path: manifest_path.to_path_buf(),
238            message: format!(
239                "skill `name` (`{}`) must match its directory name (`{dir_name}`)",
240                manifest.name
241            ),
242        });
243    }
244
245    if manifest.description.len() > DESCRIPTION_SOFT_LIMIT {
246        tracing::warn!(
247            skill = %dir_name,
248            len = manifest.description.len(),
249            limit = DESCRIPTION_SOFT_LIMIT,
250            "skill description exceeds the soft length limit; it inflates the L1 manifest budget",
251        );
252    }
253
254    // Process triggers: compile `globs` into a `GlobSet` (invalid globs hard-fail
255    // immediately, with the skill path), and keep `keywords` as-is. No `[triggers]` table
256    // means default empty triggers.
257    let triggers = match manifest.triggers {
258        Some(t) => SkillTriggers {
259            globs: compile_globs(&t.globs, manifest_path)?,
260            keywords: t.keywords,
261        },
262        None => SkillTriggers::default(),
263    };
264
265    Ok(SkillSpec {
266        name: manifest.name,
267        dir: dir.to_path_buf(),
268        description: manifest.description,
269        body: body.to_string(),
270        always: manifest.always.unwrap_or(false),
271        triggers,
272    })
273}
274
275/// Compiles `triggers.globs` into a [`globset::GlobSet`]. Empty input ⇒ `None` (no glob
276/// triggers).
277/// Any invalid glob ⇒ [`ConfigError::Invalid`] with the `SKILL.md` path and the globset
278/// error
279/// (fails loudly, does not silently swallow bad globs).
280fn compile_globs(
281    globs: &[String],
282    manifest_path: &Path,
283) -> Result<Option<globset::GlobSet>, ConfigError> {
284    if globs.is_empty() {
285        return Ok(None);
286    }
287    let mut builder = globset::GlobSetBuilder::new();
288    for pat in globs {
289        let glob = globset::Glob::new(pat).map_err(|err| ConfigError::Invalid {
290            path: manifest_path.to_path_buf(),
291            message: format!("invalid trigger glob `{pat}`: {err}"),
292        })?;
293        builder.add(glob);
294    }
295    let set = builder.build().map_err(|err| ConfigError::Invalid {
296        path: manifest_path.to_path_buf(),
297        message: format!("failed to build trigger glob set: {err}"),
298    })?;
299    Ok(Some(set))
300}
301
302/// Resolves the user-level `skills/` directory. Follows the same priority as
303/// [`crate::profiles`]'s `resolve_user_agents_dir` (`XDG_CONFIG_HOME` → `HOME/.config`);
304/// returns `None` when not found (if neither `XDG_CONFIG_HOME` nor `HOME` is set, user
305/// skills are simply absent, not a hard error).
306fn resolve_user_skills_dir(opts: &LoadConfigOptions) -> Option<PathBuf> {
307    // `--local`: ignore the user-level skills directory.
308    if opts.local {
309        return None;
310    }
311    if let Some(xdg) = &opts.xdg_config_home {
312        return Some(xdg.join(USER_SKILLS_RELATIVE));
313    }
314    if let Ok(xdg) = env::var("XDG_CONFIG_HOME") {
315        return Some(PathBuf::from(xdg).join(USER_SKILLS_RELATIVE));
316    }
317    if let Some(home) = &opts.home_dir {
318        return Some(home.join(".config/defect/skills"));
319    }
320    if let Ok(home) = env::var("HOME") {
321        return Some(PathBuf::from(home).join(".config/defect/skills"));
322    }
323    None
324}
325
326#[cfg(test)]
327mod tests;