From 84b033814bbc191391171d08665ba57c35dfe83a Mon Sep 17 00:00:00 2001 From: Alan Wizemann Date: Sat, 25 Apr 2026 09:58:29 +0200 Subject: [PATCH] feat(scarfcore): SkillsScanner populates v0.11 frontmatter (Phase C) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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.md` opportunistically (after the `/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) --- .../Parsing/SkillFrontmatterParser.swift | 53 ++++++++++++-- .../ScarfCore/Services/SkillsScanner.swift | 28 +++++++- .../SkillFrontmatterParserTests.swift | 72 +++++++++++++++++++ 3 files changed, 145 insertions(+), 8 deletions(-) diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/Parsing/SkillFrontmatterParser.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Parsing/SkillFrontmatterParser.swift index b58c734..ae10755 100644 --- a/scarf/Packages/ScarfCore/Sources/ScarfCore/Parsing/SkillFrontmatterParser.swift +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Parsing/SkillFrontmatterParser.swift @@ -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.. (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) + } } diff --git a/scarf/Packages/ScarfCore/Tests/ScarfCoreTests/SkillFrontmatterParserTests.swift b/scarf/Packages/ScarfCore/Tests/ScarfCoreTests/SkillFrontmatterParserTests.swift index 07fbdfe..4aee7ae 100644 --- a/scarf/Packages/ScarfCore/Tests/ScarfCoreTests/SkillFrontmatterParserTests.swift +++ b/scarf/Packages/ScarfCore/Tests/ScarfCoreTests/SkillFrontmatterParserTests.swift @@ -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) + } }