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
)
}
}
@@ -135,6 +135,22 @@ private struct SkillDetailView: View {
}
}
if let tools = skill.allowedTools, !tools.isEmpty {
Section("Allowed tools") {
chipRow(tools)
}
}
if let related = skill.relatedSkills, !related.isEmpty {
Section("Related skills") {
chipRow(related)
}
}
if let deps = skill.dependencies, !deps.isEmpty {
Section("Dependencies") {
chipRow(deps)
}
}
if !skill.files.isEmpty {
Section("Files") {
ForEach(skill.files, id: \.self) { file in
@@ -159,4 +175,66 @@ private struct SkillDetailView: View {
npxStatus = await svc.probe(binary: "npx")
}
}
/// Render a list of strings as wrapping pill chips. Used for
/// allowed_tools / related_skills / dependencies sections (v2.5
/// SKILL.md frontmatter).
@ViewBuilder
private func chipRow(_ items: [String]) -> some View {
FlowLayout(spacing: 6) {
ForEach(items, id: \.self) { item in
Text(item)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(.secondary.opacity(0.12), in: Capsule())
}
}
.padding(.vertical, 4)
}
}
/// Minimal flow-layout for chip rows (wraps onto multiple lines when
/// content overflows the available width). Built-in `Layout` API,
/// no third-party dep. Used by the Skills detail view for the v2.5
/// allowed_tools / related_skills / dependencies sections.
private struct FlowLayout: Layout {
var spacing: CGFloat = 4
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
guard let maxWidth = proposal.width else { return .zero }
var rowWidth: CGFloat = 0
var totalHeight: CGFloat = 0
var rowHeight: CGFloat = 0
for subview in subviews {
let size = subview.sizeThatFits(.unspecified)
if rowWidth + size.width > maxWidth, rowWidth > 0 {
totalHeight += rowHeight + spacing
rowWidth = 0
rowHeight = 0
}
rowWidth += size.width + spacing
rowHeight = max(rowHeight, size.height)
}
totalHeight += rowHeight
return CGSize(width: maxWidth, height: totalHeight)
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
var x = bounds.minX
var y = bounds.minY
var rowHeight: CGFloat = 0
for subview in subviews {
let size = subview.sizeThatFits(.unspecified)
if x + size.width > bounds.maxX, x > bounds.minX {
x = bounds.minX
y += rowHeight + spacing
rowHeight = 0
}
subview.place(at: CGPoint(x: x, y: y), proposal: ProposedViewSize(size))
x += size.width + spacing
rowHeight = max(rowHeight, size.height)
}
}
}
@@ -510,13 +510,22 @@ struct HermesFileService: Sendable {
guard transport.stat(skillPath)?.isDirectory == true else { return nil }
let files = (try? transport.listDirectory(skillPath)) ?? []
let requiredConfig = parseSkillRequiredConfig(skillPath + "/skill.yaml")
// v2.5: Hermes v2026.4.23 ships richer SKILL.md
// frontmatter (allowed_tools, related_skills,
// dependencies). Parse opportunistically old skills
// without a SKILL.md file or without these fields stay
// nil and the detail view skips the chip rows.
let frontmatter = parseSkillFrontmatter(skillPath + "/SKILL.md")
return HermesSkill(
id: categoryName + "/" + skillName,
name: skillName,
category: categoryName,
path: skillPath,
files: files.sorted(),
requiredConfig: requiredConfig
requiredConfig: requiredConfig,
allowedTools: frontmatter.allowedTools,
relatedSkills: frontmatter.relatedSkills,
dependencies: frontmatter.dependencies
)
}
@@ -543,6 +552,32 @@ struct HermesFileService: Sendable {
return true
}
/// Parse `allowed_tools`, `related_skills`, `dependencies` from a
/// SKILL.md YAML frontmatter block (Hermes v2026.4.23+). Returns
/// nil-filled tuple when the file is absent or has no frontmatter
/// pre-v0.11 skills behave as before.
nonisolated private func parseSkillFrontmatter(
_ path: String
) -> (allowedTools: [String]?, relatedSkills: [String]?, dependencies: [String]?) {
guard let content = readFile(path) else { return (nil, nil, nil) }
let lines = content.components(separatedBy: "\n")
// Frontmatter must be the first line `---` followed by another
// `---` somewhere below. Anything else and we bail.
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 allowedTools = parsed.lists["allowed_tools"]
let relatedSkills = parsed.lists["related_skills"]
let dependencies = parsed.lists["dependencies"]
return (
allowedTools: (allowedTools?.isEmpty ?? true) ? nil : allowedTools,
relatedSkills: (relatedSkills?.isEmpty ?? true) ? nil : relatedSkills,
dependencies: (dependencies?.isEmpty ?? true) ? nil : dependencies
)
}
nonisolated private func parseSkillRequiredConfig(_ path: String) -> [String] {
guard let content = readFile(path) else { return [] }
var result: [String] = []
@@ -170,6 +170,18 @@ struct SkillsView: View {
case .missing(let hint) = designMdNpxStatus {
designMdNpxBanner(hint: hint)
}
// v2.5 SKILL.md frontmatter chips. Render only the
// sections that are populated old skills without
// this metadata show no extra rows.
if let tools = skill.allowedTools, !tools.isEmpty {
skillChipSection(title: "Allowed tools", items: tools)
}
if let related = skill.relatedSkills, !related.isEmpty {
skillChipSection(title: "Related skills", items: related)
}
if let deps = skill.dependencies, !deps.isEmpty {
skillChipSection(title: "Dependencies", items: deps)
}
Divider()
if !skill.files.isEmpty {
VStack(alignment: .leading, spacing: 4) {
@@ -231,6 +243,28 @@ struct SkillsView: View {
}
}
/// Render a labelled chip row for v2.5 SKILL.md frontmatter
/// sections (allowed_tools, related_skills, dependencies). Items
/// flow horizontally with wrapping; the row hides itself when
/// there's nothing to show (caller already gates on `!isEmpty`).
private func skillChipSection(title: String, items: [String]) -> some View {
VStack(alignment: .leading, spacing: 4) {
Text(title)
.font(.caption.bold())
.foregroundStyle(.secondary)
HStack(spacing: 4) {
ForEach(items, id: \.self) { item in
Text(item)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(.secondary.opacity(0.12), in: Capsule())
}
}
}
}
/// Yellow banner surfaced on the design-md skill detail when the
/// host's `npx` probe came back missing. Reuses the same color
/// language as the missing-config banner.