feat(hermes-v12): Skills v0.12 surface — URL install + reload + pin/disable badges (Phase E)

Hermes v0.12 added three skills surfaces Scarf can now reach:

- Direct-URL install: `hermes skills install <https://...>` 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) <noreply@anthropic.com>
This commit is contained in:
Alan Wizemann
2026-05-01 12:44:15 +02:00
parent 686fb37630
commit ee3791a1b2
7 changed files with 375 additions and 13 deletions
@@ -37,6 +37,16 @@ public struct HermesSkill: Identifiable, Sendable {
/// Python packages). Used by `SkillPrereqService` to know what to /// Python packages). Used by `SkillPrereqService` to know what to
/// probe; nil when the field is absent. /// probe; nil when the field is absent.
public let dependencies: [String]? 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 <name>`.
/// 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( public init(
id: String, id: String,
@@ -47,7 +57,9 @@ public struct HermesSkill: Identifiable, Sendable {
requiredConfig: [String], requiredConfig: [String],
allowedTools: [String]? = nil, allowedTools: [String]? = nil,
relatedSkills: [String]? = nil, relatedSkills: [String]? = nil,
dependencies: [String]? = nil dependencies: [String]? = nil,
enabled: Bool = true,
pinned: Bool = false
) { ) {
self.id = id self.id = id
self.name = name self.name = name
@@ -58,5 +70,7 @@ public struct HermesSkill: Identifiable, Sendable {
self.allowedTools = allowedTools self.allowedTools = allowedTools
self.relatedSkills = relatedSkills self.relatedSkills = relatedSkills
self.dependencies = dependencies self.dependencies = dependencies
self.enabled = enabled
self.pinned = pinned
} }
} }
@@ -13,7 +13,12 @@ import os
public enum SkillsScanner: Sendable { public enum SkillsScanner: Sendable {
private static let logger = Logger(subsystem: "com.scarf", category: "SkillsScanner") 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<String> = [],
pinnedNames: Set<String> = []
) -> [HermesSkillCategory] {
let dir = context.paths.skillsDir let dir = context.paths.skillsDir
// Fresh install: skills/ may not exist yet return [] without // Fresh install: skills/ may not exist yet return [] without
// logging an error. // logging an error.
@@ -59,7 +64,9 @@ public enum SkillsScanner: Sendable {
requiredConfig: requiredConfig, requiredConfig: requiredConfig,
allowedTools: v011.allowedTools, allowedTools: v011.allowedTools,
relatedSkills: v011.relatedSkills, relatedSkills: v011.relatedSkills,
dependencies: v011.dependencies dependencies: v011.dependencies,
enabled: !disabledNames.contains(skillName),
pinned: pinnedNames.contains(skillName)
) )
} }
@@ -70,19 +70,104 @@ public final class SkillsViewModel {
/// Awaitable scan. iOS's `.task { await vm.load() }` and the /// Awaitable scan. iOS's `.task { await vm.load() }` and the
/// ScarfCore unit tests use this directly; Mac call sites wrap in /// ScarfCore unit tests use this directly; Mac call sites wrap in
/// `Task { await ... }` from `onAppear`. /// `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 @MainActor
public func load() async { public func load(pinnedNames: Set<String>? = nil) async {
isLoading = true isLoading = true
lastError = nil lastError = nil
let ctx = context let ctx = context
let xport = transport let xport = transport
let pins = pinnedNames
let cats: [HermesSkillCategory] = await Task.detached { 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 }.value
categories = cats categories = cats
isLoading = false 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<String> {
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<String> {
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) { public func selectSkill(_ skill: HermesSkill) {
selectedSkill = skill selectedSkill = skill
let mainFile = skill.files.first(where: { $0.hasSuffix(".md") }) ?? skill.files.first 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) { public func uninstallHubSkill(_ identifier: String) {
let bin = context.paths.hermesBinary let bin = context.paths.hermesBinary
let xport = transport let xport = transport
@@ -36,12 +36,33 @@ struct InstalledSkillsListView: View {
NavigationLink { NavigationLink {
SkillDetailView(skill: skill, vm: vm) SkillDetailView(skill: skill, vm: vm)
} label: { } label: {
VStack(alignment: .leading, spacing: 2) { HStack(spacing: 8) {
Text(skill.name) VStack(alignment: .leading, spacing: 2) {
.font(.body) Text(skill.name)
Text("\(skill.files.count) file\(skill.files.count == 1 ? "" : "s")") .font(.body)
.font(.caption) .foregroundStyle(skill.enabled ? .primary : .secondary)
.foregroundStyle(ScarfColor.foregroundMuted) .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() .scarfGoCompactListRow()
@@ -31,6 +31,34 @@ struct SkillDetailView: View {
.font(.caption.monospaced()) .font(.caption.monospaced())
.foregroundStyle(ScarfColor.foregroundMuted) .foregroundStyle(ScarfColor.foregroundMuted)
.textSelection(.enabled) .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) .listRowBackground(ScarfColor.backgroundSecondary)
@@ -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/<category>/<name>/`. 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/<category>/<name>/`.")
.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)
}
}
@@ -14,7 +14,11 @@ struct SkillsView: View {
/// for the active server. Drives the v2.5 "What's New" pill at /// for the active server. Drives the v2.5 "What's New" pill at
/// the top of the Skills list. Nil before first compute. /// the top of the Skills list. Nil before first compute.
@State private var snapshotDiff: SkillSnapshotDiff? @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(\.serverContext) private var serverContext
@Environment(\.hermesCapabilities) private var capabilitiesStore
@State private var currentTab: Tab = .installed @State private var currentTab: Tab = .installed
init(context: ServerContext) { init(context: ServerContext) {
@@ -42,7 +46,26 @@ struct SkillsView: View {
ScarfPageHeader( ScarfPageHeader(
"Skills", "Skills",
subtitle: "Pre-packaged prompt collections the agent can call into. \(viewModel.totalSkillCount) installed." 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 modePicker
// v2.5 "What's New" pill only renders when the diff has // v2.5 "What's New" pill only renders when the diff has
// changes against a non-empty prior snapshot (first launch // changes against a non-empty prior snapshot (first launch
@@ -92,6 +115,9 @@ struct SkillsView: View {
.task { .task {
recomputeSnapshotDiff() recomputeSnapshotDiff()
} }
.sheet(isPresented: $showInstallFromURLSheet) {
InstallFromURLSheet(viewModel: viewModel)
}
} }
/// Compute the snapshot diff against the active server's last-seen /// Compute the snapshot diff against the active server's last-seen
@@ -186,7 +212,7 @@ struct SkillsView: View {
ForEach(viewModel.filteredCategories) { category in ForEach(viewModel.filteredCategories) { category in
Section(category.name) { Section(category.name) {
ForEach(category.skills) { skill in ForEach(category.skills) { skill in
Label(skill.name, systemImage: "lightbulb") skillRow(skill)
.tag(skill.id) .tag(skill.id)
} }
} }
@@ -195,6 +221,38 @@ struct SkillsView: View {
.listStyle(.sidebar) .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 @ViewBuilder
private var skillDetail: some View { private var skillDetail: some View {
if let skill = viewModel.selectedSkill { if let skill = viewModel.selectedSkill {