fix(skills): accept plain markdown SKILL files (#869)
Accepts SKILL.md files without YAML frontmatter by using the first Markdown H1 heading as the skill name. This lets compatible plain Markdown skill files show up instead of being skipped with parse warnings.\n\nI added a small maintainer test commit to keep the command tests hermetic and cover both the plain-heading fallback and missing-heading warning path.\n\nVerification:\n- cargo test -p deepseek-tui skills --all-features\n- cargo fmt --all -- --check\n- git diff --check xieshutao/main...HEAD\n- cargo clippy -p deepseek-tui --all-targets --all-features -- -D warnings\n- GitGuardian Security Checks passed
This commit is contained in:
@@ -470,7 +470,9 @@ mod tests {
|
||||
resume_session_id: None,
|
||||
initial_input: None,
|
||||
};
|
||||
App::new(options, &Config::default())
|
||||
let mut app = App::new(options, &Config::default());
|
||||
app.skills_dir = tmpdir.path().join("skills");
|
||||
app
|
||||
}
|
||||
|
||||
fn create_skill_dir(tmpdir: &TempDir, skill_name: &str, skill_content: &str) {
|
||||
|
||||
@@ -194,7 +194,11 @@ impl SkillRegistry {
|
||||
|
||||
fn parse_skill(_path: &Path, content: &str) -> std::result::Result<Skill, String> {
|
||||
let trimmed = content.trim_start();
|
||||
let (frontmatter, body) = if trimmed.starts_with("---") {
|
||||
|
||||
// Try to parse frontmatter block first. If absent, fall back to
|
||||
// extracting the first `# Heading` as the skill name so that plain
|
||||
// Markdown files (no `---` fence) are accepted instead of rejected.
|
||||
if trimmed.starts_with("---") {
|
||||
let start = content
|
||||
.find("---")
|
||||
.ok_or_else(|| "missing frontmatter opening delimiter".to_string())?;
|
||||
@@ -202,38 +206,54 @@ impl SkillRegistry {
|
||||
let end = rest
|
||||
.find("---")
|
||||
.ok_or_else(|| "missing frontmatter closing delimiter".to_string())?;
|
||||
(&rest[..end], &rest[end + 3..])
|
||||
} else {
|
||||
return Err("missing frontmatter opening delimiter '---'".to_string());
|
||||
};
|
||||
let frontmatter = &rest[..end];
|
||||
let body = &rest[end + 3..];
|
||||
|
||||
let mut metadata = HashMap::new();
|
||||
for raw in frontmatter.lines() {
|
||||
let line = raw.trim();
|
||||
if line.is_empty() || line.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
if let Some((key, value)) = line.split_once(':') {
|
||||
metadata.insert(key.trim().to_ascii_lowercase(), value.trim().to_string());
|
||||
let mut metadata = HashMap::new();
|
||||
for raw in frontmatter.lines() {
|
||||
let line = raw.trim();
|
||||
if line.is_empty() || line.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
if let Some((key, value)) = line.split_once(':') {
|
||||
metadata.insert(key.trim().to_ascii_lowercase(), value.trim().to_string());
|
||||
}
|
||||
}
|
||||
|
||||
let name = metadata
|
||||
.get("name")
|
||||
.filter(|name| !name.is_empty())
|
||||
.cloned()
|
||||
.ok_or_else(|| "missing required frontmatter field: name".to_string())?;
|
||||
|
||||
let description = metadata.get("description").cloned().unwrap_or_default();
|
||||
|
||||
return Ok(Skill {
|
||||
name,
|
||||
description,
|
||||
body: body.trim().to_string(),
|
||||
// Filled in by `discover` after parse succeeds; default to an
|
||||
// empty path so direct constructors (e.g. tests) compile.
|
||||
path: PathBuf::new(),
|
||||
});
|
||||
}
|
||||
|
||||
let name = metadata
|
||||
.get("name")
|
||||
.filter(|name| !name.is_empty())
|
||||
.cloned()
|
||||
.ok_or_else(|| "missing required frontmatter field: name".to_string())?;
|
||||
|
||||
let description = metadata.get("description").cloned().unwrap_or_default();
|
||||
|
||||
let body = body.trim().to_string();
|
||||
// Graceful degradation: no frontmatter fence found.
|
||||
// Extract the first `# Heading` as the skill name.
|
||||
let heading_re = regex::Regex::new(r"(?m)^#\s+(.+)$").expect("static regex is valid");
|
||||
let name = heading_re
|
||||
.captures(content)
|
||||
.and_then(|c| c.get(1))
|
||||
.map(|m| m.as_str().trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.ok_or_else(|| {
|
||||
"no frontmatter and no `# Heading` found to use as skill name".to_string()
|
||||
})?;
|
||||
|
||||
Ok(Skill {
|
||||
name,
|
||||
description,
|
||||
body,
|
||||
// Filled in by `discover` after parse succeeds; default to an
|
||||
// empty path so direct constructors (e.g. tests) compile.
|
||||
description: String::new(),
|
||||
body: content.trim().to_string(),
|
||||
path: PathBuf::new(),
|
||||
})
|
||||
}
|
||||
@@ -824,6 +844,46 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discover_accepts_plain_markdown_heading_without_frontmatter() {
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
let skill_dir = tmpdir.path().join("plain-skill");
|
||||
std::fs::create_dir_all(&skill_dir).unwrap();
|
||||
std::fs::write(
|
||||
skill_dir.join("SKILL.md"),
|
||||
"# Plain Skill\n\nUse this skill without YAML frontmatter.\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let registry = super::SkillRegistry::discover(tmpdir.path());
|
||||
let skill = registry.get("Plain Skill").expect("plain skill parsed");
|
||||
assert_eq!(skill.description, "");
|
||||
assert!(skill.body.contains("Use this skill"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discover_warns_for_plain_markdown_without_heading() {
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
let skill_dir = tmpdir.path().join("plain-skill");
|
||||
std::fs::create_dir_all(&skill_dir).unwrap();
|
||||
std::fs::write(
|
||||
skill_dir.join("SKILL.md"),
|
||||
"Use this skill without a heading or YAML frontmatter.\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let registry = super::SkillRegistry::discover(tmpdir.path());
|
||||
assert!(registry.is_empty());
|
||||
assert!(
|
||||
registry
|
||||
.warnings()
|
||||
.iter()
|
||||
.any(|warning| warning.contains("no `# Heading` found")),
|
||||
"expected missing-heading warning, got {:?}",
|
||||
registry.warnings()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_available_skills_context_for_workspace_picks_up_cross_tool_dirs() {
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
|
||||
Reference in New Issue
Block a user