feat(skills): SKILL.md frontmatter v0.11 fields (Phase 3.3)

Hermes v2026.4.23 SKILL.md files carry richer YAML frontmatter:
allowed_tools, related_skills, dependencies. Surface them as chip
rows in the skill detail view on both platforms.

ScarfCore HermesSkill:
- Three new optional fields: allowedTools, relatedSkills,
  dependencies. Default-nil so older skills (no SKILL.md, or
  SKILL.md without these fields) load unchanged.

Mac HermesFileService.parseSkillFrontmatter:
- Reads `<skill>/SKILL.md`, splits at `---` markers, parses the
  frontmatter via HermesYAML.parseNestedYAML, and extracts the three
  list fields. Tuple-of-optionals return; nil-everything when the
  file is absent or has no frontmatter.

iOS IOSSkillsViewModel.parseFrontmatter:
- Mirror with the iOS transport (over SFTP). Same parser, same
  return shape.

Mac SkillsView:
- skillChipSection(title:items:) helper renders a labelled chip
  row. Three rows added between the existing missing-config /
  Spotify / npx surfaces and the file list — only shown when the
  corresponding field is non-empty.

iOS SkillDetailView:
- chipRow(_:) helper using a small in-file FlowLayout (built-in
  Layout protocol, no third-party dep) so the chips wrap onto
  multiple lines on iPhone-narrow screens. Three sections matching
  Mac.

Verified: ScarfCore + Mac + iOS builds clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alan Wizemann
2026-04-25 09:18:54 +02:00
parent 7ec7282f36
commit 5c08c09dde
5 changed files with 210 additions and 3 deletions
@@ -23,6 +23,20 @@ public struct HermesSkill: Identifiable, Sendable {
public let path: String
public let files: [String]
public let requiredConfig: [String]
/// Tools the skill author declared the skill is allowed to invoke
/// (Hermes v2026.4.23 SKILL.md frontmatter `allowed_tools`).
/// `nil` when the skill ships no SKILL.md or the frontmatter
/// doesn't declare the field pre-v0.11 behaviour preserved.
public let allowedTools: [String]?
/// Skill names the author cross-references as related (`related_skills`
/// in SKILL.md frontmatter). Surfaced as chips in the skill detail
/// view so users can hop between connected skills.
public let relatedSkills: [String]?
/// External runtime dependencies the skill needs on the host
/// (`dependencies` in SKILL.md frontmatter; e.g. `npx`, `ffmpeg`,
/// Python packages). Used by `SkillPrereqService` to know what to
/// probe; nil when the field is absent.
public let dependencies: [String]?
public init(
id: String,
@@ -30,7 +44,10 @@ public struct HermesSkill: Identifiable, Sendable {
category: String,
path: String,
files: [String],
requiredConfig: [String]
requiredConfig: [String],
allowedTools: [String]? = nil,
relatedSkills: [String]? = nil,
dependencies: [String]? = nil
) {
self.id = id
self.name = name
@@ -38,5 +55,8 @@ public struct HermesSkill: Identifiable, Sendable {
self.path = path
self.files = files
self.requiredConfig = requiredConfig
self.allowedTools = allowedTools
self.relatedSkills = relatedSkills
self.dependencies = dependencies
}
}
@@ -64,13 +64,25 @@ public final class IOSSkillsViewModel {
let skillPath = categoryPath + "/" + skillName
guard transport.stat(skillPath)?.isDirectory == true else { continue }
let files: [String] = (try? transport.listDirectory(skillPath)) ?? []
// v2.5: parse SKILL.md frontmatter for the
// Hermes v2026.4.23 fields (allowed_tools,
// related_skills, dependencies). Falls back
// to nil-everything on absent/malformed
// frontmatter old skills behave as before.
let frontmatter = Self.parseFrontmatter(
skillMdPath: skillPath + "/SKILL.md",
transport: transport
)
skills.append(HermesSkill(
id: categoryName + "/" + skillName,
name: skillName,
category: categoryName,
path: skillPath,
files: files.filter { !$0.hasPrefix(".") }.sorted(),
requiredConfig: [] // Skills frontmatter parsing deferred.
requiredConfig: [], // skill.yaml parsing still deferred for iOS
allowedTools: frontmatter.allowedTools,
relatedSkills: frontmatter.relatedSkills,
dependencies: frontmatter.dependencies
))
}
@@ -97,4 +109,32 @@ public final class IOSSkillsViewModel {
}
isLoading = false
}
/// Read `<skill>/SKILL.md`'s YAML frontmatter and pull the v2.5
/// fields (allowed_tools, related_skills, dependencies). Returns
/// nil-filled tuple on missing file, missing frontmatter, or empty
/// fields. Mirrors Mac's `HermesFileService.parseSkillFrontmatter`.
nonisolated static func parseFrontmatter(
skillMdPath: String,
transport: any ServerTransport
) -> (allowedTools: [String]?, relatedSkills: [String]?, dependencies: [String]?) {
guard transport.fileExists(skillMdPath),
let data = try? transport.readFile(skillMdPath),
let raw = String(data: data, encoding: .utf8)
else { return (nil, nil, nil) }
let lines = raw.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
)
}
}