feat(scarfcore): SkillsScanner populates v0.11 frontmatter (Phase C)

Post-merge follow-up: the new SkillsScanner constructed HermesSkill
with `requiredConfig` only — leaving `allowedTools` / `relatedSkills`
/ `dependencies` (added in my v0.11 Phase 3.3) as nil. Detail-view
chip rows would render empty.

SkillFrontmatterParser:
- New `parseV011Fields(_:) -> (allowedTools:, relatedSkills:,
  dependencies:)` reader. Reuses HermesYAML.parseNestedYAML to
  extract the three lists from the SKILL.md frontmatter region
  between `---` markers. Returns nil-everything when the file has
  no frontmatter or the fields are absent / empty — chip rows hide.
- Existing `parseRequiredConfig(_:)` unchanged.

SkillsScanner:
- Reads `<skill>/SKILL.md` opportunistically (after the
  `<skill>/skill.yaml` read), parses v0.11 frontmatter, passes
  the three optional arrays into the HermesSkill constructor.
- Old skills without SKILL.md or without frontmatter keep nil and
  scan keeps working.

Tests:
- 5 new SkillFrontmatterParserTests cases covering happy path,
  partial fields, no frontmatter, empty fields, empty input.
- 10 total tests for the parser; all green.

Verified: ScarfCore builds clean. The chip-row data path is now
end-to-end (scan → HermesSkill → detail view) for both Mac and iOS.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alan Wizemann
2026-04-25 09:58:29 +02:00
parent 3d4a6a3a75
commit 84b033814b
3 changed files with 145 additions and 8 deletions
@@ -1,14 +1,24 @@
import Foundation
/// Pure-Swift YAML-frontmatter parser for skill manifests' `required_config:`
/// list. Extracted from `HermesFileService.parseSkillRequiredConfig` in
/// v2.5 so iOS can flag missing config keys without depending on the
/// Mac target.
/// Pure-Swift YAML parsers for skill manifests + SKILL.md frontmatter.
///
/// Two readers ship here:
///
/// - `parseRequiredConfig(_:)` the original v2.5 reader. Pulls the
/// `required_config:` list out of a skill's `skill.yaml`. Extracted
/// from `HermesFileService.parseSkillRequiredConfig` in v2.5 so iOS
/// can flag missing config keys without depending on the Mac target.
/// - `parseV011Fields(_:)` Hermes v2026.4.23+ SKILL.md frontmatter
/// reader. Extracts `allowed_tools`, `related_skills`, and
/// `dependencies` lists from the YAML block between `---` markers
/// at the top of a SKILL.md file. Used by `SkillsScanner` to populate
/// `HermesSkill`'s v0.11 fields so chip rows in the detail views
/// render correctly. Returns nil for fields that are absent or
/// empty (callers treat nil as "don't show this section").
///
/// Intentionally not a full YAML parser Hermes skill manifests use a
/// very narrow subset of YAML for this list. We look for a top-level
/// `required_config:` key followed by `- key` entries with at least one
/// space of indent. Lines outside that section are ignored.
/// very narrow subset of YAML. `parseV011Fields` reuses `HermesYAML`;
/// `parseRequiredConfig` stays inline because tests pin its behaviour.
public enum SkillFrontmatterParser: Sendable {
/// Parse the `required_config:` list from a skill.yaml's text. Empty
@@ -36,4 +46,33 @@ public enum SkillFrontmatterParser: Sendable {
}
return result
}
/// Parse Hermes v2026.4.23+ SKILL.md frontmatter for the v0.11
/// fields. The frontmatter block is the YAML region between two
/// `---` markers at the top of the file. Anything outside the
/// markers is ignored. Returns nil-everything when the file has
/// no frontmatter or no recognised fields callers should hide
/// the corresponding chip rows in that case.
///
/// Caller pre-condition: `content` is the full SKILL.md text. We
/// detect the frontmatter shape ourselves rather than requiring
/// callers to pre-strip it.
public static func parseV011Fields(
_ content: String
) -> (allowedTools: [String]?, relatedSkills: [String]?, dependencies: [String]?) {
let lines = content.components(separatedBy: "\n")
guard lines.first == "---",
let endIdx = lines.dropFirst().firstIndex(of: "---")
else { return (nil, nil, nil) }
let frontmatter = lines[1..<endIdx].joined(separator: "\n")
let parsed = HermesYAML.parseNestedYAML(frontmatter)
let allowed = parsed.lists["allowed_tools"]
let related = parsed.lists["related_skills"]
let deps = parsed.lists["dependencies"]
return (
allowedTools: (allowed?.isEmpty ?? true) ? nil : allowed,
relatedSkills: (related?.isEmpty ?? true) ? nil : related,
dependencies: (deps?.isEmpty ?? true) ? nil : deps
)
}
}
@@ -41,13 +41,25 @@ public enum SkillsScanner: Sendable {
yamlPath: skillPath + "/skill.yaml",
transport: transport
)
// v2.5 Hermes v0.11 SKILL.md frontmatter
// (allowed_tools, related_skills, dependencies).
// Opportunistic read old skills without the
// file or without those fields keep nil, and
// the chip rows hide themselves.
let v011 = readV011Fields(
mdPath: skillPath + "/SKILL.md",
transport: transport
)
return HermesSkill(
id: categoryName + "/" + skillName,
name: skillName,
category: categoryName,
path: skillPath,
files: files,
requiredConfig: requiredConfig
requiredConfig: requiredConfig,
allowedTools: v011.allowedTools,
relatedSkills: v011.relatedSkills,
dependencies: v011.dependencies
)
}
@@ -62,4 +74,18 @@ public enum SkillsScanner: Sendable {
else { return [] }
return SkillFrontmatterParser.parseRequiredConfig(content)
}
/// Read SKILL.md (Hermes v2026.4.23+) and parse its YAML frontmatter
/// for the v0.11 fields. Nil-everything when the file is absent or
/// has no frontmatter fully back-compatible with older skills.
private static func readV011Fields(
mdPath: String,
transport: any ServerTransport
) -> (allowedTools: [String]?, relatedSkills: [String]?, dependencies: [String]?) {
guard transport.fileExists(mdPath),
let data = try? transport.readFile(mdPath),
let content = String(data: data, encoding: .utf8)
else { return (nil, nil, nil) }
return SkillFrontmatterParser.parseV011Fields(content)
}
}
@@ -58,4 +58,76 @@ struct SkillFrontmatterParserTests {
@Test func handlesEmptyInput() {
#expect(SkillFrontmatterParser.parseRequiredConfig("").isEmpty)
}
// MARK: - parseV011Fields (Hermes v2026.4.23 SKILL.md frontmatter)
@Test func v011_extractsAllThreeLists() {
let md = """
---
allowed_tools:
- read_file
- write_file
related_skills:
- timer
- deploy
dependencies:
- npx
- node
---
# Skill body — body content is ignored by the parser.
"""
let r = SkillFrontmatterParser.parseV011Fields(md)
#expect(r.allowedTools == ["read_file", "write_file"])
#expect(r.relatedSkills == ["timer", "deploy"])
#expect(r.dependencies == ["npx", "node"])
}
@Test func v011_handlesAbsentFields() {
// Frontmatter present but only one v0.11 field declared.
let md = """
---
allowed_tools:
- run_shell
---
Body.
"""
let r = SkillFrontmatterParser.parseV011Fields(md)
#expect(r.allowedTools == ["run_shell"])
#expect(r.relatedSkills == nil)
#expect(r.dependencies == nil)
}
@Test func v011_returnsNilOnMissingFrontmatter() {
// SKILL.md without --- markers pre-v0.11 file shape.
let md = "# Skill\n\nBody only, no frontmatter."
let r = SkillFrontmatterParser.parseV011Fields(md)
#expect(r.allowedTools == nil)
#expect(r.relatedSkills == nil)
#expect(r.dependencies == nil)
}
@Test func v011_returnsNilWhenFieldEmpty() {
// Field declared but with an empty list treated same as absent
// (no chip row, no ghost section).
let md = """
---
allowed_tools:
related_skills:
- foo
---
"""
let r = SkillFrontmatterParser.parseV011Fields(md)
#expect(r.allowedTools == nil)
#expect(r.relatedSkills == ["foo"])
#expect(r.dependencies == nil)
}
@Test func v011_returnsNilOnEmptyInput() {
let r = SkillFrontmatterParser.parseV011Fields("")
#expect(r.allowedTools == nil)
#expect(r.relatedSkills == nil)
#expect(r.dependencies == nil)
}
}