Files
optimclaw/src/skills/parser.rs
T
c1926c83d9 fix: skills module audit cleanup (#173)
* fix: skills module audit cleanup — deduplicate loading, async gating, pre-compute scoring fields

Address 7 issues from the skills module audit (#157–#163):

- Extract shared `load_and_validate_skill` helper, eliminating ~90 lines
  of duplication between `load_skill_md` and `load_skill_md_standalone`
- Wrap blocking gating subprocess calls (`which`/`where`) in
  `tokio::task::spawn_blocking` to avoid blocking the async runtime
- Remove dead `SkillParseError::FileTooLarge` and `SkillSource::Registry`
- Replace `HashMap<String, ()>` with `HashSet<String>` in discovery
- Fix misleading doc comment and unnecessary `ref` clone pattern
- Use `CARGO_PKG_VERSION` for catalog HTTP user-agent instead of
  hardcoded "0.1"
- Pre-compute lowercased keywords/tags at load time to avoid
  per-message allocation in the scoring hot path
- Add tests for flat SKILL.md layout, mixed layouts, and lowercased
  field population

Closes #157, closes #158, closes #159, closes #160, closes #161,
closes #162, closes #163

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: address PR #173 review feedback

- Distinguish cancel vs panic in spawn_blocking JoinError and include
  error details in the gating failure message (Copilot review)
- Restore lowercased_keywords/lowercased_tags to `pub` for consistency
  with other LoadedSkill fields (Copilot review)

Co-Authored-By: Claude Opus 4.6 <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-02-18 05:56:00 +00:00

212 lines
6.4 KiB
Rust

//! SKILL.md parser for the OpenClaw skill format.
//!
//! Parses files with YAML frontmatter delimited by `---` lines, followed by a
//! markdown prompt body.
use crate::skills::{SkillManifest, validate_skill_name};
/// Error type for SKILL.md parsing failures.
#[derive(Debug, thiserror::Error)]
pub enum SkillParseError {
#[error("Missing YAML frontmatter delimiters (expected `---` at start of file)")]
MissingFrontmatter,
#[error("Invalid YAML frontmatter: {0}")]
InvalidYaml(String),
#[error("Prompt body is empty (no content after frontmatter)")]
EmptyPrompt,
#[error("Invalid skill name '{name}': must match [a-zA-Z0-9][a-zA-Z0-9._-]{{0,63}}")]
InvalidName { name: String },
}
/// Result of parsing a SKILL.md file.
#[derive(Debug)]
pub struct ParsedSkill {
/// Parsed manifest from YAML frontmatter.
pub manifest: SkillManifest,
/// Prompt content (markdown body after frontmatter).
pub prompt_content: String,
}
/// Parse a SKILL.md file from its raw content string.
///
/// Expected format:
/// ```text
/// ---
/// name: my-skill
/// description: Does something
/// activation:
/// keywords: ["foo", "bar"]
/// ---
///
/// You are a helpful assistant that...
/// ```
pub fn parse_skill_md(content: &str) -> Result<ParsedSkill, SkillParseError> {
// Strip optional UTF-8 BOM
let content = content.strip_prefix('\u{feff}').unwrap_or(content);
// Find the first `---` delimiter (must be at line 1)
let trimmed = content.trim_start_matches(['\n', '\r']);
if !trimmed.starts_with("---") {
return Err(SkillParseError::MissingFrontmatter);
}
// Find the second `---` delimiter
let after_first = &trimmed[3..];
// Skip the rest of the first `---` line (including any trailing chars/newline)
let after_first_line = match after_first.find('\n') {
Some(pos) => &after_first[pos + 1..],
None => return Err(SkillParseError::MissingFrontmatter),
};
// Find closing `---` on its own line
let yaml_end =
find_closing_delimiter(after_first_line).ok_or(SkillParseError::MissingFrontmatter)?;
let yaml_str = &after_first_line[..yaml_end];
// Parse YAML frontmatter
let mut manifest: SkillManifest =
serde_yml::from_str(yaml_str).map_err(|e| SkillParseError::InvalidYaml(e.to_string()))?;
// Validate skill name
if !validate_skill_name(&manifest.name) {
return Err(SkillParseError::InvalidName {
name: manifest.name.clone(),
});
}
// Enforce activation criteria limits
manifest.activation.enforce_limits();
// Extract prompt content (everything after the closing `---` line)
let after_yaml = &after_first_line[yaml_end..];
// Skip the `---` line itself
let prompt_start = after_yaml
.find('\n')
.map(|p| p + 1)
.unwrap_or(after_yaml.len());
let prompt_content = after_yaml[prompt_start..]
.trim_start_matches('\n')
.to_string();
if prompt_content.trim().is_empty() {
return Err(SkillParseError::EmptyPrompt);
}
Ok(ParsedSkill {
manifest,
prompt_content,
})
}
/// Find the position of a closing `---` delimiter on its own line.
/// Returns the byte offset of the start of the `---` line within `content`.
fn find_closing_delimiter(content: &str) -> Option<usize> {
let mut pos = 0;
for line in content.lines() {
if line.trim() == "---" {
return Some(pos);
}
pos += line.len() + 1; // +1 for newline
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_valid_full() {
let content = r#"---
name: writing-assistant
version: "1.0.0"
description: Professional writing help
activation:
keywords: ["write", "edit", "proofread"]
max_context_tokens: 2000
metadata:
openclaw:
requires:
bins: ["vale"]
env: ["VALE_CONFIG"]
---
You are a writing assistant. When the user asks to write or edit...
"#;
let result = parse_skill_md(content).expect("should parse");
assert_eq!(result.manifest.name, "writing-assistant");
assert_eq!(result.manifest.version, "1.0.0");
assert_eq!(result.manifest.activation.keywords.len(), 3);
assert!(result.prompt_content.starts_with("You are a writing"));
let meta = result.manifest.metadata.unwrap();
let openclaw = meta.openclaw.unwrap();
assert_eq!(openclaw.requires.bins, vec!["vale"]);
}
#[test]
fn test_parse_minimal() {
let content = "---\nname: minimal\n---\n\nHello world.\n";
let result = parse_skill_md(content).expect("should parse");
assert_eq!(result.manifest.name, "minimal");
assert_eq!(result.manifest.version, "0.0.0"); // default
assert_eq!(result.prompt_content.trim(), "Hello world.");
}
#[test]
fn test_missing_frontmatter() {
let content = "Just some markdown text without frontmatter.";
let err = parse_skill_md(content).unwrap_err();
assert!(matches!(err, SkillParseError::MissingFrontmatter));
}
#[test]
fn test_malformed_yaml() {
let content = "---\nname: [invalid yaml\n---\n\nPrompt text.\n";
let err = parse_skill_md(content).unwrap_err();
assert!(matches!(err, SkillParseError::InvalidYaml(_)));
}
#[test]
fn test_empty_body() {
let content = "---\nname: empty-body\n---\n\n \n";
let err = parse_skill_md(content).unwrap_err();
assert!(matches!(err, SkillParseError::EmptyPrompt));
}
#[test]
fn test_invalid_name() {
let content = "---\nname: has spaces\n---\n\nPrompt.\n";
let err = parse_skill_md(content).unwrap_err();
assert!(matches!(err, SkillParseError::InvalidName { .. }));
}
#[test]
fn test_activation_with_patterns_and_tags() {
let content = r#"---
name: regex-skill
activation:
keywords: ["test"]
patterns: ["(?i)\\bwrite\\b"]
tags: ["writing", "email"]
---
Test prompt.
"#;
let result = parse_skill_md(content).expect("should parse");
assert_eq!(result.manifest.activation.patterns.len(), 1);
assert_eq!(result.manifest.activation.tags.len(), 2);
}
#[test]
fn test_bom_handling() {
let content = "\u{feff}---\nname: bom-skill\n---\n\nPrompt with BOM.\n";
let result = parse_skill_md(content).expect("should handle BOM");
assert_eq!(result.manifest.name, "bom-skill");
}
}