Files
scarf/scarf/scarf/Features/Skills/Views/InstallFromURLSheet.swift
T
Alan Wizemann ee3791a1b2 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>
2026-05-01 12:44:15 +02:00

88 lines
3.7 KiB
Swift

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)
}
}