feat(hermes-v12): iOS catch-up — Webhooks/Plugins/Profiles read-only + version banner (Phase H)

Closes the iOS read-only inspection gap on three CLI-driven Hermes
surfaces and adds a Hermes-version banner so mobile users on remote
v0.11 hosts see the upgrade nudge inline.

Components:

- Scarf iOS/Components/HermesVersionBanner.swift — yellow banner shown
  on the Dashboard when the active server's HermesCapabilities returns
  detected==true && hasCurator==false. One-tap session dismiss; comes
  back on next app open. Lists the v0.12 capabilities the user is
  missing out on (curator, multimodal, new providers).

- Scarf iOS/Webhooks/WebhooksView.swift — read-only list rendered from
  `hermes webhook list`. Tolerant block parser mirrors the Mac
  WebhooksViewModel shape so future drift fixes in one canonical place
  if/when promoted into ScarfCore. Detects the "platform not enabled"
  state and shows a setup-required pane instead of synthesizing rows
  from instructional text.

- Scarf iOS/Plugins/PluginsView.swift — filesystem-first scan over
  `~/.hermes/plugins/<name>/` with plugin.json / plugin.yaml manifest
  reads (mirrors the Mac VM). Enabled/disabled badge, version, source.
  Uses HermesYAML.parseNestedYAML / stripYAMLQuotes from ScarfCore
  (already public).

- Scarf iOS/Profiles/ProfilesView.swift — `hermes profile list` text
  parser with active-profile highlighting from
  `~/.hermes/active_profile`. Defensively handles both Rich box-drawn
  table output and plain-text fallback.

ScarfGoTabRoot's System tab gains an "Inspect" section with the three
new NavigationLinks. None are capability-gated — the underlying
list verbs exist on both v0.11 and v0.12, so the read views work
against either Hermes version without surprises.

