From 799332fbcd382ffa8b3032bc21f886a00d6a59bb Mon Sep 17 00:00:00 2001 From: Alan Wizemann Date: Fri, 1 May 2026 12:58:28 +0200 Subject: [PATCH] =?UTF-8?q?feat(hermes-v12):=20iOS=20catch-up=20=E2=80=94?= =?UTF-8?q?=20Webhooks/Plugins/Profiles=20read-only=20+=20version=20banner?= =?UTF-8?q?=20(Phase=20H)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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//` 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) --- scarf/Scarf iOS/App/ScarfGoTabRoot.swift | 30 +++ .../Components/HermesVersionBanner.swift | 77 ++++++++ scarf/Scarf iOS/Dashboard/DashboardView.swift | 7 + scarf/Scarf iOS/Plugins/PluginsView.swift | 136 +++++++++++++ scarf/Scarf iOS/Profiles/ProfilesView.swift | 153 +++++++++++++++ scarf/Scarf iOS/Webhooks/WebhooksView.swift | 182 ++++++++++++++++++ 6 files changed, 585 insertions(+) create mode 100644 scarf/Scarf iOS/Components/HermesVersionBanner.swift create mode 100644 scarf/Scarf iOS/Plugins/PluginsView.swift create mode 100644 scarf/Scarf iOS/Profiles/ProfilesView.swift create mode 100644 scarf/Scarf iOS/Webhooks/WebhooksView.swift diff --git a/scarf/Scarf iOS/App/ScarfGoTabRoot.swift b/scarf/Scarf iOS/App/ScarfGoTabRoot.swift index f9063c6..977bb2a 100644 --- a/scarf/Scarf iOS/App/ScarfGoTabRoot.swift +++ b/scarf/Scarf iOS/App/ScarfGoTabRoot.swift @@ -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) { diff --git a/scarf/Scarf iOS/Components/HermesVersionBanner.swift b/scarf/Scarf iOS/Components/HermesVersionBanner.swift new file mode 100644 index 0000000..84b54ea --- /dev/null +++ b/scarf/Scarf iOS/Components/HermesVersionBanner.swift @@ -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" + } +} diff --git a/scarf/Scarf iOS/Dashboard/DashboardView.swift b/scarf/Scarf iOS/Dashboard/DashboardView.swift index d5b291c..e569d45 100644 --- a/scarf/Scarf iOS/Dashboard/DashboardView.swift +++ b/scarf/Scarf iOS/Dashboard/DashboardView.swift @@ -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) diff --git a/scarf/Scarf iOS/Plugins/PluginsView.swift b/scarf/Scarf iOS/Plugins/PluginsView.swift new file mode 100644 index 0000000..196022c --- /dev/null +++ b/scarf/Scarf iOS/Plugins/PluginsView.swift @@ -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//`. Install one with `hermes plugins install ` 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 + } +} diff --git a/scarf/Scarf iOS/Profiles/ProfilesView.swift b/scarf/Scarf iOS/Profiles/ProfilesView.swift new file mode 100644 index 0000000..3126c92 --- /dev/null +++ b/scarf/Scarf iOS/Profiles/ProfilesView.swift @@ -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 ` 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() + return results.filter { seen.insert($0.name).inserted } + } + + private struct ProfileRow: Identifiable { + var id: String { name } + let name: String + let aliasesLabel: String? + } +} diff --git a/scarf/Scarf iOS/Webhooks/WebhooksView.swift b/scarf/Scarf iOS/Webhooks/WebhooksView.swift new file mode 100644 index 0000000..7dc55d1 --- /dev/null +++ b/scarf/Scarf iOS/Webhooks/WebhooksView.swift @@ -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 + } +}