mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 18:44:45 +00:00
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user