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}