mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 02:26:37 +00:00
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:
@@ -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 <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(
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String> = [],
|
||||
pinnedNames: Set<String> = []
|
||||
) -> [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)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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<String>? = 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<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) {
|
||||
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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
/// 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 {
|
||||
|
||||
Reference in New Issue
Block a user