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