diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesSkill.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesSkill.swift index 6ced10b..95bb4af 100644 --- a/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesSkill.swift +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesSkill.swift @@ -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 } } diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/IOSSkillsViewModel.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/IOSSkillsViewModel.swift index e2eda01..9ca3365 100644 --- a/scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/IOSSkillsViewModel.swift +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/IOSSkillsViewModel.swift @@ -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.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.. 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) + } + } } diff --git a/scarf/scarf/Core/Services/HermesFileService.swift b/scarf/scarf/Core/Services/HermesFileService.swift index 6286015..3751548 100644 --- a/scarf/scarf/Core/Services/HermesFileService.swift +++ b/scarf/scarf/Core/Services/HermesFileService.swift @@ -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.. [String] { guard let content = readFile(path) else { return [] } var result: [String] = [] diff --git a/scarf/scarf/Features/Skills/Views/SkillsView.swift b/scarf/scarf/Features/Skills/Views/SkillsView.swift index fabfb1f..35d72c4 100644 --- a/scarf/scarf/Features/Skills/Views/SkillsView.swift +++ b/scarf/scarf/Features/Skills/Views/SkillsView.swift @@ -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.