From c81a8a56e8dd535af5bf77a238326dd571e191f8 Mon Sep 17 00:00:00 2001 From: Alan Wizemann Date: Sat, 9 May 2026 18:34:27 +0200 Subject: [PATCH 1/4] feat(mcp): add SSE transport support gated on hasMCPSSETransport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends MCPTransport with a third .sse case (alongside stdio + http), plumbed through the YAML parser, add-server form, list view, detail view, and editor. The add-server form filters .sse out of the segmented picker on pre-v0.13 hosts (capability-gated on hasMCPSSETransport) so Hermes never sees a transport flag it can't parse. The editor renders a third numeric "SSE read timeout" field only for .sse servers. YAML layer: - HermesMCPServer.sseReadTimeout: Int? — defaulted in init, decoded from `sse_read_timeout` scalar. - parseMCPServersBlock: 3-way transport discriminator — `transport: sse` scalar wins, then url-bearing entries default to .http (v0.12 shape), command-bearing to .stdio. Pre-v0.13 entries are byte-for-byte unaffected. - HermesFileService.addMCPServerSSE writes via `hermes mcp add --url --transport sse [--sse-read-timeout ]`. - HermesFileService.setMCPServerSSETimeout patches the scalar via the same surgical patcher used by setMCPServerTimeouts. TODO markers (WS-7-Q1/Q2/Q3) flag the wire-format unknowns the plan called out — verify against a v0.13 Hermes install during integration. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../ScarfCore/Models/HermesMCPServer.swift | 17 ++++- .../Core/Services/HermesFileService.swift | 51 ++++++++++++- .../ViewModels/MCPServerEditorViewModel.swift | 11 +++ .../ViewModels/MCPServersViewModel.swift | 36 +++++++++ .../Views/MCPServerAddCustomView.swift | 74 ++++++++++++++++--- .../Views/MCPServerDetailView.swift | 5 ++ .../Views/MCPServerEditorView.swift | 10 +++ .../MCPServers/Views/MCPServersView.swift | 8 ++ 8 files changed, 197 insertions(+), 15 deletions(-) diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesMCPServer.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesMCPServer.swift index 2578a6e..0be0484 100644 --- a/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesMCPServer.swift +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesMCPServer.swift @@ -3,6 +3,10 @@ import Foundation public enum MCPTransport: String, Sendable, Equatable, CaseIterable, Identifiable { case stdio case http + /// Server-Sent Events transport. Hermes v0.13+ only. + // TODO(WS-7-Q1): Verify Hermes uses the literal `sse` transport name + // (vs. `streamable-http`/`http-sse`/etc.) once a v0.13 host is on hand. + case sse public var id: String { rawValue } @@ -11,6 +15,7 @@ public enum MCPTransport: String, Sendable, Equatable, CaseIterable, Identifiabl switch self { case .stdio: return "Local (stdio)" case .http: return "Remote (HTTP)" + case .sse: return "Remote (SSE)" } } #endif @@ -33,6 +38,12 @@ public struct HermesMCPServer: Identifiable, Sendable, Equatable { public let resourcesEnabled: Bool public let promptsEnabled: Bool public let hasOAuthToken: Bool + /// Hermes-side keepalive interval (seconds) for SSE transport. `nil` + /// when the YAML doesn't specify `sse_read_timeout` (Hermes default + /// applies). Pre-v0.13 hosts always have this as `nil`. + // TODO(WS-7-Q2): Default is assumed to be 300s per WS-7 plan; placeholder + // copy uses that. Verify against `~/.hermes/hermes-agent/hermes_cli/mcp.py`. + public let sseReadTimeout: Int? public init( @@ -51,7 +62,8 @@ public struct HermesMCPServer: Identifiable, Sendable, Equatable { toolsExclude: [String], resourcesEnabled: Bool, promptsEnabled: Bool, - hasOAuthToken: Bool + hasOAuthToken: Bool, + sseReadTimeout: Int? = nil ) { self.name = name self.transport = transport @@ -69,6 +81,7 @@ public struct HermesMCPServer: Identifiable, Sendable, Equatable { self.resourcesEnabled = resourcesEnabled self.promptsEnabled = promptsEnabled self.hasOAuthToken = hasOAuthToken + self.sseReadTimeout = sseReadTimeout } public var id: String { name } @@ -79,6 +92,8 @@ public struct HermesMCPServer: Identifiable, Sendable, Equatable { return (command ?? "") + argString case .http: return url ?? "" + case .sse: + return url ?? "" } } } diff --git a/scarf/scarf/Core/Services/HermesFileService.swift b/scarf/scarf/Core/Services/HermesFileService.swift index 3938d09..7bcedc3 100644 --- a/scarf/scarf/Core/Services/HermesFileService.swift +++ b/scarf/scarf/Core/Services/HermesFileService.swift @@ -599,7 +599,8 @@ struct HermesFileService: Sendable { toolsExclude: server.toolsExclude, resourcesEnabled: server.resourcesEnabled, promptsEnabled: server.promptsEnabled, - hasOAuthToken: hasToken + hasOAuthToken: hasToken, + sseReadTimeout: server.sseReadTimeout ) } } @@ -630,6 +631,37 @@ struct HermesFileService: Sendable { return runHermesCLI(args: cliArgs, timeout: 45, stdinInput: "y\ny\ny\n") } + /// Adds an SSE-transport MCP server. v0.13+ only — caller is responsible + /// for capability-gating; pre-v0.13 hosts will reject the `--transport` + /// flag at argparse time. The optional `sseReadTimeout` is passed via + /// `--sse-read-timeout ` and persisted as `sse_read_timeout: ` + /// in the YAML entry. + // TODO(WS-7-Q3): Verify exact CLI flag spelling against `hermes mcp add --help` + // on a v0.13 install. Plan assumes `--transport sse` + `--sse-read-timeout`; + // alternatives could be `--sse` (boolean) + `--read-timeout`. + @discardableResult + nonisolated func addMCPServerSSE(name: String, url: String, sseReadTimeout: Int?) -> (exitCode: Int32, output: String) { + var cliArgs: [String] = ["mcp", "add", name, "--url", url, "--transport", "sse"] + if let timeout = sseReadTimeout { + cliArgs.append(contentsOf: ["--sse-read-timeout", String(timeout)]) + } + return runHermesCLI(args: cliArgs, timeout: 45, stdinInput: "y\ny\ny\n") + } + + /// Updates the `sse_read_timeout` scalar in-place via the same surgical + /// patcher used by `setMCPServerTimeouts`. Pass `nil` to remove the + /// scalar entirely (Hermes default applies). + @discardableResult + nonisolated func setMCPServerSSETimeout(name: String, sseReadTimeout: Int?) -> Bool { + patchMCPServerField(name: name) { entryLines in + if let timeout = sseReadTimeout { + Self.replaceOrInsertScalar(key: "sse_read_timeout", value: String(timeout), in: &entryLines) + } else { + Self.removeScalar(key: "sse_read_timeout", in: &entryLines) + } + } + } + @discardableResult nonisolated func setMCPServerArgs(name: String, args: [String]) -> Bool { patchMCPServerField(name: name) { entryLines in @@ -812,11 +844,23 @@ struct HermesFileService: Sendable { func flush() { guard let name = currentName else { return } - let transport: MCPTransport = fields["url"] != nil ? .http : .stdio + // 3-way transport discriminator: an explicit `transport: sse` scalar + // wins (Hermes v0.13+ emits it for SSE servers); otherwise URL-bearing + // entries fall back to .http (v0.12 shape) and command-bearing entries + // to .stdio. This preserves byte-for-byte round-trip on existing files + // — pre-v0.13 entries have no `transport:` key so they parse identically. + // TODO(WS-7-Q1): Verify Hermes v0.13 actually emits `transport: sse` + // (vs. inferring from the schema/url shape) once a v0.13 host is on hand. + let transport: MCPTransport = { + if fields["transport"]?.lowercased() == "sse" { return .sse } + if fields["url"] != nil { return .http } + return .stdio + }() let enabledStr = fields["enabled"]?.lowercased() let enabled = enabledStr != "false" let timeout = fields["timeout"].flatMap(Int.init) let connectTimeout = fields["connect_timeout"].flatMap(Int.init) + let sseReadTimeout = fields["sse_read_timeout"].flatMap(Int.init) let server = HermesMCPServer( name: name, transport: transport, @@ -833,7 +877,8 @@ struct HermesFileService: Sendable { toolsExclude: excludeList, resourcesEnabled: resources, promptsEnabled: prompts, - hasOAuthToken: false + hasOAuthToken: false, + sseReadTimeout: sseReadTimeout ) servers.append(server) diff --git a/scarf/scarf/Features/MCPServers/ViewModels/MCPServerEditorViewModel.swift b/scarf/scarf/Features/MCPServers/ViewModels/MCPServerEditorViewModel.swift index 6f85fa2..636f3a6 100644 --- a/scarf/scarf/Features/MCPServers/ViewModels/MCPServerEditorViewModel.swift +++ b/scarf/scarf/Features/MCPServers/ViewModels/MCPServerEditorViewModel.swift @@ -21,6 +21,9 @@ final class MCPServerEditorViewModel { var promptsEnabled: Bool var timeoutDraft: String var connectTimeoutDraft: String + /// SSE-only — renders as a third numeric on `.sse` servers. Empty string + /// means "use Hermes default" (writer drops the scalar). + var sseReadTimeoutDraft: String var showSecrets: Bool = false var isSaving: Bool = false var saveError: String? @@ -37,6 +40,7 @@ final class MCPServerEditorViewModel { self.promptsEnabled = server.promptsEnabled self.timeoutDraft = server.timeout.map { String($0) } ?? "" self.connectTimeoutDraft = server.connectTimeout.map { String($0) } ?? "" + self.sseReadTimeoutDraft = server.sseReadTimeout.map { String($0) } ?? "" } func appendEnvRow() { @@ -69,6 +73,8 @@ final class MCPServerEditorViewModel { let exclude = excludeDraft.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty } let timeoutValue = Int(timeoutDraft.trimmingCharacters(in: .whitespaces)) let connectValue = Int(connectTimeoutDraft.trimmingCharacters(in: .whitespaces)) + let trimmedSSE = sseReadTimeoutDraft.trimmingCharacters(in: .whitespaces) + let sseTimeoutValue: Int? = trimmedSSE.isEmpty ? nil : Int(trimmedSSE) let service = fileService let transport = server.transport @@ -87,6 +93,11 @@ final class MCPServerEditorViewModel { if !service.setMCPServerEnv(name: name, env: envMap) { ok = false } case .http: if !service.setMCPServerHeaders(name: name, headers: headerMap) { ok = false } + case .sse: + // SSE servers carry headers like .http does, plus an + // optional sse_read_timeout written below. + if !service.setMCPServerHeaders(name: name, headers: headerMap) { ok = false } + if !service.setMCPServerSSETimeout(name: name, sseReadTimeout: sseTimeoutValue) { ok = false } } if !service.updateMCPToolFilters( name: name, diff --git a/scarf/scarf/Features/MCPServers/ViewModels/MCPServersViewModel.swift b/scarf/scarf/Features/MCPServers/ViewModels/MCPServersViewModel.swift index 71deaa7..ceef6e1 100644 --- a/scarf/scarf/Features/MCPServers/ViewModels/MCPServersViewModel.swift +++ b/scarf/scarf/Features/MCPServers/ViewModels/MCPServersViewModel.swift @@ -42,6 +42,10 @@ final class MCPServersViewModel { filteredServers.filter { $0.transport == .http } } + var sseServers: [HermesMCPServer] { + filteredServers.filter { $0.transport == .sse } + } + var selectedServer: HermesMCPServer? { guard let name = selectedServerName else { return nil } return servers.first(where: { $0.name == name }) @@ -167,6 +171,11 @@ final class MCPServersViewModel { url: preset.url ?? "", auth: preset.auth ) + case .sse: + // No SSE-transport presets ship today; the preset picker + // only surfaces stdio/http servers. Treat as a no-op + // failure if a preset somehow declares .sse. + addResult = (exitCode: 1, output: "SSE-transport presets are not supported.") } guard addResult.exitCode == 0 else { await MainActor.run { @@ -196,6 +205,11 @@ final class MCPServersViewModel { result = fileService.addMCPServerStdio(name: name, command: command, args: args) case .http: result = fileService.addMCPServerHTTP(name: name, url: url, auth: auth) + case .sse: + // Routed through addCustomSSE; this branch is unreachable from + // the add-server form (which dispatches per-transport in submit()) + // but kept so the switch is exhaustive without `@unknown default`. + result = (exitCode: 1, output: "SSE servers must be added via addCustomSSE.") } await MainActor.run { if result.exitCode == 0 { @@ -211,6 +225,28 @@ final class MCPServersViewModel { } } + /// v0.13+ SSE-transport server creation. Caller is responsible for + /// capability-gating; the form filters `.sse` out of `availableTransports` + /// when `hasMCPSSETransport` is false, so this method is unreachable + /// from the UI on pre-v0.13 hosts. + func addCustomSSE(name: String, url: String, sseReadTimeout: Int?) { + let fileService = self.fileService + Task.detached { + let result = fileService.addMCPServerSSE(name: name, url: url, sseReadTimeout: sseReadTimeout) + await MainActor.run { + if result.exitCode == 0 { + self.flashStatus("Added \(name)") + self.load() + self.selectedServerName = name + self.showRestartBanner = true + self.showAddCustom = false + } else { + self.activeError = "Add failed: \(result.output)" + } + } + } + } + func restartGateway() { let fileService = self.fileService Task.detached { diff --git a/scarf/scarf/Features/MCPServers/Views/MCPServerAddCustomView.swift b/scarf/scarf/Features/MCPServers/Views/MCPServerAddCustomView.swift index 88da9cc..431c529 100644 --- a/scarf/scarf/Features/MCPServers/Views/MCPServerAddCustomView.swift +++ b/scarf/scarf/Features/MCPServers/Views/MCPServerAddCustomView.swift @@ -6,12 +6,26 @@ struct MCPServerAddCustomView: View { let viewModel: MCPServersViewModel @Environment(\.dismiss) private var dismiss + @Environment(\.hermesCapabilities) private var capabilitiesStore @State private var name: String = "" @State private var transport: MCPTransport = .stdio @State private var command: String = "npx" @State private var argsText: String = "" @State private var url: String = "" @State private var auth: String = "none" + @State private var sseReadTimeout: String = "" + + /// `.sse` is a v0.13+ surface; pre-v0.13 hosts only see stdio + http. + /// Iterating `MCPTransport.allCases` directly would render the SSE + /// segment unconditionally and Hermes would reject the resulting CLI + /// invocation at argparse time. + private var availableTransports: [MCPTransport] { + var t: [MCPTransport] = [.stdio, .http] + if capabilitiesStore?.capabilities.hasMCPSSETransport ?? false { + t.append(.sse) + } + return t + } var body: some View { VStack(spacing: 0) { @@ -44,17 +58,20 @@ struct MCPServerAddCustomView: View { } sectionBox(title: "Transport") { Picker("", selection: $transport) { - ForEach(MCPTransport.allCases) { t in + ForEach(availableTransports) { t in Text(t.displayName).tag(t) } } .pickerStyle(.segmented) .labelsHidden() } - if transport == .stdio { + switch transport { + case .stdio: stdioSection - } else { + case .http: httpSection + case .sse: + sseSection } Text("Env vars, headers, and tool filters can be edited after the server is added.") .font(.caption) @@ -112,6 +129,28 @@ struct MCPServerAddCustomView: View { } } + private var sseSection: some View { + sectionBox(title: "Endpoint (SSE)") { + VStack(alignment: .leading, spacing: 8) { + VStack(alignment: .leading, spacing: 4) { + Text("URL").font(.caption.bold()) + TextField("https://.../sse", text: $url) + .textFieldStyle(.roundedBorder) + .font(.system(.body, design: .monospaced)) + } + VStack(alignment: .leading, spacing: 4) { + Text("SSE Read Timeout (seconds)").font(.caption.bold()) + TextField("default 300", text: $sseReadTimeout) + .textFieldStyle(.roundedBorder) + .frame(maxWidth: 140) + Text("Hermes-side keepalive interval. Leave blank to use the default.") + .font(.caption2) + .foregroundStyle(.secondary) + } + } + } + } + private var canSubmit: Bool { let trimmedName = name.trimmingCharacters(in: .whitespaces) guard !trimmedName.isEmpty else { return false } @@ -120,6 +159,8 @@ struct MCPServerAddCustomView: View { return !command.trimmingCharacters(in: .whitespaces).isEmpty case .http: return !url.trimmingCharacters(in: .whitespaces).isEmpty + case .sse: + return !url.trimmingCharacters(in: .whitespaces).isEmpty } } @@ -130,14 +171,25 @@ struct MCPServerAddCustomView: View { .map { $0.trimmingCharacters(in: .whitespaces) } .filter { !$0.isEmpty } let resolvedAuth: String? = (auth == "none") ? nil : auth - viewModel.addCustom( - name: trimmedName, - transport: transport, - command: command.trimmingCharacters(in: .whitespaces), - args: args, - url: url.trimmingCharacters(in: .whitespaces), - auth: resolvedAuth - ) + switch transport { + case .stdio, .http: + viewModel.addCustom( + name: trimmedName, + transport: transport, + command: command.trimmingCharacters(in: .whitespaces), + args: args, + url: url.trimmingCharacters(in: .whitespaces), + auth: resolvedAuth + ) + case .sse: + let trimmedTimeout = sseReadTimeout.trimmingCharacters(in: .whitespaces) + let parsedTimeout: Int? = trimmedTimeout.isEmpty ? nil : Int(trimmedTimeout) + viewModel.addCustomSSE( + name: trimmedName, + url: url.trimmingCharacters(in: .whitespaces), + sseReadTimeout: parsedTimeout + ) + } dismiss() } diff --git a/scarf/scarf/Features/MCPServers/Views/MCPServerDetailView.swift b/scarf/scarf/Features/MCPServers/Views/MCPServerDetailView.swift index 1fc8634..5db184a 100644 --- a/scarf/scarf/Features/MCPServers/Views/MCPServerDetailView.swift +++ b/scarf/scarf/Features/MCPServers/Views/MCPServerDetailView.swift @@ -127,6 +127,11 @@ struct MCPServerDetailView: View { if let auth = server.auth, !auth.isEmpty { summaryRow(label: "Auth", value: auth) } + case .sse: + summaryRow(label: "URL", value: server.url ?? "—") + if let timeout = server.sseReadTimeout { + summaryRow(label: "Read TO", value: "\(timeout)s") + } } } .padding(ScarfSpace.s3) diff --git a/scarf/scarf/Features/MCPServers/Views/MCPServerEditorView.swift b/scarf/scarf/Features/MCPServers/Views/MCPServerEditorView.swift index 4062c9f..8bc8a75 100644 --- a/scarf/scarf/Features/MCPServers/Views/MCPServerEditorView.swift +++ b/scarf/scarf/Features/MCPServers/Views/MCPServerEditorView.swift @@ -186,6 +186,16 @@ struct MCPServerEditorView: View { .textFieldStyle(.roundedBorder) .frame(maxWidth: 140) } + if viewModel.server.transport == .sse { + VStack(alignment: .leading, spacing: 4) { + Text("SSE read timeout") + .font(.caption) + .foregroundStyle(.secondary) + TextField("default 300", text: $viewModel.sseReadTimeoutDraft) + .textFieldStyle(.roundedBorder) + .frame(maxWidth: 140) + } + } Spacer() } } diff --git a/scarf/scarf/Features/MCPServers/Views/MCPServersView.swift b/scarf/scarf/Features/MCPServers/Views/MCPServersView.swift index 12210a0..4d86e28 100644 --- a/scarf/scarf/Features/MCPServers/Views/MCPServersView.swift +++ b/scarf/scarf/Features/MCPServers/Views/MCPServersView.swift @@ -132,6 +132,14 @@ struct MCPServersView: View { } } } + if !viewModel.sseServers.isEmpty { + Section("Remote (SSE)") { + ForEach(viewModel.sseServers) { server in + serverRow(server) + .tag(server.name as String?) + } + } + } if viewModel.servers.isEmpty && !viewModel.isLoading { Section { Text("No servers configured yet") From fd33b714e348096286f309d54d85ce1a36260dfe Mon Sep 17 00:00:00 2001 From: Alan Wizemann Date: Sat, 9 May 2026 18:43:03 +0200 Subject: [PATCH 2/4] feat(cron): add --no-agent watchdog toggle gated on hasCronNoAgent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a "Run script only (no agent call)" toggle to the cron job editor. When ON, the prompt + skills sections dim + disable visually but stay rendered (no layout shift mid-edit), the script field stays fully active, and the form passes `noAgent: true` to `createJob`/`updateJob`. The toggle is hidden on pre-v0.13 hosts via `supportsNoAgent: hasCronNoAgent` and defensively stripped at the call site (`hasCronNoAgent ? form.noAgent : false` on create, `: nil` on edit) — same shape as the v0.12 `workdir` strip. Read-side: `HermesCronJob.noAgent: Bool?` is decoded via `decodeIfPresent` so pre-v0.13 jobs.json files round-trip unchanged. The display rule `job.noAgent == true` treats `nil` and `false` identically — a script-only job must opt in. Write-side: - `createJob` appends `--no-agent` and passes an empty positional prompt (per WS-7-Q5) to keep argparse happy when the prompt is the trailing positional. - `updateJob` sends `--no-agent` / `--agent` to flip the flag in edit mode (per WS-7-Q4 — verify the toggle-off spelling on integration; if Hermes is one-way, disable the toggle in edit mode with a tooltip). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../ScarfCore/Models/HermesCronJob.swift | 13 +++++- .../Cron/ViewModels/CronViewModel.swift | 29 +++++++++++-- .../scarf/Features/Cron/Views/CronView.swift | 41 +++++++++++++++++-- 3 files changed, 75 insertions(+), 8 deletions(-) diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesCronJob.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesCronJob.swift index 11c671e..27b1ef1 100644 --- a/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesCronJob.swift +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesCronJob.swift @@ -28,6 +28,12 @@ public struct HermesCronJob: Identifiable, Sendable, Codable { /// job's prompt. YAML-only field today (no `--context-from` CLI /// flag yet) — Scarf displays it but doesn't write it. public nonisolated let contextFrom: [String]? + /// Hermes v0.13+ — script-only watchdog mode. When `true` the + /// pre-run script runs but the AI turn is skipped. `nil` means the + /// jobs.json file is pre-v0.13 (treat as `false`); `false` is the + /// explicit v0.13+ default. Capability-gated on `hasCronNoAgent` + /// at all write call sites. + public nonisolated let noAgent: Bool? public enum CodingKeys: String, CodingKey { case id, name, prompt, skills, model, schedule, enabled, state, deliver, silent @@ -41,6 +47,7 @@ public struct HermesCronJob: Identifiable, Sendable, Codable { case timeoutSeconds = "timeout_seconds" case workdir case contextFrom = "context_from" + case noAgent = "no_agent" } /// Memberwise init. Swift doesn't synthesize one for us because @@ -66,7 +73,8 @@ public struct HermesCronJob: Identifiable, Sendable, Codable { timeoutSeconds: Int? = nil, silent: Bool? = nil, workdir: String? = nil, - contextFrom: [String]? = nil + contextFrom: [String]? = nil, + noAgent: Bool? = nil ) { self.id = id self.name = name @@ -88,6 +96,7 @@ public struct HermesCronJob: Identifiable, Sendable, Codable { self.silent = silent self.workdir = workdir self.contextFrom = contextFrom + self.noAgent = noAgent } public nonisolated init(from decoder: any Decoder) throws { @@ -112,6 +121,7 @@ public struct HermesCronJob: Identifiable, Sendable, Codable { self.silent = try c.decodeIfPresent(Bool.self, forKey: .silent) self.workdir = try c.decodeIfPresent(String.self, forKey: .workdir) self.contextFrom = try c.decodeIfPresent([String].self, forKey: .contextFrom) + self.noAgent = try c.decodeIfPresent(Bool.self, forKey: .noAgent) } public nonisolated func encode(to encoder: any Encoder) throws { @@ -136,6 +146,7 @@ public struct HermesCronJob: Identifiable, Sendable, Codable { try c.encodeIfPresent(silent, forKey: .silent) try c.encodeIfPresent(workdir, forKey: .workdir) try c.encodeIfPresent(contextFrom, forKey: .contextFrom) + try c.encodeIfPresent(noAgent, forKey: .noAgent) } public nonisolated var stateIcon: String { diff --git a/scarf/scarf/Features/Cron/ViewModels/CronViewModel.swift b/scarf/scarf/Features/Cron/ViewModels/CronViewModel.swift index 002c24b..c80c77f 100644 --- a/scarf/scarf/Features/Cron/ViewModels/CronViewModel.swift +++ b/scarf/scarf/Features/Cron/ViewModels/CronViewModel.swift @@ -146,7 +146,7 @@ final class CronViewModel { } } - func createJob(schedule: String, prompt: String, name: String, deliver: String, skills: [String], script: String, repeatCount: String, workdir: String = "") { + func createJob(schedule: String, prompt: String, name: String, deliver: String, skills: [String], script: String, repeatCount: String, workdir: String = "", noAgent: Bool = false) { var args = ["cron", "create"] if !name.isEmpty { args += ["--name", name] } if !deliver.isEmpty { args += ["--deliver", deliver] } @@ -158,12 +158,25 @@ final class CronViewModel { // know the flag — argparse rejects unknown args, so the form // omits the flag when the field is empty. if !workdir.isEmpty { args += ["--workdir", workdir] } + // v0.13+: --no-agent runs the pre-run script and skips the AI turn. + // Caller (CronView) strips this on pre-v0.13 hosts so the flag is + // never emitted to a Hermes that can't parse it. + if noAgent { args.append("--no-agent") } args.append(schedule) - if !prompt.isEmpty { args.append(prompt) } + // TODO(WS-7-Q5): When --no-agent is set Hermes ignores the prompt arg, + // but argparse still wants positional args to line up with the + // schedule. The plan recommends passing an empty string explicitly so + // the positional parser doesn't treat the prompt as missing — verify + // this behaviour against `hermes cron create --help` on a v0.13 host. + if noAgent { + args.append("") + } else if !prompt.isEmpty { + args.append(prompt) + } runAndReload(args, success: "Job created") } - func updateJob(id: String, schedule: String?, prompt: String?, name: String?, deliver: String?, repeatCount: String?, newSkills: [String]?, clearSkills: Bool, script: String?, workdir: String? = nil) { + func updateJob(id: String, schedule: String?, prompt: String?, name: String?, deliver: String?, repeatCount: String?, newSkills: [String]?, clearSkills: Bool, script: String?, workdir: String? = nil, noAgent: Bool? = nil) { var args = ["cron", "edit", id] if let schedule, !schedule.isEmpty { args += ["--schedule", schedule] } if let prompt, !prompt.isEmpty { args += ["--prompt", prompt] } @@ -180,6 +193,16 @@ final class CronViewModel { // = user cleared an existing workdir; Hermes documents `--workdir ""` // on edit as the explicit clear gesture, mirroring the `--script` shape. if let workdir { args += ["--workdir", workdir] } + // TODO(WS-7-Q4): The toggle-off shape of `--no-agent` on edit is + // unverified. Plan assumes Hermes accepts `--agent` to flip the flag + // back; if the CLI is one-way (`--no-agent` only), the edit-mode + // toggle should disable itself with a tooltip explaining the + // limitation. Send the flag in the assumed shape for now and adjust + // post-integration. + if let noAgent { + if noAgent { args.append("--no-agent") } + else { args.append("--agent") } + } runAndReload(args, success: "Updated") } diff --git a/scarf/scarf/Features/Cron/Views/CronView.swift b/scarf/scarf/Features/Cron/Views/CronView.swift index 214b1dc..4fb0756 100644 --- a/scarf/scarf/Features/Cron/Views/CronView.swift +++ b/scarf/scarf/Features/Cron/Views/CronView.swift @@ -25,6 +25,10 @@ struct CronView: View { capabilitiesStore?.capabilities.hasCronWorkdir ?? false } + private var hasCronNoAgent: Bool { + capabilitiesStore?.capabilities.hasCronNoAgent ?? false + } + var body: some View { VStack(spacing: 0) { pageHeader @@ -47,7 +51,7 @@ struct CronView: View { // polling timer. Same wiring ActivityView uses. .onChange(of: fileWatcher.lastChangeDate) { viewModel.load() } .sheet(isPresented: $viewModel.showCreateSheet) { - CronJobEditor(mode: .create, availableSkills: viewModel.availableSkills, supportsWorkdir: hasCronWorkdir) { form in + CronJobEditor(mode: .create, availableSkills: viewModel.availableSkills, supportsWorkdir: hasCronWorkdir, supportsNoAgent: hasCronNoAgent) { form in viewModel.createJob( schedule: form.schedule, prompt: form.prompt, @@ -56,7 +60,12 @@ struct CronView: View { skills: form.skills, script: form.script, repeatCount: form.repeatCount, - workdir: hasCronWorkdir ? form.workdir : "" + workdir: hasCronWorkdir ? form.workdir : "", + // Mirrors the workdir strip-on-pre-version pattern: pre-v0.13 + // hosts get a hard `false`, so a stale form value (or a + // hand-edited jobs.json round-tripped through edit-mode) + // can't sneak `--no-agent` into a CLI that doesn't grok it. + noAgent: hasCronNoAgent ? form.noAgent : false ) viewModel.showCreateSheet = false } onCancel: { @@ -64,7 +73,7 @@ struct CronView: View { } } .sheet(item: $viewModel.editingJob) { job in - CronJobEditor(mode: .edit(job), availableSkills: viewModel.availableSkills, supportsWorkdir: hasCronWorkdir) { form in + CronJobEditor(mode: .edit(job), availableSkills: viewModel.availableSkills, supportsWorkdir: hasCronWorkdir, supportsNoAgent: hasCronNoAgent) { form in viewModel.updateJob( id: job.id, schedule: form.schedule, @@ -75,7 +84,8 @@ struct CronView: View { newSkills: form.skills, clearSkills: form.clearSkills, script: form.script, - workdir: hasCronWorkdir ? form.workdir : nil + workdir: hasCronWorkdir ? form.workdir : nil, + noAgent: hasCronNoAgent ? form.noAgent : nil ) viewModel.editingJob = nil } onCancel: { @@ -643,6 +653,9 @@ struct CronJobEditor: View { /// v0.12+ workdir flag — fills `--workdir `. Empty string /// preserves the v0.11 behaviour of running with no cwd hint. var workdir: String = "" + /// v0.13+ `--no-agent` flag — script-only watchdog mode. Hermes + /// runs the pre-run script and skips the AI turn. + var noAgent: Bool = false } let mode: Mode @@ -650,6 +663,10 @@ struct CronJobEditor: View { /// Pass `false` on pre-v0.12 hosts; the `--workdir` field is hidden and /// the form's value is dropped when the parent calls `createJob`/`updateJob`. let supportsWorkdir: Bool + /// Pass `false` on pre-v0.13 hosts; the `--no-agent` toggle is hidden + /// and the parent strips the form's value before calling + /// `createJob`/`updateJob`. Mirrors the `supportsWorkdir` pattern. + let supportsNoAgent: Bool let onSave: (FormState) -> Void let onCancel: () -> Void @@ -681,12 +698,25 @@ struct CronJobEditor: View { ) .scrollContentBackground(.hidden) } + .opacity(form.noAgent ? 0.4 : 1.0) + .disabled(form.noAgent) formField("Deliver", text: $form.deliver, placeholder: "origin | local | discord:CHANNEL | telegram:CHAT", mono: true) formField("Repeat", text: $form.repeatCount, placeholder: "Optional count") formField("Script path", text: $form.script, placeholder: "Python script whose stdout is injected", mono: true) if supportsWorkdir { formField("Workdir", text: $form.workdir, placeholder: "Absolute path; pulls AGENTS.md/CLAUDE.md context", mono: true) } + if supportsNoAgent { + Toggle("Run script only (no agent call)", isOn: $form.noAgent) + .scarfStyle(.body) + .tint(ScarfColor.accent) + if form.noAgent { + Text("Watchdog mode — Hermes runs the pre-run script and skips the AI turn. Prompt + skills are ignored.") + .scarfStyle(.caption) + .foregroundStyle(ScarfColor.foregroundMuted) + .padding(.leading, ScarfSpace.s3) + } + } if !availableSkills.isEmpty { VStack(alignment: .leading, spacing: 4) { Text("Skills") @@ -723,6 +753,8 @@ struct CronJobEditor: View { .tint(ScarfColor.accent) } } + .opacity(form.noAgent ? 0.4 : 1.0) + .disabled(form.noAgent) } HStack { Spacer() @@ -746,6 +778,7 @@ struct CronJobEditor: View { form.skills = job.skills ?? [] form.script = job.preRunScript ?? "" form.workdir = job.workdir ?? "" + form.noAgent = job.noAgent ?? false } } } From 6c96fcfa4305876658f5ff965b96b3347335b0e8 Mon Sep 17 00:00:00 2001 From: Alan Wizemann Date: Sat, 9 May 2026 18:56:08 +0200 Subject: [PATCH 3/4] feat(settings): add Web Tools tab with v0.13 search/extract split MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new "Web Tools" Settings tab (between Browser and Voice) with two distinct shapes that share the same chrome: - Pre-v0.13: a single "Backend" picker writing the legacy `web_tools.backend` key (so v0.12 users still configure web tools). - v0.13+: two pickers — Search backend writes `web_tools.search.backend` (SearXNG appears here only — Hermes registers it as a search-only dispatch), Extract backend writes `web_tools.extract.backend`. Capability gate: `hasWebToolsBackendSplit` chooses which shape renders. The tab itself is always visible — pre-v0.13 users would otherwise lose access to the legacy combined-backend picker. Model layer: - `HermesConfig.webToolsBackend` / `webToolsSearchBackend` / `webToolsExtractBackend` — three fields, each round-tripping its own YAML key. Defaults: `duckduckgo` / `duckduckgo` / `reader`. - YAML parser reads all three keys via the existing `str(...)` helper. Pre-v0.13 hosts populate only `webToolsBackend`; the split keys default to the same backend so the picker shows the same value the user already had. TODO markers (WS-7-Q6/Q7) flag the inline backend lists + legacy fallback semantics — verify against `~/.hermes/hermes-agent/ hermes_cli/web_tools.py` during integration. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../ScarfCore/Models/HermesConfig.swift | 20 ++++- .../ScarfCore/Parsing/HermesConfig+YAML.swift | 9 ++- .../ViewModels/SettingsViewModel.swift | 10 +++ .../Settings/Views/SettingsView.swift | 4 + .../Settings/Views/Tabs/WebToolsTab.swift | 76 +++++++++++++++++++ 5 files changed, 117 insertions(+), 2 deletions(-) create mode 100644 scarf/scarf/Features/Settings/Views/Tabs/WebToolsTab.swift diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesConfig.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesConfig.swift index a62ccef..7a9048f 100644 --- a/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesConfig.swift +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesConfig.swift @@ -666,6 +666,18 @@ public struct HermesConfig: Sendable { /// final reply (provider/model/cost/turn count). Off by default; /// useful for cost auditing and screen-recording demos. public var runtimeMetadataFooter: Bool + /// Pre-v0.13: single combined Web Tools backend at `web_tools.backend`. + /// v0.13 split this into per-capability keys (see below). Kept readable + /// for round-trip compatibility on hosts that never migrated; v0.13+ + /// hosts ignore this scalar and read the split keys instead. + public var webToolsBackend: String + /// v0.13+: `web_tools.search.backend`. SearXNG is search-only and + /// can land here. Pre-v0.13 hosts default to the same value as the + /// combined backend. + public var webToolsSearchBackend: String + /// v0.13+: `web_tools.extract.backend`. Pre-v0.13 hosts default to + /// the same value as the combined backend. + public var webToolsExtractBackend: String // Grouped blocks public var display: DisplaySettings @@ -747,11 +759,17 @@ public struct HermesConfig: Sendable { homeAssistant: HomeAssistantSettings, cacheTTL: String = "5m", redactionEnabled: Bool = false, - runtimeMetadataFooter: Bool = false + runtimeMetadataFooter: Bool = false, + webToolsBackend: String = "duckduckgo", + webToolsSearchBackend: String = "duckduckgo", + webToolsExtractBackend: String = "reader" ) { self.cacheTTL = cacheTTL self.redactionEnabled = redactionEnabled self.runtimeMetadataFooter = runtimeMetadataFooter + self.webToolsBackend = webToolsBackend + self.webToolsSearchBackend = webToolsSearchBackend + self.webToolsExtractBackend = webToolsExtractBackend self.model = model self.provider = provider self.maxTurns = maxTurns diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/Parsing/HermesConfig+YAML.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Parsing/HermesConfig+YAML.swift index d172bbc..215224f 100644 --- a/scarf/Packages/ScarfCore/Sources/ScarfCore/Parsing/HermesConfig+YAML.swift +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Parsing/HermesConfig+YAML.swift @@ -284,7 +284,14 @@ public extension HermesConfig { homeAssistant: homeAssistant, cacheTTL: str("prompt_caching.cache_ttl", default: "5m"), redactionEnabled: bool("redaction.enabled", default: false), - runtimeMetadataFooter: bool("agent.runtime_metadata_footer", default: false) + runtimeMetadataFooter: bool("agent.runtime_metadata_footer", default: false), + // Pre-v0.13 hosts wrote a single `web_tools.backend`. v0.13 split + // it into per-capability keys. Read all three so the round-trip + // never loses a value the user already set; the WebTools tab + // chooses which to render based on `hasWebToolsBackendSplit`. + webToolsBackend: str("web_tools.backend", default: "duckduckgo"), + webToolsSearchBackend: str("web_tools.search.backend", default: "duckduckgo"), + webToolsExtractBackend: str("web_tools.extract.backend", default: "reader") ) } } diff --git a/scarf/scarf/Features/Settings/ViewModels/SettingsViewModel.swift b/scarf/scarf/Features/Settings/ViewModels/SettingsViewModel.swift index 369edb3..a27939e 100644 --- a/scarf/scarf/Features/Settings/ViewModels/SettingsViewModel.swift +++ b/scarf/scarf/Features/Settings/ViewModels/SettingsViewModel.swift @@ -143,6 +143,16 @@ final class SettingsViewModel { func setBrowserAllowPrivateURLs(_ value: Bool) { setSetting("browser.allow_private_urls", value: value ? "true" : "false") } func setCamofoxManagedPersistence(_ value: Bool) { setSetting("browser.camofox.managed_persistence", value: value ? "true" : "false") } + // MARK: - Web Tools + + /// Pre-v0.13 combined backend. Pre-v0.13 hosts read this; v0.13+ + /// hosts read it for back-compat but the WebToolsTab gates writes + /// on `hasWebToolsBackendSplit` so the tab only writes the split + /// keys on v0.13. + func setWebToolsBackend(_ value: String) { setSetting("web_tools.backend", value: value) } + func setWebToolsSearchBackend(_ value: String) { setSetting("web_tools.search.backend", value: value) } + func setWebToolsExtractBackend(_ value: String) { setSetting("web_tools.extract.backend", value: value) } + // MARK: - Voice / TTS / STT func setAutoTTS(_ value: Bool) { setSetting("voice.auto_tts", value: value ? "true" : "false") } diff --git a/scarf/scarf/Features/Settings/Views/SettingsView.swift b/scarf/scarf/Features/Settings/Views/SettingsView.swift index 6e361ab..3b5e664 100644 --- a/scarf/scarf/Features/Settings/Views/SettingsView.swift +++ b/scarf/scarf/Features/Settings/Views/SettingsView.swift @@ -26,6 +26,7 @@ struct SettingsView: View { case agent = "Agent" case terminal = "Terminal" case browser = "Browser" + case webTools = "Web Tools" case voice = "Voice" case memory = "Memory" case auxiliary = "Aux Models" @@ -41,6 +42,7 @@ struct SettingsView: View { case .agent: return "Agent" case .terminal: return "Terminal" case .browser: return "Browser" + case .webTools: return "Web Tools" case .voice: return "Voice" case .memory: return "Memory" case .auxiliary: return "Aux Models" @@ -56,6 +58,7 @@ struct SettingsView: View { case .agent: return "brain.head.profile" case .terminal: return "terminal" case .browser: return "globe" + case .webTools: return "globe.americas" case .voice: return "mic" case .memory: return "memorychip" case .auxiliary: return "sparkles.rectangle.stack" @@ -171,6 +174,7 @@ struct SettingsView: View { case .agent: AgentTab(viewModel: viewModel) case .terminal: TerminalTab(viewModel: viewModel) case .browser: BrowserTab(viewModel: viewModel) + case .webTools: WebToolsTab(viewModel: viewModel) case .voice: VoiceTab(viewModel: viewModel) case .memory: MemoryTab(viewModel: viewModel) case .auxiliary: AuxiliaryTab(viewModel: viewModel) diff --git a/scarf/scarf/Features/Settings/Views/Tabs/WebToolsTab.swift b/scarf/scarf/Features/Settings/Views/Tabs/WebToolsTab.swift new file mode 100644 index 0000000..904be1b --- /dev/null +++ b/scarf/scarf/Features/Settings/Views/Tabs/WebToolsTab.swift @@ -0,0 +1,76 @@ +import SwiftUI +import ScarfCore +import ScarfDesign + +/// Web Tools tab — search + extract backend pickers. Pre-v0.13 hosts +/// see a single "Combined backend" row writing to the legacy +/// `web_tools.backend` key. v0.13+ hosts see two rows writing to the +/// per-capability split keys (`web_tools.search.backend` + +/// `web_tools.extract.backend`); SearXNG appears in the search picker +/// only because Hermes registers it as a search-only backend. +struct WebToolsTab: View { + @Bindable var viewModel: SettingsViewModel + @Environment(\.hermesCapabilities) private var capabilitiesStore + + private var split: Bool { + capabilitiesStore?.capabilities.hasWebToolsBackendSplit ?? false + } + + // TODO(WS-7-Q6): Backend lists are curated inline based on the v0.13 + // release notes ("SearXNG joined search-only"). The exact dispatch + // table lives in `~/.hermes/hermes-agent/hermes_cli/web_tools.py` — + // verify during integration. A wrong entry just produces a + // `hermes config set` failure on save (recoverable, not silent). + private static let searchBackends: [String] = [ + "duckduckgo", "tavily", "brave", "exa", "you", "searxng" + ] + private static let extractBackends: [String] = [ + "reader", "browserless", "trafilatura", "firecrawl" + ] + /// v0.12 combined-backend list — superset of the v0.13 search list + /// minus SearXNG (which only dispatches as search) plus the v0.13 + /// extract-only entries that pre-v0.13 hosts handled under the + /// combined key. + private static let combinedBackends: [String] = [ + "duckduckgo", "tavily", "brave", "exa", "you", + "reader", "browserless", "trafilatura", "firecrawl" + ] + + var body: some View { + if split { + SettingsSection(title: "Web Tools", icon: "globe.americas") { + PickerRow( + label: "Search backend", + selection: viewModel.config.webToolsSearchBackend, + options: Self.searchBackends + ) { viewModel.setWebToolsSearchBackend($0) } + PickerRow( + label: "Extract backend", + selection: viewModel.config.webToolsExtractBackend, + options: Self.extractBackends + ) { viewModel.setWebToolsExtractBackend($0) } + } + Text("SearXNG joined v0.13 as a search-only backend. Backend-specific tuning (host URLs, API keys) lives in the raw YAML editor for now.") + .scarfStyle(.caption) + .foregroundStyle(ScarfColor.foregroundMuted) + .padding(.horizontal, ScarfSpace.s4) + } else { + // TODO(WS-7-Q7): Pre-v0.13 hosts fall back to the legacy single + // backend. v0.13 may or may not honour `web_tools.backend` as a + // fallback when the split keys are absent — verify with Hermes + // and consider a one-time migration prompt in a follow-up if + // upgrading from v0.12 silently resets the user's backend. + SettingsSection(title: "Web Tools", icon: "globe.americas") { + PickerRow( + label: "Backend", + selection: viewModel.config.webToolsBackend, + options: Self.combinedBackends + ) { viewModel.setWebToolsBackend($0) } + } + Text("Hermes v0.13 splits search and extract into separate backends. Update Hermes to access the per-capability picker.") + .scarfStyle(.caption) + .foregroundStyle(ScarfColor.foregroundFaint) + .padding(.horizontal, ScarfSpace.s4) + } + } +} From 00704412439760f9d0cbfac801551f1537370df0 Mon Sep 17 00:00:00 2001 From: Alan Wizemann Date: Sat, 9 May 2026 19:03:06 +0200 Subject: [PATCH 4/4] feat(profiles): add --no-skills toggle to create-profile sheet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an "Empty profile (no skills)" toggle to the Mac create-profile sheet, gated on `hasProfileNoSkills` (v0.13+). When ON, the create flow appends `--no-skills` to `hermes profile create`. The toggle is disabled (greyed out) when "Full copy of active profile" is on, per WS-7 plan Decision H — a full clone copies skills wholesale, so `--no-skills` would be a contradiction at the UX layer. The wire itself stays permissive: a user can stack `--clone --no-skills` to clone config but skip skills, which is a plausible workflow. Defensive write-strip: even though the toggle is hidden on pre-v0.13 hosts, the call site reads `createNoSkills` through the capability gate so a stale state value can't sneak `--no-skills` past argparse on a CLI that doesn't know it. iOS Profiles is read-only (per CLAUDE.md "v0.12 iOS catch-up Phase H") so no toggle there. TODO marker (WS-7-Q8) flags the assumed `--clone-all` interaction — verify Hermes's behaviour with both flags during integration. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../ViewModels/ProfilesViewModel.swift | 9 +++++- .../Profiles/Views/ProfilesView.swift | 29 +++++++++++++++++-- 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/scarf/scarf/Features/Profiles/ViewModels/ProfilesViewModel.swift b/scarf/scarf/Features/Profiles/ViewModels/ProfilesViewModel.swift index d71a4b6..6739d54 100644 --- a/scarf/scarf/Features/Profiles/ViewModels/ProfilesViewModel.swift +++ b/scarf/scarf/Features/Profiles/ViewModels/ProfilesViewModel.swift @@ -112,10 +112,17 @@ final class ProfilesViewModel { } } - func create(name: String, cloneConfig: Bool, cloneAll: Bool) { + func create(name: String, cloneConfig: Bool, cloneAll: Bool, noSkills: Bool = false) { var args = ["profile", "create", name] if cloneAll { args.append("--clone-all") } else if cloneConfig { args.append("--clone") } + // v0.13+: Empty-profile creation. The wire is independent of + // --clone / --clone-all per the v0.13 release notes — the user + // can stack `--clone --no-skills` to clone config but skip + // skills, which is a plausible workflow. The UI still disables + // the toggle under --clone-all (Decision H, see ProfilesView) + // but the wire is permissive. + if noSkills { args.append("--no-skills") } runAndReload(args, success: "Profile '\(name)' created") } diff --git a/scarf/scarf/Features/Profiles/Views/ProfilesView.swift b/scarf/scarf/Features/Profiles/Views/ProfilesView.swift index 21f48b5..e0186e8 100644 --- a/scarf/scarf/Features/Profiles/Views/ProfilesView.swift +++ b/scarf/scarf/Features/Profiles/Views/ProfilesView.swift @@ -11,7 +11,12 @@ struct ProfilesView: View { @State private var createName = "" @State private var createCloneConfig = true @State private var createCloneAll = false + /// v0.13+ `--no-skills` toggle. Mutually exclusive with `--clone-all` + /// at the UX layer (Decision H from the WS-7 plan): a full clone + /// copies skills wholesale — `--no-skills` would be a contradiction. + @State private var createNoSkills = false @State private var showRename = false + @Environment(\.hermesCapabilities) private var capabilitiesStore init(context: ServerContext) { _viewModel = State(initialValue: ProfilesViewModel(context: context)) @@ -123,7 +128,7 @@ struct ProfilesView: View { } Spacer() Button { - createName = ""; createCloneConfig = true; createCloneAll = false + createName = ""; createCloneConfig = true; createCloneAll = false; createNoSkills = false showCreate = true } label: { Label("Create", systemImage: "plus") @@ -300,11 +305,31 @@ struct ProfilesView: View { Toggle("Clone config, .env, SOUL.md from active profile", isOn: $createCloneConfig) .disabled(createCloneAll) Toggle("Full copy of active profile (all state)", isOn: $createCloneAll) + // TODO(WS-7-Q8): Decision H — disable --no-skills when --clone-all + // is on. A full clone copies skills wholesale; --no-skills would + // be a contradiction. Verify Hermes's behaviour with both flags + // (argparse mutual exclusion vs. last-flag-wins vs. clone-but- + // skip-skills) and relax the disabled state if Hermes does + // something useful with the combination. + if capabilitiesStore?.capabilities.hasProfileNoSkills ?? false { + Toggle("Empty profile (no skills)", isOn: $createNoSkills) + .disabled(createCloneAll) + } HStack { Spacer() Button("Cancel") { showCreate = false } Button("Create") { - viewModel.create(name: createName, cloneConfig: createCloneConfig, cloneAll: createCloneAll) + viewModel.create( + name: createName, + cloneConfig: createCloneConfig, + cloneAll: createCloneAll, + // Defensive: if the toggle isn't visible (pre-v0.13) + // the state is always `false`, but read it through + // the capability gate anyway so a stale state value + // can't sneak `--no-skills` to a CLI that doesn't + // know it. + noSkills: (capabilitiesStore?.capabilities.hasProfileNoSkills ?? false) ? createNoSkills : false + ) showCreate = false } .buttonStyle(.borderedProminent)