From ee3791a1b2bef18eafb812955207f8d19f6b696e Mon Sep 17 00:00:00 2001 From: Alan Wizemann Date: Fri, 1 May 2026 12:44:15 +0200 Subject: [PATCH] =?UTF-8?q?feat(hermes-v12):=20Skills=20v0.12=20surface=20?= =?UTF-8?q?=E2=80=94=20URL=20install=20+=20reload=20+=20pin/disable=20badg?= =?UTF-8?q?es=20(Phase=20E)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hermes v0.12 added three skills surfaces Scarf can now reach: - Direct-URL install: `hermes skills install ` lets users pull a one-off skill without going through a registry. Mac SkillsView grew an "Install from URL…" toolbar button (capability-gated on HermesCapabilities.hasSkillURLInstall) opening a sheet with the URL field plus optional --category / --name overrides. - Reload: `hermes skills audit` rescans `~/.hermes/skills/` and refreshes the agent's view of available skills without restarting. Wired to a "Reload" toolbar button next to the install button on Mac. - Enabled state: skills.disabled in config.yaml is now read at scan time (SkillsViewModel.readDisabledSkillNames). Disabled skills render strikethrough + an "OFF" pill on Mac and iOS rows so users see what Hermes won't load. iOS detail view explains the state in plain text. - Curator pin badge: pinned-skill names from `~/.hermes/skills/.curator_state` (SkillsViewModel.readPinnedSkillNames) surface as a pin glyph on each row. Mac sidebar + iOS list both show it; iOS detail view explains "pinned by curator — won't auto-archive." Model + scanner: - HermesSkill gains `enabled: Bool` (default true) and `pinned: Bool` (default false). Both default to backwards-compatible values so unmodified call sites keep compiling. - SkillsScanner.scan now takes optional `disabledNames` and `pinnedNames` sets and applies them per skill at scan time. - SkillsViewModel.load auto-fetches both sets internally so Mac/iOS callers don't have to plumb curator state manually; an opt-in `pinnedNames` override is available for the Curator screen which has a fresher snapshot in hand. Tests: 215 ScarfCore tests pass; both Mac and iOS schemes build clean. Note: the disable-toggle path (writing the array back into config.yaml) is deferred to v2.7 — Hermes ships `hermes skills config` as an interactive verb only, and we'd rather read accurately than risk clobbering the user's list with a half-tested write path. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../ScarfCore/Models/HermesSkill.swift | 16 +- .../ScarfCore/Services/SkillsScanner.swift | 11 +- .../ViewModels/SkillsViewModel.swift | 151 +++++++++++++++++- .../Installed/InstalledSkillsListView.swift | 33 +++- .../Skills/Installed/SkillDetailView.swift | 28 ++++ .../Skills/Views/InstallFromURLSheet.swift | 87 ++++++++++ .../Features/Skills/Views/SkillsView.swift | 62 ++++++- 7 files changed, 375 insertions(+), 13 deletions(-) create mode 100644 scarf/scarf/Features/Skills/Views/InstallFromURLSheet.swift diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesSkill.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesSkill.swift index 95bb4af..1c2432c 100644 --- a/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesSkill.swift +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesSkill.swift @@ -37,6 +37,16 @@ public struct HermesSkill: Identifiable, Sendable { /// Python packages). Used by `SkillPrereqService` to know what to /// probe; nil when the field is absent. public let dependencies: [String]? + /// `false` when the skill name appears in `skills.disabled` in + /// `~/.hermes/config.yaml`. Hermes v0.12 stores disable state in + /// the config rather than per-skill markers; this is read-only + /// from Scarf's side until the toggle UI lands. Defaults to `true`. + public let enabled: Bool + /// `true` when the skill is pinned via `hermes curator pin `. + /// Pinned skills are protected from auto-archive / consolidation. + /// Read from `CuratorViewModel.status.pinnedNames`; defaults to + /// `false` when curator state is unavailable. + public let pinned: Bool public init( id: String, @@ -47,7 +57,9 @@ public struct HermesSkill: Identifiable, Sendable { requiredConfig: [String], allowedTools: [String]? = nil, relatedSkills: [String]? = nil, - dependencies: [String]? = nil + dependencies: [String]? = nil, + enabled: Bool = true, + pinned: Bool = false ) { self.id = id self.name = name @@ -58,5 +70,7 @@ public struct HermesSkill: Identifiable, Sendable { self.allowedTools = allowedTools self.relatedSkills = relatedSkills self.dependencies = dependencies + self.enabled = enabled + self.pinned = pinned } } diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/SkillsScanner.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/SkillsScanner.swift index 2ed8591..29cd5a5 100644 --- a/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/SkillsScanner.swift +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/SkillsScanner.swift @@ -13,7 +13,12 @@ import os public enum SkillsScanner: Sendable { private static let logger = Logger(subsystem: "com.scarf", category: "SkillsScanner") - public static func scan(context: ServerContext, transport: any ServerTransport) -> [HermesSkillCategory] { + public static func scan( + context: ServerContext, + transport: any ServerTransport, + disabledNames: Set = [], + pinnedNames: Set = [] + ) -> [HermesSkillCategory] { let dir = context.paths.skillsDir // Fresh install: skills/ may not exist yet — return [] without // logging an error. @@ -59,7 +64,9 @@ public enum SkillsScanner: Sendable { requiredConfig: requiredConfig, allowedTools: v011.allowedTools, relatedSkills: v011.relatedSkills, - dependencies: v011.dependencies + dependencies: v011.dependencies, + enabled: !disabledNames.contains(skillName), + pinned: pinnedNames.contains(skillName) ) } diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/SkillsViewModel.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/SkillsViewModel.swift index 0d964ca..6565f50 100644 --- a/scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/SkillsViewModel.swift +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/SkillsViewModel.swift @@ -70,19 +70,104 @@ public final class SkillsViewModel { /// Awaitable scan. iOS's `.task { await vm.load() }` and the /// ScarfCore unit tests use this directly; Mac call sites wrap in /// `Task { await ... }` from `onAppear`. + /// + /// Pinned-name set is auto-fetched from the curator state file on + /// v0.12+ hosts; callers can override by passing an explicit set + /// (the Curator screen does this when it has a fresher snapshot in + /// hand). @MainActor - public func load() async { + public func load(pinnedNames: Set? = nil) async { isLoading = true lastError = nil let ctx = context let xport = transport + let pins = pinnedNames let cats: [HermesSkillCategory] = await Task.detached { - SkillsScanner.scan(context: ctx, transport: xport) + let disabled = Self.readDisabledSkillNames(context: ctx) + let pinned = pins ?? Self.readPinnedSkillNames(context: ctx) + return SkillsScanner.scan( + context: ctx, + transport: xport, + disabledNames: disabled, + pinnedNames: pinned + ) }.value categories = cats isLoading = false } + /// Read the curator's pinned-skills list from + /// `~/.hermes/skills/.curator_state` (JSON despite the lack of an + /// extension). Pre-v0.12 hosts won't have this file yet — return + /// an empty set so the pin badge stays hidden. + nonisolated static func readPinnedSkillNames(context: ServerContext) -> Set { + guard let data = context.readData(context.paths.curatorStateFile), + let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + else { return [] } + // Curator stores pins in either `pinned: [name, ...]` or + // `pinned_skills: [name, ...]` depending on Hermes version — + // accept both shapes so we don't break on a future rename. + let raw = (obj["pinned"] as? [String]) ?? (obj["pinned_skills"] as? [String]) ?? [] + return Set(raw) + } + + /// Read the `skills.disabled:` array from `~/.hermes/config.yaml`. + /// Hermes v0.12 stores skill disable state there (one global list + /// + optional `skills.platform_disabled` overrides). Returns the + /// global list only — Scarf doesn't surface platform overrides + /// today. Empty set on missing file / parse failure. + nonisolated static func readDisabledSkillNames(context: ServerContext) -> Set { + guard let yaml = context.readText(context.paths.configYAML) else { return [] } + // Lightweight match: find `skills:` block, then `disabled:` array + // inside it. The full YAML parser is overkill for one nested array. + var inSkillsBlock = false + var disabledIndent: Int? + var collected: [String] = [] + for raw in yaml.components(separatedBy: "\n") { + // Top-level `skills:` declaration. + if raw.hasPrefix("skills:") { + inSkillsBlock = true + continue + } + if inSkillsBlock { + // A new top-level block ends the `skills:` scope. + if !raw.hasPrefix(" ") && !raw.hasPrefix("\t") && raw.contains(":") { + break + } + let trimmed = raw.trimmingCharacters(in: .whitespaces) + if trimmed.hasPrefix("disabled:") { + // Inline form `disabled: [a, b, c]` + let after = trimmed.dropFirst("disabled:".count).trimmingCharacters(in: .whitespaces) + if after.hasPrefix("[") && after.hasSuffix("]") { + let body = after.dropFirst().dropLast() + let parts = body.split(separator: ",").map { String($0).trimmingCharacters(in: .whitespaces) } + for p in parts where !p.isEmpty { + collected.append(p.trimmingCharacters(in: CharacterSet(charactersIn: "\"' "))) + } + return Set(collected) + } + // Block form: `disabled:` followed by ` - name` + disabledIndent = raw.prefix { $0 == " " || $0 == "\t" }.count + continue + } + if let baseIndent = disabledIndent { + let leading = raw.prefix { $0 == " " || $0 == "\t" }.count + if leading <= baseIndent && !trimmed.isEmpty { + // Out of the `disabled:` block. + break + } + if trimmed.hasPrefix("- ") { + let name = trimmed.dropFirst(2).trimmingCharacters(in: CharacterSet(charactersIn: "\"' ")) + if !name.isEmpty { + collected.append(String(name)) + } + } + } + } + } + return Set(collected) + } + public func selectSkill(_ skill: HermesSkill) { selectedSkill = skill let mainFile = skill.files.first(where: { $0.hasSuffix(".md") }) ?? skill.files.first @@ -200,6 +285,68 @@ public final class SkillsViewModel { } } + /// v0.12: install a skill from a direct HTTPS URL pointing at a + /// SKILL.md (or a tarball). Hermes pulls + installs without going + /// through the registry indirection. The Mac UI gates this on + /// `HermesCapabilities.hasSkillURLInstall` so a v0.11 host doesn't + /// see a button that errors out with "unrecognized argument". + /// + /// `categoryOverride` and `nameOverride` map to `--category` / + /// `--name` flags Hermes ships for direct-URL installs (the URL's + /// SKILL.md may not declare those, especially for one-off scripts). + public func installFromURL( + _ url: String, + categoryOverride: String? = nil, + nameOverride: String? = nil + ) { + isHubLoading = true + hubMessage = "Installing from URL…" + let bin = context.paths.hermesBinary + let xport = transport + Task.detached { [weak self] in + var args = ["skills", "install", url, "--yes"] + if let category = categoryOverride, !category.isEmpty { + args += ["--category", category] + } + if let name = nameOverride, !name.isEmpty { + args += ["--name", name] + } + let result = Self.runHermes( + executable: bin, + args: args, + transport: xport, + timeout: 180 + ) + await self?.finishInstall(identifier: url, exitCode: result.exitCode) + } + } + + /// v0.12: trigger a hot reload of `~/.hermes/skills/` so the agent + /// picks up file edits without a session restart. Hermes ships + /// `/reload-skills` as a slash command in chat AND `hermes skills + /// audit` as a CLI form. We use `audit` here so the reload works + /// even when no chat session is active. + public func reloadSkills() async { + isHubLoading = true + let bin = context.paths.hermesBinary + let xport = transport + let result = await Task.detached { + Self.runHermes( + executable: bin, + args: ["skills", "audit"], + transport: xport, + timeout: 30 + ) + }.value + hubMessage = result.exitCode == 0 ? "Skills reloaded" : "Reload failed" + isHubLoading = false + await load() + Task { @MainActor [weak self] in + try? await Task.sleep(nanoseconds: 3_000_000_000) + self?.hubMessage = nil + } + } + public func uninstallHubSkill(_ identifier: String) { let bin = context.paths.hermesBinary let xport = transport diff --git a/scarf/Scarf iOS/Skills/Installed/InstalledSkillsListView.swift b/scarf/Scarf iOS/Skills/Installed/InstalledSkillsListView.swift index d6e9971..001cb3a 100644 --- a/scarf/Scarf iOS/Skills/Installed/InstalledSkillsListView.swift +++ b/scarf/Scarf iOS/Skills/Installed/InstalledSkillsListView.swift @@ -36,12 +36,33 @@ struct InstalledSkillsListView: View { NavigationLink { SkillDetailView(skill: skill, vm: vm) } label: { - VStack(alignment: .leading, spacing: 2) { - Text(skill.name) - .font(.body) - Text("\(skill.files.count) file\(skill.files.count == 1 ? "" : "s")") - .font(.caption) - .foregroundStyle(ScarfColor.foregroundMuted) + HStack(spacing: 8) { + VStack(alignment: .leading, spacing: 2) { + Text(skill.name) + .font(.body) + .foregroundStyle(skill.enabled ? .primary : .secondary) + .strikethrough(!skill.enabled, color: .secondary) + Text("\(skill.files.count) file\(skill.files.count == 1 ? "" : "s")") + .font(.caption) + .foregroundStyle(ScarfColor.foregroundMuted) + } + Spacer(minLength: 0) + if skill.pinned { + Image(systemName: "pin.fill") + .font(.caption2) + .foregroundStyle(ScarfColor.accent) + .accessibilityLabel("Pinned by curator") + } + if !skill.enabled { + Text("OFF") + .font(.caption2.weight(.semibold)) + .padding(.horizontal, 4) + .padding(.vertical, 1) + .background(ScarfColor.backgroundTertiary) + .clipShape(RoundedRectangle(cornerRadius: 3)) + .foregroundStyle(ScarfColor.foregroundMuted) + .accessibilityLabel("Disabled — Hermes won't load this skill") + } } } .scarfGoCompactListRow() diff --git a/scarf/Scarf iOS/Skills/Installed/SkillDetailView.swift b/scarf/Scarf iOS/Skills/Installed/SkillDetailView.swift index 3e11b8b..ed9a1af 100644 --- a/scarf/Scarf iOS/Skills/Installed/SkillDetailView.swift +++ b/scarf/Scarf iOS/Skills/Installed/SkillDetailView.swift @@ -31,6 +31,34 @@ struct SkillDetailView: View { .font(.caption.monospaced()) .foregroundStyle(ScarfColor.foregroundMuted) .textSelection(.enabled) + if !skill.enabled { + Label { + VStack(alignment: .leading, spacing: 2) { + Text("Disabled").font(.callout.weight(.medium)) + Text("This skill is in `skills.disabled` in `~/.hermes/config.yaml`. Hermes won't load it. Re-enable from the Mac app's Skills config UI or with `hermes skills config`.") + .font(.caption) + .foregroundStyle(ScarfColor.foregroundMuted) + .fixedSize(horizontal: false, vertical: true) + } + } icon: { + Image(systemName: "circle.slash") + .foregroundStyle(.secondary) + } + } + if skill.pinned { + Label { + VStack(alignment: .leading, spacing: 2) { + Text("Pinned by curator").font(.callout.weight(.medium)) + Text("The autonomous curator won't auto-archive or rewrite this skill. Unpin from the Curator screen.") + .font(.caption) + .foregroundStyle(ScarfColor.foregroundMuted) + .fixedSize(horizontal: false, vertical: true) + } + } icon: { + Image(systemName: "pin.fill") + .foregroundStyle(ScarfColor.accent) + } + } } .listRowBackground(ScarfColor.backgroundSecondary) diff --git a/scarf/scarf/Features/Skills/Views/InstallFromURLSheet.swift b/scarf/scarf/Features/Skills/Views/InstallFromURLSheet.swift new file mode 100644 index 0000000..d4b46df --- /dev/null +++ b/scarf/scarf/Features/Skills/Views/InstallFromURLSheet.swift @@ -0,0 +1,87 @@ +import SwiftUI +import ScarfCore +import ScarfDesign + +/// v0.12+ direct-URL skill install. Hermes accepts an HTTPS URL pointing +/// at a SKILL.md (or a tarball) and installs it under +/// `~/.hermes/skills///`. Authors who don't ship via a +/// registry can use this to share a one-off skill with a single URL. +/// +/// Capability-gated upstream — SkillsView only opens this sheet when +/// `HermesCapabilities.hasSkillURLInstall` is true. +struct InstallFromURLSheet: View { + let viewModel: SkillsViewModel + + @Environment(\.dismiss) private var dismiss + @State private var url: String = "" + @State private var category: String = "" + @State private var nameOverride: String = "" + + /// Loose validity check — accept anything that starts with `https://` + /// (HTTP gets blocked because Hermes refuses non-TLS skill URLs by + /// default to keep MITM-injected SKILL.md off the host). + private var isValid: Bool { + let trimmed = url.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.lowercased().hasPrefix("https://") && trimmed.count > 10 + } + + var body: some View { + VStack(alignment: .leading, spacing: ScarfSpace.s3) { + Text("Install Skill from URL") + .scarfStyle(.headline) + .foregroundStyle(ScarfColor.foregroundPrimary) + + Text("Paste an HTTPS URL pointing at a SKILL.md or a tarball. Hermes downloads, scans, and installs it under `~/.hermes/skills///`.") + .scarfStyle(.caption) + .foregroundStyle(ScarfColor.foregroundMuted) + + VStack(alignment: .leading, spacing: 4) { + Text("URL") + .scarfStyle(.captionUppercase) + .foregroundStyle(ScarfColor.foregroundMuted) + ScarfTextField("https://example.com/path/to/SKILL.md", text: $url) + } + + DisclosureGroup("Optional overrides") { + VStack(alignment: .leading, spacing: ScarfSpace.s2) { + VStack(alignment: .leading, spacing: 4) { + Text("Category") + .scarfStyle(.captionUppercase) + .foregroundStyle(ScarfColor.foregroundMuted) + ScarfTextField("e.g. productivity (defaults to `local`)", text: $category) + } + VStack(alignment: .leading, spacing: 4) { + Text("Skill name") + .scarfStyle(.captionUppercase) + .foregroundStyle(ScarfColor.foregroundMuted) + ScarfTextField("Override if SKILL.md has no `name:`", text: $nameOverride) + } + } + .padding(.top, ScarfSpace.s2) + } + .scarfStyle(.body) + + HStack { + Spacer() + Button("Cancel") { dismiss() } + .buttonStyle(ScarfGhostButton()) + Button("Install") { + let trimmedURL = url.trimmingCharacters(in: .whitespacesAndNewlines) + let cat = category.trimmingCharacters(in: .whitespacesAndNewlines) + let name = nameOverride.trimmingCharacters(in: .whitespacesAndNewlines) + viewModel.installFromURL( + trimmedURL, + categoryOverride: cat.isEmpty ? nil : cat, + nameOverride: name.isEmpty ? nil : name + ) + dismiss() + } + .buttonStyle(ScarfPrimaryButton()) + .keyboardShortcut(.defaultAction) + .disabled(!isValid) + } + } + .padding(ScarfSpace.s5) + .frame(width: 460) + } +} diff --git a/scarf/scarf/Features/Skills/Views/SkillsView.swift b/scarf/scarf/Features/Skills/Views/SkillsView.swift index 7d48c81..553d7ae 100644 --- a/scarf/scarf/Features/Skills/Views/SkillsView.swift +++ b/scarf/scarf/Features/Skills/Views/SkillsView.swift @@ -14,7 +14,11 @@ struct SkillsView: View { /// for the active server. Drives the v2.5 "What's New" pill at /// the top of the Skills list. Nil before first compute. @State private var snapshotDiff: SkillSnapshotDiff? + /// Sheet for v0.12 direct-URL skill install. Capability-gated so + /// the trigger button only appears on hosts that support it. + @State private var showInstallFromURLSheet = false @Environment(\.serverContext) private var serverContext + @Environment(\.hermesCapabilities) private var capabilitiesStore @State private var currentTab: Tab = .installed init(context: ServerContext) { @@ -42,7 +46,26 @@ struct SkillsView: View { ScarfPageHeader( "Skills", subtitle: "Pre-packaged prompt collections the agent can call into. \(viewModel.totalSkillCount) installed." - ) + ) { + HStack(spacing: 6) { + Button { + Task { await viewModel.reloadSkills() } + } label: { + Label("Reload", systemImage: "arrow.clockwise") + } + .buttonStyle(ScarfGhostButton()) + .help("Re-scan ~/.hermes/skills/ and pick up edits without restarting Hermes") + + if capabilitiesStore?.capabilities.hasSkillURLInstall ?? false { + Button { + showInstallFromURLSheet = true + } label: { + Label("Install from URL…", systemImage: "link.badge.plus") + } + .buttonStyle(ScarfPrimaryButton()) + } + } + } modePicker // v2.5 "What's New" pill — only renders when the diff has // changes against a non-empty prior snapshot (first launch @@ -92,6 +115,9 @@ struct SkillsView: View { .task { recomputeSnapshotDiff() } + .sheet(isPresented: $showInstallFromURLSheet) { + InstallFromURLSheet(viewModel: viewModel) + } } /// Compute the snapshot diff against the active server's last-seen @@ -186,7 +212,7 @@ struct SkillsView: View { ForEach(viewModel.filteredCategories) { category in Section(category.name) { ForEach(category.skills) { skill in - Label(skill.name, systemImage: "lightbulb") + skillRow(skill) .tag(skill.id) } } @@ -195,6 +221,38 @@ struct SkillsView: View { .listStyle(.sidebar) } + /// Sidebar row with enabled/disabled visual state + pin badge. + /// Disabled skills render at .secondary opacity so the user can see + /// they exist but Hermes won't load them. + @ViewBuilder + private func skillRow(_ skill: HermesSkill) -> some View { + HStack(spacing: 4) { + Image(systemName: "lightbulb") + .frame(width: 14) + .foregroundStyle(skill.enabled ? .primary : .secondary) + Text(skill.name) + .foregroundStyle(skill.enabled ? .primary : .secondary) + .strikethrough(!skill.enabled, color: .secondary) + Spacer(minLength: 0) + if skill.pinned { + Image(systemName: "pin.fill") + .font(.system(size: 9)) + .foregroundStyle(ScarfColor.accent) + .help("Pinned by curator") + } + if !skill.enabled { + Text("OFF") + .font(.system(size: 9, weight: .semibold)) + .padding(.horizontal, 4) + .padding(.vertical, 1) + .background(ScarfColor.backgroundTertiary) + .clipShape(RoundedRectangle(cornerRadius: 3)) + .foregroundStyle(ScarfColor.foregroundMuted) + .help("Disabled in skills.disabled — Hermes won't load this one") + } + } + } + @ViewBuilder private var skillDetail: some View { if let skill = viewModel.selectedSkill {