Skip to main content

opi_coding_agent/
skill.rs

1//! Skill progressive discovery and registry.
2//!
3//! Provides the discovery and registry system for skills that are progressively
4//! loaded from project, user, explicit, and package resources. Skill metadata
5//! (name, description) is available without loading the full skill body, which
6//! is loaded on demand when needed.
7//!
8//! # Skill Format
9//!
10//! Each skill is a directory containing a `SKILL.md` file with YAML frontmatter:
11//!
12//! ```markdown
13//! ---
14//! name: my-skill
15//! description: What this skill does and when to use it.
16//! disable-model-invocation: false   # optional, defaults to false
17//! ---
18//!
19//! Full skill instructions go here.
20//! ```
21//!
22//! # Name Validation
23//!
24//! Skill names must consist of lowercase ASCII letters (`a-z`), digits (`0-9`),
25//! and hyphens (`-`), with a maximum length of 64 characters.
26//!
27//! # Description Validation
28//!
29//! Descriptions must be non-empty and at most 1024 characters.
30//!
31//! # Progressive Disclosure
32//!
33//! Discovery returns [`SkillResource`] entries containing only the parsed
34//! frontmatter metadata. The full skill body (everything after the frontmatter)
35//! can be loaded on demand via [`SkillResource::load_body`]. This keeps the
36//! initial context small while allowing rich instructions when a skill is
37//! actually invoked.
38//!
39//! # Discovery Precedence
40//!
41//! Skills are discovered from multiple layers using the same precedence model
42//! as extensions (see [`crate::resource`]). Higher precedence values override
43//! lower ones when skill names collide across layers.
44//!
45//! # Unstable
46//!
47//! This module is part of the **unstable 0.x extension API**. Breaking changes
48//! may occur between minor versions without a major version bump.
49
50use std::path::{Path, PathBuf};
51
52// ---------------------------------------------------------------------------
53// Error types
54// ---------------------------------------------------------------------------
55
56/// Errors from skill discovery and manifest parsing.
57#[derive(Debug, thiserror::Error)]
58pub enum SkillDiscoveryError {
59    /// The SKILL.md file has no valid YAML frontmatter delimiters (`---`).
60    #[error("invalid frontmatter in {path}: {reason}")]
61    InvalidFrontmatter { path: PathBuf, reason: String },
62    /// A required field is missing or empty in the frontmatter.
63    #[error("missing required field '{field}' in skill at {path}")]
64    MissingField { field: String, path: PathBuf },
65    /// Two skills in the same precedence layer use the same name.
66    #[error("duplicate skill name '{name}' in discovery layer at {path}")]
67    DuplicateName { name: String, path: PathBuf },
68    /// The skill name contains invalid characters or exceeds the length limit.
69    #[error("invalid skill name in {path}: {reason}")]
70    InvalidName { path: PathBuf, reason: String },
71    /// The description is empty or exceeds the length limit.
72    #[error("invalid description in skill at {path}: {reason}")]
73    InvalidDescription { path: PathBuf, reason: String },
74    /// An I/O error occurred during discovery or body loading.
75    #[error("I/O error discovering skills: {0}")]
76    Io(#[from] std::io::Error),
77}
78
79// ---------------------------------------------------------------------------
80// Constants
81// ---------------------------------------------------------------------------
82
83/// Maximum allowed length for a skill name.
84const MAX_NAME_LEN: usize = 64;
85
86/// Maximum allowed length for a skill description.
87const MAX_DESCRIPTION_LEN: usize = 1024;
88
89// ---------------------------------------------------------------------------
90// Manifest types
91// ---------------------------------------------------------------------------
92
93/// Parsed skill manifest from `SKILL.md` frontmatter.
94#[derive(Debug, Clone, PartialEq)]
95pub struct SkillManifest {
96    /// Skill name. Required, non-empty. Lowercase ASCII letters, digits,
97    /// and hyphens. Maximum 64 characters.
98    pub name: String,
99    /// Human-readable description. Required, non-empty. Maximum 1024
100    /// characters.
101    pub description: String,
102    /// When `true`, the model should not automatically invoke this skill.
103    /// The skill is still available for human-triggered use. Defaults to
104    /// `false`.
105    pub disable_model_invocation: bool,
106}
107
108impl SkillManifest {
109    /// Parse a manifest from the full content of a `SKILL.md` file.
110    ///
111    /// The content must contain YAML frontmatter between `---` delimiters.
112    /// Only the frontmatter is parsed; the body is ignored.
113    pub fn from_skill_md(content: &str, path: &Path) -> Result<Self, SkillDiscoveryError> {
114        let fm = extract_frontmatter(content, path)?;
115
116        let name = parse_field(fm, "name")
117            .map(strip_yaml_quotes)
118            .filter(|n| !n.is_empty())
119            .ok_or_else(|| SkillDiscoveryError::MissingField {
120                field: "name".into(),
121                path: path.to_path_buf(),
122            })?;
123
124        validate_name(name, path)?;
125
126        let description = parse_field(fm, "description")
127            .map(strip_yaml_quotes)
128            .filter(|d| !d.is_empty())
129            .ok_or_else(|| SkillDiscoveryError::MissingField {
130                field: "description".into(),
131                path: path.to_path_buf(),
132            })?;
133
134        validate_description(description, path)?;
135
136        let disable_model_invocation = parse_field(fm, "disable-model-invocation")
137            .map(|v| strip_yaml_quotes(v).eq_ignore_ascii_case("true"))
138            .unwrap_or(false);
139
140        Ok(Self {
141            name: name.to_string(),
142            description: description.to_string(),
143            disable_model_invocation,
144        })
145    }
146}
147
148// ---------------------------------------------------------------------------
149// Frontmatter parsing helpers
150// ---------------------------------------------------------------------------
151
152/// Extract the text between the first two `---` delimiters.
153fn extract_frontmatter<'a>(content: &'a str, path: &Path) -> Result<&'a str, SkillDiscoveryError> {
154    let trimmed = content.trim_start();
155    if !trimmed.starts_with("---") {
156        return Err(SkillDiscoveryError::InvalidFrontmatter {
157            path: path.to_path_buf(),
158            reason: "SKILL.md must start with '---' frontmatter delimiter".into(),
159        });
160    }
161
162    // Skip the opening --- and any trailing whitespace/newline.
163    let after_open = trimmed.get(3..).unwrap_or("");
164    let after_open = after_open.trim_start_matches(['\r', '\n']);
165
166    // Find the closing ---.
167    let close_pos = after_open
168        .find("\n---")
169        .or_else(|| after_open.find("\r\n---"));
170
171    let frontmatter = match close_pos {
172        Some(pos) => &after_open[..pos],
173        None => {
174            return Err(SkillDiscoveryError::InvalidFrontmatter {
175                path: path.to_path_buf(),
176                reason: "SKILL.md frontmatter is missing closing '---' delimiter".into(),
177            });
178        }
179    };
180
181    Ok(frontmatter)
182}
183
184/// Parse a `key: value` field from frontmatter text.
185///
186/// Handles simple single-line `key: value` pairs. Returns `None` if the key
187/// is not found.
188fn parse_field<'a>(frontmatter: &'a str, key: &str) -> Option<&'a str> {
189    let prefix = format!("{key}:");
190    for line in frontmatter.lines() {
191        let trimmed = line.trim();
192        if let Some(rest) = trimmed.strip_prefix(&prefix) {
193            return Some(rest.trim());
194        }
195    }
196    None
197}
198
199/// Strip surrounding single or double quotes from a YAML scalar value.
200///
201/// Handles `""`, `''`, and bare strings. Returns the inner content without
202/// quotes.
203fn strip_yaml_quotes(value: &str) -> &str {
204    if (value.starts_with('"') && value.ends_with('"'))
205        || (value.starts_with('\'') && value.ends_with('\''))
206    {
207        &value[1..value.len().saturating_sub(1)]
208    } else {
209        value
210    }
211}
212
213/// Validate that a skill name contains only allowed characters and is within
214/// length bounds.
215fn validate_name(name: &str, path: &Path) -> Result<(), SkillDiscoveryError> {
216    if name.len() > MAX_NAME_LEN {
217        return Err(SkillDiscoveryError::InvalidName {
218            path: path.to_path_buf(),
219            reason: format!(
220                "name exceeds maximum length of {MAX_NAME_LEN} characters ({} found)",
221                name.len()
222            ),
223        });
224    }
225
226    for ch in name.chars() {
227        let valid = ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-';
228        if !valid {
229            return Err(SkillDiscoveryError::InvalidName {
230                path: path.to_path_buf(),
231                reason: format!(
232                    "name contains invalid character '{ch}': \
233                     only lowercase a-z, 0-9, and hyphens are allowed"
234                ),
235            });
236        }
237    }
238
239    Ok(())
240}
241
242/// Validate that a description is non-empty and within length bounds.
243fn validate_description(desc: &str, path: &Path) -> Result<(), SkillDiscoveryError> {
244    if desc.len() > MAX_DESCRIPTION_LEN {
245        return Err(SkillDiscoveryError::InvalidDescription {
246            path: path.to_path_buf(),
247            reason: format!(
248                "description exceeds maximum length of {MAX_DESCRIPTION_LEN} characters \
249                 ({} found)",
250                desc.len()
251            ),
252        });
253    }
254    Ok(())
255}
256
257// ---------------------------------------------------------------------------
258// Discovery types
259// ---------------------------------------------------------------------------
260
261/// A discovered skill resource with its manifest, filesystem path, and layer
262/// precedence.
263///
264/// The manifest metadata is available immediately. The full skill body can be
265/// loaded on demand via [`load_body`](SkillResource::load_body).
266#[derive(Debug, Clone)]
267pub struct SkillResource {
268    /// The parsed skill manifest (metadata only).
269    pub manifest: SkillManifest,
270    /// Absolute path to the skill directory (containing `SKILL.md`).
271    pub path: PathBuf,
272    /// Path to the `SKILL.md` file itself, for on-demand body loading.
273    pub skill_md_path: PathBuf,
274    /// Precedence value of the discovery layer that produced this resource.
275    pub layer_precedence: u32,
276}
277
278impl SkillResource {
279    /// Load the full skill body (everything after the frontmatter) on demand.
280    ///
281    /// This reads the `SKILL.md` file from disk, strips the frontmatter, and
282    /// returns the remaining content. This is the "progressive disclosure"
283    /// mechanism: metadata is always available, but the full instructions are
284    /// only loaded when the skill is actually invoked.
285    pub fn load_body(&self) -> Result<String, SkillDiscoveryError> {
286        let content = std::fs::read_to_string(&self.skill_md_path)?;
287        Ok(extract_body(&content))
288    }
289}
290
291/// Extract the body (everything after the closing `---`) from a SKILL.md.
292fn extract_body(content: &str) -> String {
293    let trimmed = content.trim_start();
294    // Skip opening ---.
295    let after_open = trimmed.get(3..).unwrap_or("");
296    let after_open = after_open.trim_start_matches(['\r', '\n']);
297
298    // Find closing ---.
299    let close_pos = after_open
300        .find("\n---")
301        .or_else(|| after_open.find("\r\n---"));
302
303    match close_pos {
304        Some(pos) => {
305            // Skip past the closing --- and any trailing whitespace/newline.
306            let after_close = &after_open[pos..];
307            // Skip the newline + ---.
308            let delimiter_end = after_close.find("---").map(|i| i + 3).unwrap_or(pos + 4);
309            let body_start = after_close.get(delimiter_end..).unwrap_or("");
310            body_start.trim_start_matches(['\r', '\n']).to_string()
311        }
312        None => String::new(),
313    }
314}
315
316// ---------------------------------------------------------------------------
317// Discovery
318// ---------------------------------------------------------------------------
319
320/// Discover skills across multiple layers with precedence-based deduplication.
321///
322/// Each layer's scan directory is enumerated for subdirectories containing
323/// `SKILL.md` files. When multiple layers produce skills with the same name,
324/// the one with the highest `precedence` value is kept. Duplicate names within
325/// the same precedence layer are reported as an error.
326///
327/// Returns the deduplicated list of discovered skill resources, sorted by name.
328/// Missing scan directories are silently skipped.
329pub fn discover_skills(
330    layers: &[crate::resource::DiscoveryLayer],
331) -> Result<Vec<SkillResource>, SkillDiscoveryError> {
332    let mut seen: std::collections::HashMap<String, SkillResource> =
333        std::collections::HashMap::new();
334
335    for layer in layers {
336        let scan_dir = layer.scan_dir();
337        if !scan_dir.is_dir() {
338            continue;
339        }
340
341        if scan_dir.join("SKILL.md").exists() {
342            discover_skill_dir(&scan_dir, layer, &mut seen)?;
343            continue;
344        }
345
346        let entries = match std::fs::read_dir(&scan_dir) {
347            Ok(entries) => entries,
348            Err(e) => return Err(SkillDiscoveryError::Io(e)),
349        };
350
351        for entry in entries {
352            let entry = entry?;
353            let path = entry.path();
354
355            // Only process directories.
356            if !path.is_dir() {
357                continue;
358            }
359
360            let skill_md = path.join("SKILL.md");
361            if !skill_md.exists() {
362                continue;
363            }
364
365            discover_skill_dir(&path, layer, &mut seen)?;
366        }
367    }
368
369    let mut resources: Vec<SkillResource> = seen.into_values().collect();
370    resources.sort_by(|a, b| a.manifest.name.cmp(&b.manifest.name));
371    Ok(resources)
372}
373
374fn discover_skill_dir(
375    path: &Path,
376    layer: &crate::resource::DiscoveryLayer,
377    seen: &mut std::collections::HashMap<String, SkillResource>,
378) -> Result<(), SkillDiscoveryError> {
379    let skill_md = path.join("SKILL.md");
380    let content = std::fs::read_to_string(&skill_md)?;
381    let manifest = SkillManifest::from_skill_md(&content, &skill_md)?;
382
383    let canonical = path.canonicalize()?;
384
385    match seen.get(&manifest.name) {
386        Some(existing) if layer.precedence == existing.layer_precedence => {
387            return Err(SkillDiscoveryError::DuplicateName {
388                name: manifest.name,
389                path: canonical,
390            });
391        }
392        Some(existing) if layer.precedence < existing.layer_precedence => return Ok(()),
393        Some(_) | None => {
394            seen.insert(
395                manifest.name.clone(),
396                SkillResource {
397                    manifest,
398                    path: canonical,
399                    skill_md_path: skill_md,
400                    layer_precedence: layer.precedence,
401                },
402            );
403        }
404    }
405
406    Ok(())
407}
408
409// ---------------------------------------------------------------------------
410// Registry
411// ---------------------------------------------------------------------------
412
413/// A registry of discovered skills supporting progressive disclosure.
414///
415/// Built from a list of [`SkillResource`] entries, the registry provides:
416/// - Metadata lookup by name (no body loading)
417/// - Full skill body loading on demand
418/// - Listing for prompt integration (auto-invocable vs all)
419/// - Prompt-formatted skill summaries
420pub struct SkillRegistry {
421    resources: Vec<SkillResource>,
422}
423
424impl SkillRegistry {
425    /// Build a registry from discovered skill resources.
426    pub fn from_resources(resources: Vec<SkillResource>) -> Self {
427        Self { resources }
428    }
429
430    /// Return sorted list of all skill names.
431    pub fn names(&self) -> Vec<&str> {
432        self.resources
433            .iter()
434            .map(|r| r.manifest.name.as_str())
435            .collect()
436    }
437
438    /// Look up a skill by name, returning its resource (metadata only).
439    pub fn get(&self, name: &str) -> Option<&SkillResource> {
440        self.resources.iter().find(|r| r.manifest.name == name)
441    }
442
443    /// Return skills that may be automatically invoked by the model.
444    ///
445    /// Excludes skills with `disable-model-invocation: true`.
446    pub fn auto_invocable(&self) -> Vec<&SkillResource> {
447        self.resources
448            .iter()
449            .filter(|r| !r.manifest.disable_model_invocation)
450            .collect()
451    }
452
453    /// Load the full body of a skill by name.
454    ///
455    /// Returns `None` if the skill is not found or `Some(Err(...))` if the
456    /// file cannot be read.
457    pub fn load_body(&self, name: &str) -> Option<Result<String, SkillDiscoveryError>> {
458        self.get(name).map(|r| r.load_body())
459    }
460
461    /// Format all skill metadata as a string suitable for inclusion in a
462    /// system prompt or command listing.
463    ///
464    /// Each skill is represented as a brief entry with name and description.
465    pub fn format_for_prompt(&self) -> String {
466        if self.resources.is_empty() {
467            return String::new();
468        }
469
470        let mut parts = Vec::new();
471        for r in &self.resources {
472            let flag = if r.manifest.disable_model_invocation {
473                " [manual-only]"
474            } else {
475                ""
476            };
477            parts.push(format!(
478                "- {}: {}{}",
479                r.manifest.name, r.manifest.description, flag
480            ));
481        }
482        parts.join("\n")
483    }
484}