Tests: 215 ScarfCore tests pass; both Mac and iOS schemes build clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alan Wizemann
2026-05-01 12:58:28 +02:00
parent 7a833b6c5a
commit 799332fbcd
6 changed files with 585 additions and 0 deletions
+30
View File
@@ -241,6 +241,36 @@ private struct SystemTab: View {
.listRowBackground(ScarfColor.backgroundSecondary)
}
// v2.6: read-only mobile views over CLI-driven Hermes
// surfaces. Mac owns the create/edit paths; phones get a
// monitoring window into what the remote agent is honoring.
// None of these are capability-gated the underlying
// `hermes plugins/profile/webhook list` verbs exist on
// both v0.11 and v0.12, so the read views work on either.
Section("Inspect") {
NavigationLink {
WebhooksView(config: config)
} label: {
Label("Webhooks", systemImage: "arrow.up.right.square")
}
.scarfGoCompactListRow()
.listRowBackground(ScarfColor.backgroundSecondary)
NavigationLink {
PluginsView(config: config)
} label: {
Label("Plugins", systemImage: "app.badge.checkmark")
}
.scarfGoCompactListRow()
.listRowBackground(ScarfColor.backgroundSecondary)
NavigationLink {
ProfilesView(config: config)
} label: {
Label("Profiles", systemImage: "person.2.crop.square.stack")
}
.scarfGoCompactListRow()
.listRowBackground(ScarfColor.backgroundSecondary)
}
Section {
Toggle(isOn: $iCloudSyncEnabled) {
HStack(spacing: 10) {
@@ -0,0 +1,77 @@
import SwiftUI
import ScarfCore
import ScarfDesign
/// Yellow banner that nudges users to upgrade Hermes when the remote
/// is running pre-v0.12. Shown on the Dashboard tab; auto-dismissed
/// for the rest of the session when the user taps the X. Persistent
/// re-show on each app open keeps the prompt visible without nagging
/// inside a single session.
///
/// Hidden entirely on v0.12+ (the new features are reachable) and
/// while capability detection is still in flight.
struct HermesVersionBanner: View {
@Environment(\.hermesCapabilities) private var capabilitiesStore
@State private var dismissedThisSession = false
/// Capability gate only render when:
/// - the store finished its initial detection AND
/// - the host returned an actual version string AND
/// - that version is below v0.12 AND
/// - the user hasn't dismissed this banner during this session.
private var shouldShow: Bool {
guard let store = capabilitiesStore else { return false }
let caps = store.capabilities
guard caps.detected else { return false } // skip while loading / on detection failure
guard !caps.hasCurator else { return false } // already on v0.12+
return !dismissedThisSession
}
var body: some View {
if shouldShow {
HStack(alignment: .top, spacing: 10) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(ScarfColor.warning)
VStack(alignment: .leading, spacing: 2) {
Text("Hermes update available")
.font(.callout.weight(.semibold))
Text("This server runs \(versionLabel). Update to v0.12 to unlock the autonomous curator, multimodal image input, GMI Cloud / Azure / LM Studio / MiniMax / Tencent providers, and more.")
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
Spacer(minLength: 8)
Button {
dismissedThisSession = true
} label: {
Image(systemName: "xmark")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
.accessibilityLabel("Dismiss this version notice for the rest of the session")
}
.padding(.horizontal, 12)
.padding(.vertical, 10)
.background(ScarfColor.warning.opacity(0.12))
.overlay(
Rectangle()
.fill(ScarfColor.warning.opacity(0.4))
.frame(height: 1),
alignment: .bottom
)
.transition(.opacity)
}
}
/// Pretty-print the detected version. Falls back to the raw line
/// if parsing didn't extract semver keeps the banner honest
/// when Hermes ships an unexpected version string.
private var versionLabel: String {
let caps = capabilitiesStore?.capabilities
if let semver = caps?.semver {
return "Hermes v\(semver.description)"
}
return caps?.versionLine ?? "an older Hermes"
}
}
@@ -42,6 +42,13 @@ struct DashboardView: View {
var body: some View {
VStack(spacing: 0) {
// v2.6 Hermes-version banner. Renders only when the remote
// is pre-v0.12 and the user hasn't dismissed for this
// session. v0.12+ hosts get a tab with no banner above
// the picker; older hosts see the upgrade nudge inline so
// it's visible without burying it inside Settings.
HermesVersionBanner()
Picker("View", selection: $selectedSection) {
Text("Overview").tag(Section.overview)
Text("Sessions").tag(Section.sessions)
+136
View File
@@ -0,0 +1,136 @@
import SwiftUI
import ScarfCore
import ScarfDesign
/// iOS read-only Plugins view (v2.6).
///
/// Walks `~/.hermes/plugins/` (each subdirectory is one plugin) and
/// reads the optional `plugin.json` / `plugin.yaml` manifest for each.
/// Mirrors the Mac PluginsViewModel's filesystem-first source-of-truth
/// approach `hermes plugins list`'s box-drawn output is fragile to
/// parse from a phone form-factor.
///
/// Install / update / remove / enable / disable verbs stay on Mac for
/// v2.6 installing a plugin from a phone is an unusual flow.
struct PluginsView: View {
let config: IOSServerConfig
@State private var plugins: [PluginRow] = []
@State private var isLoading = true
@State private var lastError: String?
@Environment(\.serverContext) private var contextFromEnv
private var context: ServerContext {
config.toServerContext(id: contextFromEnv.id)
}
var body: some View {
List {
if let err = lastError {
Section {
Label(err, systemImage: "exclamationmark.triangle.fill")
.foregroundStyle(ScarfColor.warning)
}
}
if plugins.isEmpty && !isLoading {
Section {
ContentUnavailableView(
"No plugins installed",
systemImage: "app.badge.checkmark",
description: Text("Hermes plugins live under `~/.hermes/plugins/<name>/`. Install one with `hermes plugins install <repo>` from the Mac app.")
)
}
} else {
ForEach(plugins) { plugin in
Section(plugin.name) {
HStack {
statusBadge(plugin.enabled)
if !plugin.version.isEmpty {
Text("v\(plugin.version)")
.font(ScarfFont.monoSmall)
.foregroundStyle(.secondary)
}
Spacer()
}
if !plugin.source.isEmpty {
LabeledContent("Source", value: plugin.source)
.font(.caption.monospaced())
}
Text(plugin.path)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.textSelection(.enabled)
}
}
}
}
.navigationTitle("Plugins")
.navigationBarTitleDisplayMode(.large)
.refreshable { await load() }
.task { await load() }
}
private func statusBadge(_ enabled: Bool) -> some View {
ScarfBadge(enabled ? "Enabled" : "Disabled", kind: enabled ? .success : .neutral)
}
private func load() async {
isLoading = true
defer { isLoading = false }
let ctx = context
let entries = await Task.detached {
Self.scan(context: ctx)
}.value
self.plugins = entries
}
nonisolated private static func scan(context: ServerContext) -> [PluginRow] {
let transport = context.makeTransport()
let dir = context.paths.pluginsDir
guard let entries = try? transport.listDirectory(dir) else { return [] }
var results: [PluginRow] = []
for entry in entries.sorted() where !entry.hasPrefix(".") {
let path = dir + "/" + entry
guard transport.stat(path)?.isDirectory == true else { continue }
let manifest = readManifest(path: path, context: context)
let disabled = transport.fileExists(path + "/.disabled")
results.append(PluginRow(
name: entry,
version: manifest.version,
source: manifest.source,
path: path,
enabled: !disabled
))
}
return results
}
/// Read `plugin.json` first; fall back to `plugin.yaml` for plugins
/// that author manifest in YAML. Same shape as the Mac VM so
/// parsing stays consistent across targets.
nonisolated private static func readManifest(path: String, context: ServerContext) -> (source: String, version: String) {
if let data = context.readData(path + "/plugin.json"),
let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
let source = (obj["source"] as? String) ?? (obj["repository"] as? String) ?? (obj["url"] as? String) ?? ""
let version = (obj["version"] as? String) ?? ""
return (source, version)
}
if let yaml = context.readText(path + "/plugin.yaml") {
let parsed = HermesYAML.parseNestedYAML(yaml)
let source = HermesYAML.stripYAMLQuotes(parsed.values["source"] ?? parsed.values["repository"] ?? parsed.values["url"] ?? "")
let version = HermesYAML.stripYAMLQuotes(parsed.values["version"] ?? "")
return (source, version)
}
return ("", "")
}
private struct PluginRow: Identifiable {
var id: String { name }
let name: String
let version: String
let source: String
let path: String
let enabled: Bool
}
}
+153
View File
@@ -0,0 +1,153 @@
import SwiftUI
import ScarfCore
import ScarfDesign
/// iOS read-only Profiles view (v2.6).
///
/// Lists `hermes profile list` output and highlights the active profile.
/// Profile switching, creation, deletion, and import/export remain on
/// the Mac app those involve writing data we don't want to risk
/// fat-fingering on a phone (e.g., wiping the active profile by accident).
struct ProfilesView: View {
let config: IOSServerConfig
@State private var profiles: [ProfileRow] = []
@State private var activeProfile: String?
@State private var isLoading = true
@State private var lastError: String?
@Environment(\.serverContext) private var contextFromEnv
private var context: ServerContext {
config.toServerContext(id: contextFromEnv.id)
}
var body: some View {
List {
if let err = lastError {
Section {
Label(err, systemImage: "exclamationmark.triangle.fill")
.foregroundStyle(ScarfColor.warning)
}
}
if profiles.isEmpty && !isLoading {
Section {
ContentUnavailableView(
"No profiles",
systemImage: "person.2.crop.square.stack",
description: Text("Hermes profiles let you keep multiple HERMES_HOME directories side-by-side. Create one with `hermes profile create <name>` from the Mac app.")
)
}
} else {
Section {
ForEach(profiles) { p in
HStack(spacing: 8) {
VStack(alignment: .leading, spacing: 2) {
Text(p.name)
.font(.body)
if let aliases = p.aliasesLabel {
Text(aliases)
.font(.caption)
.foregroundStyle(.secondary)
}
}
Spacer()
if p.name == activeProfile {
ScarfBadge("Active", kind: .success)
}
}
}
} header: {
if let active = activeProfile {
Text("Active profile: \(active)")
} else {
Text("All profiles")
}
} footer: {
Text("Switching profiles, creating new ones, and import/export live in the Mac app — they touch enough state that we keep them off the phone.")
.font(.caption)
}
}
}
.navigationTitle("Profiles")
.navigationBarTitleDisplayMode(.large)
.refreshable { await load() }
.task { await load() }
}
private func load() async {
isLoading = true
defer { isLoading = false }
let ctx = context
let result = await Task.detached { () -> (output: String, active: String?) in
let listOut = Self.runHermes(context: ctx, args: ["profile", "list"])
// Active profile lives at ~/.hermes/active_profile (text file
// with one line). Reading directly is faster than another
// CLI round-trip.
let activeRaw = ctx.readText(ctx.paths.home + "/active_profile")
let active = activeRaw?.trimmingCharacters(in: .whitespacesAndNewlines)
return (listOut, active)
}.value
self.profiles = Self.parse(result.output)
self.activeProfile = result.active.flatMap { $0.isEmpty ? nil : $0 }
}
nonisolated private static func runHermes(context: ServerContext, args: [String]) -> String {
let transport = context.makeTransport()
do {
let r = try transport.runProcess(
executable: context.paths.hermesBinary,
args: args,
stdin: nil,
timeout: 30
)
return r.stdoutString + r.stderrString
} catch {
return ""
}
}
/// Tolerant parser for `hermes profile list`. The CLI prints a
/// table-like format with the profile name on the leading column
/// and optional alias / path columns afterwards. We surface the
/// name (always present); aliases collapse into a comma-separated
/// label in the row when present.
nonisolated private static func parse(_ output: String) -> [ProfileRow] {
var results: [ProfileRow] = []
for raw in output.components(separatedBy: "\n") {
let trimmed = raw.trimmingCharacters(in: .whitespaces)
guard !trimmed.isEmpty else { continue }
// Skip table-rule and header lines.
if trimmed.hasPrefix("") || trimmed.hasPrefix("") || trimmed.hasPrefix("")
|| trimmed.hasPrefix("") || trimmed.hasPrefix("") || trimmed.hasPrefix("") {
// Strip box-drawing chars and try to extract the leading column.
let body = trimmed
.replacingOccurrences(of: "", with: "|")
.replacingOccurrences(of: "", with: "|")
if !body.contains("|") { continue }
let cols = body.split(separator: "|", omittingEmptySubsequences: true)
.map { $0.trimmingCharacters(in: .whitespaces) }
guard let name = cols.first, !name.isEmpty,
name.range(of: "^[A-Za-z0-9_.-]+$", options: .regularExpression) != nil
else { continue }
let aliases = cols.dropFirst().filter { !$0.isEmpty }.joined(separator: ", ")
results.append(ProfileRow(name: name, aliasesLabel: aliases.isEmpty ? nil : aliases))
continue
}
// Plain-text fallback: first whitespace-delimited token is the name.
if let name = trimmed.split(whereSeparator: { $0 == " " || $0 == "\t" }).first,
name.range(of: "^[A-Za-z0-9_.-]+$", options: .regularExpression) != nil {
results.append(ProfileRow(name: String(name), aliasesLabel: nil))
}
}
// Dedupe (the table-row + plain-text passes can overlap).
var seen = Set<String>()
return results.filter { seen.insert($0.name).inserted }
}
private struct ProfileRow: Identifiable {
var id: String { name }
let name: String
let aliasesLabel: String?
}
}
+182
View File
@@ -0,0 +1,182 @@
import SwiftUI
import ScarfCore
import ScarfDesign
import os
/// iOS read-only Webhooks view (v2.6).
///
/// Lists `hermes webhook list` output so mobile users can see what
/// dynamic webhook subscriptions the remote agent is honoring. Create /
/// remove / test actions stay on Mac for v2.6 most webhook setup
/// involves pasting URLs / secrets that are inconvenient on a phone.
///
/// Reuses the same tolerant text parser the Mac WebhooksViewModel uses.
struct WebhooksView: View {
let config: IOSServerConfig
@State private var webhooks: [WebhookRow] = []
@State private var notEnabled = false
@State private var isLoading = true
@State private var lastError: String?
@Environment(\.serverContext) private var contextFromEnv
private var context: ServerContext {
// The view receives `IOSServerConfig` directly (matches the
// sibling Skills/Settings tabs); use that to construct a
// context bound to the active server. Falls back to env when
// the navigation host hasn't injected a config-derived ctx.
config.toServerContext(id: contextFromEnv.id)
}
var body: some View {
List {
if let err = lastError {
Section {
Label(err, systemImage: "exclamationmark.triangle.fill")
.foregroundStyle(ScarfColor.warning)
}
}
if notEnabled {
Section("Setup required") {
Text("The webhook gateway platform isn't enabled on this server. Run `hermes setup` from the Mac app or a shell to enable it.")
.font(.caption)
.foregroundStyle(.secondary)
}
} else if webhooks.isEmpty && !isLoading {
Section {
ContentUnavailableView(
"No webhooks subscribed",
systemImage: "arrow.up.right.square",
description: Text("Run `hermes webhook subscribe …` from the Mac app to register one.")
)
}
} else {
ForEach(webhooks) { hook in
Section(hook.name) {
if !hook.description.isEmpty {
LabeledContent("Description", value: hook.description)
}
if !hook.deliver.isEmpty {
LabeledContent("Deliver", value: hook.deliver)
}
if !hook.events.isEmpty {
LabeledContent("Events", value: hook.events.joined(separator: ", "))
}
LabeledContent("Route", value: hook.routeSuffix)
.font(.caption.monospaced())
}
}
}
}
.navigationTitle("Webhooks")
.navigationBarTitleDisplayMode(.large)
.refreshable { await load() }
.task { await load() }
}
private func load() async {
isLoading = true
defer { isLoading = false }
let ctx = context
let result = await Task.detached {
return Self.runHermesList(context: ctx)
}.value
if Self.detectNotEnabled(result) {
self.notEnabled = true
self.webhooks = []
self.lastError = nil
return
}
self.notEnabled = false
let parsed = Self.parse(result)
self.webhooks = parsed
self.lastError = parsed.isEmpty && !result.isEmpty
? nil
: nil
}
nonisolated private static func runHermesList(context: ServerContext) -> String {
let transport = context.makeTransport()
do {
let r = try transport.runProcess(
executable: context.paths.hermesBinary,
args: ["webhook", "list"],
stdin: nil,
timeout: 30
)
return r.stdoutString + r.stderrString
} catch {
return ""
}
}
nonisolated private static func detectNotEnabled(_ output: String) -> Bool {
let lower = output.lowercased()
return lower.contains("webhook platform is not enabled")
|| lower.contains("run the gateway setup wizard")
|| lower.contains("webhook_enabled=true")
}
/// Tolerant block-parser. Each subscription begins on a non-indented
/// line; description / deliver / events / url details follow as
/// indented `key: value` lines. Mirrors the Mac parser shape so
/// future drift only has to be fixed in one canonical place if/when
/// we promote this VM into ScarfCore.
nonisolated private static func parse(_ output: String) -> [WebhookRow] {
var results: [WebhookRow] = []
var name = ""
var desc = ""
var deliver = ""
var events: [String] = []
var route = ""
func flush() {
if !name.isEmpty {
results.append(WebhookRow(
name: name,
description: desc,
deliver: deliver,
events: events,
routeSuffix: route.isEmpty ? "/webhooks/\(name)" : route
))
}
name = ""; desc = ""; deliver = ""; events = []; route = ""
}
for raw in output.components(separatedBy: "\n") {
let line = raw
let trimmed = line.trimmingCharacters(in: .whitespaces)
if trimmed.isEmpty { continue }
if !line.hasPrefix(" ") && !line.hasPrefix("\t") {
flush()
let candidate = trimmed.trimmingCharacters(in: CharacterSet(charactersIn: ":"))
if candidate.range(of: "^[A-Za-z0-9_-]+$", options: .regularExpression) != nil {
name = candidate
}
continue
}
if trimmed.lowercased().hasPrefix("description:") {
desc = String(trimmed.dropFirst("description:".count)).trimmingCharacters(in: .whitespaces)
} else if trimmed.lowercased().hasPrefix("deliver:") {
deliver = String(trimmed.dropFirst("deliver:".count)).trimmingCharacters(in: .whitespaces)
} else if trimmed.lowercased().hasPrefix("events:") {
let list = String(trimmed.dropFirst("events:".count)).trimmingCharacters(in: .whitespaces)
events = list.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty }
} else if trimmed.lowercased().hasPrefix("url:") || trimmed.lowercased().hasPrefix("route:") {
route = trimmed.components(separatedBy: ":").dropFirst().joined(separator: ":").trimmingCharacters(in: .whitespaces)
}
}
flush()
return results
}
private struct WebhookRow: Identifiable {
var id: String { name }
let name: String
let description: String
let deliver: String
let events: [String]
let routeSuffix: String
}
}