mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 18:44:45 +00:00
feat: Settings tabs, Platforms, Credential Pools, Model Picker, and Configure sidebar
Major expansion of Scarf's Hermes platform coverage. Settings is now a 10-tab layout exposing ~60 previously hidden config fields. A new "Configure" sidebar section groups per-platform setup, personality management, quick commands, credential pools, plugins, webhooks, and profile switching. ## Highlights - **Platforms feature** — Native GUI setup for all 13 messaging platforms (Telegram, Discord, Slack, WhatsApp, Signal, Email, Matrix, Mattermost, Feishu, iMessage, Home Assistant, Webhook, CLI). Per-platform forms write credentials to ~/.hermes/.env and behavior toggles to ~/.hermes/config.yaml. WhatsApp and Signal use an inline SwiftTerm terminal for QR/link pairing. - **Credential Pools** — Provider-aware add/remove with proper type handling. OAuth flow uses Process + pipes to extract the authorization URL, open the browser explicitly, and accept the code via a form field. Fixes the Anthropic OAuth failure where the code had nowhere to be entered. - **Model Picker** — Hierarchical provider -> model picker backed by ~/.hermes/models_dev_cache.json (111 providers, every major model). Used in Settings -> General and Delegation. "Custom..." escape hatch for unlisted IDs. - **Settings as tabs** — 10 tabs (General, Display, Agent, Terminal, Browser, Voice, Memory, Aux Models, Security, Advanced). HermesConfig grew from 32 to ~90 fields via grouped sub-structs. All new fields round-trip through `hermes config set`. - **Extended existing features** — Cron (create/edit/pause/resume/run-now/ delete), Skills (Browse Hub + Updates tabs), Health (run `hermes dump` and `hermes debug share` with confirmation dialog), Sessions (rename/delete/ export/export-all). ## Bug fixes - Tools platform picker showed only CLI (was reading a nonexistent `platform_toolsets:` YAML section). Now enumerates KnownPlatforms.all with live connectivity dots from gateway_state.json. - Credentials add with --api-key was triggering OAuth for providers like Anthropic because --type was missing. Now always passes --type api-key. - Remove-by-index used 0-based indexing; hermes CLI expects 1-based. Fixed. - Various CLI parser fragility issues (plugins, profiles, skills hub, webhooks) replaced with structured file reads or proper box-drawn table parsers. ## New core services - HermesEnvService — reads/writes ~/.hermes/.env atomically, preserves comments, commented-out keys get enabled in-place on save, values with spaces/specials get quoted, unset commented out (non-destructive). - ModelCatalogService — decodes the models.dev cache into typed providers and models with context/cost/release-date metadata. - OAuthFlowController — manages the OAuth Process subprocess: extracts the auth URL via regex, opens the browser, pipes the code back via stdin, detects success/failure markers in output. ## New sidebar structure Monitor / Projects / Interact / **Configure (new)** / Manage The Configure section gathers the setup-style features that used to require the CLI: Platforms, Personalities, Quick Commands, Credential Pools, Plugins, Webhooks, Profiles. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -14,36 +14,28 @@ struct ContentView: View {
|
||||
@ViewBuilder
|
||||
private var detailView: some View {
|
||||
switch coordinator.selectedSection {
|
||||
case .dashboard:
|
||||
DashboardView()
|
||||
case .insights:
|
||||
InsightsView()
|
||||
case .sessions:
|
||||
SessionsView()
|
||||
case .activity:
|
||||
ActivityView()
|
||||
case .projects:
|
||||
ProjectsView()
|
||||
case .chat:
|
||||
ChatView()
|
||||
case .memory:
|
||||
MemoryView()
|
||||
case .skills:
|
||||
SkillsView()
|
||||
case .tools:
|
||||
ToolsView()
|
||||
case .mcpServers:
|
||||
MCPServersView()
|
||||
case .gateway:
|
||||
GatewayView()
|
||||
case .cron:
|
||||
CronView()
|
||||
case .health:
|
||||
HealthView()
|
||||
case .logs:
|
||||
LogsView()
|
||||
case .settings:
|
||||
SettingsView()
|
||||
case .dashboard: DashboardView()
|
||||
case .insights: InsightsView()
|
||||
case .sessions: SessionsView()
|
||||
case .activity: ActivityView()
|
||||
case .projects: ProjectsView()
|
||||
case .chat: ChatView()
|
||||
case .memory: MemoryView()
|
||||
case .skills: SkillsView()
|
||||
case .platforms: PlatformsView()
|
||||
case .personalities: PersonalitiesView()
|
||||
case .quickCommands: QuickCommandsView()
|
||||
case .credentialPools: CredentialPoolsView()
|
||||
case .plugins: PluginsView()
|
||||
case .webhooks: WebhooksView()
|
||||
case .profiles: ProfilesView()
|
||||
case .tools: ToolsView()
|
||||
case .mcpServers: MCPServersView()
|
||||
case .gateway: GatewayView()
|
||||
case .cron: CronView()
|
||||
case .health: HealthView()
|
||||
case .logs: LogsView()
|
||||
case .settings: SettingsView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,304 @@
|
||||
import Foundation
|
||||
|
||||
/// Settings for one of hermes's auxiliary model tasks (vision, compression, approvals, etc.).
|
||||
/// Every auxiliary task follows the same provider/model/base_url/api_key/timeout pattern.
|
||||
struct AuxiliaryModel: Sendable, Equatable {
|
||||
var provider: String
|
||||
var model: String
|
||||
var baseURL: String
|
||||
var apiKey: String
|
||||
var timeout: Int
|
||||
|
||||
static let empty = AuxiliaryModel(provider: "auto", model: "", baseURL: "", apiKey: "", timeout: 30)
|
||||
}
|
||||
|
||||
/// Group of display-related settings mirroring the `display:` block in config.yaml.
|
||||
struct DisplaySettings: Sendable, Equatable {
|
||||
var skin: String
|
||||
var compact: Bool
|
||||
var resumeDisplay: String // "full" | "minimal"
|
||||
var bellOnComplete: Bool
|
||||
var inlineDiffs: Bool
|
||||
var toolProgressCommand: Bool
|
||||
var toolPreviewLength: Int
|
||||
var busyInputMode: String // e.g. "interrupt"
|
||||
|
||||
static let empty = DisplaySettings(
|
||||
skin: "default",
|
||||
compact: false,
|
||||
resumeDisplay: "full",
|
||||
bellOnComplete: false,
|
||||
inlineDiffs: true,
|
||||
toolProgressCommand: false,
|
||||
toolPreviewLength: 0,
|
||||
busyInputMode: "interrupt"
|
||||
)
|
||||
}
|
||||
|
||||
/// Container/terminal backend options. These map to `terminal.*` keys in config.yaml.
|
||||
struct TerminalSettings: Sendable, Equatable {
|
||||
var cwd: String
|
||||
var timeout: Int
|
||||
var envPassthrough: [String]
|
||||
var persistentShell: Bool
|
||||
var dockerImage: String
|
||||
var dockerMountCwdToWorkspace: Bool
|
||||
var dockerForwardEnv: [String]
|
||||
var dockerVolumes: [String]
|
||||
var containerCPU: Int // 0 = unlimited
|
||||
var containerMemory: Int // MB, 0 = unlimited
|
||||
var containerDisk: Int // MB, 0 = unlimited
|
||||
var containerPersistent: Bool
|
||||
var modalImage: String
|
||||
var modalMode: String // "auto" | other
|
||||
var daytonaImage: String
|
||||
var singularityImage: String
|
||||
|
||||
static let empty = TerminalSettings(
|
||||
cwd: ".",
|
||||
timeout: 180,
|
||||
envPassthrough: [],
|
||||
persistentShell: true,
|
||||
dockerImage: "",
|
||||
dockerMountCwdToWorkspace: false,
|
||||
dockerForwardEnv: [],
|
||||
dockerVolumes: [],
|
||||
containerCPU: 0,
|
||||
containerMemory: 0,
|
||||
containerDisk: 0,
|
||||
containerPersistent: false,
|
||||
modalImage: "",
|
||||
modalMode: "auto",
|
||||
daytonaImage: "",
|
||||
singularityImage: ""
|
||||
)
|
||||
}
|
||||
|
||||
/// Browser automation tuning (`browser.*`).
|
||||
struct BrowserSettings: Sendable, Equatable {
|
||||
var inactivityTimeout: Int
|
||||
var commandTimeout: Int
|
||||
var recordSessions: Bool
|
||||
var allowPrivateURLs: Bool
|
||||
var camofoxManagedPersistence: Bool
|
||||
|
||||
static let empty = BrowserSettings(
|
||||
inactivityTimeout: 120,
|
||||
commandTimeout: 30,
|
||||
recordSessions: false,
|
||||
allowPrivateURLs: false,
|
||||
camofoxManagedPersistence: false
|
||||
)
|
||||
}
|
||||
|
||||
/// Voice push-to-talk plus TTS/STT provider settings.
|
||||
struct VoiceSettings: Sendable, Equatable {
|
||||
var recordKey: String
|
||||
var maxRecordingSeconds: Int
|
||||
var silenceDuration: Double
|
||||
|
||||
// TTS
|
||||
var ttsProvider: String
|
||||
var ttsEdgeVoice: String
|
||||
var ttsElevenLabsVoiceID: String
|
||||
var ttsElevenLabsModelID: String
|
||||
var ttsOpenAIModel: String
|
||||
var ttsOpenAIVoice: String
|
||||
var ttsNeuTTSModel: String
|
||||
var ttsNeuTTSDevice: String
|
||||
|
||||
// STT
|
||||
var sttEnabled: Bool
|
||||
var sttProvider: String
|
||||
var sttLocalModel: String
|
||||
var sttLocalLanguage: String
|
||||
var sttOpenAIModel: String
|
||||
var sttMistralModel: String
|
||||
|
||||
static let empty = VoiceSettings(
|
||||
recordKey: "ctrl+b",
|
||||
maxRecordingSeconds: 120,
|
||||
silenceDuration: 3.0,
|
||||
ttsProvider: "edge",
|
||||
ttsEdgeVoice: "en-US-AriaNeural",
|
||||
ttsElevenLabsVoiceID: "",
|
||||
ttsElevenLabsModelID: "eleven_multilingual_v2",
|
||||
ttsOpenAIModel: "gpt-4o-mini-tts",
|
||||
ttsOpenAIVoice: "alloy",
|
||||
ttsNeuTTSModel: "neuphonic/neutts-air-q4-gguf",
|
||||
ttsNeuTTSDevice: "cpu",
|
||||
sttEnabled: true,
|
||||
sttProvider: "local",
|
||||
sttLocalModel: "base",
|
||||
sttLocalLanguage: "",
|
||||
sttOpenAIModel: "whisper-1",
|
||||
sttMistralModel: "voxtral-mini-latest"
|
||||
)
|
||||
}
|
||||
|
||||
/// Eight sub-models that share the same provider/model/base_url/api_key/timeout shape.
|
||||
struct AuxiliarySettings: Sendable, Equatable {
|
||||
var vision: AuxiliaryModel
|
||||
var webExtract: AuxiliaryModel
|
||||
var compression: AuxiliaryModel
|
||||
var sessionSearch: AuxiliaryModel
|
||||
var skillsHub: AuxiliaryModel
|
||||
var approval: AuxiliaryModel
|
||||
var mcp: AuxiliaryModel
|
||||
var flushMemories: AuxiliaryModel
|
||||
|
||||
static let empty = AuxiliarySettings(
|
||||
vision: .empty,
|
||||
webExtract: .empty,
|
||||
compression: .empty,
|
||||
sessionSearch: .empty,
|
||||
skillsHub: .empty,
|
||||
approval: .empty,
|
||||
mcp: .empty,
|
||||
flushMemories: .empty
|
||||
)
|
||||
}
|
||||
|
||||
/// Security/redaction/firewall config. Website blocklist is nested in YAML.
|
||||
struct SecuritySettings: Sendable, Equatable {
|
||||
var redactSecrets: Bool
|
||||
var redactPII: Bool // from privacy.redact_pii
|
||||
var tirithEnabled: Bool
|
||||
var tirithPath: String
|
||||
var tirithTimeout: Int
|
||||
var tirithFailOpen: Bool
|
||||
var blocklistEnabled: Bool
|
||||
var blocklistDomains: [String]
|
||||
|
||||
static let empty = SecuritySettings(
|
||||
redactSecrets: true,
|
||||
redactPII: false,
|
||||
tirithEnabled: true,
|
||||
tirithPath: "tirith",
|
||||
tirithTimeout: 5,
|
||||
tirithFailOpen: true,
|
||||
blocklistEnabled: false,
|
||||
blocklistDomains: []
|
||||
)
|
||||
}
|
||||
|
||||
/// Human-delay simulates realistic typing pace (`human_delay.*`).
|
||||
struct HumanDelaySettings: Sendable, Equatable {
|
||||
var mode: String // "off" | "natural" | "custom"
|
||||
var minMS: Int
|
||||
var maxMS: Int
|
||||
|
||||
static let empty = HumanDelaySettings(mode: "off", minMS: 800, maxMS: 2500)
|
||||
}
|
||||
|
||||
/// Compression / context routing.
|
||||
struct CompressionSettings: Sendable, Equatable {
|
||||
var enabled: Bool
|
||||
var threshold: Double
|
||||
var targetRatio: Double
|
||||
var protectLastN: Int
|
||||
|
||||
static let empty = CompressionSettings(enabled: true, threshold: 0.5, targetRatio: 0.2, protectLastN: 20)
|
||||
}
|
||||
|
||||
struct CheckpointSettings: Sendable, Equatable {
|
||||
var enabled: Bool
|
||||
var maxSnapshots: Int
|
||||
|
||||
static let empty = CheckpointSettings(enabled: true, maxSnapshots: 50)
|
||||
}
|
||||
|
||||
struct LoggingSettings: Sendable, Equatable {
|
||||
var level: String // DEBUG | INFO | WARNING | ERROR
|
||||
var maxSizeMB: Int
|
||||
var backupCount: Int
|
||||
|
||||
static let empty = LoggingSettings(level: "INFO", maxSizeMB: 5, backupCount: 3)
|
||||
}
|
||||
|
||||
struct DelegationSettings: Sendable, Equatable {
|
||||
var model: String
|
||||
var provider: String
|
||||
var baseURL: String
|
||||
var apiKey: String
|
||||
var maxIterations: Int
|
||||
|
||||
static let empty = DelegationSettings(model: "", provider: "", baseURL: "", apiKey: "", maxIterations: 50)
|
||||
}
|
||||
|
||||
/// Discord-specific platform settings (`discord.*`). Other platforms currently have thinner schemas.
|
||||
struct DiscordSettings: Sendable, Equatable {
|
||||
var requireMention: Bool
|
||||
var freeResponseChannels: String
|
||||
var autoThread: Bool
|
||||
var reactions: Bool
|
||||
|
||||
static let empty = DiscordSettings(requireMention: true, freeResponseChannels: "", autoThread: true, reactions: true)
|
||||
}
|
||||
|
||||
/// Telegram settings under `telegram.*` in config.yaml. Most Telegram tuning is
|
||||
/// done via environment variables (`TELEGRAM_*`) — this is the subset that lives
|
||||
/// in the YAML.
|
||||
struct TelegramSettings: Sendable, Equatable {
|
||||
var requireMention: Bool
|
||||
var reactions: Bool
|
||||
|
||||
static let empty = TelegramSettings(requireMention: true, reactions: false)
|
||||
}
|
||||
|
||||
/// Slack settings under `platforms.slack.*` (and a couple of top-level keys).
|
||||
struct SlackSettings: Sendable, Equatable {
|
||||
var replyToMode: String // "off" | "first" | "all"
|
||||
var requireMention: Bool
|
||||
var replyInThread: Bool
|
||||
var replyBroadcast: Bool
|
||||
|
||||
static let empty = SlackSettings(replyToMode: "first", requireMention: true, replyInThread: true, replyBroadcast: false)
|
||||
}
|
||||
|
||||
/// Matrix settings under `matrix.*`.
|
||||
struct MatrixSettings: Sendable, Equatable {
|
||||
var requireMention: Bool
|
||||
var autoThread: Bool
|
||||
var dmMentionThreads: Bool
|
||||
|
||||
static let empty = MatrixSettings(requireMention: true, autoThread: true, dmMentionThreads: false)
|
||||
}
|
||||
|
||||
/// Mattermost settings. Mattermost is mostly driven by env vars; config.yaml
|
||||
/// currently just exposes `group_sessions_per_user` at the top level, but we
|
||||
/// reserve this struct for future expansion so the form has a stable type.
|
||||
struct MattermostSettings: Sendable, Equatable {
|
||||
var requireMention: Bool
|
||||
var replyMode: String // "thread" | "off"
|
||||
|
||||
static let empty = MattermostSettings(requireMention: true, replyMode: "off")
|
||||
}
|
||||
|
||||
/// WhatsApp settings under `whatsapp.*`.
|
||||
struct WhatsAppSettings: Sendable, Equatable {
|
||||
var unauthorizedDMBehavior: String // "pair" | "ignore"
|
||||
var replyPrefix: String
|
||||
|
||||
static let empty = WhatsAppSettings(unauthorizedDMBehavior: "pair", replyPrefix: "")
|
||||
}
|
||||
|
||||
/// Home Assistant filters under `platforms.homeassistant.extra`. Hermes ignores
|
||||
/// every state change by default; users must opt-in via at least one filter.
|
||||
struct HomeAssistantSettings: Sendable, Equatable {
|
||||
var watchDomains: [String]
|
||||
var watchEntities: [String]
|
||||
var watchAll: Bool
|
||||
var ignoreEntities: [String]
|
||||
var cooldownSeconds: Int
|
||||
|
||||
static let empty = HomeAssistantSettings(watchDomains: [], watchEntities: [], watchAll: false, ignoreEntities: [], cooldownSeconds: 30)
|
||||
}
|
||||
|
||||
// MARK: - Root Config
|
||||
|
||||
struct HermesConfig: Sendable {
|
||||
// Original fields — preserved for zero breakage with existing call sites.
|
||||
var model: String
|
||||
var provider: String
|
||||
var maxTurns: Int
|
||||
@@ -30,6 +328,37 @@ struct HermesConfig: Sendable {
|
||||
var interimAssistantMessages: Bool
|
||||
var honchoInitOnSessionStart: Bool
|
||||
|
||||
// Phase 1 additions
|
||||
var timezone: String
|
||||
var userProfileEnabled: Bool
|
||||
var toolUseEnforcement: String // "auto" | "true" | "false" | comma list
|
||||
var gatewayTimeout: Int
|
||||
var approvalTimeout: Int
|
||||
var fileReadMaxChars: Int
|
||||
var cronWrapResponse: Bool
|
||||
var prefillMessagesFile: String
|
||||
var skillsExternalDirs: [String]
|
||||
|
||||
// Grouped blocks
|
||||
var display: DisplaySettings
|
||||
var terminal: TerminalSettings
|
||||
var browser: BrowserSettings
|
||||
var voice: VoiceSettings
|
||||
var auxiliary: AuxiliarySettings
|
||||
var security: SecuritySettings
|
||||
var humanDelay: HumanDelaySettings
|
||||
var compression: CompressionSettings
|
||||
var checkpoints: CheckpointSettings
|
||||
var logging: LoggingSettings
|
||||
var delegation: DelegationSettings
|
||||
var discord: DiscordSettings
|
||||
var telegram: TelegramSettings
|
||||
var slack: SlackSettings
|
||||
var matrix: MatrixSettings
|
||||
var mattermost: MattermostSettings
|
||||
var whatsapp: WhatsAppSettings
|
||||
var homeAssistant: HomeAssistantSettings
|
||||
|
||||
static let empty = HermesConfig(
|
||||
model: "unknown",
|
||||
provider: "unknown",
|
||||
@@ -58,7 +387,34 @@ struct HermesConfig: Sendable {
|
||||
forceIPv4: false,
|
||||
contextEngine: "compressor",
|
||||
interimAssistantMessages: true,
|
||||
honchoInitOnSessionStart: false
|
||||
honchoInitOnSessionStart: false,
|
||||
timezone: "",
|
||||
userProfileEnabled: true,
|
||||
toolUseEnforcement: "auto",
|
||||
gatewayTimeout: 1800,
|
||||
approvalTimeout: 60,
|
||||
fileReadMaxChars: 100_000,
|
||||
cronWrapResponse: true,
|
||||
prefillMessagesFile: "",
|
||||
skillsExternalDirs: [],
|
||||
display: .empty,
|
||||
terminal: .empty,
|
||||
browser: .empty,
|
||||
voice: .empty,
|
||||
auxiliary: .empty,
|
||||
security: .empty,
|
||||
humanDelay: .empty,
|
||||
compression: .empty,
|
||||
checkpoints: .empty,
|
||||
logging: .empty,
|
||||
delegation: .empty,
|
||||
discord: .empty,
|
||||
telegram: .empty,
|
||||
slack: .empty,
|
||||
matrix: .empty,
|
||||
mattermost: .empty,
|
||||
whatsapp: .empty,
|
||||
homeAssistant: .empty
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
/// Read/write `~/.hermes/.env` while preserving comments, blank lines, and the
|
||||
/// ordering of keys we don't touch.
|
||||
///
|
||||
/// Hermes treats `.env` as a traditional dotenv file: `KEY=value`, `#` comments,
|
||||
/// and optional double-quoted values for strings with spaces or special chars.
|
||||
/// We do NOT attempt to implement full shell-style escaping; the fields we write
|
||||
/// from the GUI are bot tokens, user IDs, URLs, and on/off flags — none of which
|
||||
/// contain characters needing escaping beyond double-quoting.
|
||||
///
|
||||
/// Design choices:
|
||||
/// - **Non-destructive "unset"**: clearing a field comments the line out rather
|
||||
/// than deleting it, so users can restore a key by uncommenting without losing
|
||||
/// their value.
|
||||
/// - **Atomic write**: write to `.env.tmp`, then rename. Avoids a partially
|
||||
/// written file if Scarf crashes mid-write.
|
||||
/// - **Never logs values**: secrets flow through this service.
|
||||
struct HermesEnvService: Sendable {
|
||||
private let logger = Logger(subsystem: "com.scarf", category: "HermesEnvService")
|
||||
|
||||
/// Path to `~/.hermes/.env`. Kept configurable for tests.
|
||||
let path: String
|
||||
|
||||
init(path: String = HermesPaths.home + "/.env") {
|
||||
self.path = path
|
||||
}
|
||||
|
||||
/// Read the .env file into a `[key: value]` dict. Comments and commented-out
|
||||
/// assignments are ignored. Missing file returns an empty dict.
|
||||
func load() -> [String: String] {
|
||||
guard let content = try? String(contentsOfFile: path, encoding: .utf8) else {
|
||||
return [:]
|
||||
}
|
||||
var result: [String: String] = [:]
|
||||
for line in content.components(separatedBy: "\n") {
|
||||
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||
// Skip blanks and comments. A line beginning with `#` is either a pure
|
||||
// comment or a disabled assignment — both should be treated as "unset".
|
||||
if trimmed.isEmpty || trimmed.hasPrefix("#") { continue }
|
||||
guard let eq = trimmed.firstIndex(of: "=") else { continue }
|
||||
let key = String(trimmed[trimmed.startIndex..<eq]).trimmingCharacters(in: .whitespaces)
|
||||
let raw = String(trimmed[trimmed.index(after: eq)...]).trimmingCharacters(in: .whitespaces)
|
||||
result[key] = Self.stripEnvQuotes(raw)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func get(_ key: String) -> String? {
|
||||
load()[key]
|
||||
}
|
||||
|
||||
/// Write/update a single key. Preserves the position of existing assignments
|
||||
/// (even if they were commented out — the new assignment replaces the comment
|
||||
/// line in place). New keys are appended at the end.
|
||||
@discardableResult
|
||||
func set(_ key: String, value: String) -> Bool {
|
||||
setMany([key: value])
|
||||
}
|
||||
|
||||
/// Update multiple keys in one atomic rewrite. Use this when a form saves
|
||||
/// several fields at once so the file doesn't get repeatedly rewritten.
|
||||
///
|
||||
/// Returns `true` on success, `false` if the atomic rewrite failed.
|
||||
@discardableResult
|
||||
func setMany(_ pairs: [String: String]) -> Bool {
|
||||
var remaining = pairs
|
||||
var lines: [String]
|
||||
|
||||
// Start from existing file contents, or a minimal header if creating new.
|
||||
if let content = try? String(contentsOfFile: path, encoding: .utf8) {
|
||||
lines = content.components(separatedBy: "\n")
|
||||
// Trim a single trailing empty line from splitting the final newline;
|
||||
// we'll re-add it on write.
|
||||
if lines.last == "" { lines.removeLast() }
|
||||
} else {
|
||||
lines = ["# Hermes Agent Environment Configuration"]
|
||||
}
|
||||
|
||||
// First pass: update in-place (handles both live and commented-out lines).
|
||||
for (idx, line) in lines.enumerated() {
|
||||
guard let match = Self.extractKey(fromLine: line) else { continue }
|
||||
if let newValue = remaining.removeValue(forKey: match.key) {
|
||||
// A commented-out `# KEY=...` becomes a live `KEY=...` with the new value.
|
||||
lines[idx] = Self.formatLine(key: match.key, value: newValue)
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: append any keys that didn't match an existing line.
|
||||
if !remaining.isEmpty {
|
||||
// Leave a blank line before appending new keys for visual separation.
|
||||
if let last = lines.last, !last.isEmpty {
|
||||
lines.append("")
|
||||
}
|
||||
for key in remaining.keys.sorted() {
|
||||
lines.append(Self.formatLine(key: key, value: remaining[key]!))
|
||||
}
|
||||
}
|
||||
|
||||
return atomicWrite(lines.joined(separator: "\n") + "\n")
|
||||
}
|
||||
|
||||
/// Comment out a key. The value is preserved so the user can restore by
|
||||
/// uncommenting. If the key doesn't exist, this is a no-op.
|
||||
@discardableResult
|
||||
func unset(_ key: String) -> Bool {
|
||||
guard let content = try? String(contentsOfFile: path, encoding: .utf8) else {
|
||||
return true
|
||||
}
|
||||
var lines = content.components(separatedBy: "\n")
|
||||
if lines.last == "" { lines.removeLast() }
|
||||
|
||||
var changed = false
|
||||
for (idx, line) in lines.enumerated() {
|
||||
guard let match = Self.extractKey(fromLine: line), match.key == key else { continue }
|
||||
// Skip lines that are already commented — nothing to do.
|
||||
if Self.isCommentedOutAssignment(line) { continue }
|
||||
lines[idx] = "# " + line
|
||||
changed = true
|
||||
}
|
||||
guard changed else { return true }
|
||||
return atomicWrite(lines.joined(separator: "\n") + "\n")
|
||||
}
|
||||
|
||||
// MARK: - Internals
|
||||
|
||||
/// Writes the entire file in one shot via a tmp + rename to avoid corrupting
|
||||
/// `.env` if the process is killed mid-write. Preserves `0600` permissions
|
||||
/// since `.env` typically holds secrets.
|
||||
private func atomicWrite(_ content: String) -> Bool {
|
||||
let tmp = path + ".tmp"
|
||||
do {
|
||||
try content.write(toFile: tmp, atomically: false, encoding: .utf8)
|
||||
// Mirror the typical `.env` mode of `0600` (owner read/write only).
|
||||
try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: tmp)
|
||||
// Swap into place. FileManager.replaceItem handles the replacement
|
||||
// atomically on the same volume; fall back to a two-step rename.
|
||||
let destURL = URL(fileURLWithPath: path)
|
||||
let tmpURL = URL(fileURLWithPath: tmp)
|
||||
if FileManager.default.fileExists(atPath: path) {
|
||||
_ = try FileManager.default.replaceItemAt(destURL, withItemAt: tmpURL)
|
||||
} else {
|
||||
try FileManager.default.moveItem(at: tmpURL, to: destURL)
|
||||
}
|
||||
return true
|
||||
} catch {
|
||||
logger.error("Failed to write .env: \(error.localizedDescription)")
|
||||
try? FileManager.default.removeItem(atPath: tmp)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract a key name and whether the line was active or commented-out.
|
||||
/// Accepts both `KEY=value` and `# KEY=value` (any amount of whitespace after `#`).
|
||||
private static func extractKey(fromLine line: String) -> (key: String, active: Bool)? {
|
||||
var work = line.trimmingCharacters(in: .whitespaces)
|
||||
var active = true
|
||||
if work.hasPrefix("#") {
|
||||
active = false
|
||||
work = String(work.dropFirst()).trimmingCharacters(in: .whitespaces)
|
||||
}
|
||||
guard let eq = work.firstIndex(of: "=") else { return nil }
|
||||
let key = String(work[work.startIndex..<eq]).trimmingCharacters(in: .whitespaces)
|
||||
// Reject non-identifier looking keys to avoid matching prose in comments
|
||||
// (e.g. "# This is a note about something = nice").
|
||||
guard key.range(of: "^[A-Za-z_][A-Za-z0-9_]*$", options: .regularExpression) != nil else {
|
||||
return nil
|
||||
}
|
||||
return (key, active)
|
||||
}
|
||||
|
||||
private static func isCommentedOutAssignment(_ line: String) -> Bool {
|
||||
guard let match = extractKey(fromLine: line) else { return false }
|
||||
return !match.active
|
||||
}
|
||||
|
||||
/// Format a single `KEY=value` line. Values containing whitespace or shell
|
||||
/// metacharacters get double-quoted; simple tokens go in unquoted to match
|
||||
/// hermes's own output style.
|
||||
private static func formatLine(key: String, value: String) -> String {
|
||||
if Self.needsQuoting(value) {
|
||||
// Escape embedded backslashes and double quotes, then wrap.
|
||||
let escaped = value
|
||||
.replacingOccurrences(of: "\\", with: "\\\\")
|
||||
.replacingOccurrences(of: "\"", with: "\\\"")
|
||||
return "\(key)=\"\(escaped)\""
|
||||
}
|
||||
return "\(key)=\(value)"
|
||||
}
|
||||
|
||||
private static func needsQuoting(_ value: String) -> Bool {
|
||||
if value.isEmpty { return false }
|
||||
// Whitespace, shell metacharacters, or quotes trigger quoting.
|
||||
let metacharacters: Set<Character> = [" ", "\t", "#", "$", "`", "\"", "'", "\\", "(", ")", "{", "}", "[", "]", "|", "&", ";", "<", ">", "*", "?"]
|
||||
return value.contains(where: { metacharacters.contains($0) })
|
||||
}
|
||||
|
||||
/// Strip one layer of matched double or single quotes from a loaded value.
|
||||
private static func stripEnvQuotes(_ s: String) -> String {
|
||||
guard s.count >= 2 else { return s }
|
||||
let first = s.first!
|
||||
let last = s.last!
|
||||
if (first == "\"" && last == "\"") || (first == "'" && last == "'") {
|
||||
var inner = String(s.dropFirst().dropLast())
|
||||
if first == "\"" {
|
||||
inner = inner
|
||||
.replacingOccurrences(of: "\\\"", with: "\"")
|
||||
.replacingOccurrences(of: "\\\\", with: "\\")
|
||||
}
|
||||
return inner
|
||||
}
|
||||
return s
|
||||
}
|
||||
}
|
||||
@@ -10,91 +10,364 @@ struct HermesFileService: Sendable {
|
||||
}
|
||||
|
||||
private func parseConfig(_ yaml: String) -> HermesConfig {
|
||||
var values: [String: String] = [:]
|
||||
var currentSection = ""
|
||||
var dockerEnv: [String: String] = [:]
|
||||
var commandAllowlist: [String] = []
|
||||
var inDockerEnv = false
|
||||
var inAllowlist = false
|
||||
let parsed = Self.parseNestedYAML(yaml)
|
||||
let values = parsed.values
|
||||
let lists = parsed.lists
|
||||
let maps = parsed.maps
|
||||
|
||||
for line in yaml.components(separatedBy: "\n") {
|
||||
func bool(_ key: String, default def: Bool) -> Bool {
|
||||
guard let v = values[key] else { return def }
|
||||
return v == "true"
|
||||
}
|
||||
func int(_ key: String, default def: Int) -> Int {
|
||||
Int(values[key] ?? "") ?? def
|
||||
}
|
||||
func double(_ key: String, default def: Double) -> Double {
|
||||
Double(values[key] ?? "") ?? def
|
||||
}
|
||||
func str(_ key: String, default def: String = "") -> String {
|
||||
// Strip quotes added by Hermes's YAML dumper around strings with special chars.
|
||||
let raw = values[key] ?? def
|
||||
return Self.stripYAMLQuotes(raw)
|
||||
}
|
||||
|
||||
let dockerEnv = maps["terminal.docker_env"] ?? [:]
|
||||
let commandAllowlist = lists["permanent_allowlist"] ?? lists["command_allowlist"] ?? []
|
||||
|
||||
let display = DisplaySettings(
|
||||
skin: str("display.skin", default: "default"),
|
||||
compact: bool("display.compact", default: false),
|
||||
resumeDisplay: str("display.resume_display", default: "full"),
|
||||
bellOnComplete: bool("display.bell_on_complete", default: false),
|
||||
inlineDiffs: bool("display.inline_diffs", default: true),
|
||||
toolProgressCommand: bool("display.tool_progress_command", default: false),
|
||||
toolPreviewLength: int("display.tool_preview_length", default: 0),
|
||||
busyInputMode: str("display.busy_input_mode", default: "interrupt")
|
||||
)
|
||||
|
||||
let terminal = TerminalSettings(
|
||||
cwd: str("terminal.cwd", default: "."),
|
||||
timeout: int("terminal.timeout", default: 180),
|
||||
envPassthrough: lists["terminal.env_passthrough"] ?? [],
|
||||
persistentShell: bool("terminal.persistent_shell", default: true),
|
||||
dockerImage: str("terminal.docker_image"),
|
||||
dockerMountCwdToWorkspace: bool("terminal.docker_mount_cwd_to_workspace", default: false),
|
||||
dockerForwardEnv: lists["terminal.docker_forward_env"] ?? [],
|
||||
dockerVolumes: lists["terminal.docker_volumes"] ?? [],
|
||||
containerCPU: int("terminal.container_cpu", default: 0),
|
||||
containerMemory: int("terminal.container_memory", default: 0),
|
||||
containerDisk: int("terminal.container_disk", default: 0),
|
||||
containerPersistent: bool("terminal.container_persistent", default: false),
|
||||
modalImage: str("terminal.modal_image"),
|
||||
modalMode: str("terminal.modal_mode", default: "auto"),
|
||||
daytonaImage: str("terminal.daytona_image"),
|
||||
singularityImage: str("terminal.singularity_image")
|
||||
)
|
||||
|
||||
let browser = BrowserSettings(
|
||||
inactivityTimeout: int("browser.inactivity_timeout", default: 120),
|
||||
commandTimeout: int("browser.command_timeout", default: 30),
|
||||
recordSessions: bool("browser.record_sessions", default: false),
|
||||
allowPrivateURLs: bool("browser.allow_private_urls", default: false),
|
||||
camofoxManagedPersistence: bool("browser.camofox.managed_persistence", default: false)
|
||||
)
|
||||
|
||||
let voice = VoiceSettings(
|
||||
recordKey: str("voice.record_key", default: "ctrl+b"),
|
||||
maxRecordingSeconds: int("voice.max_recording_seconds", default: 120),
|
||||
silenceDuration: double("voice.silence_duration", default: 3.0),
|
||||
ttsProvider: str("tts.provider", default: "edge"),
|
||||
ttsEdgeVoice: str("tts.edge.voice", default: "en-US-AriaNeural"),
|
||||
ttsElevenLabsVoiceID: str("tts.elevenlabs.voice_id"),
|
||||
ttsElevenLabsModelID: str("tts.elevenlabs.model_id", default: "eleven_multilingual_v2"),
|
||||
ttsOpenAIModel: str("tts.openai.model", default: "gpt-4o-mini-tts"),
|
||||
ttsOpenAIVoice: str("tts.openai.voice", default: "alloy"),
|
||||
ttsNeuTTSModel: str("tts.neutts.model"),
|
||||
ttsNeuTTSDevice: str("tts.neutts.device", default: "cpu"),
|
||||
sttEnabled: bool("stt.enabled", default: true),
|
||||
sttProvider: str("stt.provider", default: "local"),
|
||||
sttLocalModel: str("stt.local.model", default: "base"),
|
||||
sttLocalLanguage: str("stt.local.language"),
|
||||
sttOpenAIModel: str("stt.openai.model", default: "whisper-1"),
|
||||
sttMistralModel: str("stt.mistral.model", default: "voxtral-mini-latest")
|
||||
)
|
||||
|
||||
func aux(_ name: String) -> AuxiliaryModel {
|
||||
AuxiliaryModel(
|
||||
provider: str("auxiliary.\(name).provider", default: "auto"),
|
||||
model: str("auxiliary.\(name).model"),
|
||||
baseURL: str("auxiliary.\(name).base_url"),
|
||||
apiKey: str("auxiliary.\(name).api_key"),
|
||||
timeout: int("auxiliary.\(name).timeout", default: 30)
|
||||
)
|
||||
}
|
||||
let auxiliary = AuxiliarySettings(
|
||||
vision: aux("vision"),
|
||||
webExtract: aux("web_extract"),
|
||||
compression: aux("compression"),
|
||||
sessionSearch: aux("session_search"),
|
||||
skillsHub: aux("skills_hub"),
|
||||
approval: aux("approval"),
|
||||
mcp: aux("mcp"),
|
||||
flushMemories: aux("flush_memories")
|
||||
)
|
||||
|
||||
let security = SecuritySettings(
|
||||
redactSecrets: bool("security.redact_secrets", default: true),
|
||||
redactPII: bool("privacy.redact_pii", default: false),
|
||||
tirithEnabled: bool("security.tirith_enabled", default: true),
|
||||
tirithPath: str("security.tirith_path", default: "tirith"),
|
||||
tirithTimeout: int("security.tirith_timeout", default: 5),
|
||||
tirithFailOpen: bool("security.tirith_fail_open", default: true),
|
||||
blocklistEnabled: bool("security.website_blocklist.enabled", default: false),
|
||||
blocklistDomains: lists["security.website_blocklist.domains"] ?? []
|
||||
)
|
||||
|
||||
let humanDelay = HumanDelaySettings(
|
||||
mode: str("human_delay.mode", default: "off"),
|
||||
minMS: int("human_delay.min_ms", default: 800),
|
||||
maxMS: int("human_delay.max_ms", default: 2500)
|
||||
)
|
||||
|
||||
let compression = CompressionSettings(
|
||||
enabled: bool("compression.enabled", default: true),
|
||||
threshold: double("compression.threshold", default: 0.5),
|
||||
targetRatio: double("compression.target_ratio", default: 0.2),
|
||||
protectLastN: int("compression.protect_last_n", default: 20)
|
||||
)
|
||||
|
||||
let checkpoints = CheckpointSettings(
|
||||
enabled: bool("checkpoints.enabled", default: true),
|
||||
maxSnapshots: int("checkpoints.max_snapshots", default: 50)
|
||||
)
|
||||
|
||||
let logging = LoggingSettings(
|
||||
level: str("logging.level", default: "INFO"),
|
||||
maxSizeMB: int("logging.max_size_mb", default: 5),
|
||||
backupCount: int("logging.backup_count", default: 3)
|
||||
)
|
||||
|
||||
let delegation = DelegationSettings(
|
||||
model: str("delegation.model"),
|
||||
provider: str("delegation.provider"),
|
||||
baseURL: str("delegation.base_url"),
|
||||
apiKey: str("delegation.api_key"),
|
||||
maxIterations: int("delegation.max_iterations", default: 50)
|
||||
)
|
||||
|
||||
let discord = DiscordSettings(
|
||||
requireMention: bool("discord.require_mention", default: true),
|
||||
freeResponseChannels: str("discord.free_response_channels"),
|
||||
autoThread: bool("discord.auto_thread", default: true),
|
||||
reactions: bool("discord.reactions", default: true)
|
||||
)
|
||||
|
||||
let telegram = TelegramSettings(
|
||||
requireMention: bool("telegram.require_mention", default: true),
|
||||
reactions: bool("telegram.reactions", default: false)
|
||||
)
|
||||
|
||||
// Slack fields live under both `platforms.slack.*` (newer) and `slack.*`
|
||||
// (legacy) in config.yaml. Prefer the newer path but fall back.
|
||||
let slack = SlackSettings(
|
||||
replyToMode: values["platforms.slack.reply_to_mode"] ?? values["slack.reply_to_mode"] ?? "first",
|
||||
requireMention: (values["platforms.slack.require_mention"] ?? values["slack.require_mention"]) != "false",
|
||||
replyInThread: (values["platforms.slack.extra.reply_in_thread"] ?? "true") != "false",
|
||||
replyBroadcast: (values["platforms.slack.extra.reply_broadcast"] ?? "false") == "true"
|
||||
)
|
||||
|
||||
let matrix = MatrixSettings(
|
||||
requireMention: bool("matrix.require_mention", default: true),
|
||||
autoThread: bool("matrix.auto_thread", default: true),
|
||||
dmMentionThreads: bool("matrix.dm_mention_threads", default: false)
|
||||
)
|
||||
|
||||
let mattermost = MattermostSettings(
|
||||
requireMention: bool("mattermost.require_mention", default: true),
|
||||
replyMode: str("mattermost.reply_mode", default: "off")
|
||||
)
|
||||
|
||||
let whatsapp = WhatsAppSettings(
|
||||
unauthorizedDMBehavior: str("whatsapp.unauthorized_dm_behavior", default: "pair"),
|
||||
replyPrefix: str("whatsapp.reply_prefix")
|
||||
)
|
||||
|
||||
// Home Assistant lives under `platforms.homeassistant.extra.*`.
|
||||
let homeAssistant = HomeAssistantSettings(
|
||||
watchDomains: lists["platforms.homeassistant.extra.watch_domains"] ?? [],
|
||||
watchEntities: lists["platforms.homeassistant.extra.watch_entities"] ?? [],
|
||||
watchAll: bool("platforms.homeassistant.extra.watch_all", default: false),
|
||||
ignoreEntities: lists["platforms.homeassistant.extra.ignore_entities"] ?? [],
|
||||
cooldownSeconds: int("platforms.homeassistant.extra.cooldown_seconds", default: 30)
|
||||
)
|
||||
|
||||
return HermesConfig(
|
||||
model: str("model.default", default: "unknown"),
|
||||
provider: str("model.provider", default: "unknown"),
|
||||
maxTurns: int("agent.max_turns", default: 0),
|
||||
personality: str("display.personality", default: "default"),
|
||||
terminalBackend: str("terminal.backend", default: "local"),
|
||||
memoryEnabled: bool("memory.memory_enabled", default: false),
|
||||
memoryCharLimit: int("memory.memory_char_limit", default: 0),
|
||||
userCharLimit: int("memory.user_char_limit", default: 0),
|
||||
nudgeInterval: int("memory.nudge_interval", default: 0),
|
||||
streaming: values["display.streaming"] != "false",
|
||||
showReasoning: bool("display.show_reasoning", default: false),
|
||||
verbose: bool("agent.verbose", default: false),
|
||||
autoTTS: values["voice.auto_tts"] != "false",
|
||||
silenceThreshold: int("voice.silence_threshold", default: QueryDefaults.defaultSilenceThreshold),
|
||||
reasoningEffort: str("agent.reasoning_effort", default: "medium"),
|
||||
showCost: bool("display.show_cost", default: false),
|
||||
approvalMode: str("approvals.mode", default: "manual"),
|
||||
browserBackend: str("browser.backend"),
|
||||
memoryProvider: str("memory.provider"),
|
||||
dockerEnv: dockerEnv,
|
||||
commandAllowlist: commandAllowlist,
|
||||
memoryProfile: str("memory.profile"),
|
||||
serviceTier: str("agent.service_tier", default: "normal"),
|
||||
gatewayNotifyInterval: int("agent.gateway_notify_interval", default: 600),
|
||||
forceIPv4: bool("network.force_ipv4", default: false),
|
||||
contextEngine: str("context.engine", default: "compressor"),
|
||||
interimAssistantMessages: values["display.interim_assistant_messages"] != "false",
|
||||
honchoInitOnSessionStart: bool("honcho.initOnSessionStart", default: false),
|
||||
timezone: str("timezone"),
|
||||
userProfileEnabled: bool("memory.user_profile_enabled", default: true),
|
||||
toolUseEnforcement: str("agent.tool_use_enforcement", default: "auto"),
|
||||
gatewayTimeout: int("agent.gateway_timeout", default: 1800),
|
||||
approvalTimeout: int("approvals.timeout", default: 60),
|
||||
fileReadMaxChars: int("file_read_max_chars", default: 100_000),
|
||||
cronWrapResponse: bool("cron.wrap_response", default: true),
|
||||
prefillMessagesFile: str("prefill_messages_file"),
|
||||
skillsExternalDirs: lists["skills.external_dirs"] ?? [],
|
||||
display: display,
|
||||
terminal: terminal,
|
||||
browser: browser,
|
||||
voice: voice,
|
||||
auxiliary: auxiliary,
|
||||
security: security,
|
||||
humanDelay: humanDelay,
|
||||
compression: compression,
|
||||
checkpoints: checkpoints,
|
||||
logging: logging,
|
||||
delegation: delegation,
|
||||
discord: discord,
|
||||
telegram: telegram,
|
||||
slack: slack,
|
||||
matrix: matrix,
|
||||
mattermost: mattermost,
|
||||
whatsapp: whatsapp,
|
||||
homeAssistant: homeAssistant
|
||||
)
|
||||
}
|
||||
|
||||
/// Parsed YAML result bundle.
|
||||
struct ParsedYAML: Sendable {
|
||||
var values: [String: String] // "section.key" -> scalar string
|
||||
var lists: [String: [String]] // "section.key" -> items from a bullet list
|
||||
var maps: [String: [String: String]] // "section.key" -> nested key-value map
|
||||
}
|
||||
|
||||
/// Parse a subset of YAML into flat dotted paths.
|
||||
///
|
||||
/// Supports:
|
||||
/// - Scalar key-value pairs at any indent level → `values["a.b.c"] = "..."`
|
||||
/// - Empty-valued section headers → acts as a path prefix for nested scalars
|
||||
/// - Bullet lists (`- item`) nested under a `key:` → `lists["a.b"]`
|
||||
/// - Nested maps where a header has no value and children are `k: v` pairs →
|
||||
/// captured as `maps["a.b"]` AND each child as `values["a.b.k"]`.
|
||||
///
|
||||
/// This is sufficient for Hermes config; we do not attempt full YAML compliance.
|
||||
nonisolated static func parseNestedYAML(_ yaml: String) -> ParsedYAML {
|
||||
var values: [String: String] = [:]
|
||||
var lists: [String: [String]] = [:]
|
||||
var maps: [String: [String: String]] = [:]
|
||||
// Path stack: each entry is (indent, name). Pop when indent shrinks.
|
||||
var stack: [(indent: Int, name: String)] = []
|
||||
|
||||
func currentPath(joinedWith child: String? = nil) -> String {
|
||||
var parts = stack.map(\.name)
|
||||
if let child { parts.append(child) }
|
||||
return parts.joined(separator: ".")
|
||||
}
|
||||
|
||||
let rawLines = yaml.components(separatedBy: "\n")
|
||||
for line in rawLines {
|
||||
// Skip comment-only and blank lines but preserve indent semantics.
|
||||
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||
if trimmed.isEmpty || trimmed.hasPrefix("#") { continue }
|
||||
|
||||
let indent = line.prefix(while: { $0 == " " }).count
|
||||
let isListItem = trimmed.hasPrefix("- ")
|
||||
|
||||
// Detect end of nested blocks when indent returns to section level
|
||||
if indent <= 2 && (inDockerEnv || inAllowlist) {
|
||||
inDockerEnv = false
|
||||
inAllowlist = false
|
||||
}
|
||||
|
||||
// Collect docker_env nested key-value pairs
|
||||
if inDockerEnv, indent >= 4, let colonIdx = trimmed.firstIndex(of: ":") {
|
||||
let key = String(trimmed[trimmed.startIndex..<colonIdx]).trimmingCharacters(in: .whitespaces)
|
||||
let val = String(trimmed[trimmed.index(after: colonIdx)...]).trimmingCharacters(in: .whitespaces)
|
||||
dockerEnv[key] = val
|
||||
continue
|
||||
}
|
||||
|
||||
// Collect allowlist items
|
||||
if inAllowlist, indent >= 4, trimmed.hasPrefix("- ") {
|
||||
commandAllowlist.append(String(trimmed.dropFirst(2)))
|
||||
continue
|
||||
}
|
||||
|
||||
if indent == 0 && trimmed.hasSuffix(":") {
|
||||
currentSection = String(trimmed.dropLast())
|
||||
continue
|
||||
}
|
||||
|
||||
if let colonIdx = trimmed.firstIndex(of: ":") {
|
||||
let key = String(trimmed[trimmed.startIndex..<colonIdx]).trimmingCharacters(in: .whitespaces)
|
||||
let val = String(trimmed[trimmed.index(after: colonIdx)...]).trimmingCharacters(in: .whitespaces)
|
||||
|
||||
if key == "docker_env" && val.isEmpty {
|
||||
inDockerEnv = true
|
||||
continue
|
||||
}
|
||||
if key == "permanent_allowlist" && val.isEmpty {
|
||||
inAllowlist = true
|
||||
continue
|
||||
// Pop stack entries with indent >= current indent.
|
||||
// Exception: a list item at the same indent as its parent key is
|
||||
// valid block-style YAML ("toolsets:\n- hermes-cli") — keep the
|
||||
// parent so the item is attributed to it.
|
||||
while let top = stack.last {
|
||||
let shouldPop: Bool
|
||||
if isListItem && top.indent == indent {
|
||||
shouldPop = false
|
||||
} else {
|
||||
shouldPop = top.indent >= indent
|
||||
}
|
||||
if shouldPop { stack.removeLast() } else { break }
|
||||
}
|
||||
|
||||
values[currentSection + "." + key] = val
|
||||
if isListItem {
|
||||
let item = String(trimmed.dropFirst(2)).trimmingCharacters(in: .whitespaces)
|
||||
let stripped = stripYAMLQuotes(item)
|
||||
let path = currentPath()
|
||||
guard !path.isEmpty else { continue }
|
||||
lists[path, default: []].append(stripped)
|
||||
continue
|
||||
}
|
||||
|
||||
// Key-value or section line.
|
||||
guard let colonIdx = trimmed.firstIndex(of: ":") else { continue }
|
||||
let key = String(trimmed[trimmed.startIndex..<colonIdx]).trimmingCharacters(in: .whitespaces)
|
||||
let afterColon = String(trimmed[trimmed.index(after: colonIdx)...]).trimmingCharacters(in: .whitespaces)
|
||||
|
||||
let path = currentPath(joinedWith: key)
|
||||
|
||||
if afterColon.isEmpty || afterColon == "|" || afterColon == ">" {
|
||||
// Section header or empty-valued key — push onto stack so children nest.
|
||||
stack.append((indent: indent, name: key))
|
||||
continue
|
||||
}
|
||||
|
||||
// Inline `{}` / `[]` literals → treat as empty.
|
||||
if afterColon == "{}" {
|
||||
values[path] = ""
|
||||
maps[path] = [:]
|
||||
continue
|
||||
}
|
||||
if afterColon == "[]" {
|
||||
values[path] = ""
|
||||
lists[path] = []
|
||||
continue
|
||||
}
|
||||
|
||||
values[path] = afterColon
|
||||
|
||||
// Also record as a map entry under the parent, so we can treat blocks
|
||||
// like `terminal.docker_env` as `[String: String]` without a separate scan.
|
||||
if !stack.isEmpty {
|
||||
let parentPath = currentPath()
|
||||
maps[parentPath, default: [:]][key] = stripYAMLQuotes(afterColon)
|
||||
}
|
||||
}
|
||||
return ParsedYAML(values: values, lists: lists, maps: maps)
|
||||
}
|
||||
|
||||
return HermesConfig(
|
||||
model: values["model.default"] ?? "unknown",
|
||||
provider: values["model.provider"] ?? "unknown",
|
||||
maxTurns: Int(values["agent.max_turns"] ?? "") ?? 0,
|
||||
personality: values["display.personality"] ?? "default",
|
||||
terminalBackend: values["terminal.backend"] ?? "local",
|
||||
memoryEnabled: values["memory.memory_enabled"] == "true",
|
||||
memoryCharLimit: Int(values["memory.memory_char_limit"] ?? "") ?? 0,
|
||||
userCharLimit: Int(values["memory.user_char_limit"] ?? "") ?? 0,
|
||||
nudgeInterval: Int(values["memory.nudge_interval"] ?? "") ?? 0,
|
||||
streaming: values["display.streaming"] != "false",
|
||||
showReasoning: values["display.show_reasoning"] == "true",
|
||||
verbose: values["agent.verbose"] == "true",
|
||||
autoTTS: values["voice.auto_tts"] != "false",
|
||||
silenceThreshold: Int(values["voice.silence_threshold"] ?? "") ?? QueryDefaults.defaultSilenceThreshold,
|
||||
reasoningEffort: values["agent.reasoning_effort"] ?? "medium",
|
||||
showCost: values["display.show_cost"] == "true",
|
||||
approvalMode: values["approvals.mode"] ?? "manual",
|
||||
browserBackend: values["browser.backend"] ?? "",
|
||||
memoryProvider: values["memory.provider"] ?? "",
|
||||
dockerEnv: dockerEnv,
|
||||
commandAllowlist: commandAllowlist,
|
||||
memoryProfile: values["memory.profile"] ?? "",
|
||||
serviceTier: values["agent.service_tier"] ?? "normal",
|
||||
gatewayNotifyInterval: Int(values["agent.gateway_notify_interval"] ?? "") ?? 600,
|
||||
forceIPv4: values["network.force_ipv4"] == "true",
|
||||
contextEngine: values["context.engine"] ?? "compressor",
|
||||
interimAssistantMessages: values["display.interim_assistant_messages"] != "false",
|
||||
honchoInitOnSessionStart: values["honcho.initOnSessionStart"] == "true"
|
||||
)
|
||||
/// Strip a single layer of surrounding single or double quotes from a YAML scalar.
|
||||
nonisolated static func stripYAMLQuotes(_ s: String) -> String {
|
||||
guard s.count >= 2 else { return s }
|
||||
let first = s.first!
|
||||
let last = s.last!
|
||||
if (first == "'" && last == "'") || (first == "\"" && last == "\"") {
|
||||
return String(s.dropFirst().dropLast())
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// MARK: - Gateway State
|
||||
|
||||
@@ -12,6 +12,7 @@ final class HermesFileWatcher {
|
||||
HermesPaths.stateDB,
|
||||
HermesPaths.stateDB + "-wal",
|
||||
HermesPaths.configYAML,
|
||||
HermesPaths.home + "/.env", // Platform setup forms write here.
|
||||
HermesPaths.memoryMD,
|
||||
HermesPaths.userMD,
|
||||
HermesPaths.cronJobsJSON,
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
/// A single model from the models.dev catalog shipped with hermes.
|
||||
struct HermesModelInfo: Sendable, Identifiable, Hashable {
|
||||
var id: String { providerID + ":" + modelID }
|
||||
|
||||
let providerID: String
|
||||
let providerName: String
|
||||
let modelID: String
|
||||
let modelName: String
|
||||
let contextWindow: Int?
|
||||
let maxOutput: Int?
|
||||
let costInput: Double? // USD per 1M input tokens
|
||||
let costOutput: Double? // USD per 1M output tokens
|
||||
let reasoning: Bool
|
||||
let toolCall: Bool
|
||||
let releaseDate: String?
|
||||
|
||||
/// Display-friendly cost string, or nil if cost is unknown.
|
||||
var costDisplay: String? {
|
||||
guard let input = costInput, let output = costOutput else { return nil }
|
||||
return String(format: "$%.2f / $%.2f", input, output)
|
||||
}
|
||||
|
||||
/// Display-friendly context window ("200K", "1M", etc.).
|
||||
var contextDisplay: String? {
|
||||
guard let ctx = contextWindow else { return nil }
|
||||
if ctx >= 1_000_000 { return "\(ctx / 1_000_000)M" }
|
||||
if ctx >= 1_000 { return "\(ctx / 1_000)K" }
|
||||
return "\(ctx)"
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider summary — one row in the left column of the picker.
|
||||
struct HermesProviderInfo: Sendable, Identifiable, Hashable {
|
||||
var id: String { providerID }
|
||||
|
||||
let providerID: String
|
||||
let providerName: String
|
||||
let envVars: [String] // e.g. ["ANTHROPIC_API_KEY"]
|
||||
let docURL: String?
|
||||
let modelCount: Int
|
||||
}
|
||||
|
||||
/// Reads the models.dev catalog that hermes caches at
|
||||
/// `~/.hermes/models_dev_cache.json`. Offline-capable, fast enough to read per
|
||||
/// call (~1500 models across ~110 providers).
|
||||
///
|
||||
/// We decode a trimmed subset so unknown fields don't break loading. Every
|
||||
/// field we care about is optional on disk — providers may omit cost, context
|
||||
/// limits, etc.
|
||||
struct ModelCatalogService: Sendable {
|
||||
private let logger = Logger(subsystem: "com.scarf", category: "ModelCatalogService")
|
||||
let path: String
|
||||
|
||||
init(path: String = HermesPaths.home + "/models_dev_cache.json") {
|
||||
self.path = path
|
||||
}
|
||||
|
||||
/// All providers, sorted by display name.
|
||||
func loadProviders() -> [HermesProviderInfo] {
|
||||
guard let catalog = loadCatalog() else { return [] }
|
||||
return catalog
|
||||
.map { (id, p) in
|
||||
HermesProviderInfo(
|
||||
providerID: id,
|
||||
providerName: p.name ?? id,
|
||||
envVars: p.env ?? [],
|
||||
docURL: p.doc,
|
||||
modelCount: p.models?.count ?? 0
|
||||
)
|
||||
}
|
||||
.sorted { $0.providerName.localizedCaseInsensitiveCompare($1.providerName) == .orderedAscending }
|
||||
}
|
||||
|
||||
/// Models for one provider, sorted by release date (newest first), then name.
|
||||
func loadModels(for providerID: String) -> [HermesModelInfo] {
|
||||
guard let catalog = loadCatalog(), let provider = catalog[providerID] else { return [] }
|
||||
let providerName = provider.name ?? providerID
|
||||
let models = (provider.models ?? [:]).map { (id, m) in
|
||||
HermesModelInfo(
|
||||
providerID: providerID,
|
||||
providerName: providerName,
|
||||
modelID: id,
|
||||
modelName: m.name ?? id,
|
||||
contextWindow: m.limit?.context,
|
||||
maxOutput: m.limit?.output,
|
||||
costInput: m.cost?.input,
|
||||
costOutput: m.cost?.output,
|
||||
reasoning: m.reasoning ?? false,
|
||||
toolCall: m.tool_call ?? false,
|
||||
releaseDate: m.release_date
|
||||
)
|
||||
}
|
||||
return models.sorted { lhs, rhs in
|
||||
// Newest-first by release date if both are known; otherwise fall
|
||||
// back to alphabetical on display name.
|
||||
if let lDate = lhs.releaseDate, let rDate = rhs.releaseDate, lDate != rDate {
|
||||
return lDate > rDate
|
||||
}
|
||||
return lhs.modelName.localizedCaseInsensitiveCompare(rhs.modelName) == .orderedAscending
|
||||
}
|
||||
}
|
||||
|
||||
/// Find the provider that ships a given model ID. Useful for auto-syncing
|
||||
/// provider when the user picks a model from a flat list or types one in.
|
||||
func provider(for modelID: String) -> HermesProviderInfo? {
|
||||
guard let catalog = loadCatalog() else { return nil }
|
||||
for (providerID, p) in catalog {
|
||||
if p.models?[modelID] != nil {
|
||||
return HermesProviderInfo(
|
||||
providerID: providerID,
|
||||
providerName: p.name ?? providerID,
|
||||
envVars: p.env ?? [],
|
||||
docURL: p.doc,
|
||||
modelCount: p.models?.count ?? 0
|
||||
)
|
||||
}
|
||||
}
|
||||
// Handle provider-prefixed IDs like "openai/gpt-4o" — look up the
|
||||
// prefix before the slash.
|
||||
if let slash = modelID.firstIndex(of: "/") {
|
||||
let prefix = String(modelID[modelID.startIndex..<slash])
|
||||
if let p = catalog[prefix] {
|
||||
return HermesProviderInfo(
|
||||
providerID: prefix,
|
||||
providerName: p.name ?? prefix,
|
||||
envVars: p.env ?? [],
|
||||
docURL: p.doc,
|
||||
modelCount: p.models?.count ?? 0
|
||||
)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Look up a specific model by provider + ID. Returns nil if not in the
|
||||
/// catalog (e.g., free-typed custom model).
|
||||
func model(providerID: String, modelID: String) -> HermesModelInfo? {
|
||||
guard let catalog = loadCatalog(),
|
||||
let provider = catalog[providerID],
|
||||
let raw = provider.models?[modelID] else { return nil }
|
||||
return HermesModelInfo(
|
||||
providerID: providerID,
|
||||
providerName: provider.name ?? providerID,
|
||||
modelID: modelID,
|
||||
modelName: raw.name ?? modelID,
|
||||
contextWindow: raw.limit?.context,
|
||||
maxOutput: raw.limit?.output,
|
||||
costInput: raw.cost?.input,
|
||||
costOutput: raw.cost?.output,
|
||||
reasoning: raw.reasoning ?? false,
|
||||
toolCall: raw.tool_call ?? false,
|
||||
releaseDate: raw.release_date
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Decoding
|
||||
|
||||
private func loadCatalog() -> [String: ProviderEntry]? {
|
||||
guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)) else {
|
||||
return nil
|
||||
}
|
||||
do {
|
||||
return try JSONDecoder().decode([String: ProviderEntry].self, from: data)
|
||||
} catch {
|
||||
logger.error("Failed to decode models_dev_cache.json: \(error.localizedDescription)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Trimmed representations — we decode a subset of fields and tolerate
|
||||
// anything new hermes adds later. `snake_case` field names match the file.
|
||||
private struct ProviderEntry: Decodable {
|
||||
let id: String?
|
||||
let name: String?
|
||||
let env: [String]?
|
||||
let doc: String?
|
||||
let models: [String: ModelEntry]?
|
||||
}
|
||||
|
||||
private struct ModelEntry: Decodable {
|
||||
let name: String?
|
||||
let reasoning: Bool?
|
||||
let tool_call: Bool?
|
||||
let release_date: String?
|
||||
let cost: CostEntry?
|
||||
let limit: LimitEntry?
|
||||
}
|
||||
|
||||
private struct CostEntry: Decodable {
|
||||
let input: Double?
|
||||
let output: Double?
|
||||
}
|
||||
|
||||
private struct LimitEntry: Decodable {
|
||||
let context: Int?
|
||||
let output: Int?
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
import Foundation
|
||||
import AppKit
|
||||
import os
|
||||
|
||||
/// A single pooled credential for a provider (rotation entry).
|
||||
struct HermesCredential: Identifiable, Sendable, Equatable {
|
||||
var id: String { "\(provider):\(index):\(internalID)" }
|
||||
let internalID: String // Stable id from auth.json (e.g. "9f8d9b")
|
||||
let provider: String
|
||||
let index: Int // 0-based index in the provider's pool
|
||||
let label: String // Human label ("OPENROUTER_API_KEY")
|
||||
let authType: String // "api_key" | "oauth"
|
||||
let source: String // "env:OPENROUTER_API_KEY" | "gh_cli" | "file:..."
|
||||
let tokenTail: String // Last 4 chars of the token — NEVER store full token in UI state
|
||||
let lastStatus: String // "ok" | "cooldown" | "exhausted" | ""
|
||||
let requestCount: Int
|
||||
}
|
||||
|
||||
/// Summary of one provider's pool with its rotation strategy.
|
||||
struct HermesCredentialPool: Identifiable, Sendable {
|
||||
var id: String { provider }
|
||||
let provider: String
|
||||
let strategy: String // "fill_first" | "round_robin" | "least_used" | "random"
|
||||
let credentials: [HermesCredential]
|
||||
}
|
||||
|
||||
@Observable
|
||||
@MainActor
|
||||
final class CredentialPoolsViewModel {
|
||||
private let logger = Logger(subsystem: "com.scarf", category: "CredentialPoolsViewModel")
|
||||
|
||||
var pools: [HermesCredentialPool] = []
|
||||
var isLoading = false
|
||||
var message: String?
|
||||
|
||||
/// Driver for the OAuth flow. Uses Process + pipes (not SwiftTerm) so we
|
||||
/// can extract the authorization URL, pop it open with an explicit button,
|
||||
/// and feed the code back via stdin. See OAuthFlowController for why we
|
||||
/// moved off the embedded-terminal approach.
|
||||
let oauthFlow = OAuthFlowController()
|
||||
var oauthProvider: String = ""
|
||||
/// Convenience — the sheet keys a lot of UI off "is the flow running?".
|
||||
var oauthInProgress: Bool { oauthFlow.isRunning }
|
||||
|
||||
let strategyOptions = ["fill_first", "round_robin", "least_used", "random"]
|
||||
|
||||
/// Source of truth is `~/.hermes/auth.json`. Parsing box-drawn `hermes auth list`
|
||||
/// output is fragile — the JSON file is structured, stable, and already stores
|
||||
/// exactly the pool data the UI needs. We never display full tokens.
|
||||
func load() {
|
||||
isLoading = true
|
||||
defer { isLoading = false }
|
||||
|
||||
let authPath = HermesPaths.home + "/auth.json"
|
||||
let strategies = parseStrategies()
|
||||
|
||||
guard let data = try? Data(contentsOf: URL(fileURLWithPath: authPath)) else {
|
||||
pools = []
|
||||
return
|
||||
}
|
||||
do {
|
||||
let decoded = try JSONDecoder().decode(AuthFile.self, from: data)
|
||||
pools = Self.buildPools(from: decoded, strategies: strategies)
|
||||
} catch {
|
||||
logger.error("Failed to decode auth.json: \(error.localizedDescription)")
|
||||
pools = []
|
||||
}
|
||||
}
|
||||
|
||||
/// The `credential_pool_strategies:` map lives in config.yaml as `<provider>: <strategy>`.
|
||||
private func parseStrategies() -> [String: String] {
|
||||
guard let yaml = try? String(contentsOfFile: HermesPaths.configYAML, encoding: .utf8) else { return [:] }
|
||||
let parsed = HermesFileService.parseNestedYAML(yaml)
|
||||
return parsed.maps["credential_pool_strategies"] ?? [:]
|
||||
}
|
||||
|
||||
private static func buildPools(from auth: AuthFile, strategies: [String: String]) -> [HermesCredentialPool] {
|
||||
auth.credential_pool.keys.sorted().map { provider in
|
||||
let entries = auth.credential_pool[provider] ?? []
|
||||
let creds = entries.enumerated().map { index, entry in
|
||||
HermesCredential(
|
||||
internalID: entry.id ?? "",
|
||||
provider: provider,
|
||||
index: index,
|
||||
label: entry.label ?? entry.source ?? "",
|
||||
authType: entry.auth_type ?? "",
|
||||
source: entry.source ?? "",
|
||||
tokenTail: Self.tail(of: entry.access_token ?? ""),
|
||||
lastStatus: entry.last_status ?? "",
|
||||
requestCount: entry.request_count ?? 0
|
||||
)
|
||||
}
|
||||
return HermesCredentialPool(
|
||||
provider: provider,
|
||||
strategy: strategies[provider] ?? "fill_first",
|
||||
credentials: creds
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Return last 4 chars prefixed with "…", or "" if the token is too short.
|
||||
/// Callers MUST NOT pass the full token anywhere user-visible beyond this.
|
||||
private static func tail(of token: String) -> String {
|
||||
guard token.count >= 4 else { return "" }
|
||||
return "…" + String(token.suffix(4))
|
||||
}
|
||||
|
||||
// MARK: - Mutations (all routed through the hermes CLI so hermes stays authoritative)
|
||||
|
||||
func setStrategy(_ strategy: String, for provider: String) {
|
||||
let result = runHermes(["config", "set", "credential_pool_strategies.\(provider)", strategy])
|
||||
if result.exitCode == 0 {
|
||||
message = "Strategy updated for \(provider)"
|
||||
load()
|
||||
} else {
|
||||
message = "Failed to update strategy"
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
|
||||
self?.message = nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Add an API-key credential to a provider's pool. Runs non-interactively.
|
||||
///
|
||||
/// **Critical:** we must pass `--type api-key` in addition to `--api-key`.
|
||||
/// Without `--type`, hermes falls back to the provider's default (OAuth for
|
||||
/// Anthropic, etc.) and launches the browser flow even though the user
|
||||
/// just gave us a key.
|
||||
func addAPIKey(provider: String, apiKey: String, label: String) {
|
||||
var args = ["auth", "add", provider, "--type", "api-key", "--api-key", apiKey]
|
||||
let trimmedLabel = label.trimmingCharacters(in: .whitespaces)
|
||||
if !trimmedLabel.isEmpty {
|
||||
args += ["--label", trimmedLabel]
|
||||
}
|
||||
let result = runHermes(args)
|
||||
if result.exitCode == 0 {
|
||||
message = "Credential added"
|
||||
load()
|
||||
} else {
|
||||
logger.warning("Add credential failed: \(result.output)")
|
||||
message = "Add failed: \(result.output.prefix(160))"
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
||||
self?.message = nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Kick off the OAuth flow. Uses OAuthFlowController (Process + pipes) so
|
||||
/// we can detect the authorization URL from hermes's output, open the
|
||||
/// browser ourselves, and feed the code back via stdin — avoiding the
|
||||
/// subprocess-can't-open-browser problem SwiftTerm had.
|
||||
func startOAuth(provider: String, label: String) {
|
||||
guard !provider.isEmpty else { return }
|
||||
oauthProvider = provider
|
||||
|
||||
oauthFlow.onExit = { [weak self] _ in
|
||||
guard let self else { return }
|
||||
self.message = self.oauthFlow.succeeded
|
||||
? "OAuth login succeeded"
|
||||
: (self.oauthFlow.errorMessage ?? "OAuth login failed or cancelled")
|
||||
// Reload regardless — hermes may have written a partial credential
|
||||
// even on a soft failure, and we want the list to reflect truth.
|
||||
self.load()
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 4) { [weak self] in
|
||||
self?.message = nil
|
||||
}
|
||||
}
|
||||
|
||||
oauthFlow.start(provider: provider, label: label)
|
||||
}
|
||||
|
||||
/// Submit the authorization code the user pasted into the form's text
|
||||
/// field. Writes it to hermes's stdin.
|
||||
func submitOAuthCode(_ code: String) {
|
||||
oauthFlow.submitCode(code)
|
||||
}
|
||||
|
||||
/// Cancel an in-progress OAuth attempt (e.g., user closed the sheet).
|
||||
func cancelOAuth() {
|
||||
oauthFlow.stop()
|
||||
}
|
||||
|
||||
func removeCredential(provider: String, index: Int) {
|
||||
// The CLI uses 1-based indexing ("#1", "#2" in `hermes auth list`); our
|
||||
// stored `index` is 0-based, so add 1 when handing to the CLI.
|
||||
let result = runHermes(["auth", "remove", provider, String(index + 1)])
|
||||
if result.exitCode == 0 {
|
||||
message = "Credential removed"
|
||||
load()
|
||||
} else {
|
||||
message = "Remove failed"
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
|
||||
self?.message = nil
|
||||
}
|
||||
}
|
||||
|
||||
func resetProvider(_ provider: String) {
|
||||
let result = runHermes(["auth", "reset", provider])
|
||||
message = result.exitCode == 0 ? "Cooldowns cleared for \(provider)" : "Reset failed"
|
||||
load()
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
|
||||
self?.message = nil
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func runHermes(_ arguments: [String]) -> (output: String, exitCode: Int32) {
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: HermesPaths.hermesBinary)
|
||||
process.arguments = arguments
|
||||
process.environment = HermesFileService.enrichedEnvironment()
|
||||
let pipe = Pipe()
|
||||
process.standardOutput = pipe
|
||||
process.standardError = Pipe()
|
||||
do {
|
||||
try process.run()
|
||||
process.waitUntilExit()
|
||||
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
||||
return (String(data: data, encoding: .utf8) ?? "", process.terminationStatus)
|
||||
} catch {
|
||||
return ("", -1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - auth.json decoding
|
||||
// Shape verified against a real `~/.hermes/auth.json` — see sample in plan notes.
|
||||
// All fields are optional because the format evolves and we want decoding to
|
||||
// succeed even if hermes adds new keys or omits some for certain auth types.
|
||||
|
||||
private struct AuthFile: Decodable {
|
||||
let credential_pool: [String: [AuthEntry]]
|
||||
}
|
||||
|
||||
private struct AuthEntry: Decodable {
|
||||
let id: String?
|
||||
let label: String?
|
||||
let auth_type: String?
|
||||
let source: String?
|
||||
let access_token: String?
|
||||
let last_status: String?
|
||||
let request_count: Int?
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
import Foundation
|
||||
import AppKit
|
||||
import os
|
||||
|
||||
/// Drives the `hermes auth add <provider> --type oauth` flow via `Process` +
|
||||
/// pipes instead of SwiftTerm. The embedded terminal approach turned out to
|
||||
/// have two problems:
|
||||
///
|
||||
/// 1. Python's `webbrowser.open` called from a subprocess doesn't reliably
|
||||
/// open the user's browser — the macOS `open` command can fail silently
|
||||
/// depending on how the parent app was launched.
|
||||
/// 2. Even when it works, users can't easily copy the URL from a terminal
|
||||
/// emulator to click or share.
|
||||
///
|
||||
/// This controller runs hermes with `--no-browser`, captures stdout/stderr,
|
||||
/// regex-extracts the authorization URL, and exposes it to the UI as a plain
|
||||
/// string. The UI shows a real "Open in Browser" button (via NSWorkspace) and
|
||||
/// a code input text field. Submitting writes the code + newline to hermes's
|
||||
/// stdin pipe, which Python's `input()` reads normally — verified in shell
|
||||
/// testing that hermes accepts piped stdin when a TTY isn't available.
|
||||
///
|
||||
/// Hermes exits 0 even on "login did not return credentials" failures, so we
|
||||
/// detect success by scanning output for failure markers AND by letting the
|
||||
/// calling VM reload `auth.json` to see whether a new credential actually
|
||||
/// landed.
|
||||
@Observable
|
||||
@MainActor
|
||||
final class OAuthFlowController {
|
||||
private let logger = Logger(subsystem: "com.scarf", category: "OAuthFlowController")
|
||||
|
||||
// MARK: - Observable state
|
||||
|
||||
/// Accumulated terminal output for display. Grows monotonically during
|
||||
/// the flow; cleared on `start(...)`.
|
||||
var output: String = ""
|
||||
|
||||
/// Authorization URL extracted from hermes's output. Shown as a prominent
|
||||
/// "Open in Browser" button once detected.
|
||||
var authorizationURL: String?
|
||||
|
||||
/// True once hermes has printed the "Authorization code:" prompt. Gates
|
||||
/// the code submit button so users can't submit too early.
|
||||
var awaitingCode: Bool = false
|
||||
|
||||
/// True between `start(...)` and process termination.
|
||||
var isRunning: Bool = false
|
||||
|
||||
/// Set when the process exits with a success signal (both zero exit AND
|
||||
/// no failure marker in output). The VM checks this + reloads auth.json.
|
||||
var succeeded: Bool = false
|
||||
|
||||
/// Human-readable error message if start/submit failed mid-flow.
|
||||
var errorMessage: String?
|
||||
|
||||
/// Fired when the process exits, with the raw exit code. Use this to
|
||||
/// trigger a UI reload or close the sheet.
|
||||
var onExit: ((Int32) -> Void)?
|
||||
|
||||
// MARK: - Private state
|
||||
|
||||
private var process: Process?
|
||||
private var stdinPipe: Pipe?
|
||||
private var stdoutPipe: Pipe?
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
/// Start the OAuth flow. Any prior in-flight flow is terminated first.
|
||||
func start(provider: String, label: String) {
|
||||
stop()
|
||||
|
||||
output = ""
|
||||
authorizationURL = nil
|
||||
awaitingCode = false
|
||||
succeeded = false
|
||||
errorMessage = nil
|
||||
|
||||
// Pass --no-browser so hermes doesn't try (and potentially fail) to
|
||||
// launch the browser itself — we do it explicitly with the button.
|
||||
var args = ["auth", "add", provider, "--type", "oauth", "--no-browser"]
|
||||
let trimmedLabel = label.trimmingCharacters(in: .whitespaces)
|
||||
if !trimmedLabel.isEmpty {
|
||||
args += ["--label", trimmedLabel]
|
||||
}
|
||||
|
||||
let proc = Process()
|
||||
proc.executableURL = URL(fileURLWithPath: HermesPaths.hermesBinary)
|
||||
proc.arguments = args
|
||||
proc.environment = HermesFileService.enrichedEnvironment()
|
||||
|
||||
let outPipe = Pipe()
|
||||
let inPipe = Pipe()
|
||||
// Merge stderr into stdout: hermes prints the URL + prompt to stdout,
|
||||
// but diagnostic messages can land on stderr; we want both interleaved
|
||||
// in display order.
|
||||
proc.standardOutput = outPipe
|
||||
proc.standardError = outPipe
|
||||
proc.standardInput = inPipe
|
||||
|
||||
outPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in
|
||||
let data = handle.availableData
|
||||
if data.isEmpty {
|
||||
// EOF — the peer closed its write end. Drop the handler so
|
||||
// Foundation doesn't keep calling us with empty reads.
|
||||
handle.readabilityHandler = nil
|
||||
return
|
||||
}
|
||||
let chunk = String(data: data, encoding: .utf8) ?? ""
|
||||
// Hop onto the main actor to mutate observable state.
|
||||
Task { @MainActor [weak self] in
|
||||
self?.handleOutputChunk(chunk)
|
||||
}
|
||||
}
|
||||
|
||||
proc.terminationHandler = { [weak self] p in
|
||||
let code = p.terminationStatus
|
||||
Task { @MainActor [weak self] in
|
||||
outPipe.fileHandleForReading.readabilityHandler = nil
|
||||
self?.handleTermination(exitCode: code)
|
||||
}
|
||||
}
|
||||
|
||||
do {
|
||||
try proc.run()
|
||||
process = proc
|
||||
stdinPipe = inPipe
|
||||
stdoutPipe = outPipe
|
||||
isRunning = true
|
||||
} catch {
|
||||
errorMessage = "Failed to start hermes: \(error.localizedDescription)"
|
||||
logger.error("Failed to start hermes: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Terminate the in-flight process (if any). Safe to call when nothing is running.
|
||||
func stop() {
|
||||
stdoutPipe?.fileHandleForReading.readabilityHandler = nil
|
||||
process?.terminate()
|
||||
process = nil
|
||||
stdinPipe = nil
|
||||
stdoutPipe = nil
|
||||
isRunning = false
|
||||
awaitingCode = false
|
||||
}
|
||||
|
||||
/// Send the authorization code to hermes's stdin. Called when the user
|
||||
/// taps "Submit" in the sheet's code input field.
|
||||
func submitCode(_ code: String) {
|
||||
let trimmed = code.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else {
|
||||
errorMessage = "Authorization code is empty"
|
||||
return
|
||||
}
|
||||
guard let stdinPipe else {
|
||||
errorMessage = "Process is no longer accepting input"
|
||||
return
|
||||
}
|
||||
let payload = trimmed + "\n"
|
||||
guard let data = payload.data(using: .utf8) else {
|
||||
errorMessage = "Could not encode code"
|
||||
return
|
||||
}
|
||||
do {
|
||||
try stdinPipe.fileHandleForWriting.write(contentsOf: data)
|
||||
// After writing, we don't close stdin — hermes might prompt again
|
||||
// on failure. Instead we flip `awaitingCode` off so the UI can
|
||||
// dim the submit button until another prompt appears.
|
||||
awaitingCode = false
|
||||
} catch {
|
||||
errorMessage = "Failed to send code: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
|
||||
/// Explicitly open the detected authorization URL in the default browser.
|
||||
/// Does nothing if no URL has been detected yet.
|
||||
func openURLInBrowser() {
|
||||
guard let url = authorizationURL, let parsed = URL(string: url) else { return }
|
||||
NSWorkspace.shared.open(parsed)
|
||||
}
|
||||
|
||||
// MARK: - Output handling
|
||||
|
||||
private func handleOutputChunk(_ chunk: String) {
|
||||
output += chunk
|
||||
|
||||
if authorizationURL == nil, let url = Self.extractAuthURL(from: output) {
|
||||
authorizationURL = url
|
||||
// Auto-open the browser on first detection, since that's what a
|
||||
// well-behaved hermes would have done. We keep the manual button
|
||||
// available for retries / copy-paste.
|
||||
if let parsed = URL(string: url) {
|
||||
NSWorkspace.shared.open(parsed)
|
||||
}
|
||||
}
|
||||
|
||||
// The prompt may arrive in the same chunk as the URL. Checking
|
||||
// cumulative output (rather than just this chunk) is safer.
|
||||
if !awaitingCode, output.contains("Authorization code:") {
|
||||
awaitingCode = true
|
||||
}
|
||||
}
|
||||
|
||||
private func handleTermination(exitCode: Int32) {
|
||||
isRunning = false
|
||||
// Hermes exits 0 even on "login did not return credentials" — detect
|
||||
// that failure marker explicitly so we don't report false success.
|
||||
let failureMarkers = [
|
||||
"did not return credentials",
|
||||
"Token exchange failed",
|
||||
"OAuth login failed",
|
||||
"HTTP Error"
|
||||
]
|
||||
let outputFailed = failureMarkers.contains { output.localizedCaseInsensitiveContains($0) }
|
||||
succeeded = exitCode == 0 && !outputFailed
|
||||
if !succeeded, errorMessage == nil {
|
||||
if outputFailed {
|
||||
errorMessage = "OAuth did not complete — check the output above for details"
|
||||
} else if exitCode != 0 {
|
||||
errorMessage = "hermes exited with code \(exitCode)"
|
||||
}
|
||||
}
|
||||
onExit?(exitCode)
|
||||
}
|
||||
|
||||
// MARK: - URL extraction
|
||||
|
||||
/// Extract the OAuth authorization URL from hermes's output. Hermes prints
|
||||
/// it on its own line in a Rich-rendered box; we want a plain https URL
|
||||
/// that looks like a provider OAuth endpoint.
|
||||
///
|
||||
/// Priority order:
|
||||
/// 1. URLs containing `client_id=` — real OAuth auth URLs always have this.
|
||||
/// 2. URLs containing `/authorize` — fallback for providers that don't
|
||||
/// include client_id in the query (unusual but possible).
|
||||
/// 3. URLs containing `/oauth/` — last resort.
|
||||
///
|
||||
/// Docs URLs and generic callback URLs are filtered out by these checks.
|
||||
nonisolated static func extractAuthURL(from text: String) -> String? {
|
||||
let pattern = #"https://[^\s\)\]\"'`<>]+"#
|
||||
guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil }
|
||||
let range = NSRange(text.startIndex..., in: text)
|
||||
let urls: [String] = regex.matches(in: text, range: range).compactMap { match in
|
||||
Range(match.range, in: text).map { String(text[$0]) }
|
||||
}
|
||||
// Prefer the strongest signal so we don't accidentally surface the
|
||||
// redirect callback URL when both appear unencoded in output.
|
||||
if let url = urls.first(where: { $0.contains("client_id=") }) { return url }
|
||||
if let url = urls.first(where: { $0.contains("/authorize") }) { return url }
|
||||
if let url = urls.first(where: { $0.contains("/oauth/") }) { return url }
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,472 @@
|
||||
import SwiftUI
|
||||
|
||||
struct CredentialPoolsView: View {
|
||||
@State private var viewModel = CredentialPoolsViewModel()
|
||||
@State private var showAddSheet = false
|
||||
@State private var pendingRemove: HermesCredential?
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
header
|
||||
safetyNotice
|
||||
if viewModel.isLoading {
|
||||
ProgressView().padding()
|
||||
} else if viewModel.pools.isEmpty {
|
||||
emptyState
|
||||
} else {
|
||||
ForEach(viewModel.pools) { pool in
|
||||
poolSection(pool)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||
}
|
||||
.navigationTitle("Credential Pools")
|
||||
.onAppear { viewModel.load() }
|
||||
.sheet(isPresented: $showAddSheet) {
|
||||
AddCredentialSheet(viewModel: viewModel) {
|
||||
showAddSheet = false
|
||||
}
|
||||
}
|
||||
.confirmationDialog(
|
||||
pendingRemove.map { "Remove credential for \($0.provider)?" } ?? "",
|
||||
isPresented: Binding(get: { pendingRemove != nil }, set: { if !$0 { pendingRemove = nil } })
|
||||
) {
|
||||
Button("Remove", role: .destructive) {
|
||||
if let target = pendingRemove {
|
||||
viewModel.removeCredential(provider: target.provider, index: target.index)
|
||||
}
|
||||
pendingRemove = nil
|
||||
}
|
||||
Button("Cancel", role: .cancel) { pendingRemove = nil }
|
||||
} message: {
|
||||
Text("This removes the credential from hermes. The upstream provider key is not revoked.")
|
||||
}
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack {
|
||||
if let msg = viewModel.message {
|
||||
Label(msg, systemImage: "info.circle.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Button {
|
||||
showAddSheet = true
|
||||
} label: {
|
||||
Label("Add Credential", systemImage: "plus")
|
||||
}
|
||||
.controlSize(.small)
|
||||
Button("Reload") { viewModel.load() }
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
|
||||
private var safetyNotice: some View {
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
Image(systemName: "lock.shield")
|
||||
.foregroundStyle(.secondary)
|
||||
Text("API keys are never displayed in full. Scarf only shows the last 4 characters for identification. Full key values are stored by hermes in ~/.hermes/auth.json.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(8)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
|
||||
private var emptyState: some View {
|
||||
VStack(spacing: 8) {
|
||||
Image(systemName: "key.horizontal")
|
||||
.font(.largeTitle)
|
||||
.foregroundStyle(.secondary)
|
||||
Text("No credential pools configured")
|
||||
.foregroundStyle(.secondary)
|
||||
Text("Add rotation credentials so hermes can failover between keys when one hits rate limits.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 40)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func poolSection(_ pool: HermesCredentialPool) -> some View {
|
||||
SettingsSection(title: pool.provider, icon: "key.horizontal") {
|
||||
PickerRow(label: "Rotation", selection: pool.strategy, options: viewModel.strategyOptions) { strategy in
|
||||
viewModel.setStrategy(strategy, for: pool.provider)
|
||||
}
|
||||
ForEach(pool.credentials) { cred in
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: cred.authType == "oauth" ? "person.badge.key" : "key.fill")
|
||||
.foregroundStyle(.secondary)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(spacing: 6) {
|
||||
Text("#\(cred.index + 1)")
|
||||
.font(.system(.caption, design: .monospaced, weight: .bold))
|
||||
if !cred.label.isEmpty {
|
||||
Text(cred.label).font(.caption)
|
||||
}
|
||||
if !cred.authType.isEmpty {
|
||||
Text(cred.authType)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal, 5)
|
||||
.padding(.vertical, 1)
|
||||
.background(.quaternary)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
if !cred.lastStatus.isEmpty {
|
||||
Text(cred.lastStatus)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(statusColor(cred.lastStatus))
|
||||
}
|
||||
}
|
||||
HStack(spacing: 8) {
|
||||
Text(cred.tokenTail.isEmpty ? "—" : cred.tokenTail)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
if !cred.source.isEmpty {
|
||||
Text(cred.source)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
if cred.requestCount > 0 {
|
||||
Text("\(cred.requestCount) req")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
Button("Remove", role: .destructive) { pendingRemove = cred }
|
||||
.controlSize(.small)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
}
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("Reset Cooldowns") { viewModel.resetProvider(pool.provider) }
|
||||
.controlSize(.small)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
}
|
||||
}
|
||||
|
||||
private func statusColor(_ status: String) -> Color {
|
||||
switch status {
|
||||
case "ok", "active": return .green
|
||||
case "cooldown": return .orange
|
||||
case "exhausted": return .red
|
||||
default: return .secondary
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Two-step sheet for adding a credential:
|
||||
/// 1. Provider picker (populated from the models catalog, falls back to free text)
|
||||
/// + type selector (API Key vs OAuth) + optional label
|
||||
/// 2. Either an immediate save (API key) or an embedded terminal running the
|
||||
/// OAuth flow so the user can paste the authorization code back.
|
||||
private struct AddCredentialSheet: View {
|
||||
@Bindable var viewModel: CredentialPoolsViewModel
|
||||
let onDismiss: () -> Void
|
||||
|
||||
enum AuthType: String, CaseIterable, Identifiable {
|
||||
case apiKey = "API Key"
|
||||
case oauth = "OAuth"
|
||||
var id: String { rawValue }
|
||||
}
|
||||
|
||||
@State private var providerID: String = ""
|
||||
@State private var authType: AuthType = .apiKey
|
||||
@State private var apiKey: String = ""
|
||||
@State private var label: String = ""
|
||||
@State private var providers: [HermesProviderInfo] = []
|
||||
@State private var oauthStarted: Bool = false
|
||||
@State private var authCode: String = ""
|
||||
|
||||
private let catalog = ModelCatalogService()
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("Add Credential")
|
||||
.font(.headline)
|
||||
if !oauthStarted {
|
||||
configSection
|
||||
} else {
|
||||
oauthSection
|
||||
}
|
||||
Divider()
|
||||
footer
|
||||
}
|
||||
.padding()
|
||||
.frame(minWidth: 600, minHeight: 460)
|
||||
.onAppear {
|
||||
providers = catalog.loadProviders()
|
||||
}
|
||||
// Auto-close the sheet once a credential is actually saved. We key
|
||||
// off `succeeded` which the controller sets only when hermes exited
|
||||
// zero AND the output has no failure markers. The 0.8s delay lets the
|
||||
// user see the success banner before the sheet disappears.
|
||||
.onChange(of: viewModel.oauthFlow.succeeded) { _, newValue in
|
||||
guard newValue else { return }
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) {
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Step 1: provider + type + label + optional API key
|
||||
|
||||
private var configSection: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Provider").font(.caption).foregroundStyle(.secondary)
|
||||
HStack {
|
||||
// Free-text first so providers missing from the catalog
|
||||
// (e.g. "nous") are still addable.
|
||||
TextField("e.g. anthropic", text: $providerID)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
Menu("Browse") {
|
||||
ForEach(providers) { provider in
|
||||
Button(provider.providerName + " (\(provider.providerID))") {
|
||||
providerID = provider.providerID
|
||||
}
|
||||
}
|
||||
}
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Credential Type").font(.caption).foregroundStyle(.secondary)
|
||||
Picker("", selection: $authType) {
|
||||
ForEach(AuthType.allCases) { type in
|
||||
Text(type.rawValue).tag(type)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.labelsHidden()
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Label (optional)").font(.caption).foregroundStyle(.secondary)
|
||||
TextField("e.g. team-prod", text: $label)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
|
||||
if authType == .apiKey {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("API Key").font(.caption).foregroundStyle(.secondary)
|
||||
SecureField("sk-…", text: $apiKey)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
}
|
||||
} else {
|
||||
oauthPreamble
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Brief explanation shown before the user clicks "Start OAuth". Sets
|
||||
/// expectations about the embedded-terminal flow so the browser window
|
||||
/// and code-paste step aren't surprises.
|
||||
private var oauthPreamble: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Clicking Start OAuth opens the provider's authorization page in your browser. After you approve, copy the code the provider displays and paste it back into the terminal that appears next.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Text("The terminal is a real TTY — paste with ⌘V, press Return, and wait for the process to exit with \"login succeeded\".")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(8)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
|
||||
// MARK: - Step 2: OAuth — URL button, code field, live output log
|
||||
|
||||
private var oauthSection: some View {
|
||||
// Pull the observable controller into a local so the view redraws
|
||||
// when its @Observable properties change.
|
||||
let flow = viewModel.oauthFlow
|
||||
return VStack(alignment: .leading, spacing: 10) {
|
||||
oauthHeader(flow: flow)
|
||||
urlBlock(flow: flow)
|
||||
codeEntryBlock(flow: flow)
|
||||
outputLogBlock(flow: flow)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func oauthHeader(flow: OAuthFlowController) -> some View {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "person.badge.key")
|
||||
Text("OAuth login for \(viewModel.oauthProvider)")
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
if flow.isRunning {
|
||||
ProgressView().controlSize(.small)
|
||||
} else if flow.succeeded {
|
||||
Label("Succeeded", systemImage: "checkmark.circle.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.green)
|
||||
} else if let err = flow.errorMessage {
|
||||
Label(err, systemImage: "exclamationmark.triangle.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.orange)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Authorization URL block. Hermes prints the URL on startup; we detect
|
||||
/// it via regex and expose a prominent Open + Copy pair. The URL keeps
|
||||
/// showing even after the browser is opened so users can paste it into
|
||||
/// a different browser profile if needed.
|
||||
@ViewBuilder
|
||||
private func urlBlock(flow: OAuthFlowController) -> some View {
|
||||
if let url = flow.authorizationURL {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Label("Authorization URL", systemImage: "link")
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(.secondary)
|
||||
HStack(spacing: 6) {
|
||||
Text(url)
|
||||
.font(.caption.monospaced())
|
||||
.textSelection(.enabled)
|
||||
.lineLimit(2)
|
||||
.truncationMode(.middle)
|
||||
Spacer()
|
||||
Button {
|
||||
flow.openURLInBrowser()
|
||||
} label: {
|
||||
Label("Open in Browser", systemImage: "safari")
|
||||
}
|
||||
.controlSize(.small)
|
||||
.buttonStyle(.borderedProminent)
|
||||
Button {
|
||||
NSPasteboard.general.clearContents()
|
||||
NSPasteboard.general.setString(url, forType: .string)
|
||||
} label: {
|
||||
Label("Copy", systemImage: "doc.on.doc")
|
||||
}
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
.padding(8)
|
||||
.background(.blue.opacity(0.08))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
} else if flow.isRunning {
|
||||
// Still waiting for hermes to print the URL — usually <1s.
|
||||
HStack(spacing: 6) {
|
||||
ProgressView().controlSize(.small)
|
||||
Text("Waiting for authorization URL…")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Authorization code input. Only active once hermes has printed its
|
||||
/// "Authorization code:" prompt so users can't submit before hermes is
|
||||
/// ready to receive input.
|
||||
@ViewBuilder
|
||||
private func codeEntryBlock(flow: OAuthFlowController) -> some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Label("Authorization Code", systemImage: "keyboard")
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(.secondary)
|
||||
Text("After approving in your browser, the provider shows a code. Paste it below and submit.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
HStack(spacing: 6) {
|
||||
TextField("Paste code here…", text: $authCode)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.disabled(!flow.awaitingCode)
|
||||
.onSubmit { submitCode(flow: flow) }
|
||||
Button("Submit") { submitCode(flow: flow) }
|
||||
.controlSize(.small)
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(!flow.awaitingCode || authCode.trimmingCharacters(in: .whitespaces).isEmpty)
|
||||
}
|
||||
if !flow.awaitingCode && flow.isRunning {
|
||||
Text("Waiting for hermes to prompt for the code…")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Live output log — useful for diagnostics if the flow stalls or errors.
|
||||
@ViewBuilder
|
||||
private func outputLogBlock(flow: OAuthFlowController) -> some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Label("Output", systemImage: "text.alignleft")
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(.secondary)
|
||||
ScrollView {
|
||||
Text(flow.output.isEmpty ? "(no output yet)" : flow.output)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(8)
|
||||
}
|
||||
.frame(minHeight: 120, maxHeight: 200)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
}
|
||||
|
||||
private func submitCode(flow: OAuthFlowController) {
|
||||
let trimmed = authCode.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
viewModel.submitOAuthCode(trimmed)
|
||||
authCode = ""
|
||||
}
|
||||
|
||||
// MARK: - Footer (buttons)
|
||||
|
||||
private var footer: some View {
|
||||
HStack {
|
||||
Spacer()
|
||||
if oauthStarted {
|
||||
Button("Close") {
|
||||
// Closing mid-flow terminates hermes so we don't leave a
|
||||
// zombie process waiting for stdin forever.
|
||||
viewModel.cancelOAuth()
|
||||
onDismiss()
|
||||
}
|
||||
} else {
|
||||
Button("Cancel") { onDismiss() }
|
||||
if authType == .apiKey {
|
||||
Button("Add") {
|
||||
viewModel.addAPIKey(provider: providerID, apiKey: apiKey, label: label)
|
||||
onDismiss()
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(providerID.trimmingCharacters(in: .whitespaces).isEmpty || apiKey.trimmingCharacters(in: .whitespaces).isEmpty)
|
||||
} else {
|
||||
Button("Start OAuth") {
|
||||
viewModel.startOAuth(provider: providerID, label: label)
|
||||
oauthStarted = true
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(providerID.trimmingCharacters(in: .whitespaces).isEmpty)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,101 @@
|
||||
import Foundation
|
||||
import AppKit
|
||||
import os
|
||||
|
||||
@Observable
|
||||
final class CronViewModel {
|
||||
private let logger = Logger(subsystem: "com.scarf", category: "CronViewModel")
|
||||
private let fileService = HermesFileService()
|
||||
|
||||
var jobs: [HermesCronJob] = []
|
||||
var selectedJob: HermesCronJob?
|
||||
var jobOutput: String?
|
||||
var availableSkills: [String] = []
|
||||
var message: String?
|
||||
var showCreateSheet = false
|
||||
var editingJob: HermesCronJob?
|
||||
|
||||
func load() {
|
||||
jobs = fileService.loadCronJobs()
|
||||
availableSkills = fileService.loadSkills().flatMap { $0.skills.map(\.id) }.sorted()
|
||||
if let selected = selectedJob, let refreshed = jobs.first(where: { $0.id == selected.id }) {
|
||||
selectedJob = refreshed
|
||||
jobOutput = fileService.loadCronOutput(jobId: refreshed.id)
|
||||
}
|
||||
}
|
||||
|
||||
func selectJob(_ job: HermesCronJob) {
|
||||
selectedJob = job
|
||||
jobOutput = fileService.loadCronOutput(jobId: job.id)
|
||||
}
|
||||
|
||||
// MARK: - CLI wrappers
|
||||
|
||||
func pauseJob(_ job: HermesCronJob) {
|
||||
runAndReload(["cron", "pause", job.id], success: "Paused")
|
||||
}
|
||||
|
||||
func resumeJob(_ job: HermesCronJob) {
|
||||
runAndReload(["cron", "resume", job.id], success: "Resumed")
|
||||
}
|
||||
|
||||
func runNow(_ job: HermesCronJob) {
|
||||
runAndReload(["cron", "run", job.id], success: "Scheduled for next tick")
|
||||
}
|
||||
|
||||
func deleteJob(_ job: HermesCronJob) {
|
||||
runAndReload(["cron", "remove", job.id], success: "Removed")
|
||||
if selectedJob?.id == job.id {
|
||||
selectedJob = nil
|
||||
jobOutput = nil
|
||||
}
|
||||
}
|
||||
|
||||
func createJob(schedule: String, prompt: String, name: String, deliver: String, skills: [String], script: String, repeatCount: String) {
|
||||
var args = ["cron", "create"]
|
||||
if !name.isEmpty { args += ["--name", name] }
|
||||
if !deliver.isEmpty { args += ["--deliver", deliver] }
|
||||
if !repeatCount.isEmpty { args += ["--repeat", repeatCount] }
|
||||
for skill in skills where !skill.isEmpty { args += ["--skill", skill] }
|
||||
if !script.isEmpty { args += ["--script", script] }
|
||||
args.append(schedule)
|
||||
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?) {
|
||||
var args = ["cron", "edit", id]
|
||||
if let schedule, !schedule.isEmpty { args += ["--schedule", schedule] }
|
||||
if let prompt, !prompt.isEmpty { args += ["--prompt", prompt] }
|
||||
if let name, !name.isEmpty { args += ["--name", name] }
|
||||
if let deliver { args += ["--deliver", deliver] }
|
||||
if let repeatCount, !repeatCount.isEmpty { args += ["--repeat", repeatCount] }
|
||||
if clearSkills {
|
||||
args.append("--clear-skills")
|
||||
} else if let newSkills {
|
||||
for skill in newSkills where !skill.isEmpty { args += ["--skill", skill] }
|
||||
}
|
||||
if let script { args += ["--script", script] }
|
||||
runAndReload(args, success: "Updated")
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func runAndReload(_ arguments: [String], success: String) {
|
||||
Task.detached { [fileService] in
|
||||
let result = fileService.runHermesCLI(args: arguments, timeout: 60)
|
||||
await MainActor.run {
|
||||
if result.exitCode == 0 {
|
||||
self.message = success
|
||||
} else {
|
||||
self.message = "Failed: \(result.output.prefix(200))"
|
||||
self.logger.warning("cron command failed: args=\(arguments) output=\(result.output)")
|
||||
}
|
||||
self.load()
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
||||
self?.message = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,60 +2,141 @@ import SwiftUI
|
||||
|
||||
struct CronView: View {
|
||||
@State private var viewModel = CronViewModel()
|
||||
@State private var pendingDelete: HermesCronJob?
|
||||
|
||||
var body: some View {
|
||||
HSplitView {
|
||||
jobsList
|
||||
.frame(minWidth: 300, idealWidth: 350)
|
||||
.frame(minWidth: 320, idealWidth: 360)
|
||||
jobDetail
|
||||
.frame(minWidth: 400)
|
||||
}
|
||||
.navigationTitle("Cron Jobs")
|
||||
.onAppear { viewModel.load() }
|
||||
.sheet(isPresented: $viewModel.showCreateSheet) {
|
||||
CronJobEditor(mode: .create, availableSkills: viewModel.availableSkills) { form in
|
||||
viewModel.createJob(
|
||||
schedule: form.schedule,
|
||||
prompt: form.prompt,
|
||||
name: form.name,
|
||||
deliver: form.deliver,
|
||||
skills: form.skills,
|
||||
script: form.script,
|
||||
repeatCount: form.repeatCount
|
||||
)
|
||||
viewModel.showCreateSheet = false
|
||||
} onCancel: {
|
||||
viewModel.showCreateSheet = false
|
||||
}
|
||||
}
|
||||
.sheet(item: $viewModel.editingJob) { job in
|
||||
CronJobEditor(mode: .edit(job), availableSkills: viewModel.availableSkills) { form in
|
||||
viewModel.updateJob(
|
||||
id: job.id,
|
||||
schedule: form.schedule,
|
||||
prompt: form.prompt,
|
||||
name: form.name,
|
||||
deliver: form.deliver,
|
||||
repeatCount: form.repeatCount,
|
||||
newSkills: form.skills,
|
||||
clearSkills: form.clearSkills,
|
||||
script: form.script
|
||||
)
|
||||
viewModel.editingJob = nil
|
||||
} onCancel: {
|
||||
viewModel.editingJob = nil
|
||||
}
|
||||
}
|
||||
.confirmationDialog(
|
||||
pendingDelete.map { "Delete \($0.name)?" } ?? "",
|
||||
isPresented: Binding(get: { pendingDelete != nil }, set: { if !$0 { pendingDelete = nil } })
|
||||
) {
|
||||
Button("Delete", role: .destructive) {
|
||||
if let job = pendingDelete { viewModel.deleteJob(job) }
|
||||
pendingDelete = nil
|
||||
}
|
||||
Button("Cancel", role: .cancel) { pendingDelete = nil }
|
||||
} message: {
|
||||
Text("This removes the scheduled job permanently.")
|
||||
}
|
||||
}
|
||||
|
||||
private var jobsList: some View {
|
||||
List(selection: Binding(
|
||||
get: { viewModel.selectedJob?.id },
|
||||
set: { id in
|
||||
if let id, let job = viewModel.jobs.first(where: { $0.id == id }) {
|
||||
viewModel.selectJob(job)
|
||||
} else {
|
||||
viewModel.selectedJob = nil
|
||||
viewModel.jobOutput = nil
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
if let msg = viewModel.message {
|
||||
Label(msg, systemImage: "info.circle.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Button {
|
||||
viewModel.showCreateSheet = true
|
||||
} label: {
|
||||
Label("Add", systemImage: "plus")
|
||||
}
|
||||
.controlSize(.small)
|
||||
Button("Reload") { viewModel.load() }
|
||||
.controlSize(.small)
|
||||
}
|
||||
)) {
|
||||
ForEach(viewModel.jobs) { job in
|
||||
HStack {
|
||||
Image(systemName: job.stateIcon)
|
||||
.foregroundStyle(job.enabled ? .primary : .secondary)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(job.name)
|
||||
.lineLimit(1)
|
||||
Text(job.schedule.display ?? job.schedule.kind)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
if job.silent == true {
|
||||
Text("SILENT")
|
||||
.font(.caption2.bold())
|
||||
.foregroundStyle(.purple)
|
||||
}
|
||||
if !job.enabled {
|
||||
Text("Disabled")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 6)
|
||||
Divider()
|
||||
List(selection: Binding(
|
||||
get: { viewModel.selectedJob?.id },
|
||||
set: { id in
|
||||
if let id, let job = viewModel.jobs.first(where: { $0.id == id }) {
|
||||
viewModel.selectJob(job)
|
||||
} else {
|
||||
viewModel.selectedJob = nil
|
||||
viewModel.jobOutput = nil
|
||||
}
|
||||
}
|
||||
)) {
|
||||
ForEach(viewModel.jobs) { job in
|
||||
HStack {
|
||||
Image(systemName: job.stateIcon)
|
||||
.foregroundStyle(job.enabled ? .primary : .secondary)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(job.name)
|
||||
.lineLimit(1)
|
||||
Text(job.schedule.display ?? job.schedule.kind)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
if job.silent == true {
|
||||
Text("SILENT")
|
||||
.font(.caption2.bold())
|
||||
.foregroundStyle(.purple)
|
||||
}
|
||||
if !job.enabled {
|
||||
Text("Disabled")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.tag(job.id)
|
||||
.contextMenu {
|
||||
Button(job.enabled ? "Pause" : "Resume") {
|
||||
if job.enabled {
|
||||
viewModel.pauseJob(job)
|
||||
} else {
|
||||
viewModel.resumeJob(job)
|
||||
}
|
||||
}
|
||||
Button("Run Now") { viewModel.runNow(job) }
|
||||
Button("Edit") { viewModel.editingJob = job }
|
||||
Divider()
|
||||
Button("Delete", role: .destructive) { pendingDelete = job }
|
||||
}
|
||||
}
|
||||
.tag(job.id)
|
||||
}
|
||||
}
|
||||
.listStyle(.inset)
|
||||
.overlay {
|
||||
if viewModel.jobs.isEmpty {
|
||||
ContentUnavailableView("No Cron Jobs", systemImage: "clock.arrow.2.circlepath", description: Text("No scheduled jobs configured"))
|
||||
.listStyle(.inset)
|
||||
.overlay {
|
||||
if viewModel.jobs.isEmpty {
|
||||
ContentUnavailableView("No Cron Jobs", systemImage: "clock.arrow.2.circlepath", description: Text("No scheduled jobs configured"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -65,108 +146,10 @@ struct CronView: View {
|
||||
if let job = viewModel.selectedJob {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(job.name)
|
||||
.font(.title2.bold())
|
||||
HStack(spacing: 16) {
|
||||
Label(job.state, systemImage: job.stateIcon)
|
||||
Label(job.schedule.display ?? job.schedule.kind, systemImage: "clock")
|
||||
Label(job.enabled ? "Enabled" : "Disabled", systemImage: job.enabled ? "checkmark.circle" : "xmark.circle")
|
||||
if let deliver = job.deliveryDisplay {
|
||||
Label("Deliver: \(deliver)", systemImage: "paperplane")
|
||||
}
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
detailHeader(job)
|
||||
actionBar(job)
|
||||
Divider()
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Prompt")
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(.secondary)
|
||||
Text(job.prompt)
|
||||
.textSelection(.enabled)
|
||||
.padding(8)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(.quaternary.opacity(0.5))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
if let script = job.preRunScript, !script.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Pre-Run Script")
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(.secondary)
|
||||
Text(script)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.textSelection(.enabled)
|
||||
.padding(8)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(.quaternary.opacity(0.5))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
}
|
||||
if let skills = job.skills, !skills.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Skills")
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(.secondary)
|
||||
HStack {
|
||||
ForEach(skills, id: \.self) { skill in
|
||||
Text(skill)
|
||||
.font(.caption.monospaced())
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 2)
|
||||
.background(.quaternary)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let nextRun = job.nextRunAt {
|
||||
Label("Next run: \(nextRun)", systemImage: "arrow.forward.circle")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if let lastRun = job.lastRunAt {
|
||||
Label("Last run: \(lastRun)", systemImage: "arrow.backward.circle")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if let error = job.lastError {
|
||||
Label(error, systemImage: "exclamationmark.triangle")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
if let timeout = job.timeoutSeconds {
|
||||
Label("Timeout: \(timeout)s (\(job.timeoutType ?? "wall_clock"))", systemImage: "timer")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if let failures = job.deliveryFailures, failures > 0 {
|
||||
Label("\(failures) delivery failure\(failures == 1 ? "" : "s")", systemImage: "exclamationmark.triangle")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
if let deliveryError = job.lastDeliveryError {
|
||||
Label(deliveryError, systemImage: "paperplane.circle")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
if let output = viewModel.jobOutput {
|
||||
Divider()
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Last Output")
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(.secondary)
|
||||
Text(output)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.textSelection(.enabled)
|
||||
.padding(8)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(.quaternary.opacity(0.5))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
}
|
||||
detailBody(job)
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||
@@ -176,4 +159,257 @@ struct CronView: View {
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
private func detailHeader(_ job: HermesCronJob) -> some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(job.name)
|
||||
.font(.title2.bold())
|
||||
HStack(spacing: 16) {
|
||||
Label(job.state, systemImage: job.stateIcon)
|
||||
Label(job.schedule.display ?? job.schedule.kind, systemImage: "clock")
|
||||
Label(job.enabled ? "Enabled" : "Disabled", systemImage: job.enabled ? "checkmark.circle" : "xmark.circle")
|
||||
if let deliver = job.deliveryDisplay {
|
||||
Label("Deliver: \(deliver)", systemImage: "paperplane")
|
||||
}
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
private func actionBar(_ job: HermesCronJob) -> some View {
|
||||
HStack(spacing: 8) {
|
||||
Button {
|
||||
if job.enabled { viewModel.pauseJob(job) } else { viewModel.resumeJob(job) }
|
||||
} label: {
|
||||
Label(job.enabled ? "Pause" : "Resume", systemImage: job.enabled ? "pause" : "play")
|
||||
}
|
||||
Button {
|
||||
viewModel.runNow(job)
|
||||
} label: {
|
||||
Label("Run Now", systemImage: "bolt")
|
||||
}
|
||||
Button {
|
||||
viewModel.editingJob = job
|
||||
} label: {
|
||||
Label("Edit", systemImage: "pencil")
|
||||
}
|
||||
Spacer()
|
||||
Button(role: .destructive) {
|
||||
pendingDelete = job
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
.controlSize(.small)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func detailBody(_ job: HermesCronJob) -> some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Prompt")
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(.secondary)
|
||||
Text(job.prompt)
|
||||
.textSelection(.enabled)
|
||||
.padding(8)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(.quaternary.opacity(0.5))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
if let script = job.preRunScript, !script.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Pre-Run Script")
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(.secondary)
|
||||
Text(script)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.textSelection(.enabled)
|
||||
.padding(8)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(.quaternary.opacity(0.5))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
}
|
||||
if let skills = job.skills, !skills.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Skills")
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(.secondary)
|
||||
HStack {
|
||||
ForEach(skills, id: \.self) { skill in
|
||||
Text(skill)
|
||||
.font(.caption.monospaced())
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 2)
|
||||
.background(.quaternary)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let nextRun = job.nextRunAt {
|
||||
Label("Next run: \(nextRun)", systemImage: "arrow.forward.circle")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if let lastRun = job.lastRunAt {
|
||||
Label("Last run: \(lastRun)", systemImage: "arrow.backward.circle")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if let error = job.lastError {
|
||||
Label(error, systemImage: "exclamationmark.triangle")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
if let timeout = job.timeoutSeconds {
|
||||
Label("Timeout: \(timeout)s (\(job.timeoutType ?? "wall_clock"))", systemImage: "timer")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if let failures = job.deliveryFailures, failures > 0 {
|
||||
Label("\(failures) delivery failure\(failures == 1 ? "" : "s")", systemImage: "exclamationmark.triangle")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
if let deliveryError = job.lastDeliveryError {
|
||||
Label(deliveryError, systemImage: "paperplane.circle")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
if let output = viewModel.jobOutput {
|
||||
Divider()
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Last Output")
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(.secondary)
|
||||
Text(output)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.textSelection(.enabled)
|
||||
.padding(8)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(.quaternary.opacity(0.5))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Create/edit sheet. Form fields mirror `hermes cron create|edit` flags.
|
||||
struct CronJobEditor: View {
|
||||
enum Mode {
|
||||
case create
|
||||
case edit(HermesCronJob)
|
||||
}
|
||||
|
||||
struct FormState {
|
||||
var name: String = ""
|
||||
var schedule: String = ""
|
||||
var prompt: String = ""
|
||||
var deliver: String = ""
|
||||
var repeatCount: String = ""
|
||||
var skills: [String] = []
|
||||
var clearSkills: Bool = false
|
||||
var script: String = ""
|
||||
}
|
||||
|
||||
let mode: Mode
|
||||
let availableSkills: [String]
|
||||
let onSave: (FormState) -> Void
|
||||
let onCancel: () -> Void
|
||||
|
||||
@State private var form = FormState()
|
||||
@State private var isEditMode = false
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text(headerText)
|
||||
.font(.headline)
|
||||
formField("Name", text: $form.name, placeholder: "Friendly label")
|
||||
formField("Schedule", text: $form.schedule, placeholder: "0 9 * * * or 30m or every 2h", mono: true)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Prompt")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
TextEditor(text: $form.prompt)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.frame(minHeight: 100)
|
||||
.padding(4)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
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 !availableSkills.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Skills")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
ForEach(availableSkills, id: \.self) { skill in
|
||||
Toggle(skill, isOn: Binding(
|
||||
get: { form.skills.contains(skill) },
|
||||
set: { on in
|
||||
if on {
|
||||
form.skills.append(skill)
|
||||
} else {
|
||||
form.skills.removeAll { $0 == skill }
|
||||
}
|
||||
}
|
||||
))
|
||||
.font(.caption.monospaced())
|
||||
.toggleStyle(.checkbox)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxHeight: 120)
|
||||
.padding(6)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
if isEditMode {
|
||||
Toggle("Clear all skills on save", isOn: $form.clearSkills)
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
}
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("Cancel") { onCancel() }
|
||||
Button("Save") { onSave(form) }
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(form.schedule.isEmpty)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.frame(minWidth: 560, minHeight: 560)
|
||||
.onAppear {
|
||||
if case .edit(let job) = mode {
|
||||
isEditMode = true
|
||||
form.name = job.name
|
||||
form.schedule = job.schedule.expression ?? job.schedule.display ?? ""
|
||||
form.prompt = job.prompt
|
||||
form.deliver = job.deliver ?? ""
|
||||
form.skills = job.skills ?? []
|
||||
form.script = job.preRunScript ?? ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var headerText: String {
|
||||
switch mode {
|
||||
case .create: return "Create Cron Job"
|
||||
case .edit(let job): return "Edit \(job.name)"
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func formField(_ label: String, text: Binding<String>, placeholder: String, mono: Bool = false) -> some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(label).font(.caption).foregroundStyle(.secondary)
|
||||
TextField(placeholder, text: text)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(mono ? .system(.caption, design: .monospaced) : .caption)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,10 @@ final class HealthViewModel {
|
||||
var hermesPID: pid_t?
|
||||
var actionMessage: String?
|
||||
|
||||
/// Text output from `hermes dump` / `hermes debug share`. Shown in an expandable panel.
|
||||
var diagnosticsOutput: String = ""
|
||||
var isSharingDebug = false
|
||||
|
||||
func load() {
|
||||
isLoading = true
|
||||
refreshProcessStatus()
|
||||
@@ -201,6 +205,37 @@ final class HealthViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
/// Capture `hermes dump` output — a setup summary used for debugging / support.
|
||||
/// Does NOT upload anything.
|
||||
func runDump() {
|
||||
actionMessage = "Running dump…"
|
||||
let result = runHermes(["dump"])
|
||||
diagnosticsOutput = result.output
|
||||
actionMessage = result.exitCode == 0 ? "Dump captured" : "Dump failed"
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
||||
self?.actionMessage = nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Upload a debug report via `hermes debug share`. THIS UPLOADS DATA to Nous
|
||||
/// Research support infrastructure — caller must confirm with the user first.
|
||||
func runDebugShare() {
|
||||
isSharingDebug = true
|
||||
actionMessage = "Uploading debug report…"
|
||||
Task.detached { [fileService] in
|
||||
let result = fileService.runHermesCLI(args: ["debug", "share"], timeout: 120)
|
||||
await MainActor.run {
|
||||
self.isSharingDebug = false
|
||||
self.diagnosticsOutput = result.output
|
||||
self.actionMessage = result.exitCode == 0 ? "Upload complete" : "Upload failed"
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 4) { [weak self] in
|
||||
self?.actionMessage = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func runHermes(_ arguments: [String]) -> (output: String, exitCode: Int32) {
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: HermesPaths.hermesBinary)
|
||||
|
||||
@@ -4,18 +4,38 @@ struct HealthView: View {
|
||||
@State private var viewModel = HealthViewModel()
|
||||
@State private var expandedSection: UUID?
|
||||
@State private var selectedTab = 0
|
||||
@State private var showShareConfirm = false
|
||||
@State private var showDiagnostics = false
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
headerBar
|
||||
Divider()
|
||||
Picker("", selection: $selectedTab) {
|
||||
Text("Status").tag(0)
|
||||
Text("Diagnostics").tag(1)
|
||||
HStack {
|
||||
Picker("", selection: $selectedTab) {
|
||||
Text("Status").tag(0)
|
||||
Text("Diagnostics").tag(1)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.frame(maxWidth: 300)
|
||||
Spacer()
|
||||
Button("Run Dump") {
|
||||
viewModel.runDump()
|
||||
showDiagnostics = true
|
||||
}
|
||||
.controlSize(.small)
|
||||
Button("Share Debug Report…") {
|
||||
showShareConfirm = true
|
||||
}
|
||||
.controlSize(.small)
|
||||
.disabled(viewModel.isSharingDebug)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.frame(maxWidth: 300)
|
||||
.padding(.vertical, 8)
|
||||
.padding(.horizontal)
|
||||
if showDiagnostics && !viewModel.diagnosticsOutput.isEmpty {
|
||||
Divider()
|
||||
diagnosticsPanel
|
||||
}
|
||||
Divider()
|
||||
ScrollView {
|
||||
sectionGrid(selectedTab == 0 ? viewModel.statusSections : viewModel.doctorSections)
|
||||
@@ -24,6 +44,40 @@ struct HealthView: View {
|
||||
}
|
||||
.navigationTitle("Health")
|
||||
.onAppear { viewModel.load() }
|
||||
.confirmationDialog("Upload debug report?", isPresented: $showShareConfirm) {
|
||||
Button("Upload", role: .destructive) {
|
||||
viewModel.runDebugShare()
|
||||
showDiagnostics = true
|
||||
}
|
||||
Button("Cancel", role: .cancel) {}
|
||||
} message: {
|
||||
Text("This uploads logs, config (with secrets redacted), and system info to Nous Research support infrastructure. Review the output below before sharing the returned URL.")
|
||||
}
|
||||
}
|
||||
|
||||
private var diagnosticsPanel: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack {
|
||||
Text("Diagnostic Output")
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Button("Hide") { showDiagnostics = false }
|
||||
.controlSize(.mini)
|
||||
}
|
||||
ScrollView {
|
||||
Text(viewModel.diagnosticsOutput)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.textSelection(.enabled)
|
||||
.padding(8)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.frame(maxHeight: 240)
|
||||
.background(.quaternary.opacity(0.5))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
|
||||
// MARK: - Header
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import Foundation
|
||||
import AppKit
|
||||
import os
|
||||
|
||||
/// A personality defined under the `personalities:` block in config.yaml.
|
||||
/// Each entry may have a free-form `prompt` string plus arbitrary extra fields.
|
||||
struct HermesPersonality: Identifiable, Sendable, Equatable {
|
||||
var id: String { name }
|
||||
let name: String
|
||||
let prompt: String
|
||||
}
|
||||
|
||||
@Observable
|
||||
final class PersonalitiesViewModel {
|
||||
private let logger = Logger(subsystem: "com.scarf", category: "PersonalitiesViewModel")
|
||||
private let fileService = HermesFileService()
|
||||
|
||||
var personalities: [HermesPersonality] = []
|
||||
var activeName: String = ""
|
||||
var soulMarkdown: String = ""
|
||||
var soulPath: String { HermesPaths.home + "/SOUL.md" }
|
||||
var message: String?
|
||||
|
||||
func load() {
|
||||
let config = fileService.loadConfig()
|
||||
activeName = config.personality
|
||||
personalities = parsePersonalitiesBlock()
|
||||
soulMarkdown = (try? String(contentsOfFile: soulPath, encoding: .utf8)) ?? ""
|
||||
}
|
||||
|
||||
/// Parse the `personalities:` section of config.yaml using the nested parser.
|
||||
/// Each personality is a top-level key under `personalities`, optionally with
|
||||
/// a `prompt:` child.
|
||||
private func parsePersonalitiesBlock() -> [HermesPersonality] {
|
||||
guard let yaml = try? String(contentsOfFile: HermesPaths.configYAML, encoding: .utf8) else { return [] }
|
||||
let parsed = HermesFileService.parseNestedYAML(yaml)
|
||||
// Find all keys "personalities.<name>[.subkey]"
|
||||
var nameSet: Set<String> = []
|
||||
for key in parsed.values.keys where key.hasPrefix("personalities.") {
|
||||
let parts = key.split(separator: ".", maxSplits: 2, omittingEmptySubsequences: false)
|
||||
if parts.count >= 2 { nameSet.insert(String(parts[1])) }
|
||||
}
|
||||
for key in parsed.lists.keys where key.hasPrefix("personalities.") {
|
||||
let parts = key.split(separator: ".", maxSplits: 2, omittingEmptySubsequences: false)
|
||||
if parts.count >= 2 { nameSet.insert(String(parts[1])) }
|
||||
}
|
||||
return nameSet.sorted().map { name in
|
||||
let prompt = parsed.values["personalities.\(name).prompt"] ?? ""
|
||||
return HermesPersonality(name: name, prompt: HermesFileService.stripYAMLQuotes(prompt))
|
||||
}
|
||||
}
|
||||
|
||||
func setActive(_ name: String) {
|
||||
let result = runHermes(["config", "set", "display.personality", name])
|
||||
if result.exitCode == 0 {
|
||||
activeName = name
|
||||
message = "Active personality set to \(name)"
|
||||
} else {
|
||||
logger.warning("Failed to set personality: \(result.output)")
|
||||
message = "Failed to set personality"
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
|
||||
self?.message = nil
|
||||
}
|
||||
}
|
||||
|
||||
func saveSOUL(_ content: String) {
|
||||
do {
|
||||
try content.write(toFile: soulPath, atomically: true, encoding: .utf8)
|
||||
soulMarkdown = content
|
||||
message = "SOUL.md saved"
|
||||
} catch {
|
||||
logger.error("Failed to write SOUL.md: \(error.localizedDescription)")
|
||||
message = "Save failed"
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
|
||||
self?.message = nil
|
||||
}
|
||||
}
|
||||
|
||||
func openConfigInEditor() {
|
||||
NSWorkspace.shared.open(URL(fileURLWithPath: HermesPaths.configYAML))
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func runHermes(_ arguments: [String]) -> (output: String, exitCode: Int32) {
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: HermesPaths.hermesBinary)
|
||||
process.arguments = arguments
|
||||
process.environment = HermesFileService.enrichedEnvironment()
|
||||
let pipe = Pipe()
|
||||
process.standardOutput = pipe
|
||||
process.standardError = Pipe()
|
||||
do {
|
||||
try process.run()
|
||||
process.waitUntilExit()
|
||||
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
||||
return (String(data: data, encoding: .utf8) ?? "", process.terminationStatus)
|
||||
} catch {
|
||||
return ("", -1)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import SwiftUI
|
||||
|
||||
struct PersonalitiesView: View {
|
||||
@State private var viewModel = PersonalitiesViewModel()
|
||||
@State private var soulDraft = ""
|
||||
@State private var editingSOUL = false
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
header
|
||||
activeSection
|
||||
listSection
|
||||
soulSection
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||
}
|
||||
.navigationTitle("Personalities")
|
||||
.onAppear {
|
||||
viewModel.load()
|
||||
soulDraft = viewModel.soulMarkdown
|
||||
}
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack {
|
||||
if let msg = viewModel.message {
|
||||
Label(msg, systemImage: "checkmark.circle.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.green)
|
||||
}
|
||||
Spacer()
|
||||
Button("Edit config.yaml") { viewModel.openConfigInEditor() }
|
||||
.controlSize(.small)
|
||||
Button("Reload") { viewModel.load(); soulDraft = viewModel.soulMarkdown }
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
|
||||
private var activeSection: some View {
|
||||
SettingsSection(title: "Active Personality", icon: "theatermasks.fill") {
|
||||
if viewModel.personalities.isEmpty {
|
||||
ReadOnlyRow(label: "Current", value: viewModel.activeName.isEmpty ? "default" : viewModel.activeName)
|
||||
ReadOnlyRow(label: "Defined", value: "None in config.yaml — add under `personalities:` to customize.")
|
||||
} else {
|
||||
PickerRow(label: "Active", selection: viewModel.activeName, options: viewModel.personalities.map(\.name)) { viewModel.setActive($0) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var listSection: some View {
|
||||
if !viewModel.personalities.isEmpty {
|
||||
SettingsSection(title: "Defined Personalities", icon: "list.bullet") {
|
||||
ForEach(viewModel.personalities) { personality in
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
Text(personality.name)
|
||||
.font(.system(.body, design: .monospaced, weight: .medium))
|
||||
if personality.name == viewModel.activeName {
|
||||
Text("active")
|
||||
.font(.caption2.bold())
|
||||
.foregroundStyle(.green)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 1)
|
||||
.background(.green.opacity(0.15))
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
if !personality.prompt.isEmpty {
|
||||
Text(personality.prompt)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(6)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var soulSection: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack {
|
||||
Label("SOUL.md", systemImage: "sparkles")
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
if editingSOUL {
|
||||
Button("Cancel") {
|
||||
editingSOUL = false
|
||||
soulDraft = viewModel.soulMarkdown
|
||||
}
|
||||
.controlSize(.small)
|
||||
Button("Save") {
|
||||
viewModel.saveSOUL(soulDraft)
|
||||
editingSOUL = false
|
||||
}
|
||||
.controlSize(.small)
|
||||
.keyboardShortcut("s", modifiers: .command)
|
||||
} else {
|
||||
Button("Edit") { editingSOUL = true }
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
Text("SOUL.md describes the agent's voice, values, and personality at ~/.hermes/SOUL.md. It is injected into every session's context.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
if editingSOUL {
|
||||
TextEditor(text: $soulDraft)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.frame(minHeight: 220)
|
||||
.padding(6)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
} else {
|
||||
Text(viewModel.soulMarkdown.isEmpty ? "(empty)" : viewModel.soulMarkdown)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.foregroundStyle(viewModel.soulMarkdown.isEmpty ? .secondary : .primary)
|
||||
.textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(8)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
/// Discord setup. Bot token + user IDs in `.env`, behavior knobs in `discord.*`.
|
||||
/// Field reference: https://hermes-agent.nousresearch.com/docs/user-guide/messaging/discord
|
||||
@Observable
|
||||
@MainActor
|
||||
final class DiscordSetupViewModel {
|
||||
var botToken: String = ""
|
||||
var allowedUsers: String = ""
|
||||
var homeChannel: String = ""
|
||||
var homeChannelName: String = ""
|
||||
var allowBots: String = "none" // "none" | "mentions" | "all"
|
||||
var replyToMode: String = "first" // "off" | "first" | "all"
|
||||
|
||||
// config.yaml — these mirror the existing `HermesConfig.discord` block so we
|
||||
// stay consistent with whatever the Settings UI shows.
|
||||
var requireMention: Bool = true
|
||||
var freeResponseChannels: String = ""
|
||||
var autoThread: Bool = true
|
||||
var reactions: Bool = true
|
||||
|
||||
var message: String?
|
||||
|
||||
let allowBotsOptions = ["none", "mentions", "all"]
|
||||
let replyToModeOptions = ["off", "first", "all"]
|
||||
|
||||
func load() {
|
||||
let env = HermesEnvService().load()
|
||||
botToken = env["DISCORD_BOT_TOKEN"] ?? ""
|
||||
allowedUsers = env["DISCORD_ALLOWED_USERS"] ?? ""
|
||||
homeChannel = env["DISCORD_HOME_CHANNEL"] ?? ""
|
||||
homeChannelName = env["DISCORD_HOME_CHANNEL_NAME"] ?? ""
|
||||
allowBots = env["DISCORD_ALLOW_BOTS"] ?? "none"
|
||||
replyToMode = env["DISCORD_REPLY_TO_MODE"] ?? "first"
|
||||
|
||||
let cfg = HermesFileService().loadConfig().discord
|
||||
requireMention = cfg.requireMention
|
||||
freeResponseChannels = cfg.freeResponseChannels
|
||||
autoThread = cfg.autoThread
|
||||
reactions = cfg.reactions
|
||||
}
|
||||
|
||||
func save() {
|
||||
let envPairs: [String: String] = [
|
||||
"DISCORD_BOT_TOKEN": botToken,
|
||||
"DISCORD_ALLOWED_USERS": allowedUsers,
|
||||
"DISCORD_HOME_CHANNEL": homeChannel,
|
||||
"DISCORD_HOME_CHANNEL_NAME": homeChannelName,
|
||||
"DISCORD_ALLOW_BOTS": allowBots == "none" ? "" : allowBots, // default is "none", don't persist
|
||||
"DISCORD_REPLY_TO_MODE": replyToMode == "first" ? "" : replyToMode
|
||||
]
|
||||
let configKV: [String: String] = [
|
||||
"discord.require_mention": PlatformSetupHelpers.envBool(requireMention),
|
||||
"discord.free_response_channels": freeResponseChannels,
|
||||
"discord.auto_thread": PlatformSetupHelpers.envBool(autoThread),
|
||||
"discord.reactions": PlatformSetupHelpers.envBool(reactions)
|
||||
]
|
||||
message = PlatformSetupHelpers.saveForm(envPairs: envPairs, configKV: configKV)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
||||
self?.message = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import Foundation
|
||||
|
||||
/// Email setup. IMAP/SMTP with app passwords — no OAuth.
|
||||
/// Field reference: https://hermes-agent.nousresearch.com/docs/user-guide/messaging/email
|
||||
@Observable
|
||||
@MainActor
|
||||
final class EmailSetupViewModel {
|
||||
var address: String = ""
|
||||
var password: String = ""
|
||||
var imapHost: String = ""
|
||||
var smtpHost: String = ""
|
||||
var imapPort: String = "993"
|
||||
var smtpPort: String = "587"
|
||||
var pollInterval: String = "15"
|
||||
var allowedUsers: String = ""
|
||||
var homeAddress: String = ""
|
||||
var allowAllUsers: Bool = false
|
||||
var skipAttachments: Bool = false
|
||||
|
||||
var message: String?
|
||||
|
||||
/// Common provider presets so users don't have to look up IMAP/SMTP servers.
|
||||
struct Preset {
|
||||
let name: String
|
||||
let imap: String
|
||||
let smtp: String
|
||||
}
|
||||
let presets: [Preset] = [
|
||||
Preset(name: "Gmail", imap: "imap.gmail.com", smtp: "smtp.gmail.com"),
|
||||
Preset(name: "Outlook", imap: "outlook.office365.com", smtp: "smtp.office365.com"),
|
||||
Preset(name: "iCloud", imap: "imap.mail.me.com", smtp: "smtp.mail.me.com"),
|
||||
Preset(name: "Fastmail", imap: "imap.fastmail.com", smtp: "smtp.fastmail.com"),
|
||||
Preset(name: "Yahoo", imap: "imap.mail.yahoo.com", smtp: "smtp.mail.yahoo.com")
|
||||
]
|
||||
|
||||
func load() {
|
||||
let env = HermesEnvService().load()
|
||||
address = env["EMAIL_ADDRESS"] ?? ""
|
||||
password = env["EMAIL_PASSWORD"] ?? ""
|
||||
imapHost = env["EMAIL_IMAP_HOST"] ?? ""
|
||||
smtpHost = env["EMAIL_SMTP_HOST"] ?? ""
|
||||
imapPort = env["EMAIL_IMAP_PORT"] ?? "993"
|
||||
smtpPort = env["EMAIL_SMTP_PORT"] ?? "587"
|
||||
pollInterval = env["EMAIL_POLL_INTERVAL"] ?? "15"
|
||||
allowedUsers = env["EMAIL_ALLOWED_USERS"] ?? ""
|
||||
homeAddress = env["EMAIL_HOME_ADDRESS"] ?? ""
|
||||
allowAllUsers = PlatformSetupHelpers.parseEnvBool(env["EMAIL_ALLOW_ALL_USERS"])
|
||||
// skip_attachments lives in config.yaml.
|
||||
let yaml = (try? String(contentsOfFile: HermesPaths.configYAML, encoding: .utf8)) ?? ""
|
||||
let parsed = HermesFileService.parseNestedYAML(yaml)
|
||||
skipAttachments = (parsed.values["platforms.email.skip_attachments"] ?? "false") == "true"
|
||||
}
|
||||
|
||||
func applyPreset(_ preset: Preset) {
|
||||
imapHost = preset.imap
|
||||
smtpHost = preset.smtp
|
||||
}
|
||||
|
||||
func save() {
|
||||
let envPairs: [String: String] = [
|
||||
"EMAIL_ADDRESS": address,
|
||||
"EMAIL_PASSWORD": password,
|
||||
"EMAIL_IMAP_HOST": imapHost,
|
||||
"EMAIL_SMTP_HOST": smtpHost,
|
||||
"EMAIL_IMAP_PORT": imapPort,
|
||||
"EMAIL_SMTP_PORT": smtpPort,
|
||||
"EMAIL_POLL_INTERVAL": pollInterval,
|
||||
"EMAIL_ALLOWED_USERS": allowAllUsers ? "" : allowedUsers,
|
||||
"EMAIL_HOME_ADDRESS": homeAddress,
|
||||
"EMAIL_ALLOW_ALL_USERS": allowAllUsers ? "true" : ""
|
||||
]
|
||||
let configKV: [String: String] = [
|
||||
"platforms.email.skip_attachments": PlatformSetupHelpers.envBool(skipAttachments)
|
||||
]
|
||||
message = PlatformSetupHelpers.saveForm(envPairs: envPairs, configKV: configKV)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
||||
self?.message = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import Foundation
|
||||
|
||||
/// Feishu/Lark setup. Choose domain (feishu = China, lark = international).
|
||||
/// Field reference: https://hermes-agent.nousresearch.com/docs/user-guide/messaging/feishu
|
||||
@Observable
|
||||
@MainActor
|
||||
final class FeishuSetupViewModel {
|
||||
var appID: String = ""
|
||||
var appSecret: String = ""
|
||||
var domain: String = "lark"
|
||||
var encryptKey: String = ""
|
||||
var verificationToken: String = ""
|
||||
var allowedUsers: String = ""
|
||||
var connectionMode: String = "websocket" // "websocket" | "webhook"
|
||||
|
||||
var message: String?
|
||||
|
||||
let domainOptions = ["feishu", "lark"]
|
||||
let connectionOptions = ["websocket", "webhook"]
|
||||
|
||||
func load() {
|
||||
let env = HermesEnvService().load()
|
||||
appID = env["FEISHU_APP_ID"] ?? ""
|
||||
appSecret = env["FEISHU_APP_SECRET"] ?? ""
|
||||
domain = env["FEISHU_DOMAIN"] ?? "lark"
|
||||
encryptKey = env["FEISHU_ENCRYPT_KEY"] ?? ""
|
||||
verificationToken = env["FEISHU_VERIFICATION_TOKEN"] ?? ""
|
||||
allowedUsers = env["FEISHU_ALLOWED_USERS"] ?? ""
|
||||
connectionMode = env["FEISHU_CONNECTION_MODE"] ?? "websocket"
|
||||
}
|
||||
|
||||
func save() {
|
||||
let envPairs: [String: String] = [
|
||||
"FEISHU_APP_ID": appID,
|
||||
"FEISHU_APP_SECRET": appSecret,
|
||||
"FEISHU_DOMAIN": domain,
|
||||
"FEISHU_ENCRYPT_KEY": encryptKey,
|
||||
"FEISHU_VERIFICATION_TOKEN": verificationToken,
|
||||
"FEISHU_ALLOWED_USERS": allowedUsers,
|
||||
"FEISHU_CONNECTION_MODE": connectionMode == "websocket" ? "" : connectionMode
|
||||
]
|
||||
message = PlatformSetupHelpers.saveForm(envPairs: envPairs, configKV: [:])
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
||||
self?.message = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
import Foundation
|
||||
import AppKit
|
||||
|
||||
/// Home Assistant setup. Long-lived access token in `.env`, scalar filters via
|
||||
/// `hermes config set` under `platforms.homeassistant.extra.*`.
|
||||
///
|
||||
/// **List fields** (`watch_domains`, `watch_entities`, `ignore_entities`) are
|
||||
/// NOT editable in the form. `hermes config set` stores array arguments as
|
||||
/// quoted strings instead of YAML lists, which hermes would then reject as
|
||||
/// invalid. Users edit these directly in config.yaml — the view shows the
|
||||
/// current values (read-only) and an "Edit in config.yaml" button that opens
|
||||
/// the file.
|
||||
///
|
||||
/// Field reference: https://hermes-agent.nousresearch.com/docs/user-guide/messaging/homeassistant
|
||||
@Observable
|
||||
@MainActor
|
||||
final class HomeAssistantSetupViewModel {
|
||||
var url: String = "http://homeassistant.local:8123"
|
||||
var token: String = ""
|
||||
|
||||
// Scalar filters — writable via hermes config set.
|
||||
var watchAll: Bool = false
|
||||
var cooldownSeconds: Int = 30
|
||||
|
||||
// List filters — read-only; user must edit config.yaml manually.
|
||||
var watchDomains: [String] = []
|
||||
var watchEntities: [String] = []
|
||||
var ignoreEntities: [String] = []
|
||||
|
||||
var message: String?
|
||||
|
||||
func load() {
|
||||
let env = HermesEnvService().load()
|
||||
url = env["HASS_URL"] ?? "http://homeassistant.local:8123"
|
||||
token = env["HASS_TOKEN"] ?? ""
|
||||
|
||||
let cfg = HermesFileService().loadConfig().homeAssistant
|
||||
watchAll = cfg.watchAll
|
||||
cooldownSeconds = cfg.cooldownSeconds
|
||||
watchDomains = cfg.watchDomains
|
||||
watchEntities = cfg.watchEntities
|
||||
ignoreEntities = cfg.ignoreEntities
|
||||
}
|
||||
|
||||
func save() {
|
||||
let envPairs: [String: String] = [
|
||||
"HASS_URL": url,
|
||||
"HASS_TOKEN": token
|
||||
]
|
||||
// Only scalar config values — lists are skipped intentionally; see
|
||||
// file header comment for rationale.
|
||||
let configKV: [String: String] = [
|
||||
"platforms.homeassistant.extra.watch_all": PlatformSetupHelpers.envBool(watchAll),
|
||||
"platforms.homeassistant.extra.cooldown_seconds": String(cooldownSeconds)
|
||||
]
|
||||
message = PlatformSetupHelpers.saveForm(envPairs: envPairs, configKV: configKV)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
||||
self?.message = nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Open config.yaml in the user's default editor so they can manually edit
|
||||
/// the list-valued filter fields.
|
||||
func openConfigForLists() {
|
||||
NSWorkspace.shared.open(URL(fileURLWithPath: HermesPaths.configYAML))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import Foundation
|
||||
|
||||
/// iMessage via BlueBubbles. Requires a BlueBubbles Server running on a Mac
|
||||
/// that's always on, with an Apple ID signed into Messages.app.
|
||||
/// Field reference: https://hermes-agent.nousresearch.com/docs/user-guide/messaging/bluebubbles
|
||||
@Observable
|
||||
@MainActor
|
||||
final class IMessageSetupViewModel {
|
||||
var serverURL: String = ""
|
||||
var password: String = ""
|
||||
var webhookHost: String = "127.0.0.1"
|
||||
var webhookPort: String = "8645"
|
||||
var webhookPath: String = ""
|
||||
var allowedUsers: String = ""
|
||||
var homeChannel: String = ""
|
||||
var allowAllUsers: Bool = false
|
||||
var sendReadReceipts: Bool = false
|
||||
|
||||
var message: String?
|
||||
|
||||
func load() {
|
||||
let env = HermesEnvService().load()
|
||||
serverURL = env["BLUEBUBBLES_SERVER_URL"] ?? ""
|
||||
password = env["BLUEBUBBLES_PASSWORD"] ?? ""
|
||||
webhookHost = env["BLUEBUBBLES_WEBHOOK_HOST"] ?? "127.0.0.1"
|
||||
webhookPort = env["BLUEBUBBLES_WEBHOOK_PORT"] ?? "8645"
|
||||
webhookPath = env["BLUEBUBBLES_WEBHOOK_PATH"] ?? ""
|
||||
allowedUsers = env["BLUEBUBBLES_ALLOWED_USERS"] ?? ""
|
||||
homeChannel = env["BLUEBUBBLES_HOME_CHANNEL"] ?? ""
|
||||
allowAllUsers = PlatformSetupHelpers.parseEnvBool(env["BLUEBUBBLES_ALLOW_ALL_USERS"])
|
||||
sendReadReceipts = PlatformSetupHelpers.parseEnvBool(env["BLUEBUBBLES_SEND_READ_RECEIPTS"])
|
||||
}
|
||||
|
||||
func save() {
|
||||
let envPairs: [String: String] = [
|
||||
"BLUEBUBBLES_SERVER_URL": serverURL,
|
||||
"BLUEBUBBLES_PASSWORD": password,
|
||||
"BLUEBUBBLES_WEBHOOK_HOST": webhookHost,
|
||||
"BLUEBUBBLES_WEBHOOK_PORT": webhookPort,
|
||||
"BLUEBUBBLES_WEBHOOK_PATH": webhookPath,
|
||||
"BLUEBUBBLES_ALLOWED_USERS": allowAllUsers ? "" : allowedUsers,
|
||||
"BLUEBUBBLES_HOME_CHANNEL": homeChannel,
|
||||
"BLUEBUBBLES_ALLOW_ALL_USERS": allowAllUsers ? "true" : "",
|
||||
"BLUEBUBBLES_SEND_READ_RECEIPTS": sendReadReceipts ? "true" : ""
|
||||
]
|
||||
message = PlatformSetupHelpers.saveForm(envPairs: envPairs, configKV: [:])
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
||||
self?.message = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import Foundation
|
||||
|
||||
/// Matrix setup. Supports both access-token and password auth. No SSO.
|
||||
/// Field reference: https://hermes-agent.nousresearch.com/docs/user-guide/messaging/matrix
|
||||
@Observable
|
||||
@MainActor
|
||||
final class MatrixSetupViewModel {
|
||||
var homeserver: String = ""
|
||||
var accessToken: String = "" // preferred
|
||||
var userID: String = ""
|
||||
var password: String = "" // alternative to accessToken
|
||||
var allowedUsers: String = ""
|
||||
var homeRoom: String = ""
|
||||
var recoveryKey: String = ""
|
||||
var encryption: Bool = false
|
||||
|
||||
// config.yaml
|
||||
var requireMention: Bool = true
|
||||
var autoThread: Bool = true
|
||||
var dmMentionThreads: Bool = false
|
||||
|
||||
var message: String?
|
||||
|
||||
func load() {
|
||||
let env = HermesEnvService().load()
|
||||
homeserver = env["MATRIX_HOMESERVER"] ?? ""
|
||||
accessToken = env["MATRIX_ACCESS_TOKEN"] ?? ""
|
||||
userID = env["MATRIX_USER_ID"] ?? ""
|
||||
password = env["MATRIX_PASSWORD"] ?? ""
|
||||
allowedUsers = env["MATRIX_ALLOWED_USERS"] ?? ""
|
||||
homeRoom = env["MATRIX_HOME_ROOM"] ?? ""
|
||||
recoveryKey = env["MATRIX_RECOVERY_KEY"] ?? ""
|
||||
encryption = PlatformSetupHelpers.parseEnvBool(env["MATRIX_ENCRYPTION"])
|
||||
|
||||
let cfg = HermesFileService().loadConfig().matrix
|
||||
requireMention = cfg.requireMention
|
||||
autoThread = cfg.autoThread
|
||||
dmMentionThreads = cfg.dmMentionThreads
|
||||
}
|
||||
|
||||
func save() {
|
||||
let envPairs: [String: String] = [
|
||||
"MATRIX_HOMESERVER": homeserver,
|
||||
"MATRIX_ACCESS_TOKEN": accessToken,
|
||||
"MATRIX_USER_ID": userID,
|
||||
"MATRIX_PASSWORD": password,
|
||||
"MATRIX_ALLOWED_USERS": allowedUsers,
|
||||
"MATRIX_HOME_ROOM": homeRoom,
|
||||
"MATRIX_RECOVERY_KEY": recoveryKey,
|
||||
"MATRIX_ENCRYPTION": encryption ? "true" : ""
|
||||
]
|
||||
let configKV: [String: String] = [
|
||||
"matrix.require_mention": PlatformSetupHelpers.envBool(requireMention),
|
||||
"matrix.auto_thread": PlatformSetupHelpers.envBool(autoThread),
|
||||
"matrix.dm_mention_threads": PlatformSetupHelpers.envBool(dmMentionThreads)
|
||||
]
|
||||
message = PlatformSetupHelpers.saveForm(envPairs: envPairs, configKV: configKV)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
||||
self?.message = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
import Foundation
|
||||
|
||||
/// Mattermost setup. Server URL + personal access token (or bot token).
|
||||
/// Field reference: https://hermes-agent.nousresearch.com/docs/user-guide/messaging/mattermost
|
||||
@Observable
|
||||
@MainActor
|
||||
final class MattermostSetupViewModel {
|
||||
var serverURL: String = ""
|
||||
var token: String = ""
|
||||
var allowedUsers: String = ""
|
||||
var homeChannel: String = ""
|
||||
var freeResponseChannels: String = ""
|
||||
|
||||
var replyMode: String = "off"
|
||||
var requireMention: Bool = true
|
||||
|
||||
var message: String?
|
||||
let replyModeOptions = ["off", "thread"]
|
||||
|
||||
func load() {
|
||||
let env = HermesEnvService().load()
|
||||
serverURL = env["MATTERMOST_URL"] ?? ""
|
||||
token = env["MATTERMOST_TOKEN"] ?? ""
|
||||
allowedUsers = env["MATTERMOST_ALLOWED_USERS"] ?? ""
|
||||
homeChannel = env["MATTERMOST_HOME_CHANNEL"] ?? ""
|
||||
freeResponseChannels = env["MATTERMOST_FREE_RESPONSE_CHANNELS"] ?? ""
|
||||
replyMode = env["MATTERMOST_REPLY_MODE"] ?? "off"
|
||||
|
||||
let cfg = HermesFileService().loadConfig().mattermost
|
||||
requireMention = cfg.requireMention
|
||||
}
|
||||
|
||||
func save() {
|
||||
let envPairs: [String: String] = [
|
||||
"MATTERMOST_URL": serverURL,
|
||||
"MATTERMOST_TOKEN": token,
|
||||
"MATTERMOST_ALLOWED_USERS": allowedUsers,
|
||||
"MATTERMOST_HOME_CHANNEL": homeChannel,
|
||||
"MATTERMOST_FREE_RESPONSE_CHANNELS": freeResponseChannels,
|
||||
"MATTERMOST_REPLY_MODE": replyMode == "off" ? "" : replyMode,
|
||||
"MATTERMOST_REQUIRE_MENTION": PlatformSetupHelpers.envBool(requireMention)
|
||||
]
|
||||
message = PlatformSetupHelpers.saveForm(envPairs: envPairs, configKV: [:])
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
||||
self?.message = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import Foundation
|
||||
import AppKit
|
||||
import os
|
||||
|
||||
/// Shared helpers used by every per-platform setup view model.
|
||||
///
|
||||
/// Each platform form follows the same pattern:
|
||||
/// 1. Load current values from `.env` + config.yaml into local `@Observable` state.
|
||||
/// 2. Present them in a form where changes happen in-memory.
|
||||
/// 3. On save, write env vars via `HermesEnvService.setMany` and config.yaml keys
|
||||
/// via `hermes config set`, then surface a success/error toast.
|
||||
///
|
||||
/// Putting the save logic here keeps each per-platform VM focused on its own
|
||||
/// field set without re-implementing the write plumbing 12 times.
|
||||
@MainActor
|
||||
enum PlatformSetupHelpers {
|
||||
static let logger = Logger(subsystem: "com.scarf", category: "PlatformSetup")
|
||||
static let envService = HermesEnvService()
|
||||
|
||||
/// Apply a form save in one atomic batch.
|
||||
///
|
||||
/// - `envPairs`: values to write into `.env`. Empty strings trigger `unset()`
|
||||
/// (commenting the line out) rather than storing a literal empty value.
|
||||
/// - `configKV`: scalar config.yaml paths to set via `hermes config set`.
|
||||
/// Empty strings still produce a `config set <key> ""` call because
|
||||
/// some fields accept an explicit empty string (e.g., `display.skin: ""`).
|
||||
///
|
||||
/// Returns a user-facing summary message.
|
||||
@discardableResult
|
||||
static func saveForm(envPairs: [String: String], configKV: [String: String]) -> String {
|
||||
// Split env pairs into set vs. unset.
|
||||
var toSet: [String: String] = [:]
|
||||
var toUnset: [String] = []
|
||||
for (k, v) in envPairs {
|
||||
if v.isEmpty {
|
||||
toUnset.append(k)
|
||||
} else {
|
||||
toSet[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
var envOK = true
|
||||
if !toSet.isEmpty {
|
||||
envOK = envService.setMany(toSet)
|
||||
}
|
||||
for key in toUnset {
|
||||
_ = envService.unset(key)
|
||||
}
|
||||
|
||||
var configFailures: [String] = []
|
||||
for (key, value) in configKV {
|
||||
let result = runHermesCLI(args: ["config", "set", key, value])
|
||||
if result.exitCode != 0 {
|
||||
configFailures.append(key)
|
||||
logger.warning("hermes config set \(key) failed: \(result.output)")
|
||||
}
|
||||
}
|
||||
|
||||
if !envOK { return "Failed to write .env" }
|
||||
if !configFailures.isEmpty { return "Saved, but failed to update: \(configFailures.joined(separator: ", "))" }
|
||||
return "Saved — restart gateway to apply"
|
||||
}
|
||||
|
||||
/// Synchronous hermes CLI invocation. Use only for fast commands like
|
||||
/// `config set`; longer commands should use `HermesFileService.runHermesCLI`
|
||||
/// from a `Task.detached`.
|
||||
static func runHermesCLI(args: [String], timeout: TimeInterval = 15) -> (exitCode: Int32, output: String) {
|
||||
HermesFileService().runHermesCLI(args: args, timeout: timeout)
|
||||
}
|
||||
|
||||
/// Ask the user's default browser to open a URL (typically a hermes doc page
|
||||
/// or a platform developer portal).
|
||||
static func openURL(_ string: String) {
|
||||
guard let url = URL(string: string) else { return }
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
|
||||
/// Bool <-> "true"/"false" round-trip for env vars. Hermes accepts both
|
||||
/// "true"/"false" and "1"/"0"; we emit the string form for readability.
|
||||
static func envBool(_ on: Bool) -> String { on ? "true" : "false" }
|
||||
|
||||
/// Parse an env string as a bool. Treats missing/empty as `false`.
|
||||
/// "true", "1", "yes", "on" (case-insensitive) are true.
|
||||
static func parseEnvBool(_ s: String?) -> Bool {
|
||||
guard let s else { return false }
|
||||
switch s.lowercased() {
|
||||
case "true", "1", "yes", "on": return true
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import Foundation
|
||||
|
||||
/// Signal setup. Users must install `signal-cli` externally (needs Java), link
|
||||
/// their account via `signal-cli link -n ...`, and run a daemon on an HTTP port
|
||||
/// that hermes talks to. We expose an embedded terminal for both the link and
|
||||
/// daemon commands.
|
||||
///
|
||||
/// Field reference: https://hermes-agent.nousresearch.com/docs/user-guide/messaging/signal
|
||||
@Observable
|
||||
@MainActor
|
||||
final class SignalSetupViewModel {
|
||||
var httpURL: String = "http://127.0.0.1:8080"
|
||||
var account: String = "" // E.164 phone, e.g. +15551234567
|
||||
var allowedUsers: String = ""
|
||||
var groupAllowedUsers: String = ""
|
||||
var homeChannel: String = ""
|
||||
var allowAllUsers: Bool = false
|
||||
|
||||
var message: String?
|
||||
|
||||
let terminalController = EmbeddedSetupTerminalController()
|
||||
var signalCLIInstalled: Bool = false
|
||||
var activeTask: SignalTerminalTask = .none
|
||||
|
||||
enum SignalTerminalTask: Equatable {
|
||||
case none
|
||||
case link
|
||||
case daemon
|
||||
}
|
||||
|
||||
func load() {
|
||||
let env = HermesEnvService().load()
|
||||
httpURL = env["SIGNAL_HTTP_URL"] ?? "http://127.0.0.1:8080"
|
||||
account = env["SIGNAL_ACCOUNT"] ?? ""
|
||||
allowedUsers = env["SIGNAL_ALLOWED_USERS"] ?? ""
|
||||
groupAllowedUsers = env["SIGNAL_GROUP_ALLOWED_USERS"] ?? ""
|
||||
homeChannel = env["SIGNAL_HOME_CHANNEL"] ?? ""
|
||||
allowAllUsers = PlatformSetupHelpers.parseEnvBool(env["SIGNAL_ALLOW_ALL_USERS"])
|
||||
signalCLIInstalled = Self.detectSignalCLI()
|
||||
}
|
||||
|
||||
/// Best-effort `signal-cli` binary lookup on the login-shell PATH.
|
||||
private static func detectSignalCLI() -> Bool {
|
||||
let env = HermesFileService.enrichedEnvironment()
|
||||
let paths = env["PATH"]?.split(separator: ":").map(String.init) ?? []
|
||||
for dir in paths {
|
||||
if FileManager.default.isExecutableFile(atPath: dir + "/signal-cli") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func save() {
|
||||
let envPairs: [String: String] = [
|
||||
"SIGNAL_HTTP_URL": httpURL,
|
||||
"SIGNAL_ACCOUNT": account,
|
||||
"SIGNAL_ALLOWED_USERS": allowAllUsers ? "" : allowedUsers,
|
||||
"SIGNAL_GROUP_ALLOWED_USERS": groupAllowedUsers,
|
||||
"SIGNAL_HOME_CHANNEL": homeChannel,
|
||||
"SIGNAL_ALLOW_ALL_USERS": allowAllUsers ? "true" : ""
|
||||
]
|
||||
message = PlatformSetupHelpers.saveForm(envPairs: envPairs, configKV: [:])
|
||||
clearMessageAfterDelay()
|
||||
}
|
||||
|
||||
/// Run `signal-cli link -n HermesAgent` to generate a QR code.
|
||||
func startLink() {
|
||||
guard signalCLIInstalled else {
|
||||
message = "signal-cli not found on PATH — install it first"
|
||||
clearMessageAfterDelay()
|
||||
return
|
||||
}
|
||||
activeTask = .link
|
||||
terminalController.onExit = { [weak self] _ in
|
||||
self?.activeTask = .none
|
||||
self?.message = "Link step exited — save credentials and start the daemon next"
|
||||
self?.clearMessageAfterDelay()
|
||||
}
|
||||
terminalController.start(executable: "/usr/bin/env", arguments: ["signal-cli", "link", "-n", "HermesAgent"])
|
||||
}
|
||||
|
||||
/// Run the signal-cli daemon. Users can stop it by closing the panel.
|
||||
func startDaemon() {
|
||||
guard !account.isEmpty else {
|
||||
message = "Enter your Signal account (E.164 format) first"
|
||||
clearMessageAfterDelay()
|
||||
return
|
||||
}
|
||||
guard signalCLIInstalled else {
|
||||
message = "signal-cli not found on PATH"
|
||||
clearMessageAfterDelay()
|
||||
return
|
||||
}
|
||||
activeTask = .daemon
|
||||
let bind = httpURL.replacingOccurrences(of: "http://", with: "").replacingOccurrences(of: "https://", with: "")
|
||||
terminalController.onExit = { [weak self] _ in
|
||||
self?.activeTask = .none
|
||||
}
|
||||
terminalController.start(
|
||||
executable: "/usr/bin/env",
|
||||
arguments: ["signal-cli", "--account", account, "daemon", "--http", bind]
|
||||
)
|
||||
}
|
||||
|
||||
func stopTerminal() {
|
||||
terminalController.stop()
|
||||
activeTask = .none
|
||||
}
|
||||
|
||||
private func clearMessageAfterDelay() {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
||||
self?.message = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import Foundation
|
||||
|
||||
/// Slack setup. Requires two tokens (bot + app-level for Socket Mode).
|
||||
/// Field reference: https://hermes-agent.nousresearch.com/docs/user-guide/messaging/slack
|
||||
@Observable
|
||||
@MainActor
|
||||
final class SlackSetupViewModel {
|
||||
var botToken: String = "" // xoxb-...
|
||||
var appToken: String = "" // xapp-...
|
||||
var allowedUsers: String = ""
|
||||
var homeChannel: String = ""
|
||||
var homeChannelName: String = ""
|
||||
|
||||
var replyToMode: String = "first"
|
||||
var requireMention: Bool = true
|
||||
var replyInThread: Bool = true
|
||||
var replyBroadcast: Bool = false
|
||||
|
||||
var message: String?
|
||||
|
||||
let replyToModeOptions = ["off", "first", "all"]
|
||||
|
||||
func load() {
|
||||
let env = HermesEnvService().load()
|
||||
botToken = env["SLACK_BOT_TOKEN"] ?? ""
|
||||
appToken = env["SLACK_APP_TOKEN"] ?? ""
|
||||
allowedUsers = env["SLACK_ALLOWED_USERS"] ?? ""
|
||||
homeChannel = env["SLACK_HOME_CHANNEL"] ?? ""
|
||||
homeChannelName = env["SLACK_HOME_CHANNEL_NAME"] ?? ""
|
||||
|
||||
let cfg = HermesFileService().loadConfig().slack
|
||||
replyToMode = cfg.replyToMode
|
||||
requireMention = cfg.requireMention
|
||||
replyInThread = cfg.replyInThread
|
||||
replyBroadcast = cfg.replyBroadcast
|
||||
}
|
||||
|
||||
func save() {
|
||||
let envPairs: [String: String] = [
|
||||
"SLACK_BOT_TOKEN": botToken,
|
||||
"SLACK_APP_TOKEN": appToken,
|
||||
"SLACK_ALLOWED_USERS": allowedUsers,
|
||||
"SLACK_HOME_CHANNEL": homeChannel,
|
||||
"SLACK_HOME_CHANNEL_NAME": homeChannelName
|
||||
]
|
||||
// Slack uses the modern `platforms.slack.*` schema.
|
||||
let configKV: [String: String] = [
|
||||
"platforms.slack.reply_to_mode": replyToMode,
|
||||
"platforms.slack.require_mention": PlatformSetupHelpers.envBool(requireMention),
|
||||
"platforms.slack.extra.reply_in_thread": PlatformSetupHelpers.envBool(replyInThread),
|
||||
"platforms.slack.extra.reply_broadcast": PlatformSetupHelpers.envBool(replyBroadcast)
|
||||
]
|
||||
message = PlatformSetupHelpers.saveForm(envPairs: envPairs, configKV: configKV)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
||||
self?.message = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
/// Telegram platform setup. Credentials live in `.env` (`TELEGRAM_*`); mention /
|
||||
/// reactions toggles live in `config.yaml` under `telegram.*`.
|
||||
///
|
||||
/// Field reference: https://hermes-agent.nousresearch.com/docs/user-guide/messaging/telegram
|
||||
@Observable
|
||||
@MainActor
|
||||
final class TelegramSetupViewModel {
|
||||
// Required
|
||||
var botToken: String = ""
|
||||
var allowedUsers: String = ""
|
||||
// Optional
|
||||
var homeChannel: String = ""
|
||||
var webhookURL: String = ""
|
||||
var webhookPort: String = ""
|
||||
var webhookSecret: String = ""
|
||||
// Config.yaml toggles
|
||||
var requireMention: Bool = true
|
||||
var reactions: Bool = false
|
||||
|
||||
var message: String?
|
||||
|
||||
func load() {
|
||||
let env = HermesEnvService().load()
|
||||
botToken = env["TELEGRAM_BOT_TOKEN"] ?? ""
|
||||
allowedUsers = env["TELEGRAM_ALLOWED_USERS"] ?? ""
|
||||
homeChannel = env["TELEGRAM_HOME_CHANNEL"] ?? ""
|
||||
webhookURL = env["TELEGRAM_WEBHOOK_URL"] ?? ""
|
||||
webhookPort = env["TELEGRAM_WEBHOOK_PORT"] ?? ""
|
||||
webhookSecret = env["TELEGRAM_WEBHOOK_SECRET"] ?? ""
|
||||
|
||||
let cfg = HermesFileService().loadConfig()
|
||||
requireMention = cfg.telegram.requireMention
|
||||
reactions = cfg.telegram.reactions
|
||||
}
|
||||
|
||||
func save() {
|
||||
let envPairs: [String: String] = [
|
||||
"TELEGRAM_BOT_TOKEN": botToken,
|
||||
"TELEGRAM_ALLOWED_USERS": allowedUsers,
|
||||
"TELEGRAM_HOME_CHANNEL": homeChannel,
|
||||
"TELEGRAM_WEBHOOK_URL": webhookURL,
|
||||
"TELEGRAM_WEBHOOK_PORT": webhookPort,
|
||||
"TELEGRAM_WEBHOOK_SECRET": webhookSecret
|
||||
]
|
||||
let configKV: [String: String] = [
|
||||
"telegram.require_mention": PlatformSetupHelpers.envBool(requireMention),
|
||||
"telegram.reactions": PlatformSetupHelpers.envBool(reactions)
|
||||
]
|
||||
message = PlatformSetupHelpers.saveForm(envPairs: envPairs, configKV: configKV)
|
||||
clearMessageAfterDelay()
|
||||
}
|
||||
|
||||
private func clearMessageAfterDelay() {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
||||
self?.message = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import Foundation
|
||||
|
||||
/// Webhook platform setup. Just the global enable/port/secret — per-subscription
|
||||
/// routes live in the Webhooks sidebar feature.
|
||||
///
|
||||
/// Field reference: https://hermes-agent.nousresearch.com/docs/user-guide/messaging/webhooks
|
||||
@Observable
|
||||
@MainActor
|
||||
final class WebhookSetupViewModel {
|
||||
var enabled: Bool = false
|
||||
var port: String = "8644"
|
||||
var secret: String = ""
|
||||
|
||||
var message: String?
|
||||
|
||||
func load() {
|
||||
let env = HermesEnvService().load()
|
||||
enabled = PlatformSetupHelpers.parseEnvBool(env["WEBHOOK_ENABLED"])
|
||||
port = env["WEBHOOK_PORT"] ?? "8644"
|
||||
secret = env["WEBHOOK_SECRET"] ?? ""
|
||||
}
|
||||
|
||||
func save() {
|
||||
let envPairs: [String: String] = [
|
||||
"WEBHOOK_ENABLED": enabled ? "true" : "",
|
||||
"WEBHOOK_PORT": port,
|
||||
"WEBHOOK_SECRET": secret
|
||||
]
|
||||
message = PlatformSetupHelpers.saveForm(envPairs: envPairs, configKV: [:])
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
||||
self?.message = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import Foundation
|
||||
|
||||
/// WhatsApp setup. Unlike other platforms, pairing requires scanning a QR code
|
||||
/// via the `hermes whatsapp` CLI wizard — we expose that as an embedded
|
||||
/// terminal below the config form.
|
||||
///
|
||||
/// Field reference: https://hermes-agent.nousresearch.com/docs/user-guide/messaging/whatsapp
|
||||
@Observable
|
||||
@MainActor
|
||||
final class WhatsAppSetupViewModel {
|
||||
var enabled: Bool = false
|
||||
var mode: String = "bot" // "bot" | "self-chat"
|
||||
var allowedUsers: String = "" // Comma-separated phone numbers (no +)
|
||||
var allowAllUsers: Bool = false
|
||||
|
||||
// config.yaml knobs
|
||||
var unauthorizedDMBehavior: String = "pair" // "pair" | "ignore"
|
||||
var replyPrefix: String = ""
|
||||
|
||||
var message: String?
|
||||
let modeOptions = ["bot", "self-chat"]
|
||||
let unauthorizedOptions = ["pair", "ignore"]
|
||||
|
||||
/// The embedded terminal for the pairing step. Owned here so we can
|
||||
/// `stop()` it cleanly when the user navigates away.
|
||||
let terminalController = EmbeddedSetupTerminalController()
|
||||
var pairingInProgress: Bool = false
|
||||
|
||||
func load() {
|
||||
let env = HermesEnvService().load()
|
||||
enabled = PlatformSetupHelpers.parseEnvBool(env["WHATSAPP_ENABLED"])
|
||||
mode = env["WHATSAPP_MODE"] ?? "bot"
|
||||
allowedUsers = env["WHATSAPP_ALLOWED_USERS"] ?? ""
|
||||
allowAllUsers = PlatformSetupHelpers.parseEnvBool(env["WHATSAPP_ALLOW_ALL_USERS"])
|
||||
// Hermes accepts two equivalent ways to mean "allow everyone":
|
||||
// WHATSAPP_ALLOW_ALL_USERS=true OR WHATSAPP_ALLOWED_USERS=*
|
||||
// Normalize so the checkbox reflects either form.
|
||||
if allowedUsers == "*" {
|
||||
allowAllUsers = true
|
||||
allowedUsers = ""
|
||||
}
|
||||
|
||||
let cfg = HermesFileService().loadConfig().whatsapp
|
||||
unauthorizedDMBehavior = cfg.unauthorizedDMBehavior
|
||||
replyPrefix = cfg.replyPrefix
|
||||
}
|
||||
|
||||
func save() {
|
||||
let envPairs: [String: String] = [
|
||||
"WHATSAPP_ENABLED": PlatformSetupHelpers.envBool(enabled),
|
||||
"WHATSAPP_MODE": mode,
|
||||
// If "allow all" is set, the allowlist becomes "*" per hermes docs.
|
||||
"WHATSAPP_ALLOWED_USERS": allowAllUsers ? "*" : allowedUsers,
|
||||
"WHATSAPP_ALLOW_ALL_USERS": allowAllUsers ? "true" : ""
|
||||
]
|
||||
let configKV: [String: String] = [
|
||||
"whatsapp.unauthorized_dm_behavior": unauthorizedDMBehavior,
|
||||
"whatsapp.reply_prefix": replyPrefix
|
||||
]
|
||||
message = PlatformSetupHelpers.saveForm(envPairs: envPairs, configKV: configKV)
|
||||
clearMessageAfterDelay()
|
||||
}
|
||||
|
||||
/// Launch `hermes whatsapp` in the embedded terminal. The user scans the QR
|
||||
/// code; hermes writes the session to `~/.hermes/platforms/whatsapp/session`
|
||||
/// and exits when pairing is complete.
|
||||
func startPairing() {
|
||||
pairingInProgress = true
|
||||
terminalController.onExit = { [weak self] _ in
|
||||
self?.pairingInProgress = false
|
||||
self?.message = "Pairing terminal exited — check output for status"
|
||||
self?.clearMessageAfterDelay()
|
||||
}
|
||||
terminalController.start(
|
||||
executable: HermesPaths.hermesBinary,
|
||||
arguments: ["whatsapp"]
|
||||
)
|
||||
}
|
||||
|
||||
func stopPairing() {
|
||||
terminalController.stop()
|
||||
pairingInProgress = false
|
||||
}
|
||||
|
||||
private func clearMessageAfterDelay() {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
||||
self?.message = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
/// Platform list/selection coordinator. Per-platform configuration now lives in
|
||||
/// dedicated `<Platform>SetupViewModel` classes under `ViewModels/PlatformSetup/`.
|
||||
/// This VM only manages the sidebar list, connectivity detection, and the
|
||||
/// "Restart Gateway" action.
|
||||
@Observable
|
||||
@MainActor
|
||||
final class PlatformsViewModel {
|
||||
private let logger = Logger(subsystem: "com.scarf", category: "PlatformsViewModel")
|
||||
private let fileService = HermesFileService()
|
||||
|
||||
var gatewayState: GatewayState?
|
||||
var selected: HermesToolPlatform = KnownPlatforms.cli
|
||||
var message: String?
|
||||
var restartInProgress: Bool = false
|
||||
|
||||
var platforms: [HermesToolPlatform] { KnownPlatforms.all }
|
||||
|
||||
func load() {
|
||||
gatewayState = fileService.loadGatewayState()
|
||||
}
|
||||
|
||||
func connectivity(for platform: HermesToolPlatform) -> PlatformConnectivity {
|
||||
if let pState = gatewayState?.platforms?[platform.name] {
|
||||
if let err = pState.error, !err.isEmpty { return .error(err) }
|
||||
if pState.connected == true { return .connected }
|
||||
}
|
||||
return hasConfigBlock(for: platform) ? .configured : .notConfigured
|
||||
}
|
||||
|
||||
/// Does the platform have any configuration on disk — either a top-level
|
||||
/// `<platform>:` block in config.yaml, or an "identifying" env var in
|
||||
/// `.env` (e.g. `TELEGRAM_BOT_TOKEN`, `DISCORD_BOT_TOKEN`)?
|
||||
///
|
||||
/// We need the env-var check because the new per-platform setup forms
|
||||
/// write credentials to `.env` primarily; most platforms don't create a
|
||||
/// YAML block until the user saves a behavior toggle. Without this,
|
||||
/// platforms configured via the new flow would display as "Not configured"
|
||||
/// until the first YAML edit.
|
||||
func hasConfigBlock(for platform: HermesToolPlatform) -> Bool {
|
||||
if platform.name == "cli" { return true }
|
||||
let yaml = (try? String(contentsOfFile: HermesPaths.configYAML, encoding: .utf8)) ?? ""
|
||||
for line in yaml.components(separatedBy: "\n") where !line.hasPrefix(" ") && !line.hasPrefix("\t") {
|
||||
if line.trimmingCharacters(in: .whitespaces) == "\(platform.name):" { return true }
|
||||
}
|
||||
// Env-var fallback: any identifying env var for this platform counts
|
||||
// as "configured". Uses the shared `identifyingEnvVar(for:)` mapping.
|
||||
if let key = Self.identifyingEnvVar(for: platform.name) {
|
||||
let env = HermesEnvService().load()
|
||||
if let value = env[key], !value.isEmpty { return true }
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/// Primary credential env var for a platform — the one whose presence
|
||||
/// signals that the user has started setup. Centralized here so both the
|
||||
/// connectivity detector and future diagnostics agree on the check.
|
||||
private static func identifyingEnvVar(for platformName: String) -> String? {
|
||||
switch platformName {
|
||||
case "telegram": return "TELEGRAM_BOT_TOKEN"
|
||||
case "discord": return "DISCORD_BOT_TOKEN"
|
||||
case "slack": return "SLACK_BOT_TOKEN"
|
||||
case "whatsapp": return "WHATSAPP_ENABLED"
|
||||
case "signal": return "SIGNAL_ACCOUNT"
|
||||
case "email": return "EMAIL_ADDRESS"
|
||||
case "matrix": return "MATRIX_HOMESERVER"
|
||||
case "mattermost": return "MATTERMOST_URL"
|
||||
case "feishu": return "FEISHU_APP_ID"
|
||||
case "imessage": return "BLUEBUBBLES_SERVER_URL"
|
||||
case "homeassistant": return "HASS_TOKEN"
|
||||
case "webhook": return "WEBHOOK_ENABLED"
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Restart the hermes gateway so newly-saved config takes effect. Runs on a
|
||||
/// background task so the UI stays responsive during the ~second or two
|
||||
/// `hermes gateway restart` takes.
|
||||
func restartGateway() {
|
||||
restartInProgress = true
|
||||
message = "Restarting gateway…"
|
||||
Task.detached { [fileService] in
|
||||
let result = fileService.runHermesCLI(args: ["gateway", "restart"], timeout: 30)
|
||||
await MainActor.run {
|
||||
self.restartInProgress = false
|
||||
self.message = result.exitCode == 0 ? "Gateway restarted" : "Restart failed"
|
||||
self.load()
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
||||
self?.message = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import SwiftUI
|
||||
|
||||
struct DiscordSetupView: View {
|
||||
@State private var viewModel = DiscordSetupViewModel()
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
instructions
|
||||
|
||||
SettingsSection(title: "Required", icon: "key") {
|
||||
SecretTextField(label: "Bot Token", value: viewModel.botToken) { viewModel.botToken = $0 }
|
||||
EditableTextField(label: "Allowed User IDs", value: viewModel.allowedUsers) { viewModel.allowedUsers = $0 }
|
||||
}
|
||||
|
||||
SettingsSection(title: "Home Channel", icon: "house") {
|
||||
EditableTextField(label: "Home Channel ID", value: viewModel.homeChannel) { viewModel.homeChannel = $0 }
|
||||
EditableTextField(label: "Display Name", value: viewModel.homeChannelName) { viewModel.homeChannelName = $0 }
|
||||
}
|
||||
|
||||
SettingsSection(title: "Behavior", icon: "slider.horizontal.3") {
|
||||
ToggleRow(label: "Require @mention", isOn: viewModel.requireMention) { viewModel.requireMention = $0 }
|
||||
EditableTextField(label: "Free-Response Channels", value: viewModel.freeResponseChannels) { viewModel.freeResponseChannels = $0 }
|
||||
ToggleRow(label: "Auto-thread on mention", isOn: viewModel.autoThread) { viewModel.autoThread = $0 }
|
||||
ToggleRow(label: "Reactions", isOn: viewModel.reactions) { viewModel.reactions = $0 }
|
||||
PickerRow(label: "Allow Other Bots", selection: viewModel.allowBots, options: viewModel.allowBotsOptions) { viewModel.allowBots = $0 }
|
||||
PickerRow(label: "Reply Mode", selection: viewModel.replyToMode, options: viewModel.replyToModeOptions) { viewModel.replyToMode = $0 }
|
||||
}
|
||||
|
||||
saveBar
|
||||
}
|
||||
.onAppear { viewModel.load() }
|
||||
}
|
||||
|
||||
private var instructions: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Create an app in Discord's Developer Portal, enable Message Content and Server Members intents, and copy the bot token. Invite the bot to your server via the OAuth2 URL generator.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
HStack(spacing: 12) {
|
||||
Button("Open Developer Portal") { PlatformSetupHelpers.openURL("https://discord.com/developers/applications") }
|
||||
.controlSize(.small)
|
||||
Button("Discord Setup Docs") { PlatformSetupHelpers.openURL("https://hermes-agent.nousresearch.com/docs/user-guide/messaging/discord") }
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var saveBar: some View {
|
||||
HStack {
|
||||
if let msg = viewModel.message {
|
||||
Label(msg, systemImage: "checkmark.circle.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.green)
|
||||
}
|
||||
Spacer()
|
||||
Button("Reload") { viewModel.load() }.controlSize(.small)
|
||||
Button("Save") { viewModel.save() }.buttonStyle(.borderedProminent).controlSize(.small)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import SwiftUI
|
||||
|
||||
struct EmailSetupView: View {
|
||||
@State private var viewModel = EmailSetupViewModel()
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
instructions
|
||||
presetBar
|
||||
|
||||
SettingsSection(title: "Credentials", icon: "envelope") {
|
||||
EditableTextField(label: "Email Address", value: viewModel.address) { viewModel.address = $0 }
|
||||
SecretTextField(label: "App Password", value: viewModel.password) { viewModel.password = $0 }
|
||||
}
|
||||
|
||||
SettingsSection(title: "Servers", icon: "server.rack") {
|
||||
EditableTextField(label: "IMAP Host", value: viewModel.imapHost) { viewModel.imapHost = $0 }
|
||||
EditableTextField(label: "SMTP Host", value: viewModel.smtpHost) { viewModel.smtpHost = $0 }
|
||||
EditableTextField(label: "IMAP Port", value: viewModel.imapPort) { viewModel.imapPort = $0 }
|
||||
EditableTextField(label: "SMTP Port", value: viewModel.smtpPort) { viewModel.smtpPort = $0 }
|
||||
EditableTextField(label: "Poll Interval (s)", value: viewModel.pollInterval) { viewModel.pollInterval = $0 }
|
||||
}
|
||||
|
||||
SettingsSection(title: "Access Control", icon: "person.badge.shield.checkmark") {
|
||||
ToggleRow(label: "Allow All Senders", isOn: viewModel.allowAllUsers) { viewModel.allowAllUsers = $0 }
|
||||
if !viewModel.allowAllUsers {
|
||||
EditableTextField(label: "Allowed Senders", value: viewModel.allowedUsers) { viewModel.allowedUsers = $0 }
|
||||
}
|
||||
EditableTextField(label: "Home Address", value: viewModel.homeAddress) { viewModel.homeAddress = $0 }
|
||||
}
|
||||
|
||||
SettingsSection(title: "Behavior", icon: "slider.horizontal.3") {
|
||||
ToggleRow(label: "Skip Attachments", isOn: viewModel.skipAttachments) { viewModel.skipAttachments = $0 }
|
||||
}
|
||||
|
||||
saveBar
|
||||
}
|
||||
.onAppear { viewModel.load() }
|
||||
}
|
||||
|
||||
private var instructions: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Enable 2FA on your email account and generate an app password. Regular account passwords will fail. Always set allowed senders — otherwise anyone knowing the address can message the agent.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
HStack {
|
||||
Button("Email Setup Docs") { PlatformSetupHelpers.openURL("https://hermes-agent.nousresearch.com/docs/user-guide/messaging/email") }
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var presetBar: some View {
|
||||
HStack(spacing: 8) {
|
||||
Text("Preset:")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
ForEach(viewModel.presets, id: \.name) { preset in
|
||||
Button(preset.name) { viewModel.applyPreset(preset) }
|
||||
.controlSize(.small)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
|
||||
private var saveBar: some View {
|
||||
HStack {
|
||||
if let msg = viewModel.message {
|
||||
Label(msg, systemImage: "checkmark.circle.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.green)
|
||||
}
|
||||
Spacer()
|
||||
Button("Reload") { viewModel.load() }.controlSize(.small)
|
||||
Button("Save") { viewModel.save() }.buttonStyle(.borderedProminent).controlSize(.small)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import SwiftUI
|
||||
import AppKit
|
||||
import SwiftTerm
|
||||
import os
|
||||
|
||||
/// Inline SwiftTerm terminal for platform pairing wizards that genuinely require
|
||||
/// a TTY (WhatsApp QR, Signal `signal-cli link`). This is a lightweight sibling
|
||||
/// to `PersistentTerminalView` in the Chat feature — scoped to run a single
|
||||
/// command, show its output, and notify when the process exits.
|
||||
///
|
||||
/// Usage:
|
||||
/// EmbeddedSetupTerminal(controller: viewModel.terminalController)
|
||||
/// // Controller exposes start()/terminate() that the view model owns.
|
||||
struct EmbeddedSetupTerminal: NSViewRepresentable {
|
||||
let controller: EmbeddedSetupTerminalController
|
||||
|
||||
func makeNSView(context: Context) -> NSView {
|
||||
let container = NSView()
|
||||
controller.attach(to: container)
|
||||
return container
|
||||
}
|
||||
|
||||
func updateNSView(_ nsView: NSView, context: Context) {
|
||||
// If the view model recreated its terminal view (e.g., after re-launching
|
||||
// the pairing command), re-attach it to the container.
|
||||
controller.reattachIfNeeded(to: nsView)
|
||||
}
|
||||
}
|
||||
|
||||
/// Owns the `LocalProcessTerminalView` so it survives SwiftUI body redraws.
|
||||
/// Lives on the view model (one per platform that uses it).
|
||||
@MainActor
|
||||
final class EmbeddedSetupTerminalController {
|
||||
private let logger = Logger(subsystem: "com.scarf", category: "EmbeddedSetupTerminal")
|
||||
|
||||
/// The hosting NSView from the `NSViewRepresentable`. Weak because SwiftUI
|
||||
/// owns the container's lifetime; we just attach our terminal view inside it.
|
||||
private weak var container: NSView?
|
||||
|
||||
/// The actual terminal emulator. Recreated per launch so a terminated
|
||||
/// process doesn't leave stale buffer state mixed with new output.
|
||||
private var terminalView: LocalProcessTerminalView?
|
||||
private var coordinator: Coordinator?
|
||||
|
||||
/// Invoked when the spawned process exits. The `Int32` is the exit code
|
||||
/// (`0` success, non-zero failure). Runs on the main actor.
|
||||
var onExit: ((Int32) -> Void)?
|
||||
|
||||
var isRunning: Bool { terminalView != nil }
|
||||
|
||||
/// Start a process in the embedded terminal. If a process is already running,
|
||||
/// it is terminated first to avoid orphans.
|
||||
func start(executable: String, arguments: [String], environment: [String: String] = [:]) {
|
||||
stop()
|
||||
guard let container else {
|
||||
logger.warning("start() called before terminal was attached to a container")
|
||||
return
|
||||
}
|
||||
|
||||
let terminal = LocalProcessTerminalView(frame: .zero)
|
||||
terminal.font = NSFont.monospacedSystemFont(ofSize: 12, weight: .regular)
|
||||
terminal.nativeBackgroundColor = NSColor(red: 0.11, green: 0.12, blue: 0.14, alpha: 1.0)
|
||||
terminal.nativeForegroundColor = NSColor(red: 0.85, green: 0.87, blue: 0.91, alpha: 1.0)
|
||||
|
||||
let coord = Coordinator { [weak self] exitCode in
|
||||
self?.onExit?(exitCode ?? -1)
|
||||
}
|
||||
terminal.processDelegate = coord
|
||||
coordinator = coord
|
||||
|
||||
// Merge caller-provided env over the enriched shell env so `npx`, `node`,
|
||||
// `signal-cli`, etc. resolve from PATH.
|
||||
var env = HermesFileService.enrichedEnvironment()
|
||||
env["TERM"] = "xterm-256color"
|
||||
env["COLORTERM"] = "truecolor"
|
||||
for (k, v) in environment { env[k] = v }
|
||||
let envArray = env.map { "\($0.key)=\($0.value)" }
|
||||
|
||||
terminal.startProcess(
|
||||
executable: executable,
|
||||
args: arguments,
|
||||
environment: envArray,
|
||||
execName: nil
|
||||
)
|
||||
|
||||
// Attach with AutoLayout constraints — matches the pattern used by
|
||||
// Features/Chat/Views/TerminalRepresentable.swift. Relying on
|
||||
// autoresizingMask is unreliable when SwiftUI hosts the NSView,
|
||||
// because SwiftUI drives layout via AutoLayout.
|
||||
container.subviews.forEach { $0.removeFromSuperview() }
|
||||
terminal.translatesAutoresizingMaskIntoConstraints = false
|
||||
container.addSubview(terminal)
|
||||
NSLayoutConstraint.activate([
|
||||
terminal.leadingAnchor.constraint(equalTo: container.leadingAnchor),
|
||||
terminal.trailingAnchor.constraint(equalTo: container.trailingAnchor),
|
||||
terminal.topAnchor.constraint(equalTo: container.topAnchor),
|
||||
terminal.bottomAnchor.constraint(equalTo: container.bottomAnchor),
|
||||
])
|
||||
terminalView = terminal
|
||||
}
|
||||
|
||||
/// Kill the running process (if any). Safe to call when nothing is running.
|
||||
func stop() {
|
||||
terminalView?.terminate()
|
||||
terminalView?.removeFromSuperview()
|
||||
terminalView = nil
|
||||
}
|
||||
|
||||
// MARK: - NSViewRepresentable plumbing
|
||||
|
||||
func attach(to container: NSView) {
|
||||
self.container = container
|
||||
if let tv = terminalView, tv.superview !== container {
|
||||
container.subviews.forEach { $0.removeFromSuperview() }
|
||||
tv.translatesAutoresizingMaskIntoConstraints = false
|
||||
container.addSubview(tv)
|
||||
NSLayoutConstraint.activate([
|
||||
tv.leadingAnchor.constraint(equalTo: container.leadingAnchor),
|
||||
tv.trailingAnchor.constraint(equalTo: container.trailingAnchor),
|
||||
tv.topAnchor.constraint(equalTo: container.topAnchor),
|
||||
tv.bottomAnchor.constraint(equalTo: container.bottomAnchor),
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
func reattachIfNeeded(to container: NSView) {
|
||||
self.container = container
|
||||
guard let tv = terminalView, tv.superview !== container else { return }
|
||||
container.subviews.forEach { $0.removeFromSuperview() }
|
||||
tv.translatesAutoresizingMaskIntoConstraints = false
|
||||
container.addSubview(tv)
|
||||
NSLayoutConstraint.activate([
|
||||
tv.leadingAnchor.constraint(equalTo: container.leadingAnchor),
|
||||
tv.trailingAnchor.constraint(equalTo: container.trailingAnchor),
|
||||
tv.topAnchor.constraint(equalTo: container.topAnchor),
|
||||
tv.bottomAnchor.constraint(equalTo: container.bottomAnchor),
|
||||
])
|
||||
}
|
||||
|
||||
final class Coordinator: NSObject, LocalProcessTerminalViewDelegate {
|
||||
let onTerminated: (Int32?) -> Void
|
||||
|
||||
init(onTerminated: @escaping (Int32?) -> Void) {
|
||||
self.onTerminated = onTerminated
|
||||
}
|
||||
|
||||
func sizeChanged(source: LocalProcessTerminalView, newCols: Int, newRows: Int) {}
|
||||
func setTerminalTitle(source: LocalProcessTerminalView, title: String) {}
|
||||
func hostCurrentDirectoryUpdate(source: TerminalView, directory: String?) {}
|
||||
|
||||
func processTerminated(source: TerminalView, exitCode: Int32?) {
|
||||
let terminal = source.getTerminal()
|
||||
terminal.feed(text: "\r\n[Process exited with code \(exitCode ?? -1)]\r\n")
|
||||
let code = exitCode
|
||||
DispatchQueue.main.async { self.onTerminated(code) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import SwiftUI
|
||||
|
||||
struct FeishuSetupView: View {
|
||||
@State private var viewModel = FeishuSetupViewModel()
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
instructions
|
||||
|
||||
SettingsSection(title: "App Credentials", icon: "key") {
|
||||
EditableTextField(label: "App ID", value: viewModel.appID) { viewModel.appID = $0 }
|
||||
SecretTextField(label: "App Secret", value: viewModel.appSecret) { viewModel.appSecret = $0 }
|
||||
PickerRow(label: "Domain", selection: viewModel.domain, options: viewModel.domainOptions) { viewModel.domain = $0 }
|
||||
}
|
||||
|
||||
SettingsSection(title: "Webhook Security", icon: "lock.shield") {
|
||||
SecretTextField(label: "Encrypt Key", value: viewModel.encryptKey) { viewModel.encryptKey = $0 }
|
||||
SecretTextField(label: "Verification Token", value: viewModel.verificationToken) { viewModel.verificationToken = $0 }
|
||||
}
|
||||
|
||||
SettingsSection(title: "Access Control", icon: "person.badge.shield.checkmark") {
|
||||
EditableTextField(label: "Allowed Users", value: viewModel.allowedUsers) { viewModel.allowedUsers = $0 }
|
||||
PickerRow(label: "Connection Mode", selection: viewModel.connectionMode, options: viewModel.connectionOptions) { viewModel.connectionMode = $0 }
|
||||
}
|
||||
|
||||
saveBar
|
||||
}
|
||||
.onAppear { viewModel.load() }
|
||||
}
|
||||
|
||||
private var instructions: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Create an app in the Feishu/Lark Developer Console, enable Interactive Card if you need button responses, and copy the App ID and App Secret. WebSocket mode (recommended) doesn't need a public endpoint.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
HStack {
|
||||
Button("Feishu Setup Docs") { PlatformSetupHelpers.openURL("https://hermes-agent.nousresearch.com/docs/user-guide/messaging/feishu") }
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var saveBar: some View {
|
||||
HStack {
|
||||
if let msg = viewModel.message {
|
||||
Label(msg, systemImage: "checkmark.circle.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.green)
|
||||
}
|
||||
Spacer()
|
||||
Button("Reload") { viewModel.load() }.controlSize(.small)
|
||||
Button("Save") { viewModel.save() }.buttonStyle(.borderedProminent).controlSize(.small)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import SwiftUI
|
||||
|
||||
struct HomeAssistantSetupView: View {
|
||||
@State private var viewModel = HomeAssistantSetupViewModel()
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
instructions
|
||||
|
||||
SettingsSection(title: "Connection", icon: "network") {
|
||||
EditableTextField(label: "URL", value: viewModel.url) { viewModel.url = $0 }
|
||||
SecretTextField(label: "Long-Lived Token", value: viewModel.token) { viewModel.token = $0 }
|
||||
}
|
||||
|
||||
SettingsSection(title: "Event Filters", icon: "line.3.horizontal.decrease.circle") {
|
||||
ToggleRow(label: "Watch All Changes", isOn: viewModel.watchAll) { viewModel.watchAll = $0 }
|
||||
StepperRow(label: "Cooldown (s)", value: viewModel.cooldownSeconds, range: 0...3600, step: 5) { viewModel.cooldownSeconds = $0 }
|
||||
}
|
||||
|
||||
listFiltersSection
|
||||
saveBar
|
||||
}
|
||||
.onAppear { viewModel.load() }
|
||||
}
|
||||
|
||||
private var instructions: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Create a long-lived access token in Home Assistant (Profile → Security → Long-Lived Access Tokens). By default, no events are forwarded — enable Watch All Changes, or add entity filters below.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
HStack {
|
||||
Button("Home Assistant Docs") { PlatformSetupHelpers.openURL("https://hermes-agent.nousresearch.com/docs/user-guide/messaging/homeassistant") }
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Read-only display of list-valued filters (watch_domains, watch_entities,
|
||||
/// ignore_entities). Editing requires hand-modifying config.yaml because
|
||||
/// the `hermes config set` CLI can't produce YAML lists — it stores
|
||||
/// arrays as quoted strings, which hermes rejects.
|
||||
private var listFiltersSection: some View {
|
||||
SettingsSection(title: "Entity Filters (config.yaml only)", icon: "list.bullet") {
|
||||
ReadOnlyRow(label: "Watch Domains", value: viewModel.watchDomains.joined(separator: ", "))
|
||||
ReadOnlyRow(label: "Watch Entities", value: viewModel.watchEntities.joined(separator: ", "))
|
||||
ReadOnlyRow(label: "Ignore Entities", value: viewModel.ignoreEntities.joined(separator: ", "))
|
||||
HStack {
|
||||
Text("")
|
||||
.frame(width: 160, alignment: .trailing)
|
||||
Text("These list fields must be edited directly in config.yaml.")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
Button("Edit config.yaml") { viewModel.openConfigForLists() }
|
||||
.controlSize(.mini)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
}
|
||||
}
|
||||
|
||||
private var saveBar: some View {
|
||||
HStack {
|
||||
if let msg = viewModel.message {
|
||||
Label(msg, systemImage: "checkmark.circle.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.green)
|
||||
}
|
||||
Spacer()
|
||||
Button("Reload") { viewModel.load() }.controlSize(.small)
|
||||
Button("Save") { viewModel.save() }.buttonStyle(.borderedProminent).controlSize(.small)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import SwiftUI
|
||||
|
||||
struct IMessageSetupView: View {
|
||||
@State private var viewModel = IMessageSetupViewModel()
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
instructions
|
||||
|
||||
SettingsSection(title: "BlueBubbles Server", icon: "server.rack") {
|
||||
EditableTextField(label: "Server URL", value: viewModel.serverURL) { viewModel.serverURL = $0 }
|
||||
SecretTextField(label: "Server Password", value: viewModel.password) { viewModel.password = $0 }
|
||||
}
|
||||
|
||||
SettingsSection(title: "Webhook (hermes side)", icon: "arrow.up.right.square") {
|
||||
EditableTextField(label: "Host", value: viewModel.webhookHost) { viewModel.webhookHost = $0 }
|
||||
EditableTextField(label: "Port", value: viewModel.webhookPort) { viewModel.webhookPort = $0 }
|
||||
EditableTextField(label: "Path", value: viewModel.webhookPath) { viewModel.webhookPath = $0 }
|
||||
}
|
||||
|
||||
SettingsSection(title: "Access Control", icon: "person.badge.shield.checkmark") {
|
||||
ToggleRow(label: "Allow All Users", isOn: viewModel.allowAllUsers) { viewModel.allowAllUsers = $0 }
|
||||
if !viewModel.allowAllUsers {
|
||||
EditableTextField(label: "Allowed Users", value: viewModel.allowedUsers) { viewModel.allowedUsers = $0 }
|
||||
}
|
||||
EditableTextField(label: "Home Channel", value: viewModel.homeChannel) { viewModel.homeChannel = $0 }
|
||||
}
|
||||
|
||||
SettingsSection(title: "Behavior", icon: "slider.horizontal.3") {
|
||||
ToggleRow(label: "Send Read Receipts", isOn: viewModel.sendReadReceipts) { viewModel.sendReadReceipts = $0 }
|
||||
}
|
||||
|
||||
saveBar
|
||||
}
|
||||
.onAppear { viewModel.load() }
|
||||
}
|
||||
|
||||
private var instructions: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("iMessage integration runs through BlueBubbles Server. You need a Mac that stays on with Messages.app signed in — install BlueBubbles Server on it, then point hermes at that server's URL.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
HStack(spacing: 12) {
|
||||
Button("Install BlueBubbles Server") { PlatformSetupHelpers.openURL("https://bluebubbles.app/") }
|
||||
.controlSize(.small)
|
||||
Button("BlueBubbles Docs") { PlatformSetupHelpers.openURL("https://hermes-agent.nousresearch.com/docs/user-guide/messaging/bluebubbles") }
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var saveBar: some View {
|
||||
HStack {
|
||||
if let msg = viewModel.message {
|
||||
Label(msg, systemImage: "checkmark.circle.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.green)
|
||||
}
|
||||
Spacer()
|
||||
Button("Reload") { viewModel.load() }.controlSize(.small)
|
||||
Button("Save") { viewModel.save() }.buttonStyle(.borderedProminent).controlSize(.small)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import SwiftUI
|
||||
|
||||
struct MatrixSetupView: View {
|
||||
@State private var viewModel = MatrixSetupViewModel()
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
instructions
|
||||
|
||||
SettingsSection(title: "Homeserver", icon: "network") {
|
||||
EditableTextField(label: "Homeserver URL", value: viewModel.homeserver) { viewModel.homeserver = $0 }
|
||||
}
|
||||
|
||||
SettingsSection(title: "Authentication", icon: "person.badge.key") {
|
||||
SecretTextField(label: "Access Token", value: viewModel.accessToken) { viewModel.accessToken = $0 }
|
||||
Text("— or use user/password login —")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 2)
|
||||
EditableTextField(label: "User ID", value: viewModel.userID) { viewModel.userID = $0 }
|
||||
SecretTextField(label: "Password", value: viewModel.password) { viewModel.password = $0 }
|
||||
}
|
||||
|
||||
SettingsSection(title: "Access Control", icon: "person.badge.shield.checkmark") {
|
||||
EditableTextField(label: "Allowed Users", value: viewModel.allowedUsers) { viewModel.allowedUsers = $0 }
|
||||
EditableTextField(label: "Home Room", value: viewModel.homeRoom) { viewModel.homeRoom = $0 }
|
||||
}
|
||||
|
||||
SettingsSection(title: "Behavior", icon: "slider.horizontal.3") {
|
||||
ToggleRow(label: "Require @mention", isOn: viewModel.requireMention) { viewModel.requireMention = $0 }
|
||||
ToggleRow(label: "Auto-thread on mention", isOn: viewModel.autoThread) { viewModel.autoThread = $0 }
|
||||
ToggleRow(label: "DM mention threads", isOn: viewModel.dmMentionThreads) { viewModel.dmMentionThreads = $0 }
|
||||
}
|
||||
|
||||
SettingsSection(title: "End-to-End Encryption (experimental)", icon: "lock.shield") {
|
||||
ToggleRow(label: "Enable E2EE", isOn: viewModel.encryption) { viewModel.encryption = $0 }
|
||||
if viewModel.encryption {
|
||||
SecretTextField(label: "Recovery Key", value: viewModel.recoveryKey) { viewModel.recoveryKey = $0 }
|
||||
}
|
||||
}
|
||||
|
||||
saveBar
|
||||
}
|
||||
.onAppear { viewModel.load() }
|
||||
}
|
||||
|
||||
private var instructions: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Matrix uses either an access token (preferred) or username/password. Get an access token from Element: Settings → Help & About → Access Token.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
HStack {
|
||||
Button("Matrix Setup Docs") { PlatformSetupHelpers.openURL("https://hermes-agent.nousresearch.com/docs/user-guide/messaging/matrix") }
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var saveBar: some View {
|
||||
HStack {
|
||||
if let msg = viewModel.message {
|
||||
Label(msg, systemImage: "checkmark.circle.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.green)
|
||||
}
|
||||
Spacer()
|
||||
Button("Reload") { viewModel.load() }.controlSize(.small)
|
||||
Button("Save") { viewModel.save() }.buttonStyle(.borderedProminent).controlSize(.small)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import SwiftUI
|
||||
|
||||
struct MattermostSetupView: View {
|
||||
@State private var viewModel = MattermostSetupViewModel()
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
instructions
|
||||
|
||||
SettingsSection(title: "Server", icon: "network") {
|
||||
EditableTextField(label: "Server URL", value: viewModel.serverURL) { viewModel.serverURL = $0 }
|
||||
SecretTextField(label: "Token", value: viewModel.token) { viewModel.token = $0 }
|
||||
}
|
||||
|
||||
SettingsSection(title: "Access Control", icon: "person.badge.shield.checkmark") {
|
||||
EditableTextField(label: "Allowed Users", value: viewModel.allowedUsers) { viewModel.allowedUsers = $0 }
|
||||
EditableTextField(label: "Home Channel", value: viewModel.homeChannel) { viewModel.homeChannel = $0 }
|
||||
EditableTextField(label: "Free-Response Channels", value: viewModel.freeResponseChannels) { viewModel.freeResponseChannels = $0 }
|
||||
}
|
||||
|
||||
SettingsSection(title: "Behavior", icon: "slider.horizontal.3") {
|
||||
ToggleRow(label: "Require @mention", isOn: viewModel.requireMention) { viewModel.requireMention = $0 }
|
||||
PickerRow(label: "Reply Mode", selection: viewModel.replyMode, options: viewModel.replyModeOptions) { viewModel.replyMode = $0 }
|
||||
}
|
||||
|
||||
saveBar
|
||||
}
|
||||
.onAppear { viewModel.load() }
|
||||
}
|
||||
|
||||
private var instructions: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Create a personal access token under Profile → Security → Personal Access Tokens, or create a bot account. Use the token as the MATTERMOST_TOKEN value.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
HStack {
|
||||
Button("Mattermost Setup Docs") { PlatformSetupHelpers.openURL("https://hermes-agent.nousresearch.com/docs/user-guide/messaging/mattermost") }
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var saveBar: some View {
|
||||
HStack {
|
||||
if let msg = viewModel.message {
|
||||
Label(msg, systemImage: "checkmark.circle.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.green)
|
||||
}
|
||||
Spacer()
|
||||
Button("Reload") { viewModel.load() }.controlSize(.small)
|
||||
Button("Save") { viewModel.save() }.buttonStyle(.borderedProminent).controlSize(.small)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import SwiftUI
|
||||
|
||||
struct SignalSetupView: View {
|
||||
@State private var viewModel = SignalSetupViewModel()
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
instructions
|
||||
prerequisiteStatus
|
||||
|
||||
SettingsSection(title: "Daemon Endpoint", icon: "network") {
|
||||
EditableTextField(label: "HTTP URL", value: viewModel.httpURL) { viewModel.httpURL = $0 }
|
||||
EditableTextField(label: "Account (E.164)", value: viewModel.account) { viewModel.account = $0 }
|
||||
}
|
||||
|
||||
SettingsSection(title: "Access Control", icon: "person.badge.shield.checkmark") {
|
||||
ToggleRow(label: "Allow All Users", isOn: viewModel.allowAllUsers) { viewModel.allowAllUsers = $0 }
|
||||
if !viewModel.allowAllUsers {
|
||||
EditableTextField(label: "Allowed Users", value: viewModel.allowedUsers) { viewModel.allowedUsers = $0 }
|
||||
}
|
||||
EditableTextField(label: "Group Allowed Users", value: viewModel.groupAllowedUsers) { viewModel.groupAllowedUsers = $0 }
|
||||
EditableTextField(label: "Home Channel", value: viewModel.homeChannel) { viewModel.homeChannel = $0 }
|
||||
}
|
||||
|
||||
saveBar
|
||||
Divider()
|
||||
terminalSection
|
||||
}
|
||||
.onAppear { viewModel.load() }
|
||||
.onDisappear { viewModel.stopTerminal() }
|
||||
}
|
||||
|
||||
private var instructions: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Signal integration requires signal-cli (Java-based) installed locally. Link this Mac as a Signal device, then keep the daemon running so hermes can send/receive messages.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
HStack(spacing: 12) {
|
||||
Button("Install signal-cli") { PlatformSetupHelpers.openURL("https://github.com/AsamK/signal-cli/wiki/Quickstart") }
|
||||
.controlSize(.small)
|
||||
Button("Signal Setup Docs") { PlatformSetupHelpers.openURL("https://hermes-agent.nousresearch.com/docs/user-guide/messaging/signal") }
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var prerequisiteStatus: some View {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: viewModel.signalCLIInstalled ? "checkmark.circle.fill" : "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(viewModel.signalCLIInstalled ? .green : .orange)
|
||||
Text(viewModel.signalCLIInstalled ? "signal-cli is available on PATH" : "signal-cli not found on PATH — install it first")
|
||||
.font(.caption)
|
||||
.foregroundStyle(viewModel.signalCLIInstalled ? Color.primary : Color.orange)
|
||||
Spacer()
|
||||
}
|
||||
.padding(8)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
|
||||
private var saveBar: some View {
|
||||
HStack {
|
||||
if let msg = viewModel.message {
|
||||
Label(msg, systemImage: "info.circle")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Button("Reload") { viewModel.load() }.controlSize(.small)
|
||||
Button("Save") { viewModel.save() }.buttonStyle(.borderedProminent).controlSize(.small)
|
||||
}
|
||||
}
|
||||
|
||||
private var terminalSection: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
Label("signal-cli Terminal", systemImage: "terminal")
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
switch viewModel.activeTask {
|
||||
case .none:
|
||||
Button("Link Device") { viewModel.startLink() }.controlSize(.small)
|
||||
.disabled(!viewModel.signalCLIInstalled)
|
||||
Button("Start Daemon") { viewModel.startDaemon() }.buttonStyle(.borderedProminent).controlSize(.small)
|
||||
.disabled(!viewModel.signalCLIInstalled || viewModel.account.isEmpty)
|
||||
case .link:
|
||||
Text("Linking…").font(.caption).foregroundStyle(.secondary)
|
||||
Button("Stop") { viewModel.stopTerminal() }.controlSize(.small)
|
||||
case .daemon:
|
||||
Text("Daemon running").font(.caption).foregroundStyle(.green)
|
||||
Button("Stop") { viewModel.stopTerminal() }.controlSize(.small)
|
||||
}
|
||||
}
|
||||
Text("Link the device first to generate and scan a QR code. Once linked, start the daemon — it must keep running for hermes to send/receive messages.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
EmbeddedSetupTerminal(controller: viewModel.terminalController)
|
||||
.frame(minHeight: 260, maxHeight: 360)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import SwiftUI
|
||||
|
||||
struct SlackSetupView: View {
|
||||
@State private var viewModel = SlackSetupViewModel()
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
instructions
|
||||
|
||||
SettingsSection(title: "Required Tokens", icon: "key") {
|
||||
SecretTextField(label: "Bot Token (xoxb-)", value: viewModel.botToken) { viewModel.botToken = $0 }
|
||||
SecretTextField(label: "App Token (xapp-)", value: viewModel.appToken) { viewModel.appToken = $0 }
|
||||
EditableTextField(label: "Allowed User IDs", value: viewModel.allowedUsers) { viewModel.allowedUsers = $0 }
|
||||
}
|
||||
|
||||
SettingsSection(title: "Home Channel", icon: "house") {
|
||||
EditableTextField(label: "Channel ID", value: viewModel.homeChannel) { viewModel.homeChannel = $0 }
|
||||
EditableTextField(label: "Display Name", value: viewModel.homeChannelName) { viewModel.homeChannelName = $0 }
|
||||
}
|
||||
|
||||
SettingsSection(title: "Behavior", icon: "slider.horizontal.3") {
|
||||
ToggleRow(label: "Require @mention", isOn: viewModel.requireMention) { viewModel.requireMention = $0 }
|
||||
PickerRow(label: "Reply Mode", selection: viewModel.replyToMode, options: viewModel.replyToModeOptions) { viewModel.replyToMode = $0 }
|
||||
ToggleRow(label: "Reply in thread", isOn: viewModel.replyInThread) { viewModel.replyInThread = $0 }
|
||||
ToggleRow(label: "Reply broadcast", isOn: viewModel.replyBroadcast) { viewModel.replyBroadcast = $0 }
|
||||
}
|
||||
|
||||
saveBar
|
||||
}
|
||||
.onAppear { viewModel.load() }
|
||||
}
|
||||
|
||||
private var instructions: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Create a Slack app at api.slack.com/apps, enable Socket Mode, grant bot scopes (chat:write, app_mentions:read, channels:history, etc.), then copy the Bot User OAuth Token (xoxb-) and the App-Level Token (xapp-).")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
HStack(spacing: 12) {
|
||||
Button("Open Slack API") { PlatformSetupHelpers.openURL("https://api.slack.com/apps") }
|
||||
.controlSize(.small)
|
||||
Button("Slack Setup Docs") { PlatformSetupHelpers.openURL("https://hermes-agent.nousresearch.com/docs/user-guide/messaging/slack") }
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var saveBar: some View {
|
||||
HStack {
|
||||
if let msg = viewModel.message {
|
||||
Label(msg, systemImage: "checkmark.circle.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.green)
|
||||
}
|
||||
Spacer()
|
||||
Button("Reload") { viewModel.load() }.controlSize(.small)
|
||||
Button("Save") { viewModel.save() }.buttonStyle(.borderedProminent).controlSize(.small)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import SwiftUI
|
||||
|
||||
struct TelegramSetupView: View {
|
||||
@State private var viewModel = TelegramSetupViewModel()
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
instructions
|
||||
|
||||
SettingsSection(title: "Required", icon: "key") {
|
||||
SecretTextField(label: "Bot Token", value: viewModel.botToken) { viewModel.botToken = $0 }
|
||||
EditableTextField(label: "Allowed Users", value: viewModel.allowedUsers) { viewModel.allowedUsers = $0 }
|
||||
}
|
||||
|
||||
SettingsSection(title: "Optional", icon: "slider.horizontal.3") {
|
||||
EditableTextField(label: "Home Channel", value: viewModel.homeChannel) { viewModel.homeChannel = $0 }
|
||||
ToggleRow(label: "Require @mention", isOn: viewModel.requireMention) { viewModel.requireMention = $0 }
|
||||
ToggleRow(label: "Reactions", isOn: viewModel.reactions) { viewModel.reactions = $0 }
|
||||
}
|
||||
|
||||
SettingsSection(title: "Webhook (advanced)", icon: "arrow.up.right.square") {
|
||||
EditableTextField(label: "Webhook URL", value: viewModel.webhookURL) { viewModel.webhookURL = $0 }
|
||||
EditableTextField(label: "Webhook Port", value: viewModel.webhookPort) { viewModel.webhookPort = $0 }
|
||||
SecretTextField(label: "Webhook Secret", value: viewModel.webhookSecret) { viewModel.webhookSecret = $0 }
|
||||
}
|
||||
|
||||
saveBar
|
||||
}
|
||||
.onAppear { viewModel.load() }
|
||||
}
|
||||
|
||||
private var instructions: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Create a bot via @BotFather and get your numeric user ID from @userinfobot. Paste the token and your user ID below — the bot will only respond to allowed users.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
HStack(spacing: 12) {
|
||||
Button("Open BotFather") { PlatformSetupHelpers.openURL("https://t.me/BotFather") }
|
||||
.controlSize(.small)
|
||||
Button("Telegram Setup Docs") { PlatformSetupHelpers.openURL("https://hermes-agent.nousresearch.com/docs/user-guide/messaging/telegram") }
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var saveBar: some View {
|
||||
HStack {
|
||||
if let msg = viewModel.message {
|
||||
Label(msg, systemImage: "checkmark.circle.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.green)
|
||||
}
|
||||
Spacer()
|
||||
Button("Reload") { viewModel.load() }
|
||||
.controlSize(.small)
|
||||
Button("Save") { viewModel.save() }
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import SwiftUI
|
||||
|
||||
struct WebhookSetupView: View {
|
||||
@State private var viewModel = WebhookSetupViewModel()
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
instructions
|
||||
|
||||
SettingsSection(title: "Global Settings", icon: "arrow.up.right.square") {
|
||||
ToggleRow(label: "Webhook Enabled", isOn: viewModel.enabled) { viewModel.enabled = $0 }
|
||||
EditableTextField(label: "Port", value: viewModel.port) { viewModel.port = $0 }
|
||||
SecretTextField(label: "HMAC Secret", value: viewModel.secret) { viewModel.secret = $0 }
|
||||
}
|
||||
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
Image(systemName: "info.circle")
|
||||
.foregroundStyle(.secondary)
|
||||
Text("Per-route subscriptions (events, prompt template, delivery target) are managed in the Webhooks sidebar — not here. This panel only controls whether the webhook platform is listening at all.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(10)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
|
||||
saveBar
|
||||
}
|
||||
.onAppear { viewModel.load() }
|
||||
}
|
||||
|
||||
private var instructions: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Enable the webhook platform to accept event-driven agent triggers. The HMAC secret is used as a fallback when individual routes don't provide their own.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
HStack {
|
||||
Button("Webhook Setup Docs") { PlatformSetupHelpers.openURL("https://hermes-agent.nousresearch.com/docs/user-guide/messaging/webhooks") }
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var saveBar: some View {
|
||||
HStack {
|
||||
if let msg = viewModel.message {
|
||||
Label(msg, systemImage: "checkmark.circle.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.green)
|
||||
}
|
||||
Spacer()
|
||||
Button("Reload") { viewModel.load() }.controlSize(.small)
|
||||
Button("Save") { viewModel.save() }.buttonStyle(.borderedProminent).controlSize(.small)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import SwiftUI
|
||||
|
||||
struct WhatsAppSetupView: View {
|
||||
@State private var viewModel = WhatsAppSetupViewModel()
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
instructions
|
||||
|
||||
SettingsSection(title: "Status", icon: "power") {
|
||||
ToggleRow(label: "WhatsApp Enabled", isOn: viewModel.enabled) { viewModel.enabled = $0 }
|
||||
PickerRow(label: "Mode", selection: viewModel.mode, options: viewModel.modeOptions) { viewModel.mode = $0 }
|
||||
}
|
||||
|
||||
SettingsSection(title: "Access Control", icon: "person.badge.shield.checkmark") {
|
||||
ToggleRow(label: "Allow All Users", isOn: viewModel.allowAllUsers) { viewModel.allowAllUsers = $0 }
|
||||
if !viewModel.allowAllUsers {
|
||||
EditableTextField(label: "Allowed Numbers", value: viewModel.allowedUsers) { viewModel.allowedUsers = $0 }
|
||||
}
|
||||
}
|
||||
|
||||
SettingsSection(title: "Behavior", icon: "slider.horizontal.3") {
|
||||
PickerRow(label: "Unauthorized DM", selection: viewModel.unauthorizedDMBehavior, options: viewModel.unauthorizedOptions) { viewModel.unauthorizedDMBehavior = $0 }
|
||||
EditableTextField(label: "Reply Prefix", value: viewModel.replyPrefix) { viewModel.replyPrefix = $0 }
|
||||
}
|
||||
|
||||
saveBar
|
||||
Divider()
|
||||
pairingSection
|
||||
}
|
||||
.onAppear { viewModel.load() }
|
||||
.onDisappear { viewModel.stopPairing() }
|
||||
}
|
||||
|
||||
private var instructions: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("WhatsApp uses the Baileys library to emulate a WhatsApp Web session. Pair this Mac as a linked device by running the pairing wizard and scanning the QR code with your phone (Settings → Linked Devices → Link a Device).")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
HStack(spacing: 12) {
|
||||
Button("WhatsApp Setup Docs") { PlatformSetupHelpers.openURL("https://hermes-agent.nousresearch.com/docs/user-guide/messaging/whatsapp") }
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var saveBar: some View {
|
||||
HStack {
|
||||
if let msg = viewModel.message {
|
||||
Label(msg, systemImage: "checkmark.circle.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.green)
|
||||
}
|
||||
Spacer()
|
||||
Button("Reload") { viewModel.load() }.controlSize(.small)
|
||||
Button("Save") { viewModel.save() }.buttonStyle(.borderedProminent).controlSize(.small)
|
||||
}
|
||||
}
|
||||
|
||||
private var pairingSection: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
Label("Pair Device", systemImage: "qrcode")
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
if viewModel.pairingInProgress {
|
||||
Button("Stop") { viewModel.stopPairing() }
|
||||
.controlSize(.small)
|
||||
} else {
|
||||
Button("Start Pairing") { viewModel.startPairing() }
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
Text("A QR code will appear below. Scan it with WhatsApp on your phone. The session is saved to ~/.hermes/platforms/whatsapp/ so you won't need to scan again after restarts.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
EmbeddedSetupTerminal(controller: viewModel.terminalController)
|
||||
.frame(minHeight: 260, maxHeight: 360)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import SwiftUI
|
||||
|
||||
struct PlatformsView: View {
|
||||
@State private var viewModel = PlatformsViewModel()
|
||||
@Environment(HermesFileWatcher.self) private var fileWatcher
|
||||
|
||||
// HSplitView (not nested NavigationSplitView) because ContentView already
|
||||
// hosts the outer NavigationSplitView — nesting them breaks layout on macOS.
|
||||
var body: some View {
|
||||
HSplitView {
|
||||
platformList
|
||||
.frame(minWidth: 220, idealWidth: 240, maxWidth: 300)
|
||||
detail
|
||||
.frame(minWidth: 480)
|
||||
}
|
||||
.navigationTitle("Platforms")
|
||||
.onAppear { viewModel.load() }
|
||||
// Re-read config.yaml / .env / gateway_state.json when any of them
|
||||
// changes on disk. This is how the left-side connectivity dots refresh
|
||||
// after the user saves in a per-platform setup form.
|
||||
.onChange(of: fileWatcher.lastChangeDate) { viewModel.load() }
|
||||
}
|
||||
|
||||
private var platformList: some View {
|
||||
VStack(spacing: 0) {
|
||||
List(selection: Binding(
|
||||
get: { viewModel.selected.name },
|
||||
set: { name in
|
||||
if let p = viewModel.platforms.first(where: { $0.name == name }) {
|
||||
viewModel.selected = p
|
||||
}
|
||||
}
|
||||
)) {
|
||||
ForEach(viewModel.platforms) { platform in
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: KnownPlatforms.icon(for: platform.name))
|
||||
.frame(width: 20)
|
||||
Text(platform.displayName)
|
||||
Spacer()
|
||||
Circle()
|
||||
.fill(statusColor(viewModel.connectivity(for: platform)))
|
||||
.frame(width: 8, height: 8)
|
||||
}
|
||||
.tag(platform.name)
|
||||
}
|
||||
}
|
||||
.listStyle(.inset)
|
||||
|
||||
Divider()
|
||||
|
||||
VStack(spacing: 4) {
|
||||
Button {
|
||||
viewModel.restartGateway()
|
||||
} label: {
|
||||
Label("Restart Gateway", systemImage: "arrow.triangle.2.circlepath")
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.disabled(viewModel.restartInProgress)
|
||||
}
|
||||
.font(.caption)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var detail: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
header
|
||||
connectivitySection
|
||||
platformForm
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||
}
|
||||
.id(viewModel.selected.name) // Force view rebuild when platform changes so per-platform state resets.
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: KnownPlatforms.icon(for: viewModel.selected.name))
|
||||
.font(.title)
|
||||
VStack(alignment: .leading) {
|
||||
Text(viewModel.selected.displayName)
|
||||
.font(.title2.bold())
|
||||
Text(statusDescription(viewModel.connectivity(for: viewModel.selected)))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
if let msg = viewModel.message {
|
||||
Label(msg, systemImage: "checkmark.circle.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.green)
|
||||
}
|
||||
if viewModel.restartInProgress {
|
||||
ProgressView().controlSize(.small)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var connectivitySection: some View {
|
||||
SettingsSection(title: "Connection", icon: "dot.radiowaves.left.and.right") {
|
||||
let status = viewModel.connectivity(for: viewModel.selected)
|
||||
ReadOnlyRow(label: "Status", value: statusDescription(status))
|
||||
if case .error(let msg) = status {
|
||||
ReadOnlyRow(label: "Error", value: msg)
|
||||
}
|
||||
ReadOnlyRow(label: "Configured", value: viewModel.hasConfigBlock(for: viewModel.selected) ? "Yes" : "No")
|
||||
}
|
||||
}
|
||||
|
||||
/// Dispatch to the right per-platform setup view based on the selection.
|
||||
/// Each setup view owns its own `@State` view model and handles load/save
|
||||
/// independently; we don't push state down from this container.
|
||||
@ViewBuilder
|
||||
private var platformForm: some View {
|
||||
switch viewModel.selected.name {
|
||||
case "cli": cliPanel
|
||||
case "telegram": TelegramSetupView()
|
||||
case "discord": DiscordSetupView()
|
||||
case "slack": SlackSetupView()
|
||||
case "whatsapp": WhatsAppSetupView()
|
||||
case "signal": SignalSetupView()
|
||||
case "email": EmailSetupView()
|
||||
case "matrix": MatrixSetupView()
|
||||
case "mattermost": MattermostSetupView()
|
||||
case "feishu": FeishuSetupView()
|
||||
case "imessage": IMessageSetupView()
|
||||
case "homeassistant": HomeAssistantSetupView()
|
||||
case "webhook": WebhookSetupView()
|
||||
default:
|
||||
SettingsSection(title: viewModel.selected.displayName, icon: KnownPlatforms.icon(for: viewModel.selected.name)) {
|
||||
ReadOnlyRow(label: "Setup", value: "No setup form for this platform yet.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var cliPanel: some View {
|
||||
SettingsSection(title: "CLI", icon: "terminal") {
|
||||
ReadOnlyRow(label: "Scope", value: "Local terminal sessions")
|
||||
ReadOnlyRow(label: "Note", value: "CLI uses the main app — no platform-specific config.")
|
||||
}
|
||||
}
|
||||
|
||||
private func statusColor(_ status: PlatformConnectivity) -> Color {
|
||||
switch status {
|
||||
case .connected: return .green
|
||||
case .configured: return .orange
|
||||
case .notConfigured: return .secondary.opacity(0.4)
|
||||
case .error: return .red
|
||||
}
|
||||
}
|
||||
|
||||
private func statusDescription(_ status: PlatformConnectivity) -> String {
|
||||
switch status {
|
||||
case .connected: return "Connected"
|
||||
case .configured: return "Configured · not running"
|
||||
case .notConfigured: return "Not configured"
|
||||
case .error(let msg): return "Error: \(msg)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
struct HermesPlugin: Identifiable, Sendable, Equatable {
|
||||
var id: String { name }
|
||||
let name: String
|
||||
let source: String // Git URL or `owner/repo` (read from plugin manifest if present)
|
||||
let enabled: Bool // True unless a `.disabled` marker exists
|
||||
let version: String // From plugin.json / manifest if present
|
||||
let path: String // Absolute directory path
|
||||
}
|
||||
|
||||
@Observable
|
||||
final class PluginsViewModel {
|
||||
private let logger = Logger(subsystem: "com.scarf", category: "PluginsViewModel")
|
||||
private let fileService = HermesFileService()
|
||||
|
||||
var plugins: [HermesPlugin] = []
|
||||
var isLoading = false
|
||||
var message: String?
|
||||
|
||||
private var pluginsDir: String { HermesPaths.home + "/plugins" }
|
||||
|
||||
/// Source of truth is the `~/.hermes/plugins/` directory. Each plugin is a
|
||||
/// subdirectory — we read its `plugin.json` (if present) for source/version
|
||||
/// metadata. Parsing `hermes plugins list` box-drawn output is fragile.
|
||||
func load() {
|
||||
isLoading = true
|
||||
defer { isLoading = false }
|
||||
|
||||
let fm = FileManager.default
|
||||
guard let entries = try? fm.contentsOfDirectory(atPath: pluginsDir) else {
|
||||
plugins = []
|
||||
return
|
||||
}
|
||||
var result: [HermesPlugin] = []
|
||||
for entry in entries.sorted() where !entry.hasPrefix(".") {
|
||||
let path = pluginsDir + "/" + entry
|
||||
var isDir: ObjCBool = false
|
||||
guard fm.fileExists(atPath: path, isDirectory: &isDir), isDir.boolValue else { continue }
|
||||
|
||||
let manifest = Self.readManifest(path: path)
|
||||
let disabled = fm.fileExists(atPath: path + "/.disabled")
|
||||
result.append(HermesPlugin(
|
||||
name: entry,
|
||||
source: manifest.source,
|
||||
enabled: !disabled,
|
||||
version: manifest.version,
|
||||
path: path
|
||||
))
|
||||
}
|
||||
plugins = result
|
||||
}
|
||||
|
||||
/// Best-effort manifest read. Supports both plugin.json and plugin.yaml shapes.
|
||||
private static func readManifest(path: String) -> (source: String, version: String) {
|
||||
let fm = FileManager.default
|
||||
let jsonPath = path + "/plugin.json"
|
||||
if fm.fileExists(atPath: jsonPath),
|
||||
let data = try? Data(contentsOf: URL(fileURLWithPath: jsonPath)),
|
||||
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)
|
||||
}
|
||||
let yamlPath = path + "/plugin.yaml"
|
||||
if fm.fileExists(atPath: yamlPath),
|
||||
let yaml = try? String(contentsOfFile: yamlPath, encoding: .utf8) {
|
||||
let parsed = HermesFileService.parseNestedYAML(yaml)
|
||||
let source = HermesFileService.stripYAMLQuotes(parsed.values["source"] ?? parsed.values["repository"] ?? parsed.values["url"] ?? "")
|
||||
let version = HermesFileService.stripYAMLQuotes(parsed.values["version"] ?? "")
|
||||
return (source, version)
|
||||
}
|
||||
return ("", "")
|
||||
}
|
||||
|
||||
func install(_ identifier: String) {
|
||||
isLoading = true
|
||||
message = "Installing \(identifier)…"
|
||||
Task.detached { [fileService] in
|
||||
let result = fileService.runHermesCLI(args: ["plugins", "install", identifier], timeout: 180)
|
||||
await MainActor.run {
|
||||
self.isLoading = false
|
||||
self.message = result.exitCode == 0 ? "Installed" : "Install failed"
|
||||
self.load()
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
||||
self?.message = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func update(_ plugin: HermesPlugin) {
|
||||
runAndReload(["plugins", "update", plugin.name], success: "Updated")
|
||||
}
|
||||
|
||||
func remove(_ plugin: HermesPlugin) {
|
||||
runAndReload(["plugins", "remove", plugin.name], success: "Removed")
|
||||
}
|
||||
|
||||
func enable(_ plugin: HermesPlugin) {
|
||||
runAndReload(["plugins", "enable", plugin.name], success: "Enabled")
|
||||
}
|
||||
|
||||
func disable(_ plugin: HermesPlugin) {
|
||||
runAndReload(["plugins", "disable", plugin.name], success: "Disabled")
|
||||
}
|
||||
|
||||
private func runAndReload(_ args: [String], success: String) {
|
||||
Task.detached { [fileService] in
|
||||
let result = fileService.runHermesCLI(args: args, timeout: 60)
|
||||
await MainActor.run {
|
||||
self.message = result.exitCode == 0 ? success : "Failed"
|
||||
self.load()
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
|
||||
self?.message = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import SwiftUI
|
||||
|
||||
struct PluginsView: View {
|
||||
@State private var viewModel = PluginsViewModel()
|
||||
@State private var installIdentifier = ""
|
||||
@State private var showInstall = false
|
||||
@State private var pendingRemove: HermesPlugin?
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
header
|
||||
Divider()
|
||||
if viewModel.isLoading && viewModel.plugins.isEmpty {
|
||||
ProgressView().padding()
|
||||
} else if viewModel.plugins.isEmpty {
|
||||
emptyState
|
||||
} else {
|
||||
list
|
||||
}
|
||||
}
|
||||
.navigationTitle("Plugins")
|
||||
.onAppear { viewModel.load() }
|
||||
.sheet(isPresented: $showInstall) { installSheet }
|
||||
.confirmationDialog(
|
||||
pendingRemove.map { "Remove \($0.name)?" } ?? "",
|
||||
isPresented: Binding(get: { pendingRemove != nil }, set: { if !$0 { pendingRemove = nil } })
|
||||
) {
|
||||
Button("Remove", role: .destructive) {
|
||||
if let plugin = pendingRemove { viewModel.remove(plugin) }
|
||||
pendingRemove = nil
|
||||
}
|
||||
Button("Cancel", role: .cancel) { pendingRemove = nil }
|
||||
}
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack {
|
||||
if let msg = viewModel.message {
|
||||
Label(msg, systemImage: "info.circle.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.green)
|
||||
}
|
||||
Spacer()
|
||||
Button {
|
||||
installIdentifier = ""
|
||||
showInstall = true
|
||||
} label: {
|
||||
Label("Install", systemImage: "plus")
|
||||
}
|
||||
.controlSize(.small)
|
||||
Button("Reload") { viewModel.load() }
|
||||
.controlSize(.small)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
|
||||
private var emptyState: some View {
|
||||
VStack(spacing: 8) {
|
||||
Image(systemName: "app.badge.checkmark")
|
||||
.font(.largeTitle)
|
||||
.foregroundStyle(.secondary)
|
||||
Text("No plugins installed")
|
||||
.foregroundStyle(.secondary)
|
||||
Text("Plugins extend hermes with custom tools, providers, or memory backends.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: 400)
|
||||
Button("Install a Plugin") {
|
||||
installIdentifier = ""
|
||||
showInstall = true
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.small)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.padding()
|
||||
}
|
||||
|
||||
private var list: some View {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 1) {
|
||||
ForEach(viewModel.plugins) { plugin in
|
||||
row(plugin)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
private func row(_ plugin: HermesPlugin) -> some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: plugin.enabled ? "app.badge.checkmark.fill" : "app.badge")
|
||||
.foregroundStyle(plugin.enabled ? .green : .secondary)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(spacing: 6) {
|
||||
Text(plugin.name)
|
||||
.font(.system(.body, design: .monospaced, weight: .medium))
|
||||
if !plugin.version.isEmpty {
|
||||
Text(plugin.version)
|
||||
.font(.caption2.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
if !plugin.source.isEmpty {
|
||||
Text(plugin.source)
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
Button(plugin.enabled ? "Disable" : "Enable") {
|
||||
if plugin.enabled { viewModel.disable(plugin) } else { viewModel.enable(plugin) }
|
||||
}
|
||||
.controlSize(.small)
|
||||
Button("Update") { viewModel.update(plugin) }
|
||||
.controlSize(.small)
|
||||
Button("Remove", role: .destructive) { pendingRemove = plugin }
|
||||
.controlSize(.small)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
}
|
||||
|
||||
private var installSheet: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Install Plugin")
|
||||
.font(.headline)
|
||||
Text("Provide a Git URL (https://github.com/...) or a shorthand like `owner/repo`.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
TextField("github.com/owner/plugin-repo or owner/repo", text: $installIdentifier)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("Cancel") { showInstall = false }
|
||||
Button("Install") {
|
||||
viewModel.install(installIdentifier)
|
||||
showInstall = false
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(installIdentifier.trimmingCharacters(in: .whitespaces).isEmpty)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.frame(minWidth: 500, minHeight: 200)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
struct HermesProfile: Identifiable, Sendable, Equatable {
|
||||
var id: String { name }
|
||||
let name: String
|
||||
let isActive: Bool
|
||||
let path: String
|
||||
}
|
||||
|
||||
@Observable
|
||||
final class ProfilesViewModel {
|
||||
private let logger = Logger(subsystem: "com.scarf", category: "ProfilesViewModel")
|
||||
private let fileService = HermesFileService()
|
||||
|
||||
var profiles: [HermesProfile] = []
|
||||
var activeName: String = "default"
|
||||
var isLoading = false
|
||||
var message: String?
|
||||
var detailOutput: String = ""
|
||||
|
||||
func load() {
|
||||
isLoading = true
|
||||
Task.detached { [fileService] in
|
||||
let result = fileService.runHermesCLI(args: ["profile", "list"], timeout: 20)
|
||||
let (parsed, active) = Self.parseProfileList(result.output)
|
||||
await MainActor.run {
|
||||
self.isLoading = false
|
||||
self.profiles = parsed
|
||||
self.activeName = active
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func showDetail(_ profile: HermesProfile) {
|
||||
detailOutput = "Loading…"
|
||||
Task.detached { [fileService] in
|
||||
let result = fileService.runHermesCLI(args: ["profile", "show", profile.name], timeout: 15)
|
||||
await MainActor.run {
|
||||
self.detailOutput = result.output
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func switchTo(_ profile: HermesProfile) {
|
||||
runAndReload(["profile", "use", profile.name], success: "Active profile set to \(profile.name)")
|
||||
}
|
||||
|
||||
func create(name: String, cloneConfig: Bool, cloneAll: Bool) {
|
||||
var args = ["profile", "create", name]
|
||||
if cloneAll { args.append("--clone-all") }
|
||||
else if cloneConfig { args.append("--clone") }
|
||||
runAndReload(args, success: "Profile '\(name)' created")
|
||||
}
|
||||
|
||||
func rename(_ profile: HermesProfile, to newName: String) {
|
||||
runAndReload(["profile", "rename", profile.name, newName], success: "Renamed")
|
||||
}
|
||||
|
||||
func delete(_ profile: HermesProfile) {
|
||||
runAndReload(["profile", "delete", profile.name], success: "Deleted \(profile.name)")
|
||||
}
|
||||
|
||||
func export(_ profile: HermesProfile, to path: String) {
|
||||
runAndReload(["profile", "export", profile.name, "--output", path], success: "Exported")
|
||||
}
|
||||
|
||||
func `import`(from path: String) {
|
||||
runAndReload(["profile", "import", path], success: "Imported")
|
||||
}
|
||||
|
||||
private func runAndReload(_ args: [String], success: String) {
|
||||
Task.detached { [fileService] in
|
||||
let result = fileService.runHermesCLI(args: args, timeout: 60)
|
||||
await MainActor.run {
|
||||
self.message = result.exitCode == 0 ? success : "Failed: \(result.output.prefix(120))"
|
||||
self.load()
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
||||
self?.message = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse `hermes profile list` output. Hermes emits a box-drawn Rich table:
|
||||
///
|
||||
/// Profile Model Gateway Alias
|
||||
/// ─────────────── ──────── ────────── ─────
|
||||
/// ◆default — running —
|
||||
/// experimental gpt-4 stopped hermes-exp
|
||||
///
|
||||
/// Active profiles are prefixed with `◆` (U+25C6). Columns are separated by
|
||||
/// whitespace; there are no vertical bars. We ignore box-drawing lines and
|
||||
/// the header row, then extract the name from column 0 of each data row.
|
||||
nonisolated private static func parseProfileList(_ output: String) -> (profiles: [HermesProfile], active: String) {
|
||||
var results: [HermesProfile] = []
|
||||
var active = "default"
|
||||
var sawHeader = false
|
||||
|
||||
for raw in output.components(separatedBy: "\n") {
|
||||
let line = raw.trimmingCharacters(in: .whitespaces)
|
||||
if line.isEmpty { continue }
|
||||
// Box-drawing separator rows: contain only ─ (U+2500) and whitespace.
|
||||
if line.unicodeScalars.allSatisfy({ $0.value == 0x2500 || $0.properties.isWhitespace }) { continue }
|
||||
// Header row (first non-empty, non-separator line in the table).
|
||||
if !sawHeader && line.lowercased().contains("profile") && line.lowercased().contains("gateway") {
|
||||
sawHeader = true
|
||||
continue
|
||||
}
|
||||
// Data row. Strip active marker first.
|
||||
var working = line
|
||||
var isActive = false
|
||||
if working.hasPrefix("◆") {
|
||||
isActive = true
|
||||
working = String(working.dropFirst()).trimmingCharacters(in: .whitespaces)
|
||||
} else if working.hasPrefix("*") {
|
||||
isActive = true
|
||||
working = String(working.dropFirst()).trimmingCharacters(in: .whitespaces)
|
||||
}
|
||||
let tokens = working.split(whereSeparator: { $0.isWhitespace }).map(String.init)
|
||||
guard let nameStr = tokens.first else { continue }
|
||||
// Reject rows whose first token is something like "Tip:" or a localized
|
||||
// label — real profile names are lowercase alphanumeric with - or _.
|
||||
guard nameStr.range(of: "^[a-zA-Z0-9_-]+$", options: .regularExpression) != nil else { continue }
|
||||
if isActive { active = nameStr }
|
||||
results.append(HermesProfile(name: nameStr, isActive: isActive, path: ""))
|
||||
}
|
||||
return (results, active)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
import SwiftUI
|
||||
import AppKit
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
struct ProfilesView: View {
|
||||
@State private var viewModel = ProfilesViewModel()
|
||||
@State private var selected: HermesProfile?
|
||||
@State private var showCreate = false
|
||||
@State private var createName = ""
|
||||
@State private var createCloneConfig = true
|
||||
@State private var createCloneAll = false
|
||||
@State private var showRename = false
|
||||
@State private var renameTarget: HermesProfile?
|
||||
@State private var renameNewName = ""
|
||||
@State private var pendingDelete: HermesProfile?
|
||||
|
||||
var body: some View {
|
||||
HSplitView {
|
||||
listSection
|
||||
.frame(minWidth: 260, idealWidth: 300)
|
||||
detailSection
|
||||
.frame(minWidth: 400)
|
||||
}
|
||||
.navigationTitle("Profiles")
|
||||
.onAppear { viewModel.load() }
|
||||
.sheet(isPresented: $showCreate) { createSheet }
|
||||
.sheet(isPresented: Binding(get: { renameTarget != nil }, set: { if !$0 { renameTarget = nil } })) {
|
||||
renameSheet
|
||||
}
|
||||
.confirmationDialog(
|
||||
pendingDelete.map { "Delete profile '\($0.name)'?" } ?? "",
|
||||
isPresented: Binding(get: { pendingDelete != nil }, set: { if !$0 { pendingDelete = nil } })
|
||||
) {
|
||||
Button("Delete", role: .destructive) {
|
||||
if let profile = pendingDelete { viewModel.delete(profile) }
|
||||
pendingDelete = nil
|
||||
}
|
||||
Button("Cancel", role: .cancel) { pendingDelete = nil }
|
||||
} message: {
|
||||
Text("This removes the profile directory and all data within it. This cannot be undone.")
|
||||
}
|
||||
}
|
||||
|
||||
private var listSection: some View {
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
if let msg = viewModel.message {
|
||||
Label(msg, systemImage: "info.circle")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Button {
|
||||
createName = ""; createCloneConfig = true; createCloneAll = false
|
||||
showCreate = true
|
||||
} label: {
|
||||
Label("Create", systemImage: "plus")
|
||||
}
|
||||
.controlSize(.small)
|
||||
Button {
|
||||
let panel = NSOpenPanel()
|
||||
panel.allowedContentTypes = [.zip]
|
||||
panel.canChooseFiles = true
|
||||
panel.canChooseDirectories = false
|
||||
panel.allowsMultipleSelection = false
|
||||
if panel.runModal() == .OK, let url = panel.url {
|
||||
viewModel.import(from: url.path)
|
||||
}
|
||||
} label: {
|
||||
Label("Import", systemImage: "square.and.arrow.down")
|
||||
}
|
||||
.controlSize(.small)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 6)
|
||||
Divider()
|
||||
List(selection: Binding(
|
||||
get: { selected?.id },
|
||||
set: { id in
|
||||
if let id, let profile = viewModel.profiles.first(where: { $0.id == id }) {
|
||||
selected = profile
|
||||
viewModel.showDetail(profile)
|
||||
}
|
||||
}
|
||||
)) {
|
||||
ForEach(viewModel.profiles) { profile in
|
||||
HStack {
|
||||
Image(systemName: profile.isActive ? "checkmark.circle.fill" : "person.crop.square")
|
||||
.foregroundStyle(profile.isActive ? .green : .secondary)
|
||||
Text(profile.name)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
Spacer()
|
||||
if profile.isActive {
|
||||
Text("active")
|
||||
.font(.caption2.bold())
|
||||
.foregroundStyle(.green)
|
||||
}
|
||||
}
|
||||
.tag(profile.id)
|
||||
.contextMenu {
|
||||
Button("Use") { viewModel.switchTo(profile) }
|
||||
.disabled(profile.isActive)
|
||||
Button("Rename") {
|
||||
renameTarget = profile
|
||||
renameNewName = profile.name
|
||||
}
|
||||
Button("Export…") {
|
||||
let panel = NSSavePanel()
|
||||
panel.allowedContentTypes = [.zip]
|
||||
panel.nameFieldStringValue = "\(profile.name)-profile.zip"
|
||||
if panel.runModal() == .OK, let url = panel.url {
|
||||
viewModel.export(profile, to: url.path)
|
||||
}
|
||||
}
|
||||
Divider()
|
||||
Button("Delete", role: .destructive) { pendingDelete = profile }
|
||||
.disabled(profile.isActive)
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.inset)
|
||||
.overlay {
|
||||
if viewModel.profiles.isEmpty && !viewModel.isLoading {
|
||||
ContentUnavailableView("No Profiles", systemImage: "person.2.crop.square.stack", description: Text("Create a profile to isolate config and skills."))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var detailSection: some View {
|
||||
if let profile = selected {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "person.crop.square.filled.and.at.rectangle")
|
||||
.font(.title)
|
||||
VStack(alignment: .leading) {
|
||||
Text(profile.name).font(.title2.bold())
|
||||
Text(profile.isActive ? "Active profile" : "Inactive")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
if !profile.isActive {
|
||||
Button {
|
||||
viewModel.switchTo(profile)
|
||||
} label: {
|
||||
Label("Switch to This Profile", systemImage: "arrow.triangle.swap")
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
if !profile.isActive {
|
||||
profileSwitchWarning
|
||||
}
|
||||
SettingsSection(title: "Details", icon: "info.circle") {
|
||||
if !profile.path.isEmpty {
|
||||
ReadOnlyRow(label: "Path", value: profile.path)
|
||||
}
|
||||
}
|
||||
if !viewModel.detailOutput.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("hermes profile show")
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(.secondary)
|
||||
Text(viewModel.detailOutput)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.textSelection(.enabled)
|
||||
.padding(8)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(.quaternary.opacity(0.5))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||
}
|
||||
} else {
|
||||
ContentUnavailableView("Select a Profile", systemImage: "person.2.crop.square.stack", description: Text("Choose a profile to inspect."))
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
private var profileSwitchWarning: some View {
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
Image(systemName: "exclamationmark.triangle")
|
||||
.foregroundStyle(.orange)
|
||||
Text("Switching the active profile changes the `~/.hermes` directory hermes uses. Restart Scarf after switching so it re-reads from the new profile's files.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(10)
|
||||
.background(.orange.opacity(0.1))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
|
||||
private var createSheet: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Create Profile").font(.headline)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Name").font(.caption).foregroundStyle(.secondary)
|
||||
TextField("e.g. experimental", text: $createName)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
Toggle("Clone config, .env, SOUL.md from active profile", isOn: $createCloneConfig)
|
||||
.disabled(createCloneAll)
|
||||
Toggle("Full copy of active profile (all state)", isOn: $createCloneAll)
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("Cancel") { showCreate = false }
|
||||
Button("Create") {
|
||||
viewModel.create(name: createName, cloneConfig: createCloneConfig, cloneAll: createCloneAll)
|
||||
showCreate = false
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(createName.trimmingCharacters(in: .whitespaces).isEmpty)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.frame(minWidth: 460, minHeight: 240)
|
||||
}
|
||||
|
||||
private var renameSheet: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Rename Profile").font(.headline)
|
||||
if let target = renameTarget {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("New name for '\(target.name)'").font(.caption).foregroundStyle(.secondary)
|
||||
TextField("new-name", text: $renameNewName)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
}
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("Cancel") { renameTarget = nil }
|
||||
Button("Rename") {
|
||||
if let target = renameTarget {
|
||||
viewModel.rename(target, to: renameNewName)
|
||||
}
|
||||
renameTarget = nil
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(renameNewName.trimmingCharacters(in: .whitespaces).isEmpty)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.frame(minWidth: 440, minHeight: 180)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import Foundation
|
||||
import AppKit
|
||||
import os
|
||||
|
||||
/// A user-defined shell shortcut that hermes exposes in chat (e.g. `/my_cmd`).
|
||||
struct HermesQuickCommand: Identifiable, Sendable, Equatable {
|
||||
var id: String { name }
|
||||
let name: String
|
||||
let type: String // "exec" is the only supported type today
|
||||
let command: String
|
||||
}
|
||||
|
||||
@Observable
|
||||
final class QuickCommandsViewModel {
|
||||
private let logger = Logger(subsystem: "com.scarf", category: "QuickCommandsViewModel")
|
||||
|
||||
var commands: [HermesQuickCommand] = []
|
||||
var message: String?
|
||||
|
||||
func load() {
|
||||
guard let yaml = try? String(contentsOfFile: HermesPaths.configYAML, encoding: .utf8) else {
|
||||
commands = []
|
||||
return
|
||||
}
|
||||
let parsed = HermesFileService.parseNestedYAML(yaml)
|
||||
// Each quick command is `quick_commands.<name>.type` + `quick_commands.<name>.command`.
|
||||
var byName: [String: (type: String, command: String)] = [:]
|
||||
for (key, value) in parsed.values where key.hasPrefix("quick_commands.") {
|
||||
let parts = key.split(separator: ".", maxSplits: 2, omittingEmptySubsequences: false)
|
||||
guard parts.count == 3 else { continue }
|
||||
let name = String(parts[1])
|
||||
let field = String(parts[2])
|
||||
var existing = byName[name] ?? (type: "exec", command: "")
|
||||
let stripped = HermesFileService.stripYAMLQuotes(value)
|
||||
if field == "type" { existing.type = stripped }
|
||||
if field == "command" { existing.command = stripped }
|
||||
byName[name] = existing
|
||||
}
|
||||
commands = byName.map { HermesQuickCommand(name: $0.key, type: $0.value.type, command: $0.value.command) }
|
||||
.sorted { $0.name < $1.name }
|
||||
}
|
||||
|
||||
/// Check for obviously destructive shell strings. Display-only; we do not block.
|
||||
static func isDangerous(_ command: String) -> Bool {
|
||||
let lowered = command.lowercased()
|
||||
let patterns = ["rm -rf /", "rm -rf ~", ":(){", "mkfs", "dd if=", "> /dev/sd", "shutdown", "reboot"]
|
||||
return patterns.contains { lowered.contains($0) }
|
||||
}
|
||||
|
||||
func addOrUpdate(name: String, command: String) {
|
||||
guard !name.isEmpty, !command.isEmpty else {
|
||||
message = "Name and command are required"
|
||||
return
|
||||
}
|
||||
let sanitizedName = name.replacingOccurrences(of: " ", with: "_")
|
||||
let typeResult = runHermes(["config", "set", "quick_commands.\(sanitizedName).type", "exec"])
|
||||
let cmdResult = runHermes(["config", "set", "quick_commands.\(sanitizedName).command", command])
|
||||
if typeResult.exitCode == 0 && cmdResult.exitCode == 0 {
|
||||
message = "Saved /\(sanitizedName)"
|
||||
load()
|
||||
} else {
|
||||
logger.warning("Failed to save quick command: type=\(typeResult.output) cmd=\(cmdResult.output)")
|
||||
message = "Save failed"
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
|
||||
self?.message = nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Removal requires editing config.yaml directly — `hermes config set` has no
|
||||
/// unset for nested keys. Open the file in the editor for manual removal.
|
||||
func openConfigForRemoval() {
|
||||
NSWorkspace.shared.open(URL(fileURLWithPath: HermesPaths.configYAML))
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func runHermes(_ arguments: [String]) -> (output: String, exitCode: Int32) {
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: HermesPaths.hermesBinary)
|
||||
process.arguments = arguments
|
||||
process.environment = HermesFileService.enrichedEnvironment()
|
||||
let pipe = Pipe()
|
||||
process.standardOutput = pipe
|
||||
process.standardError = Pipe()
|
||||
do {
|
||||
try process.run()
|
||||
process.waitUntilExit()
|
||||
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
||||
return (String(data: data, encoding: .utf8) ?? "", process.terminationStatus)
|
||||
} catch {
|
||||
return ("", -1)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import SwiftUI
|
||||
|
||||
struct QuickCommandsView: View {
|
||||
@State private var viewModel = QuickCommandsViewModel()
|
||||
@State private var showAddSheet = false
|
||||
@State private var editTarget: HermesQuickCommand?
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
header
|
||||
intro
|
||||
if viewModel.commands.isEmpty {
|
||||
emptyState
|
||||
} else {
|
||||
list
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||
}
|
||||
.navigationTitle("Quick Commands")
|
||||
.onAppear { viewModel.load() }
|
||||
.sheet(isPresented: $showAddSheet) {
|
||||
QuickCommandEditor(initial: nil) { name, cmd in
|
||||
viewModel.addOrUpdate(name: name, command: cmd)
|
||||
showAddSheet = false
|
||||
} onCancel: {
|
||||
showAddSheet = false
|
||||
}
|
||||
}
|
||||
.sheet(item: $editTarget) { target in
|
||||
QuickCommandEditor(initial: target) { name, cmd in
|
||||
viewModel.addOrUpdate(name: name, command: cmd)
|
||||
editTarget = nil
|
||||
} onCancel: {
|
||||
editTarget = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack {
|
||||
if let msg = viewModel.message {
|
||||
Label(msg, systemImage: "checkmark.circle.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.green)
|
||||
}
|
||||
Spacer()
|
||||
Button {
|
||||
showAddSheet = true
|
||||
} label: {
|
||||
Label("Add Command", systemImage: "plus")
|
||||
}
|
||||
.controlSize(.small)
|
||||
Button("Reload") { viewModel.load() }
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
|
||||
private var intro: some View {
|
||||
Text("Quick commands are shell shortcuts hermes exposes in chat as `/command_name`. They live under `quick_commands:` in config.yaml.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
private var emptyState: some View {
|
||||
VStack(spacing: 8) {
|
||||
Image(systemName: "command.square")
|
||||
.font(.largeTitle)
|
||||
.foregroundStyle(.secondary)
|
||||
Text("No quick commands configured")
|
||||
.foregroundStyle(.secondary)
|
||||
Button("Add your first command") { showAddSheet = true }
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.small)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 40)
|
||||
}
|
||||
|
||||
private var list: some View {
|
||||
VStack(spacing: 1) {
|
||||
ForEach(viewModel.commands) { cmd in
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "command.square")
|
||||
.foregroundStyle(.blue)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(spacing: 6) {
|
||||
Text("/\(cmd.name)")
|
||||
.font(.system(.body, design: .monospaced, weight: .medium))
|
||||
if QuickCommandsViewModel.isDangerous(cmd.command) {
|
||||
Label("dangerous", systemImage: "exclamationmark.triangle.fill")
|
||||
.font(.caption2.bold())
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
Text(cmd.command)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
.lineLimit(3)
|
||||
}
|
||||
Spacer()
|
||||
Button("Edit") { editTarget = cmd }
|
||||
.controlSize(.small)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
}
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("Remove via config.yaml…") { viewModel.openConfigForRemoval() }
|
||||
.controlSize(.small)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.top, 8)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Inline editor for add/update. Removal requires hand-editing config.yaml because
|
||||
/// `hermes config set` has no unset primitive for nested keys.
|
||||
private struct QuickCommandEditor: View {
|
||||
let initial: HermesQuickCommand?
|
||||
let onSave: (String, String) -> Void
|
||||
let onCancel: () -> Void
|
||||
|
||||
@State private var name: String
|
||||
@State private var command: String
|
||||
|
||||
init(initial: HermesQuickCommand?, onSave: @escaping (String, String) -> Void, onCancel: @escaping () -> Void) {
|
||||
self.initial = initial
|
||||
self.onSave = onSave
|
||||
self.onCancel = onCancel
|
||||
_name = State(initialValue: initial?.name ?? "")
|
||||
_command = State(initialValue: initial?.command ?? "")
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text(initial == nil ? "Add Quick Command" : "Edit /\(initial!.name)")
|
||||
.font(.headline)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Name (no leading slash)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
TextField("e.g. deploy", text: $name)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.disabled(initial != nil)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Shell Command")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
TextEditor(text: $command)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.frame(minHeight: 100)
|
||||
.padding(4)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
if QuickCommandsViewModel.isDangerous(command) {
|
||||
Label("Command looks destructive. Double-check before saving.", systemImage: "exclamationmark.triangle.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("Cancel") { onCancel() }
|
||||
Button("Save") { onSave(name, command) }
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(name.trimmingCharacters(in: .whitespaces).isEmpty || command.trimmingCharacters(in: .whitespaces).isEmpty)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.frame(minWidth: 500, minHeight: 320)
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
import Foundation
|
||||
import AppKit
|
||||
import UniformTypeIdentifiers
|
||||
import os
|
||||
|
||||
@Observable
|
||||
final class SettingsViewModel {
|
||||
private let logger = Logger(subsystem: "com.scarf", category: "SettingsViewModel")
|
||||
private let fileService = HermesFileService()
|
||||
|
||||
var config = HermesConfig.empty
|
||||
@@ -14,8 +16,10 @@ final class SettingsViewModel {
|
||||
var providers = ["anthropic", "openrouter", "nous", "openai-codex", "google-ai-studio", "xai", "ollama-cloud", "zai", "kimi-coding", "minimax"]
|
||||
var terminalBackends = ["local", "docker", "singularity", "modal", "daytona", "ssh"]
|
||||
var browserBackends = ["browseruse", "firecrawl", "local"]
|
||||
var ttsProviders = ["edge", "elevenlabs", "openai", "minimax", "mistral", "neutts"]
|
||||
var sttProviders = ["local", "groq", "openai", "mistral"]
|
||||
var memoryProviders = ["", "honcho", "openviking", "mem0", "hindsight", "holographic", "retaindb", "byterover", "supermemory"]
|
||||
var saveMessage: String?
|
||||
var showAuthRemoveConfirmation = false
|
||||
|
||||
func load() {
|
||||
config = fileService.loadConfig()
|
||||
@@ -24,12 +28,14 @@ final class SettingsViewModel {
|
||||
do {
|
||||
rawConfigYAML = try String(contentsOfFile: HermesPaths.configYAML, encoding: .utf8)
|
||||
} catch {
|
||||
print("[Scarf] Failed to read config.yaml: \(error.localizedDescription)")
|
||||
logger.error("Failed to read config.yaml: \(error.localizedDescription)")
|
||||
rawConfigYAML = ""
|
||||
}
|
||||
personalities = parsePersonalities()
|
||||
}
|
||||
|
||||
/// Set a scalar config value via `hermes config set <key> <value>` and reload
|
||||
/// the config on success so the UI reflects the new state.
|
||||
func setSetting(_ key: String, value: String) {
|
||||
let result = runHermes(["config", "set", key, value])
|
||||
if result.exitCode == 0 {
|
||||
@@ -38,34 +44,172 @@ final class SettingsViewModel {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
|
||||
self?.saveMessage = nil
|
||||
}
|
||||
} else {
|
||||
logger.warning("hermes config set \(key) failed (exit \(result.exitCode)): \(result.output)")
|
||||
saveMessage = "Failed to save \(key)"
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
||||
self?.saveMessage = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Model
|
||||
|
||||
func setModel(_ value: String) { setSetting("model.default", value: value) }
|
||||
func setProvider(_ value: String) { setSetting("model.provider", value: value) }
|
||||
func setTimezone(_ value: String) { setSetting("timezone", value: value) }
|
||||
|
||||
// MARK: - Display
|
||||
|
||||
func setPersonality(_ value: String) { setSetting("display.personality", value: value) }
|
||||
func setTerminalBackend(_ value: String) { setSetting("terminal.backend", value: value) }
|
||||
func setStreaming(_ value: Bool) { setSetting("display.streaming", value: value ? "true" : "false") }
|
||||
func setShowReasoning(_ value: Bool) { setSetting("display.show_reasoning", value: value ? "true" : "false") }
|
||||
func setShowCost(_ value: Bool) { setSetting("display.show_cost", value: value ? "true" : "false") }
|
||||
func setInterimAssistantMessages(_ value: Bool) { setSetting("display.interim_assistant_messages", value: value ? "true" : "false") }
|
||||
func setSkin(_ value: String) { setSetting("display.skin", value: value) }
|
||||
func setDisplayCompact(_ value: Bool) { setSetting("display.compact", value: value ? "true" : "false") }
|
||||
func setResumeDisplay(_ value: String) { setSetting("display.resume_display", value: value) }
|
||||
func setBellOnComplete(_ value: Bool) { setSetting("display.bell_on_complete", value: value ? "true" : "false") }
|
||||
func setInlineDiffs(_ value: Bool) { setSetting("display.inline_diffs", value: value ? "true" : "false") }
|
||||
func setToolProgressCommand(_ value: Bool) { setSetting("display.tool_progress_command", value: value ? "true" : "false") }
|
||||
func setToolPreviewLength(_ value: Int) { setSetting("display.tool_preview_length", value: String(value)) }
|
||||
func setBusyInputMode(_ value: String) { setSetting("display.busy_input_mode", value: value) }
|
||||
|
||||
// MARK: - Agent
|
||||
|
||||
func setMaxTurns(_ value: Int) { setSetting("agent.max_turns", value: String(value)) }
|
||||
func setReasoningEffort(_ value: String) { setSetting("agent.reasoning_effort", value: value) }
|
||||
func setVerbose(_ value: Bool) { setSetting("agent.verbose", value: value ? "true" : "false") }
|
||||
func setServiceTier(_ value: String) { setSetting("agent.service_tier", value: value) }
|
||||
func setGatewayNotifyInterval(_ value: Int) { setSetting("agent.gateway_notify_interval", value: String(value)) }
|
||||
func setGatewayTimeout(_ value: Int) { setSetting("agent.gateway_timeout", value: String(value)) }
|
||||
func setToolUseEnforcement(_ value: String) { setSetting("agent.tool_use_enforcement", value: value) }
|
||||
func setApprovalMode(_ value: String) { setSetting("approvals.mode", value: value) }
|
||||
func setApprovalTimeout(_ value: Int) { setSetting("approvals.timeout", value: String(value)) }
|
||||
|
||||
// MARK: - Terminal
|
||||
|
||||
func setTerminalBackend(_ value: String) { setSetting("terminal.backend", value: value) }
|
||||
func setTerminalCwd(_ value: String) { setSetting("terminal.cwd", value: value) }
|
||||
func setTerminalTimeout(_ value: Int) { setSetting("terminal.timeout", value: String(value)) }
|
||||
func setPersistentShell(_ value: Bool) { setSetting("terminal.persistent_shell", value: value ? "true" : "false") }
|
||||
func setDockerImage(_ value: String) { setSetting("terminal.docker_image", value: value) }
|
||||
func setDockerMountCwd(_ value: Bool) { setSetting("terminal.docker_mount_cwd_to_workspace", value: value ? "true" : "false") }
|
||||
func setContainerCPU(_ value: Int) { setSetting("terminal.container_cpu", value: String(value)) }
|
||||
func setContainerMemory(_ value: Int) { setSetting("terminal.container_memory", value: String(value)) }
|
||||
func setContainerDisk(_ value: Int) { setSetting("terminal.container_disk", value: String(value)) }
|
||||
func setContainerPersistent(_ value: Bool) { setSetting("terminal.container_persistent", value: value ? "true" : "false") }
|
||||
func setModalImage(_ value: String) { setSetting("terminal.modal_image", value: value) }
|
||||
func setModalMode(_ value: String) { setSetting("terminal.modal_mode", value: value) }
|
||||
func setDaytonaImage(_ value: String) { setSetting("terminal.daytona_image", value: value) }
|
||||
func setSingularityImage(_ value: String) { setSetting("terminal.singularity_image", value: value) }
|
||||
|
||||
// MARK: - Browser
|
||||
|
||||
func setBrowserBackend(_ value: String) { setSetting("browser.backend", value: value) }
|
||||
func setBrowserInactivityTimeout(_ value: Int) { setSetting("browser.inactivity_timeout", value: String(value)) }
|
||||
func setBrowserCommandTimeout(_ value: Int) { setSetting("browser.command_timeout", value: String(value)) }
|
||||
func setBrowserRecordSessions(_ value: Bool) { setSetting("browser.record_sessions", value: value ? "true" : "false") }
|
||||
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: - Voice / TTS / STT
|
||||
|
||||
func setAutoTTS(_ value: Bool) { setSetting("voice.auto_tts", value: value ? "true" : "false") }
|
||||
func setSilenceThreshold(_ value: Int) { setSetting("voice.silence_threshold", value: String(value)) }
|
||||
func setRecordKey(_ value: String) { setSetting("voice.record_key", value: value) }
|
||||
func setMaxRecordingSeconds(_ value: Int) { setSetting("voice.max_recording_seconds", value: String(value)) }
|
||||
func setSilenceDuration(_ value: Double) { setSetting("voice.silence_duration", value: String(value)) }
|
||||
func setTTSProvider(_ value: String) { setSetting("tts.provider", value: value) }
|
||||
func setTTSEdgeVoice(_ value: String) { setSetting("tts.edge.voice", value: value) }
|
||||
func setTTSElevenLabsVoiceID(_ value: String) { setSetting("tts.elevenlabs.voice_id", value: value) }
|
||||
func setTTSElevenLabsModelID(_ value: String) { setSetting("tts.elevenlabs.model_id", value: value) }
|
||||
func setTTSOpenAIModel(_ value: String) { setSetting("tts.openai.model", value: value) }
|
||||
func setTTSOpenAIVoice(_ value: String) { setSetting("tts.openai.voice", value: value) }
|
||||
func setTTSNeuTTSModel(_ value: String) { setSetting("tts.neutts.model", value: value) }
|
||||
func setTTSNeuTTSDevice(_ value: String) { setSetting("tts.neutts.device", value: value) }
|
||||
func setSTTEnabled(_ value: Bool) { setSetting("stt.enabled", value: value ? "true" : "false") }
|
||||
func setSTTProvider(_ value: String) { setSetting("stt.provider", value: value) }
|
||||
func setSTTLocalModel(_ value: String) { setSetting("stt.local.model", value: value) }
|
||||
func setSTTLocalLanguage(_ value: String) { setSetting("stt.local.language", value: value) }
|
||||
func setSTTOpenAIModel(_ value: String) { setSetting("stt.openai.model", value: value) }
|
||||
func setSTTMistralModel(_ value: String) { setSetting("stt.mistral.model", value: value) }
|
||||
|
||||
// MARK: - Memory
|
||||
|
||||
func setMemoryEnabled(_ value: Bool) { setSetting("memory.memory_enabled", value: value ? "true" : "false") }
|
||||
func setUserProfileEnabled(_ value: Bool) { setSetting("memory.user_profile_enabled", value: value ? "true" : "false") }
|
||||
func setMemoryCharLimit(_ value: Int) { setSetting("memory.memory_char_limit", value: String(value)) }
|
||||
func setUserCharLimit(_ value: Int) { setSetting("memory.user_char_limit", value: String(value)) }
|
||||
func setNudgeInterval(_ value: Int) { setSetting("memory.nudge_interval", value: String(value)) }
|
||||
func setStreaming(_ value: Bool) { setSetting("display.streaming", value: value ? "true" : "false") }
|
||||
func setShowReasoning(_ value: Bool) { setSetting("display.show_reasoning", value: value ? "true" : "false") }
|
||||
func setVerbose(_ value: Bool) { setSetting("agent.verbose", value: value ? "true" : "false") }
|
||||
func setAutoTTS(_ value: Bool) { setSetting("voice.auto_tts", value: value ? "true" : "false") }
|
||||
func setSilenceThreshold(_ value: Int) { setSetting("voice.silence_threshold", value: String(value)) }
|
||||
func setReasoningEffort(_ value: String) { setSetting("agent.reasoning_effort", value: value) }
|
||||
func setShowCost(_ value: Bool) { setSetting("display.show_cost", value: value ? "true" : "false") }
|
||||
func setApprovalMode(_ value: String) { setSetting("approvals.mode", value: value) }
|
||||
func setBrowserBackend(_ value: String) { setSetting("browser.backend", value: value) }
|
||||
func setServiceTier(_ value: String) { setSetting("agent.service_tier", value: value) }
|
||||
func setGatewayNotifyInterval(_ value: Int) { setSetting("agent.gateway_notify_interval", value: String(value)) }
|
||||
func setForceIPv4(_ value: Bool) { setSetting("network.force_ipv4", value: value ? "true" : "false") }
|
||||
func setInterimAssistantMessages(_ value: Bool) { setSetting("display.interim_assistant_messages", value: value ? "true" : "false") }
|
||||
/// Provider switching for external memory plugins. Uses `hermes memory setup/off`
|
||||
/// because the CLI wizard runs provider-specific init steps beyond a simple
|
||||
/// config.yaml write.
|
||||
func setMemoryProvider(_ value: String) {
|
||||
if value.isEmpty {
|
||||
_ = runHermes(["memory", "off"])
|
||||
} else {
|
||||
setSetting("memory.provider", value: value)
|
||||
}
|
||||
config = fileService.loadConfig()
|
||||
}
|
||||
// Hermes v0.9.0 PR #6995: the key is camelCase in config.yaml (not snake_case like the rest of Hermes).
|
||||
func setHonchoInitOnSessionStart(_ value: Bool) { setSetting("honcho.initOnSessionStart", value: value ? "true" : "false") }
|
||||
|
||||
// MARK: - Auxiliary model sub-tasks
|
||||
|
||||
func setAuxiliary(_ task: String, field: String, value: String) {
|
||||
setSetting("auxiliary.\(task).\(field)", value: value)
|
||||
}
|
||||
func setAuxiliaryTimeout(_ task: String, value: Int) {
|
||||
setSetting("auxiliary.\(task).timeout", value: String(value))
|
||||
}
|
||||
|
||||
// MARK: - Security / Privacy
|
||||
|
||||
func setRedactSecrets(_ value: Bool) { setSetting("security.redact_secrets", value: value ? "true" : "false") }
|
||||
func setRedactPII(_ value: Bool) { setSetting("privacy.redact_pii", value: value ? "true" : "false") }
|
||||
func setTirithEnabled(_ value: Bool) { setSetting("security.tirith_enabled", value: value ? "true" : "false") }
|
||||
func setTirithPath(_ value: String) { setSetting("security.tirith_path", value: value) }
|
||||
func setTirithTimeout(_ value: Int) { setSetting("security.tirith_timeout", value: String(value)) }
|
||||
func setTirithFailOpen(_ value: Bool) { setSetting("security.tirith_fail_open", value: value ? "true" : "false") }
|
||||
func setBlocklistEnabled(_ value: Bool) { setSetting("security.website_blocklist.enabled", value: value ? "true" : "false") }
|
||||
func setHumanDelayMode(_ value: String) { setSetting("human_delay.mode", value: value) }
|
||||
func setHumanDelayMinMS(_ value: Int) { setSetting("human_delay.min_ms", value: String(value)) }
|
||||
func setHumanDelayMaxMS(_ value: Int) { setSetting("human_delay.max_ms", value: String(value)) }
|
||||
|
||||
// MARK: - Performance / Advanced
|
||||
|
||||
func setForceIPv4(_ value: Bool) { setSetting("network.force_ipv4", value: value ? "true" : "false") }
|
||||
func setFileReadMaxChars(_ value: Int) { setSetting("file_read_max_chars", value: String(value)) }
|
||||
func setCompressionEnabled(_ value: Bool) { setSetting("compression.enabled", value: value ? "true" : "false") }
|
||||
func setCompressionThreshold(_ value: Double) { setSetting("compression.threshold", value: String(value)) }
|
||||
func setCompressionTargetRatio(_ value: Double) { setSetting("compression.target_ratio", value: String(value)) }
|
||||
func setCompressionProtectLastN(_ value: Int) { setSetting("compression.protect_last_n", value: String(value)) }
|
||||
func setCheckpointsEnabled(_ value: Bool) { setSetting("checkpoints.enabled", value: value ? "true" : "false") }
|
||||
func setCheckpointsMaxSnapshots(_ value: Int) { setSetting("checkpoints.max_snapshots", value: String(value)) }
|
||||
func setLoggingLevel(_ value: String) { setSetting("logging.level", value: value) }
|
||||
func setLoggingMaxSizeMB(_ value: Int) { setSetting("logging.max_size_mb", value: String(value)) }
|
||||
func setLoggingBackupCount(_ value: Int) { setSetting("logging.backup_count", value: String(value)) }
|
||||
func setDelegationModel(_ value: String) { setSetting("delegation.model", value: value) }
|
||||
func setDelegationProvider(_ value: String) { setSetting("delegation.provider", value: value) }
|
||||
func setDelegationBaseURL(_ value: String) { setSetting("delegation.base_url", value: value) }
|
||||
func setDelegationMaxIterations(_ value: Int) { setSetting("delegation.max_iterations", value: String(value)) }
|
||||
func setCronWrapResponse(_ value: Bool) { setSetting("cron.wrap_response", value: value ? "true" : "false") }
|
||||
|
||||
// MARK: - Config diagnostics
|
||||
|
||||
func runConfigCheck() -> String {
|
||||
let result = runHermes(["config", "check"])
|
||||
return result.output
|
||||
}
|
||||
|
||||
func runConfigMigrate() -> String {
|
||||
let result = runHermes(["config", "migrate"])
|
||||
config = fileService.loadConfig()
|
||||
return result.output
|
||||
}
|
||||
|
||||
// MARK: - Backup & Restore (v0.9.0)
|
||||
|
||||
var backupInProgress = false
|
||||
@@ -133,18 +277,6 @@ final class SettingsViewModel {
|
||||
return url
|
||||
}
|
||||
|
||||
func removeAuth() {
|
||||
let result = runHermes(["auth", "remove"])
|
||||
if result.exitCode == 0 {
|
||||
saveMessage = "Credentials removed"
|
||||
} else {
|
||||
saveMessage = "Failed to remove credentials"
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
|
||||
self?.saveMessage = nil
|
||||
}
|
||||
}
|
||||
|
||||
func openConfigInEditor() {
|
||||
NSWorkspace.shared.open(URL(fileURLWithPath: HermesPaths.configYAML))
|
||||
}
|
||||
@@ -179,6 +311,7 @@ final class SettingsViewModel {
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: HermesPaths.hermesBinary)
|
||||
process.arguments = arguments
|
||||
process.environment = HermesFileService.enrichedEnvironment()
|
||||
let pipe = Pipe()
|
||||
process.standardOutput = pipe
|
||||
process.standardError = Pipe()
|
||||
@@ -188,6 +321,7 @@ final class SettingsViewModel {
|
||||
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
||||
return (String(data: data, encoding: .utf8) ?? "", process.terminationStatus)
|
||||
} catch {
|
||||
logger.error("Failed to run hermes \(arguments.joined(separator: " ")): \(error.localizedDescription)")
|
||||
return ("", -1)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Row-style model picker that mirrors the visual style of `PickerRow`/`EditableTextField`
|
||||
/// but opens a dedicated sheet browsing providers + models from the catalog.
|
||||
///
|
||||
/// The caller receives (modelID, providerID) and decides how to persist them —
|
||||
/// Settings → General saves both; Delegation saves both to its own keys; aux
|
||||
/// fields that only take a model can ignore the provider parameter.
|
||||
struct ModelPickerRow: View {
|
||||
let label: String
|
||||
let currentModel: String
|
||||
let currentProvider: String
|
||||
let onChange: (_ modelID: String, _ providerID: String) -> Void
|
||||
|
||||
@State private var showSheet = false
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Text(label)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 160, alignment: .trailing)
|
||||
|
||||
Button {
|
||||
showSheet = true
|
||||
} label: {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "cpu")
|
||||
Text(displayValue)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
Image(systemName: "chevron.down")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(.quaternary.opacity(0.4))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 5))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
.sheet(isPresented: $showSheet) {
|
||||
ModelPickerSheet(
|
||||
initialProvider: currentProvider,
|
||||
initialModel: currentModel,
|
||||
onSelect: { modelID, providerID in
|
||||
onChange(modelID, providerID)
|
||||
showSheet = false
|
||||
},
|
||||
onCancel: { showSheet = false }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Format as "<provider> / <model>" when both are known; fall back to
|
||||
/// whichever side exists; fall back to a dim "Select model…" placeholder
|
||||
/// when nothing has been set yet.
|
||||
private var displayValue: String {
|
||||
let hasProvider = !currentProvider.isEmpty && currentProvider != "unknown"
|
||||
let hasModel = !currentModel.isEmpty && currentModel != "unknown"
|
||||
switch (hasProvider, hasModel) {
|
||||
case (true, true): return "\(currentProvider) / \(currentModel)"
|
||||
case (false, true): return currentModel
|
||||
case (true, false): return "\(currentProvider) / (none)"
|
||||
case (false, false): return "Select model…"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Two-column model browser sheet. Left column lists providers, right column
|
||||
/// lists models for the selected provider. Supports filtering and a "Custom…"
|
||||
/// option for free-form model IDs not in the catalog.
|
||||
struct ModelPickerSheet: View {
|
||||
let initialProvider: String
|
||||
let initialModel: String
|
||||
let onSelect: (_ modelID: String, _ providerID: String) -> Void
|
||||
let onCancel: () -> Void
|
||||
|
||||
@State private var providers: [HermesProviderInfo] = []
|
||||
@State private var selectedProviderID: String = ""
|
||||
@State private var models: [HermesModelInfo] = []
|
||||
@State private var selectedModelID: String = ""
|
||||
@State private var searchText: String = ""
|
||||
|
||||
// Custom model entry — used when the catalog doesn't have the exact model
|
||||
// the user needs (e.g., provider-prefixed IDs like "openrouter/some/model").
|
||||
@State private var customMode: Bool = false
|
||||
@State private var customModelID: String = ""
|
||||
@State private var customProviderID: String = ""
|
||||
|
||||
private let catalog = ModelCatalogService()
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
header
|
||||
Divider()
|
||||
if customMode {
|
||||
customEntry
|
||||
} else {
|
||||
HSplitView {
|
||||
providerColumn.frame(minWidth: 220, idealWidth: 240)
|
||||
modelColumn.frame(minWidth: 340)
|
||||
}
|
||||
}
|
||||
Divider()
|
||||
footer
|
||||
}
|
||||
.frame(minWidth: 720, minHeight: 520)
|
||||
.onAppear {
|
||||
providers = catalog.loadProviders()
|
||||
selectedProviderID = initialProvider.isEmpty ? (providers.first?.providerID ?? "") : initialProvider
|
||||
selectedModelID = initialModel
|
||||
loadModelsForSelection()
|
||||
}
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "cpu")
|
||||
Text("Select Model")
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
if !customMode {
|
||||
TextField("Search…", text: $searchText)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 240)
|
||||
}
|
||||
Button(customMode ? "Back to Catalog" : "Custom…") {
|
||||
customMode.toggle()
|
||||
if customMode {
|
||||
customModelID = initialModel
|
||||
customProviderID = initialProvider
|
||||
}
|
||||
}
|
||||
.controlSize(.small)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
|
||||
private var providerColumn: some View {
|
||||
List(selection: Binding(
|
||||
get: { selectedProviderID },
|
||||
set: { newValue in
|
||||
selectedProviderID = newValue
|
||||
loadModelsForSelection()
|
||||
}
|
||||
)) {
|
||||
ForEach(filteredProviders) { provider in
|
||||
HStack {
|
||||
Text(provider.providerName)
|
||||
Spacer()
|
||||
Text("\(provider.modelCount)")
|
||||
.font(.caption2.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.tag(provider.providerID)
|
||||
}
|
||||
}
|
||||
.listStyle(.inset)
|
||||
}
|
||||
|
||||
private var modelColumn: some View {
|
||||
List(selection: $selectedModelID) {
|
||||
ForEach(filteredModels) { model in
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack {
|
||||
Text(model.modelName)
|
||||
.font(.system(.body, design: .default, weight: .medium))
|
||||
Spacer()
|
||||
if let ctx = model.contextDisplay {
|
||||
Text(ctx + " ctx")
|
||||
.font(.caption2.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
HStack(spacing: 6) {
|
||||
Text(model.modelID)
|
||||
.font(.caption2.monospaced())
|
||||
.foregroundStyle(.tertiary)
|
||||
if let cost = model.costDisplay {
|
||||
Text("·")
|
||||
.foregroundStyle(.tertiary)
|
||||
Text(cost)
|
||||
.font(.caption2.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if model.toolCall {
|
||||
capsuleTag("tools")
|
||||
}
|
||||
if model.reasoning {
|
||||
capsuleTag("reasoning")
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
.tag(model.modelID)
|
||||
}
|
||||
}
|
||||
.listStyle(.inset)
|
||||
.overlay {
|
||||
if filteredModels.isEmpty {
|
||||
ContentUnavailableView("No Models", systemImage: "cpu", description: Text("This provider has no catalogued models."))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var customEntry: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Use a model not in the catalog. Hermes accepts any string the provider recognizes, including provider-prefixed forms like \"openrouter/anthropic/claude-opus-4.6\".")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Model ID").font(.caption).foregroundStyle(.secondary)
|
||||
TextField("e.g. openai/gpt-4o", text: $customModelID)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Provider").font(.caption).foregroundStyle(.secondary)
|
||||
TextField("e.g. openai", text: $customProviderID)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
Text("Leave blank to infer from the model ID's prefix (\"openai/...\" → openai).")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
|
||||
private var footer: some View {
|
||||
HStack {
|
||||
if customMode {
|
||||
Text(customProviderPreview)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
} else if let preview = selectedPreview {
|
||||
Text(preview)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Button("Cancel") { onCancel() }
|
||||
Button("Select") { submitSelection() }
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(!canSubmit)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private var filteredProviders: [HermesProviderInfo] {
|
||||
guard !searchText.isEmpty else { return providers }
|
||||
let q = searchText.lowercased()
|
||||
return providers.filter {
|
||||
$0.providerName.lowercased().contains(q) || $0.providerID.lowercased().contains(q)
|
||||
}
|
||||
}
|
||||
|
||||
private var filteredModels: [HermesModelInfo] {
|
||||
guard !searchText.isEmpty else { return models }
|
||||
let q = searchText.lowercased()
|
||||
return models.filter {
|
||||
$0.modelName.lowercased().contains(q) || $0.modelID.lowercased().contains(q)
|
||||
}
|
||||
}
|
||||
|
||||
private var canSubmit: Bool {
|
||||
if customMode {
|
||||
return !customModelID.trimmingCharacters(in: .whitespaces).isEmpty
|
||||
}
|
||||
return !selectedModelID.isEmpty
|
||||
}
|
||||
|
||||
private var selectedPreview: String? {
|
||||
guard !selectedModelID.isEmpty, !selectedProviderID.isEmpty else { return nil }
|
||||
return "\(selectedProviderID) / \(selectedModelID)"
|
||||
}
|
||||
|
||||
private var customProviderPreview: String {
|
||||
let resolved = resolvedCustomProvider()
|
||||
return resolved.isEmpty ? "Provider will not be changed" : "Provider → \(resolved)"
|
||||
}
|
||||
|
||||
private func loadModelsForSelection() {
|
||||
guard !selectedProviderID.isEmpty else {
|
||||
models = []
|
||||
return
|
||||
}
|
||||
models = catalog.loadModels(for: selectedProviderID)
|
||||
// If the current selection is not in the new list, don't try to keep
|
||||
// stale highlight state — clear unless the user originally had this model.
|
||||
if !models.contains(where: { $0.modelID == selectedModelID }) {
|
||||
selectedModelID = models.first?.modelID ?? ""
|
||||
}
|
||||
}
|
||||
|
||||
/// When the user enters a custom model ID without explicitly naming a
|
||||
/// provider, infer from a `provider/model` prefix if present. Otherwise
|
||||
/// fall back to whatever is currently selected (we never blank out the
|
||||
/// existing provider silently).
|
||||
private func resolvedCustomProvider() -> String {
|
||||
let explicit = customProviderID.trimmingCharacters(in: .whitespaces)
|
||||
if !explicit.isEmpty { return explicit }
|
||||
if let slash = customModelID.firstIndex(of: "/") {
|
||||
return String(customModelID[customModelID.startIndex..<slash])
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
private func submitSelection() {
|
||||
if customMode {
|
||||
let model = customModelID.trimmingCharacters(in: .whitespaces)
|
||||
let provider = resolvedCustomProvider()
|
||||
onSelect(model, provider)
|
||||
} else {
|
||||
onSelect(selectedModelID, selectedProviderID)
|
||||
}
|
||||
}
|
||||
|
||||
private func capsuleTag(_ text: String) -> some View {
|
||||
Text(text)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal, 5)
|
||||
.padding(.vertical, 1)
|
||||
.background(.quaternary)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
import SwiftUI
|
||||
import AppKit
|
||||
|
||||
/// Shared form-row components used across the Settings tabs. Extracting these keeps
|
||||
/// individual tab views small and avoids triggering SwiftUI's type-checker timeout
|
||||
/// on large view bodies (per project guidance in CLAUDE.md).
|
||||
|
||||
struct SettingsSection<Content: View>: View {
|
||||
let title: String
|
||||
let icon: String
|
||||
@ViewBuilder let content: Content
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Label(title, systemImage: icon)
|
||||
.font(.headline)
|
||||
VStack(spacing: 1) {
|
||||
content
|
||||
}
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct EditableTextField: View {
|
||||
let label: String
|
||||
let value: String
|
||||
let onCommit: (String) -> Void
|
||||
@State private var text: String = ""
|
||||
@State private var isEditing = false
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Text(label)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 160, alignment: .trailing)
|
||||
if isEditing {
|
||||
TextField(label, text: $text, onCommit: {
|
||||
if text != value { onCommit(text) }
|
||||
isEditing = false
|
||||
})
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
Button("Cancel") { isEditing = false }
|
||||
.controlSize(.mini)
|
||||
} else {
|
||||
Text(value.isEmpty ? "—" : value)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.foregroundStyle(value.isEmpty ? .secondary : .primary)
|
||||
Spacer()
|
||||
Button("Edit") {
|
||||
text = value
|
||||
isEditing = true
|
||||
}
|
||||
.controlSize(.mini)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
}
|
||||
}
|
||||
|
||||
/// Masked text field for API keys, tokens, etc. Shows ••• until the user taps reveal.
|
||||
struct SecretTextField: View {
|
||||
let label: String
|
||||
let value: String
|
||||
let onCommit: (String) -> Void
|
||||
@State private var text: String = ""
|
||||
@State private var isEditing = false
|
||||
@State private var isRevealed = false
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Text(label)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 160, alignment: .trailing)
|
||||
if isEditing {
|
||||
TextField(label, text: $text, onCommit: {
|
||||
if text != value { onCommit(text) }
|
||||
isEditing = false
|
||||
isRevealed = false
|
||||
})
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
Button("Cancel") {
|
||||
isEditing = false
|
||||
isRevealed = false
|
||||
}
|
||||
.controlSize(.mini)
|
||||
} else {
|
||||
Text(displayValue)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.foregroundStyle(value.isEmpty ? .secondary : .primary)
|
||||
Spacer()
|
||||
if !value.isEmpty {
|
||||
Button(isRevealed ? "Hide" : "Reveal") { isRevealed.toggle() }
|
||||
.controlSize(.mini)
|
||||
}
|
||||
Button("Edit") {
|
||||
text = value
|
||||
isEditing = true
|
||||
}
|
||||
.controlSize(.mini)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
}
|
||||
|
||||
private var displayValue: String {
|
||||
if value.isEmpty { return "—" }
|
||||
if isRevealed { return value }
|
||||
let tail = value.suffix(4)
|
||||
return String(repeating: "•", count: max(0, min(12, value.count - 4))) + tail
|
||||
}
|
||||
}
|
||||
|
||||
struct PickerRow: View {
|
||||
let label: String
|
||||
let selection: String
|
||||
let options: [String]
|
||||
let onChange: (String) -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Text(label)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 160, alignment: .trailing)
|
||||
Picker("", selection: Binding(
|
||||
get: { selection },
|
||||
set: { onChange($0) }
|
||||
)) {
|
||||
ForEach(options, id: \.self) { option in
|
||||
Text(option.isEmpty ? "(none)" : option).tag(option)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: 250)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
}
|
||||
}
|
||||
|
||||
struct ToggleRow: View {
|
||||
let label: String
|
||||
let isOn: Bool
|
||||
let onChange: (Bool) -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Text(label)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 160, alignment: .trailing)
|
||||
Toggle("", isOn: Binding(
|
||||
get: { isOn },
|
||||
set: { onChange($0) }
|
||||
))
|
||||
.toggleStyle(.switch)
|
||||
.labelsHidden()
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
}
|
||||
}
|
||||
|
||||
struct StepperRow: View {
|
||||
let label: String
|
||||
let value: Int
|
||||
let range: ClosedRange<Int>
|
||||
let step: Int
|
||||
let onChange: (Int) -> Void
|
||||
|
||||
init(label: String, value: Int, range: ClosedRange<Int>, step: Int = 1, onChange: @escaping (Int) -> Void) {
|
||||
self.label = label
|
||||
self.value = value
|
||||
self.range = range
|
||||
self.step = step
|
||||
self.onChange = onChange
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Text(label)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 160, alignment: .trailing)
|
||||
Text("\(value)")
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.frame(width: 70, alignment: .leading)
|
||||
Stepper("", value: Binding(
|
||||
get: { value },
|
||||
set: { onChange($0) }
|
||||
), in: range, step: step)
|
||||
.labelsHidden()
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
}
|
||||
}
|
||||
|
||||
/// Double stepper that increments by a fractional step (e.g. 0.05 for thresholds).
|
||||
struct DoubleStepperRow: View {
|
||||
let label: String
|
||||
let value: Double
|
||||
let range: ClosedRange<Double>
|
||||
let step: Double
|
||||
let onChange: (Double) -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Text(label)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 160, alignment: .trailing)
|
||||
Text(String(format: "%.2f", value))
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.frame(width: 70, alignment: .leading)
|
||||
Stepper("", value: Binding(
|
||||
get: { value },
|
||||
set: { onChange($0) }
|
||||
), in: range, step: step)
|
||||
.labelsHidden()
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
}
|
||||
}
|
||||
|
||||
struct ReadOnlyRow: View {
|
||||
let label: String
|
||||
let value: String
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Text(label)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 160, alignment: .trailing)
|
||||
Text(value.isEmpty ? "—" : value)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.foregroundStyle(value.isEmpty ? .secondary : .primary)
|
||||
.textSelection(.enabled)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
}
|
||||
}
|
||||
|
||||
struct PathRow: View {
|
||||
let label: String
|
||||
let path: String
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Text(label)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 160, alignment: .trailing)
|
||||
Text(path)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.textSelection(.enabled)
|
||||
Spacer()
|
||||
Button {
|
||||
NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: path)
|
||||
} label: {
|
||||
Image(systemName: "folder")
|
||||
.font(.caption)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
}
|
||||
}
|
||||
@@ -1,42 +1,64 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Settings is now organized into tabs because the full Hermes config surface is far
|
||||
/// too large for a single scrolling form (~70 config fields). Each tab has its own
|
||||
/// extracted view file under `Tabs/` — per CLAUDE.md guidance, splitting avoids
|
||||
/// SwiftUI type-checker timeouts and keeps each section testable in isolation.
|
||||
struct SettingsView: View {
|
||||
@State private var viewModel = SettingsViewModel()
|
||||
@State private var showRawConfig = false
|
||||
@State private var selectedTab: SettingsTab = .general
|
||||
|
||||
enum SettingsTab: String, CaseIterable, Identifiable {
|
||||
case general = "General"
|
||||
case display = "Display"
|
||||
case agent = "Agent"
|
||||
case terminal = "Terminal"
|
||||
case browser = "Browser"
|
||||
case voice = "Voice"
|
||||
case memory = "Memory"
|
||||
case auxiliary = "Aux Models"
|
||||
case security = "Security"
|
||||
case advanced = "Advanced"
|
||||
|
||||
var id: String { rawValue }
|
||||
var icon: String {
|
||||
switch self {
|
||||
case .general: return "gear"
|
||||
case .display: return "paintbrush"
|
||||
case .agent: return "brain.head.profile"
|
||||
case .terminal: return "terminal"
|
||||
case .browser: return "globe"
|
||||
case .voice: return "mic"
|
||||
case .memory: return "memorychip"
|
||||
case .auxiliary: return "sparkles.rectangle.stack"
|
||||
case .security: return "lock.shield"
|
||||
case .advanced: return "slider.horizontal.3"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 24) {
|
||||
headerBar
|
||||
modelSection
|
||||
displaySection
|
||||
terminalSection
|
||||
if !viewModel.config.dockerEnv.isEmpty {
|
||||
dockerEnvSection
|
||||
VStack(spacing: 0) {
|
||||
headerBar
|
||||
Divider()
|
||||
TabView(selection: $selectedTab) {
|
||||
ForEach(SettingsTab.allCases) { tab in
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
tabContent(tab)
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||
}
|
||||
.tabItem {
|
||||
Label(tab.rawValue, systemImage: tab.icon)
|
||||
}
|
||||
.tag(tab)
|
||||
}
|
||||
if !viewModel.config.commandAllowlist.isEmpty {
|
||||
allowlistSection
|
||||
}
|
||||
voiceSection
|
||||
memorySection
|
||||
performanceSection
|
||||
networkSection
|
||||
advancedSection
|
||||
backupSection
|
||||
pathsSection
|
||||
rawConfigSection
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||
}
|
||||
.navigationTitle("Settings")
|
||||
.onAppear { viewModel.load() }
|
||||
.confirmationDialog("Remove Credentials?", isPresented: $viewModel.showAuthRemoveConfirmation) {
|
||||
Button("Remove", role: .destructive) { viewModel.removeAuth() }
|
||||
Button("Cancel", role: .cancel) {}
|
||||
} message: {
|
||||
Text("This will permanently clear all stored provider credentials.")
|
||||
}
|
||||
}
|
||||
|
||||
private var headerBar: some View {
|
||||
@@ -52,407 +74,23 @@ struct SettingsView: View {
|
||||
Button("Reload") { viewModel.load() }
|
||||
.controlSize(.small)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
|
||||
// MARK: - Model & Provider
|
||||
|
||||
private var modelSection: some View {
|
||||
SettingsSection(title: "Model", icon: "cpu") {
|
||||
EditableTextField(label: "Model", value: viewModel.config.model) { viewModel.setModel($0) }
|
||||
PickerRow(label: "Provider", selection: viewModel.config.provider, options: viewModel.providers) { viewModel.setProvider($0) }
|
||||
HStack {
|
||||
Text("Credentials")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 130, alignment: .trailing)
|
||||
Button("Remove Credentials", role: .destructive) {
|
||||
viewModel.showAuthRemoveConfirmation = true
|
||||
}
|
||||
.controlSize(.small)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Display
|
||||
|
||||
private var displaySection: some View {
|
||||
SettingsSection(title: "Display", icon: "paintbrush") {
|
||||
if !viewModel.personalities.isEmpty {
|
||||
PickerRow(label: "Personality", selection: viewModel.config.personality, options: viewModel.personalities) { viewModel.setPersonality($0) }
|
||||
} else {
|
||||
EditableTextField(label: "Personality", value: viewModel.config.personality) { viewModel.setPersonality($0) }
|
||||
}
|
||||
ToggleRow(label: "Streaming", isOn: viewModel.config.streaming) { viewModel.setStreaming($0) }
|
||||
ToggleRow(label: "Show Reasoning", isOn: viewModel.config.showReasoning) { viewModel.setShowReasoning($0) }
|
||||
ToggleRow(label: "Show Cost", isOn: viewModel.config.showCost) { viewModel.setShowCost($0) }
|
||||
ToggleRow(label: "Interim Messages", isOn: viewModel.config.interimAssistantMessages) { viewModel.setInterimAssistantMessages($0) }
|
||||
ToggleRow(label: "Verbose", isOn: viewModel.config.verbose) { viewModel.setVerbose($0) }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Terminal
|
||||
|
||||
private var terminalSection: some View {
|
||||
SettingsSection(title: "Terminal", icon: "terminal") {
|
||||
PickerRow(label: "Backend", selection: viewModel.config.terminalBackend, options: viewModel.terminalBackends) { viewModel.setTerminalBackend($0) }
|
||||
StepperRow(label: "Max Turns", value: viewModel.config.maxTurns, range: 1...200) { viewModel.setMaxTurns($0) }
|
||||
PickerRow(label: "Reasoning Effort", selection: viewModel.config.reasoningEffort, options: ["low", "medium", "high"]) { viewModel.setReasoningEffort($0) }
|
||||
PickerRow(label: "Approval Mode", selection: viewModel.config.approvalMode, options: ["auto", "manual", "smart"]) { viewModel.setApprovalMode($0) }
|
||||
PickerRow(label: "Browser Backend", selection: viewModel.config.browserBackend, options: viewModel.browserBackends) { viewModel.setBrowserBackend($0) }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Docker Environment
|
||||
|
||||
private var dockerEnvSection: some View {
|
||||
SettingsSection(title: "Docker Environment", icon: "shippingbox") {
|
||||
ForEach(viewModel.config.dockerEnv.sorted(by: { $0.key < $1.key }), id: \.key) { key, value in
|
||||
ReadOnlyRow(label: key, value: value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Command Allowlist
|
||||
|
||||
private var allowlistSection: some View {
|
||||
SettingsSection(title: "Command Allowlist", icon: "checkmark.shield") {
|
||||
ReadOnlyRow(label: "Commands", value: viewModel.config.commandAllowlist.joined(separator: ", "))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Voice
|
||||
|
||||
private var voiceSection: some View {
|
||||
SettingsSection(title: "Voice", icon: "mic") {
|
||||
ToggleRow(label: "Auto TTS", isOn: viewModel.config.autoTTS) { viewModel.setAutoTTS($0) }
|
||||
StepperRow(label: "Silence Threshold", value: viewModel.config.silenceThreshold, range: 50...500) { viewModel.setSilenceThreshold($0) }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Memory
|
||||
|
||||
private var memorySection: some View {
|
||||
SettingsSection(title: "Memory", icon: "brain") {
|
||||
ToggleRow(label: "Memory Enabled", isOn: viewModel.config.memoryEnabled) { viewModel.setMemoryEnabled($0) }
|
||||
if !viewModel.config.memoryProfile.isEmpty {
|
||||
ReadOnlyRow(label: "Profile", value: viewModel.config.memoryProfile)
|
||||
}
|
||||
StepperRow(label: "Memory Char Limit", value: viewModel.config.memoryCharLimit, range: 500...10000) { viewModel.setMemoryCharLimit($0) }
|
||||
StepperRow(label: "User Char Limit", value: viewModel.config.userCharLimit, range: 500...10000) { viewModel.setUserCharLimit($0) }
|
||||
StepperRow(label: "Nudge Interval", value: viewModel.config.nudgeInterval, range: 1...50) { viewModel.setNudgeInterval($0) }
|
||||
if viewModel.config.memoryProvider == "honcho" {
|
||||
ToggleRow(label: "Honcho Eager Init", isOn: viewModel.config.honchoInitOnSessionStart) { viewModel.setHonchoInitOnSessionStart($0) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Performance (v0.9.0)
|
||||
|
||||
private var performanceSection: some View {
|
||||
SettingsSection(title: "Performance", icon: "bolt") {
|
||||
ToggleRow(label: "Fast Mode", isOn: viewModel.config.serviceTier == "fast") { on in
|
||||
viewModel.setServiceTier(on ? "fast" : "normal")
|
||||
}
|
||||
StepperRow(label: "Notify Interval (s)", value: viewModel.config.gatewayNotifyInterval, range: 0...3600) { viewModel.setGatewayNotifyInterval($0) }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Network (v0.9.0)
|
||||
|
||||
private var networkSection: some View {
|
||||
SettingsSection(title: "Network", icon: "network") {
|
||||
ToggleRow(label: "Force IPv4", isOn: viewModel.config.forceIPv4) { viewModel.setForceIPv4($0) }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Advanced (v0.9.0)
|
||||
|
||||
private var advancedSection: some View {
|
||||
SettingsSection(title: "Advanced", icon: "slider.horizontal.3") {
|
||||
ReadOnlyRow(label: "Context Engine", value: viewModel.config.contextEngine)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Backup & Restore (v0.9.0)
|
||||
|
||||
@State private var showRestoreConfirm = false
|
||||
@State private var pendingRestoreURL: URL?
|
||||
|
||||
private var backupSection: some View {
|
||||
SettingsSection(title: "Backup & Restore", icon: "externaldrive") {
|
||||
HStack {
|
||||
Text("Archive")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 130, alignment: .trailing)
|
||||
Button {
|
||||
viewModel.runBackup()
|
||||
} label: {
|
||||
Label("Backup Now", systemImage: "arrow.down.doc")
|
||||
}
|
||||
.controlSize(.small)
|
||||
.disabled(viewModel.backupInProgress)
|
||||
Button {
|
||||
if let url = viewModel.presentRestorePicker() {
|
||||
pendingRestoreURL = url
|
||||
showRestoreConfirm = true
|
||||
}
|
||||
} label: {
|
||||
Label("Restore…", systemImage: "arrow.up.doc")
|
||||
}
|
||||
.controlSize(.small)
|
||||
.disabled(viewModel.backupInProgress)
|
||||
if viewModel.backupInProgress {
|
||||
ProgressView().controlSize(.small)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
}
|
||||
.confirmationDialog("Restore from backup?", isPresented: $showRestoreConfirm) {
|
||||
Button("Restore", role: .destructive) {
|
||||
if let url = pendingRestoreURL {
|
||||
viewModel.runRestore(from: url)
|
||||
}
|
||||
pendingRestoreURL = nil
|
||||
}
|
||||
Button("Cancel", role: .cancel) { pendingRestoreURL = nil }
|
||||
} message: {
|
||||
Text("This will overwrite files under ~/.hermes/ with the archive contents.")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Paths
|
||||
|
||||
private var pathsSection: some View {
|
||||
SettingsSection(title: "Paths", icon: "folder") {
|
||||
PathRow(label: "Hermes Home", path: HermesPaths.home)
|
||||
PathRow(label: "State DB", path: HermesPaths.stateDB)
|
||||
PathRow(label: "Config", path: HermesPaths.configYAML)
|
||||
PathRow(label: "Memory", path: HermesPaths.memoriesDir)
|
||||
PathRow(label: "Sessions", path: HermesPaths.sessionsDir)
|
||||
PathRow(label: "Skills", path: HermesPaths.skillsDir)
|
||||
PathRow(label: "Agent Log", path: HermesPaths.agentLog)
|
||||
PathRow(label: "Error Log", path: HermesPaths.errorsLog)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Raw Config
|
||||
|
||||
private var rawConfigSection: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
Text("Raw Config")
|
||||
.font(.headline)
|
||||
Button(showRawConfig ? "Hide" : "Show") {
|
||||
showRawConfig.toggle()
|
||||
}
|
||||
.controlSize(.small)
|
||||
}
|
||||
if showRawConfig {
|
||||
Text(viewModel.rawConfigYAML)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.textSelection(.enabled)
|
||||
.padding(8)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(.quaternary.opacity(0.5))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
@ViewBuilder
|
||||
private func tabContent(_ tab: SettingsTab) -> some View {
|
||||
switch tab {
|
||||
case .general: GeneralTab(viewModel: viewModel)
|
||||
case .display: DisplayTab(viewModel: viewModel)
|
||||
case .agent: AgentTab(viewModel: viewModel)
|
||||
case .terminal: TerminalTab(viewModel: viewModel)
|
||||
case .browser: BrowserTab(viewModel: viewModel)
|
||||
case .voice: VoiceTab(viewModel: viewModel)
|
||||
case .memory: MemoryTab(viewModel: viewModel)
|
||||
case .auxiliary: AuxiliaryTab(viewModel: viewModel)
|
||||
case .security: SecurityTab(viewModel: viewModel)
|
||||
case .advanced: AdvancedTab(viewModel: viewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Reusable Components
|
||||
|
||||
struct SettingsSection<Content: View>: View {
|
||||
let title: String
|
||||
let icon: String
|
||||
@ViewBuilder let content: Content
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Label(title, systemImage: icon)
|
||||
.font(.headline)
|
||||
VStack(spacing: 1) {
|
||||
content
|
||||
}
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct EditableTextField: View {
|
||||
let label: String
|
||||
let value: String
|
||||
let onCommit: (String) -> Void
|
||||
@State private var text: String = ""
|
||||
@State private var isEditing = false
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Text(label)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 130, alignment: .trailing)
|
||||
if isEditing {
|
||||
TextField(label, text: $text, onCommit: {
|
||||
if text != value { onCommit(text) }
|
||||
isEditing = false
|
||||
})
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
Button("Cancel") { isEditing = false }
|
||||
.controlSize(.mini)
|
||||
} else {
|
||||
Text(value)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
Spacer()
|
||||
Button("Edit") {
|
||||
text = value
|
||||
isEditing = true
|
||||
}
|
||||
.controlSize(.mini)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
}
|
||||
}
|
||||
|
||||
struct PickerRow: View {
|
||||
let label: String
|
||||
let selection: String
|
||||
let options: [String]
|
||||
let onChange: (String) -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Text(label)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 130, alignment: .trailing)
|
||||
Picker("", selection: Binding(
|
||||
get: { selection },
|
||||
set: { onChange($0) }
|
||||
)) {
|
||||
ForEach(options, id: \.self) { option in
|
||||
Text(option).tag(option)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: 250)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
}
|
||||
}
|
||||
|
||||
struct ToggleRow: View {
|
||||
let label: String
|
||||
let isOn: Bool
|
||||
let onChange: (Bool) -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Text(label)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 130, alignment: .trailing)
|
||||
Toggle("", isOn: Binding(
|
||||
get: { isOn },
|
||||
set: { onChange($0) }
|
||||
))
|
||||
.toggleStyle(.switch)
|
||||
.labelsHidden()
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
}
|
||||
}
|
||||
|
||||
struct StepperRow: View {
|
||||
let label: String
|
||||
let value: Int
|
||||
let range: ClosedRange<Int>
|
||||
let onChange: (Int) -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Text(label)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 130, alignment: .trailing)
|
||||
Text("\(value)")
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.frame(width: 50)
|
||||
Stepper("", value: Binding(
|
||||
get: { value },
|
||||
set: { onChange($0) }
|
||||
), in: range)
|
||||
.labelsHidden()
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
}
|
||||
}
|
||||
|
||||
struct ReadOnlyRow: View {
|
||||
let label: String
|
||||
let value: String
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Text(label)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 130, alignment: .trailing)
|
||||
Text(value)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.textSelection(.enabled)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
}
|
||||
}
|
||||
|
||||
struct PathRow: View {
|
||||
let label: String
|
||||
let path: String
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Text(label)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 130, alignment: .trailing)
|
||||
Text(path)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.textSelection(.enabled)
|
||||
Spacer()
|
||||
Button {
|
||||
NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: path)
|
||||
} label: {
|
||||
Image(systemName: "folder")
|
||||
.font(.caption)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Advanced tab — network, compression, checkpoints, logging, delegation, file read cap,
|
||||
/// cron wrap, config diagnostics, backup/restore, paths, raw config.
|
||||
struct AdvancedTab: View {
|
||||
@Bindable var viewModel: SettingsViewModel
|
||||
@State private var showRawConfig = false
|
||||
@State private var showRestoreConfirm = false
|
||||
@State private var pendingRestoreURL: URL?
|
||||
@State private var diagnosticsOutput: String = ""
|
||||
@State private var showDiagnostics = false
|
||||
|
||||
var body: some View {
|
||||
SettingsSection(title: "Network", icon: "network") {
|
||||
ToggleRow(label: "Force IPv4", isOn: viewModel.config.forceIPv4) { viewModel.setForceIPv4($0) }
|
||||
}
|
||||
|
||||
SettingsSection(title: "Context & Compression", icon: "arrow.down.right.and.arrow.up.left") {
|
||||
ReadOnlyRow(label: "Context Engine", value: viewModel.config.contextEngine)
|
||||
StepperRow(label: "File Read Max", value: viewModel.config.fileReadMaxChars, range: 1000...1_000_000, step: 1000) { viewModel.setFileReadMaxChars($0) }
|
||||
ToggleRow(label: "Compression Enabled", isOn: viewModel.config.compression.enabled) { viewModel.setCompressionEnabled($0) }
|
||||
DoubleStepperRow(label: "Threshold", value: viewModel.config.compression.threshold, range: 0.1...1.0, step: 0.05) { viewModel.setCompressionThreshold($0) }
|
||||
DoubleStepperRow(label: "Target Ratio", value: viewModel.config.compression.targetRatio, range: 0.05...0.9, step: 0.05) { viewModel.setCompressionTargetRatio($0) }
|
||||
StepperRow(label: "Protect Last N", value: viewModel.config.compression.protectLastN, range: 0...100) { viewModel.setCompressionProtectLastN($0) }
|
||||
}
|
||||
|
||||
SettingsSection(title: "Checkpoints", icon: "clock.arrow.circlepath") {
|
||||
ToggleRow(label: "Enabled", isOn: viewModel.config.checkpoints.enabled) { viewModel.setCheckpointsEnabled($0) }
|
||||
StepperRow(label: "Max Snapshots", value: viewModel.config.checkpoints.maxSnapshots, range: 1...500, step: 5) { viewModel.setCheckpointsMaxSnapshots($0) }
|
||||
}
|
||||
|
||||
SettingsSection(title: "Logging", icon: "doc.text") {
|
||||
PickerRow(label: "Level", selection: viewModel.config.logging.level, options: ["DEBUG", "INFO", "WARNING", "ERROR"]) { viewModel.setLoggingLevel($0) }
|
||||
StepperRow(label: "Max Size (MB)", value: viewModel.config.logging.maxSizeMB, range: 1...100) { viewModel.setLoggingMaxSizeMB($0) }
|
||||
StepperRow(label: "Backup Count", value: viewModel.config.logging.backupCount, range: 0...20) { viewModel.setLoggingBackupCount($0) }
|
||||
}
|
||||
|
||||
SettingsSection(title: "Delegation", icon: "arrow.triangle.branch") {
|
||||
// Delegation has its own model/provider pair (tasks spawned by the
|
||||
// agent use this instead of the main model). The picker keeps the
|
||||
// two in sync just like Settings → General.
|
||||
ModelPickerRow(
|
||||
label: "Model",
|
||||
currentModel: viewModel.config.delegation.model,
|
||||
currentProvider: viewModel.config.delegation.provider
|
||||
) { modelID, providerID in
|
||||
viewModel.setDelegationModel(modelID)
|
||||
if !providerID.isEmpty {
|
||||
viewModel.setDelegationProvider(providerID)
|
||||
}
|
||||
}
|
||||
ReadOnlyRow(label: "Provider", value: viewModel.config.delegation.provider)
|
||||
EditableTextField(label: "Base URL", value: viewModel.config.delegation.baseURL) { viewModel.setDelegationBaseURL($0) }
|
||||
StepperRow(label: "Max Iterations", value: viewModel.config.delegation.maxIterations, range: 1...500, step: 5) { viewModel.setDelegationMaxIterations($0) }
|
||||
}
|
||||
|
||||
SettingsSection(title: "Cron", icon: "clock") {
|
||||
ToggleRow(label: "Wrap Response", isOn: viewModel.config.cronWrapResponse) { viewModel.setCronWrapResponse($0) }
|
||||
}
|
||||
|
||||
SettingsSection(title: "Config Diagnostics", icon: "stethoscope") {
|
||||
HStack {
|
||||
Text("Actions")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 160, alignment: .trailing)
|
||||
Button("Check") {
|
||||
diagnosticsOutput = viewModel.runConfigCheck()
|
||||
showDiagnostics = true
|
||||
}
|
||||
.controlSize(.small)
|
||||
Button("Migrate") {
|
||||
diagnosticsOutput = viewModel.runConfigMigrate()
|
||||
showDiagnostics = true
|
||||
}
|
||||
.controlSize(.small)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
|
||||
if showDiagnostics {
|
||||
Text(diagnosticsOutput.isEmpty ? "(no output)" : diagnosticsOutput)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.textSelection(.enabled)
|
||||
.padding(8)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(.quaternary.opacity(0.5))
|
||||
}
|
||||
}
|
||||
|
||||
backupSection
|
||||
pathsSection
|
||||
rawConfigSection
|
||||
}
|
||||
|
||||
private var backupSection: some View {
|
||||
SettingsSection(title: "Backup & Restore", icon: "externaldrive") {
|
||||
HStack {
|
||||
Text("Archive")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 160, alignment: .trailing)
|
||||
Button {
|
||||
viewModel.runBackup()
|
||||
} label: {
|
||||
Label("Backup Now", systemImage: "arrow.down.doc")
|
||||
}
|
||||
.controlSize(.small)
|
||||
.disabled(viewModel.backupInProgress)
|
||||
Button {
|
||||
if let url = viewModel.presentRestorePicker() {
|
||||
pendingRestoreURL = url
|
||||
showRestoreConfirm = true
|
||||
}
|
||||
} label: {
|
||||
Label("Restore…", systemImage: "arrow.up.doc")
|
||||
}
|
||||
.controlSize(.small)
|
||||
.disabled(viewModel.backupInProgress)
|
||||
if viewModel.backupInProgress {
|
||||
ProgressView().controlSize(.small)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
}
|
||||
.confirmationDialog("Restore from backup?", isPresented: $showRestoreConfirm) {
|
||||
Button("Restore", role: .destructive) {
|
||||
if let url = pendingRestoreURL {
|
||||
viewModel.runRestore(from: url)
|
||||
}
|
||||
pendingRestoreURL = nil
|
||||
}
|
||||
Button("Cancel", role: .cancel) { pendingRestoreURL = nil }
|
||||
} message: {
|
||||
Text("This will overwrite files under ~/.hermes/ with the archive contents.")
|
||||
}
|
||||
}
|
||||
|
||||
private var pathsSection: some View {
|
||||
SettingsSection(title: "Paths", icon: "folder") {
|
||||
PathRow(label: "Hermes Home", path: HermesPaths.home)
|
||||
PathRow(label: "State DB", path: HermesPaths.stateDB)
|
||||
PathRow(label: "Config", path: HermesPaths.configYAML)
|
||||
PathRow(label: "Memory", path: HermesPaths.memoriesDir)
|
||||
PathRow(label: "Sessions", path: HermesPaths.sessionsDir)
|
||||
PathRow(label: "Skills", path: HermesPaths.skillsDir)
|
||||
PathRow(label: "Agent Log", path: HermesPaths.agentLog)
|
||||
PathRow(label: "Error Log", path: HermesPaths.errorsLog)
|
||||
}
|
||||
}
|
||||
|
||||
private var rawConfigSection: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
Text("Raw Config")
|
||||
.font(.headline)
|
||||
Button(showRawConfig ? "Hide" : "Show") {
|
||||
showRawConfig.toggle()
|
||||
}
|
||||
.controlSize(.small)
|
||||
}
|
||||
if showRawConfig {
|
||||
Text(viewModel.rawConfigYAML)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.textSelection(.enabled)
|
||||
.padding(8)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(.quaternary.opacity(0.5))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Agent tab — turns, reasoning effort, tool use enforcement, approvals, gateway timing, service tier.
|
||||
struct AgentTab: View {
|
||||
@Bindable var viewModel: SettingsViewModel
|
||||
|
||||
var body: some View {
|
||||
SettingsSection(title: "Turns & Reasoning", icon: "arrow.2.circlepath") {
|
||||
StepperRow(label: "Max Turns", value: viewModel.config.maxTurns, range: 1...200) { viewModel.setMaxTurns($0) }
|
||||
PickerRow(label: "Reasoning Effort", selection: viewModel.config.reasoningEffort, options: ["none", "minimal", "low", "medium", "high", "xhigh"]) { viewModel.setReasoningEffort($0) }
|
||||
PickerRow(label: "Tool Use Enforcement", selection: viewModel.config.toolUseEnforcement, options: ["auto", "true", "false"]) { viewModel.setToolUseEnforcement($0) }
|
||||
}
|
||||
|
||||
SettingsSection(title: "Approvals", icon: "checkmark.shield") {
|
||||
PickerRow(label: "Approval Mode", selection: viewModel.config.approvalMode, options: ["auto", "manual", "smart", "off"]) { viewModel.setApprovalMode($0) }
|
||||
StepperRow(label: "Approval Timeout (s)", value: viewModel.config.approvalTimeout, range: 5...600, step: 5) { viewModel.setApprovalTimeout($0) }
|
||||
}
|
||||
|
||||
SettingsSection(title: "Gateway", icon: "antenna.radiowaves.left.and.right") {
|
||||
ToggleRow(label: "Fast Mode", isOn: viewModel.config.serviceTier == "fast") { on in
|
||||
viewModel.setServiceTier(on ? "fast" : "normal")
|
||||
}
|
||||
StepperRow(label: "Gateway Timeout (s)", value: viewModel.config.gatewayTimeout, range: 60...7200, step: 60) { viewModel.setGatewayTimeout($0) }
|
||||
StepperRow(label: "Notify Interval (s)", value: viewModel.config.gatewayNotifyInterval, range: 0...3600, step: 30) { viewModel.setGatewayNotifyInterval($0) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Auxiliary tab — the 8 sub-model tasks hermes delegates to cheaper models.
|
||||
/// Each follows the same provider/model/base_url/api_key/timeout pattern.
|
||||
struct AuxiliaryTab: View {
|
||||
@Bindable var viewModel: SettingsViewModel
|
||||
|
||||
// Keyed by the config path name — matches `auxiliary.<task>.*` in config.yaml.
|
||||
private let tasks: [(key: String, title: String, icon: String)] = [
|
||||
("vision", "Vision", "eye"),
|
||||
("web_extract", "Web Extract", "doc.richtext"),
|
||||
("compression", "Compression", "arrow.down.right.and.arrow.up.left.circle"),
|
||||
("session_search", "Session Search", "magnifyingglass"),
|
||||
("skills_hub", "Skills Hub", "books.vertical"),
|
||||
("approval", "Approval", "checkmark.seal"),
|
||||
("mcp", "MCP", "puzzlepiece"),
|
||||
("flush_memories", "Flush Memories", "trash.slash")
|
||||
]
|
||||
|
||||
var body: some View {
|
||||
Text("Auxiliary tasks use separate, typically cheaper models. Leave Provider as `auto` to inherit the main provider.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.bottom, 4)
|
||||
|
||||
ForEach(tasks, id: \.key) { task in
|
||||
SettingsSection(title: task.title, icon: task.icon) {
|
||||
auxRows(for: task.key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func auxRows(for key: String) -> some View {
|
||||
let model = auxModel(for: key)
|
||||
EditableTextField(label: "Provider", value: model.provider) { viewModel.setAuxiliary(key, field: "provider", value: $0) }
|
||||
EditableTextField(label: "Model", value: model.model) { viewModel.setAuxiliary(key, field: "model", value: $0) }
|
||||
EditableTextField(label: "Base URL", value: model.baseURL) { viewModel.setAuxiliary(key, field: "base_url", value: $0) }
|
||||
SecretTextField(label: "API Key", value: model.apiKey) { viewModel.setAuxiliary(key, field: "api_key", value: $0) }
|
||||
StepperRow(label: "Timeout (s)", value: model.timeout, range: 5...3600, step: 5) { viewModel.setAuxiliaryTimeout(key, value: $0) }
|
||||
}
|
||||
|
||||
private func auxModel(for key: String) -> AuxiliaryModel {
|
||||
switch key {
|
||||
case "vision": return viewModel.config.auxiliary.vision
|
||||
case "web_extract": return viewModel.config.auxiliary.webExtract
|
||||
case "compression": return viewModel.config.auxiliary.compression
|
||||
case "session_search": return viewModel.config.auxiliary.sessionSearch
|
||||
case "skills_hub": return viewModel.config.auxiliary.skillsHub
|
||||
case "approval": return viewModel.config.auxiliary.approval
|
||||
case "mcp": return viewModel.config.auxiliary.mcp
|
||||
case "flush_memories": return viewModel.config.auxiliary.flushMemories
|
||||
default: return .empty
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Browser tab — browser backend + automation timeouts + camofox.
|
||||
struct BrowserTab: View {
|
||||
@Bindable var viewModel: SettingsViewModel
|
||||
|
||||
var body: some View {
|
||||
SettingsSection(title: "Backend", icon: "globe") {
|
||||
PickerRow(label: "Backend", selection: viewModel.config.browserBackend, options: viewModel.browserBackends) { viewModel.setBrowserBackend($0) }
|
||||
}
|
||||
|
||||
SettingsSection(title: "Timeouts", icon: "hourglass") {
|
||||
StepperRow(label: "Inactivity (s)", value: viewModel.config.browser.inactivityTimeout, range: 10...3600, step: 10) { viewModel.setBrowserInactivityTimeout($0) }
|
||||
StepperRow(label: "Command (s)", value: viewModel.config.browser.commandTimeout, range: 5...600, step: 5) { viewModel.setBrowserCommandTimeout($0) }
|
||||
}
|
||||
|
||||
SettingsSection(title: "Behavior", icon: "slider.horizontal.below.rectangle") {
|
||||
ToggleRow(label: "Record Sessions", isOn: viewModel.config.browser.recordSessions) { viewModel.setBrowserRecordSessions($0) }
|
||||
ToggleRow(label: "Allow Private URLs", isOn: viewModel.config.browser.allowPrivateURLs) { viewModel.setBrowserAllowPrivateURLs($0) }
|
||||
}
|
||||
|
||||
SettingsSection(title: "Camofox", icon: "eye.slash") {
|
||||
ToggleRow(label: "Managed Persistence", isOn: viewModel.config.browser.camofoxManagedPersistence) { viewModel.setCamofoxManagedPersistence($0) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Display tab — streaming, reasoning, cost, skin, compact mode, inline diffs, bell, etc.
|
||||
struct DisplayTab: View {
|
||||
@Bindable var viewModel: SettingsViewModel
|
||||
|
||||
var body: some View {
|
||||
SettingsSection(title: "Output", icon: "doc.plaintext") {
|
||||
ToggleRow(label: "Streaming", isOn: viewModel.config.streaming) { viewModel.setStreaming($0) }
|
||||
ToggleRow(label: "Show Reasoning", isOn: viewModel.config.showReasoning) { viewModel.setShowReasoning($0) }
|
||||
ToggleRow(label: "Show Cost", isOn: viewModel.config.showCost) { viewModel.setShowCost($0) }
|
||||
ToggleRow(label: "Interim Messages", isOn: viewModel.config.interimAssistantMessages) { viewModel.setInterimAssistantMessages($0) }
|
||||
ToggleRow(label: "Verbose", isOn: viewModel.config.verbose) { viewModel.setVerbose($0) }
|
||||
ToggleRow(label: "Inline Diffs", isOn: viewModel.config.display.inlineDiffs) { viewModel.setInlineDiffs($0) }
|
||||
}
|
||||
|
||||
SettingsSection(title: "Layout", icon: "rectangle.3.group") {
|
||||
EditableTextField(label: "Skin", value: viewModel.config.display.skin) { viewModel.setSkin($0) }
|
||||
ToggleRow(label: "Compact", isOn: viewModel.config.display.compact) { viewModel.setDisplayCompact($0) }
|
||||
PickerRow(label: "Resume Display", selection: viewModel.config.display.resumeDisplay, options: ["full", "minimal"]) { viewModel.setResumeDisplay($0) }
|
||||
PickerRow(label: "Busy Input Mode", selection: viewModel.config.display.busyInputMode, options: ["interrupt", "queue"]) { viewModel.setBusyInputMode($0) }
|
||||
}
|
||||
|
||||
SettingsSection(title: "Tool Progress", icon: "gauge") {
|
||||
ToggleRow(label: "Tool Progress Command", isOn: viewModel.config.display.toolProgressCommand) { viewModel.setToolProgressCommand($0) }
|
||||
StepperRow(label: "Preview Length", value: viewModel.config.display.toolPreviewLength, range: 0...500, step: 10) { viewModel.setToolPreviewLength($0) }
|
||||
}
|
||||
|
||||
SettingsSection(title: "Feedback", icon: "bell") {
|
||||
ToggleRow(label: "Bell on Complete", isOn: viewModel.config.display.bellOnComplete) { viewModel.setBellOnComplete($0) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import SwiftUI
|
||||
|
||||
/// General tab — model picker (provider auto-follows), personality, locale.
|
||||
/// Credential management lives in the Credential Pools sidebar item; a hint
|
||||
/// row in this tab deep-links there so users don't have to hunt for it.
|
||||
struct GeneralTab: View {
|
||||
@Bindable var viewModel: SettingsViewModel
|
||||
@Environment(AppCoordinator.self) private var coordinator
|
||||
|
||||
var body: some View {
|
||||
SettingsSection(title: "Model", icon: "cpu") {
|
||||
ModelPickerRow(
|
||||
label: "Model",
|
||||
currentModel: viewModel.config.model,
|
||||
currentProvider: viewModel.config.provider
|
||||
) { modelID, providerID in
|
||||
// Selecting a model auto-syncs the provider so the two stay in
|
||||
// lockstep. If the picker returns an empty provider (custom
|
||||
// entry without a prefix), keep the current one.
|
||||
viewModel.setModel(modelID)
|
||||
if !providerID.isEmpty {
|
||||
viewModel.setProvider(providerID)
|
||||
}
|
||||
}
|
||||
// Provider is shown read-only for clarity; users change it via the
|
||||
// Model picker, which presents providers and models together.
|
||||
ReadOnlyRow(label: "Provider", value: viewModel.config.provider)
|
||||
credentialsHint
|
||||
}
|
||||
|
||||
SettingsSection(title: "Personality", icon: "theatermasks") {
|
||||
if !viewModel.personalities.isEmpty {
|
||||
PickerRow(label: "Personality", selection: viewModel.config.personality, options: viewModel.personalities) { viewModel.setPersonality($0) }
|
||||
} else {
|
||||
EditableTextField(label: "Personality", value: viewModel.config.personality) { viewModel.setPersonality($0) }
|
||||
}
|
||||
}
|
||||
|
||||
SettingsSection(title: "Locale", icon: "globe.americas") {
|
||||
EditableTextField(label: "Timezone (IANA)", value: viewModel.config.timezone) { viewModel.setTimezone($0) }
|
||||
}
|
||||
}
|
||||
|
||||
/// Breadcrumb-style row that points users to the Credential Pools sidebar
|
||||
/// item. Replaces the old "Remove Credentials" button — that action lived
|
||||
/// here historically but duplicated Credential Pools' per-credential UI.
|
||||
private var credentialsHint: some View {
|
||||
HStack {
|
||||
Text("Credentials")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 160, alignment: .trailing)
|
||||
Button {
|
||||
coordinator.selectedSection = .credentialPools
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
Text("Manage in Credential Pools")
|
||||
.font(.caption)
|
||||
Image(systemName: "arrow.right")
|
||||
.font(.caption2)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.link)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Memory tab — built-in memory settings + external provider picker.
|
||||
struct MemoryTab: View {
|
||||
@Bindable var viewModel: SettingsViewModel
|
||||
|
||||
var body: some View {
|
||||
SettingsSection(title: "Built-in Memory", icon: "brain") {
|
||||
ToggleRow(label: "Memory Enabled", isOn: viewModel.config.memoryEnabled) { viewModel.setMemoryEnabled($0) }
|
||||
ToggleRow(label: "User Profile Enabled", isOn: viewModel.config.userProfileEnabled) { viewModel.setUserProfileEnabled($0) }
|
||||
if !viewModel.config.memoryProfile.isEmpty {
|
||||
ReadOnlyRow(label: "Profile", value: viewModel.config.memoryProfile)
|
||||
}
|
||||
StepperRow(label: "Memory Char Limit", value: viewModel.config.memoryCharLimit, range: 500...10_000, step: 100) { viewModel.setMemoryCharLimit($0) }
|
||||
StepperRow(label: "User Char Limit", value: viewModel.config.userCharLimit, range: 500...10_000, step: 100) { viewModel.setUserCharLimit($0) }
|
||||
StepperRow(label: "Nudge Interval", value: viewModel.config.nudgeInterval, range: 1...50) { viewModel.setNudgeInterval($0) }
|
||||
}
|
||||
|
||||
SettingsSection(title: "External Provider", icon: "externaldrive.connected.to.line.below") {
|
||||
PickerRow(label: "Provider", selection: viewModel.config.memoryProvider, options: viewModel.memoryProviders) { viewModel.setMemoryProvider($0) }
|
||||
if viewModel.config.memoryProvider == "honcho" {
|
||||
ToggleRow(label: "Honcho Eager Init", isOn: viewModel.config.honchoInitOnSessionStart) { viewModel.setHonchoInitOnSessionStart($0) }
|
||||
}
|
||||
HStack {
|
||||
Text("Setup")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 160, alignment: .trailing)
|
||||
Text("Run `hermes memory setup` in Terminal for full provider configuration.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Security tab — redaction, command allowlist (read-only), Tirith sandbox, website blocklist, human delay.
|
||||
struct SecurityTab: View {
|
||||
@Bindable var viewModel: SettingsViewModel
|
||||
|
||||
var body: some View {
|
||||
SettingsSection(title: "Redaction", icon: "eye.slash") {
|
||||
ToggleRow(label: "Redact Secrets", isOn: viewModel.config.security.redactSecrets) { viewModel.setRedactSecrets($0) }
|
||||
ToggleRow(label: "Redact PII", isOn: viewModel.config.security.redactPII) { viewModel.setRedactPII($0) }
|
||||
}
|
||||
|
||||
SettingsSection(title: "Tirith Sandbox", icon: "shield.checkerboard") {
|
||||
ToggleRow(label: "Enabled", isOn: viewModel.config.security.tirithEnabled) { viewModel.setTirithEnabled($0) }
|
||||
EditableTextField(label: "Binary Path", value: viewModel.config.security.tirithPath) { viewModel.setTirithPath($0) }
|
||||
StepperRow(label: "Timeout (s)", value: viewModel.config.security.tirithTimeout, range: 1...60) { viewModel.setTirithTimeout($0) }
|
||||
ToggleRow(label: "Fail Open", isOn: viewModel.config.security.tirithFailOpen) { viewModel.setTirithFailOpen($0) }
|
||||
}
|
||||
|
||||
SettingsSection(title: "Website Blocklist", icon: "xmark.shield") {
|
||||
ToggleRow(label: "Enabled", isOn: viewModel.config.security.blocklistEnabled) { viewModel.setBlocklistEnabled($0) }
|
||||
if !viewModel.config.security.blocklistDomains.isEmpty {
|
||||
ReadOnlyRow(label: "Domains", value: viewModel.config.security.blocklistDomains.joined(separator: ", "))
|
||||
}
|
||||
}
|
||||
|
||||
if !viewModel.config.commandAllowlist.isEmpty {
|
||||
SettingsSection(title: "Command Allowlist", icon: "checkmark.shield") {
|
||||
ReadOnlyRow(label: "Commands", value: viewModel.config.commandAllowlist.joined(separator: ", "))
|
||||
}
|
||||
}
|
||||
|
||||
SettingsSection(title: "Human Delay", icon: "hourglass.tophalf.filled") {
|
||||
PickerRow(label: "Mode", selection: viewModel.config.humanDelay.mode, options: ["off", "natural", "custom"]) { viewModel.setHumanDelayMode($0) }
|
||||
StepperRow(label: "Min (ms)", value: viewModel.config.humanDelay.minMS, range: 0...10_000, step: 50) { viewModel.setHumanDelayMinMS($0) }
|
||||
StepperRow(label: "Max (ms)", value: viewModel.config.humanDelay.maxMS, range: 0...10_000, step: 50) { viewModel.setHumanDelayMaxMS($0) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Terminal tab — backend plus docker/container options.
|
||||
/// Heavy docker/container settings are hidden unless a container backend is selected.
|
||||
struct TerminalTab: View {
|
||||
@Bindable var viewModel: SettingsViewModel
|
||||
|
||||
var body: some View {
|
||||
SettingsSection(title: "Backend", icon: "terminal") {
|
||||
PickerRow(label: "Backend", selection: viewModel.config.terminalBackend, options: viewModel.terminalBackends) { viewModel.setTerminalBackend($0) }
|
||||
EditableTextField(label: "Working Dir", value: viewModel.config.terminal.cwd) { viewModel.setTerminalCwd($0) }
|
||||
StepperRow(label: "Command Timeout (s)", value: viewModel.config.terminal.timeout, range: 10...3600, step: 10) { viewModel.setTerminalTimeout($0) }
|
||||
ToggleRow(label: "Persistent Shell", isOn: viewModel.config.terminal.persistentShell) { viewModel.setPersistentShell($0) }
|
||||
}
|
||||
|
||||
if isContainerBackend {
|
||||
SettingsSection(title: "Container Limits", icon: "cpu.fill") {
|
||||
StepperRow(label: "CPU Count", value: viewModel.config.terminal.containerCPU, range: 0...64) { viewModel.setContainerCPU($0) }
|
||||
StepperRow(label: "Memory (MB)", value: viewModel.config.terminal.containerMemory, range: 0...65_536, step: 256) { viewModel.setContainerMemory($0) }
|
||||
StepperRow(label: "Disk (MB)", value: viewModel.config.terminal.containerDisk, range: 0...1_048_576, step: 1024) { viewModel.setContainerDisk($0) }
|
||||
ToggleRow(label: "Persistent", isOn: viewModel.config.terminal.containerPersistent) { viewModel.setContainerPersistent($0) }
|
||||
}
|
||||
}
|
||||
|
||||
if viewModel.config.terminalBackend == "docker" {
|
||||
SettingsSection(title: "Docker", icon: "shippingbox") {
|
||||
EditableTextField(label: "Image", value: viewModel.config.terminal.dockerImage) { viewModel.setDockerImage($0) }
|
||||
ToggleRow(label: "Mount CWD", isOn: viewModel.config.terminal.dockerMountCwdToWorkspace) { viewModel.setDockerMountCwd($0) }
|
||||
if !viewModel.config.dockerEnv.isEmpty {
|
||||
ForEach(viewModel.config.dockerEnv.sorted(by: { $0.key < $1.key }), id: \.key) { key, value in
|
||||
ReadOnlyRow(label: key, value: value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if viewModel.config.terminalBackend == "modal" {
|
||||
SettingsSection(title: "Modal", icon: "cloud") {
|
||||
EditableTextField(label: "Image", value: viewModel.config.terminal.modalImage) { viewModel.setModalImage($0) }
|
||||
PickerRow(label: "Mode", selection: viewModel.config.terminal.modalMode, options: ["auto", "always", "never"]) { viewModel.setModalMode($0) }
|
||||
}
|
||||
}
|
||||
|
||||
if viewModel.config.terminalBackend == "daytona" {
|
||||
SettingsSection(title: "Daytona", icon: "externaldrive.connected.to.line.below") {
|
||||
EditableTextField(label: "Image", value: viewModel.config.terminal.daytonaImage) { viewModel.setDaytonaImage($0) }
|
||||
}
|
||||
}
|
||||
|
||||
if viewModel.config.terminalBackend == "singularity" {
|
||||
SettingsSection(title: "Singularity", icon: "aqi.medium") {
|
||||
EditableTextField(label: "Image", value: viewModel.config.terminal.singularityImage) { viewModel.setSingularityImage($0) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var isContainerBackend: Bool {
|
||||
["docker", "modal", "daytona", "singularity"].contains(viewModel.config.terminalBackend)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Voice tab — push-to-talk + TTS + STT provider settings.
|
||||
struct VoiceTab: View {
|
||||
@Bindable var viewModel: SettingsViewModel
|
||||
|
||||
var body: some View {
|
||||
SettingsSection(title: "Push-to-Talk", icon: "mic") {
|
||||
ToggleRow(label: "Auto TTS", isOn: viewModel.config.autoTTS) { viewModel.setAutoTTS($0) }
|
||||
EditableTextField(label: "Record Key", value: viewModel.config.voice.recordKey) { viewModel.setRecordKey($0) }
|
||||
StepperRow(label: "Max Recording (s)", value: viewModel.config.voice.maxRecordingSeconds, range: 10...600, step: 10) { viewModel.setMaxRecordingSeconds($0) }
|
||||
StepperRow(label: "Silence Threshold", value: viewModel.config.silenceThreshold, range: 50...500, step: 10) { viewModel.setSilenceThreshold($0) }
|
||||
DoubleStepperRow(label: "Silence Duration (s)", value: viewModel.config.voice.silenceDuration, range: 0.5...10.0, step: 0.5) { viewModel.setSilenceDuration($0) }
|
||||
}
|
||||
|
||||
SettingsSection(title: "Text-to-Speech", icon: "speaker.wave.3") {
|
||||
PickerRow(label: "Provider", selection: viewModel.config.voice.ttsProvider, options: viewModel.ttsProviders) { viewModel.setTTSProvider($0) }
|
||||
switch viewModel.config.voice.ttsProvider {
|
||||
case "edge":
|
||||
EditableTextField(label: "Voice", value: viewModel.config.voice.ttsEdgeVoice) { viewModel.setTTSEdgeVoice($0) }
|
||||
case "elevenlabs":
|
||||
EditableTextField(label: "Voice ID", value: viewModel.config.voice.ttsElevenLabsVoiceID) { viewModel.setTTSElevenLabsVoiceID($0) }
|
||||
EditableTextField(label: "Model ID", value: viewModel.config.voice.ttsElevenLabsModelID) { viewModel.setTTSElevenLabsModelID($0) }
|
||||
case "openai":
|
||||
EditableTextField(label: "Model", value: viewModel.config.voice.ttsOpenAIModel) { viewModel.setTTSOpenAIModel($0) }
|
||||
PickerRow(label: "Voice", selection: viewModel.config.voice.ttsOpenAIVoice, options: ["alloy", "echo", "fable", "onyx", "nova", "shimmer"]) { viewModel.setTTSOpenAIVoice($0) }
|
||||
case "neutts":
|
||||
EditableTextField(label: "Model", value: viewModel.config.voice.ttsNeuTTSModel) { viewModel.setTTSNeuTTSModel($0) }
|
||||
PickerRow(label: "Device", selection: viewModel.config.voice.ttsNeuTTSDevice, options: ["cpu", "cuda"]) { viewModel.setTTSNeuTTSDevice($0) }
|
||||
default:
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
|
||||
SettingsSection(title: "Speech-to-Text", icon: "waveform") {
|
||||
ToggleRow(label: "Enabled", isOn: viewModel.config.voice.sttEnabled) { viewModel.setSTTEnabled($0) }
|
||||
PickerRow(label: "Provider", selection: viewModel.config.voice.sttProvider, options: viewModel.sttProviders) { viewModel.setSTTProvider($0) }
|
||||
switch viewModel.config.voice.sttProvider {
|
||||
case "local":
|
||||
PickerRow(label: "Model", selection: viewModel.config.voice.sttLocalModel, options: ["tiny", "base", "small", "medium", "large-v3"]) { viewModel.setSTTLocalModel($0) }
|
||||
EditableTextField(label: "Language", value: viewModel.config.voice.sttLocalLanguage) { viewModel.setSTTLocalLanguage($0) }
|
||||
case "openai":
|
||||
EditableTextField(label: "Model", value: viewModel.config.voice.sttOpenAIModel) { viewModel.setSTTOpenAIModel($0) }
|
||||
case "mistral":
|
||||
EditableTextField(label: "Model", value: viewModel.config.voice.sttMistralModel) { viewModel.setSTTMistralModel($0) }
|
||||
default:
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,29 @@
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
/// A single search/browse result from a skill registry.
|
||||
struct HermesHubSkill: Identifiable, Sendable, Equatable {
|
||||
var id: String { identifier }
|
||||
let identifier: String // e.g. "openai/skills/skill-creator"
|
||||
let name: String
|
||||
let description: String
|
||||
let source: String // "official" | "skills-sh" | etc.
|
||||
}
|
||||
|
||||
/// A local skill that has an upstream version available.
|
||||
struct HermesSkillUpdate: Identifiable, Sendable, Equatable {
|
||||
var id: String { identifier }
|
||||
let identifier: String
|
||||
let currentVersion: String
|
||||
let availableVersion: String
|
||||
}
|
||||
|
||||
@Observable
|
||||
final class SkillsViewModel {
|
||||
private let logger = Logger(subsystem: "com.scarf", category: "SkillsViewModel")
|
||||
private let fileService = HermesFileService()
|
||||
|
||||
// MARK: - Installed skills (existing behavior)
|
||||
var categories: [HermesSkillCategory] = []
|
||||
var selectedSkill: HermesSkill?
|
||||
var skillContent = ""
|
||||
@@ -14,6 +34,16 @@ final class SkillsViewModel {
|
||||
var editText = ""
|
||||
private var currentConfig = HermesConfig.empty
|
||||
|
||||
// MARK: - Hub integration (new)
|
||||
var hubQuery = ""
|
||||
var hubResults: [HermesHubSkill] = []
|
||||
var updates: [HermesSkillUpdate] = []
|
||||
var isHubLoading = false
|
||||
var hubMessage: String?
|
||||
var hubSource: String = "all"
|
||||
|
||||
let hubSources = ["all", "official", "skills-sh", "well-known", "github", "clawhub", "lobehub"]
|
||||
|
||||
var filteredCategories: [HermesSkillCategory] {
|
||||
guard !searchText.isEmpty else { return categories }
|
||||
return categories.compactMap { category in
|
||||
@@ -88,4 +118,198 @@ final class SkillsViewModel {
|
||||
func cancelEditing() {
|
||||
isEditing = false
|
||||
}
|
||||
|
||||
// MARK: - Hub browse/search/install/update
|
||||
|
||||
func browseHub() {
|
||||
isHubLoading = true
|
||||
Task.detached { [fileService, hubSource] in
|
||||
var args = ["skills", "browse", "--size", "40"]
|
||||
if hubSource != "all" { args += ["--source", hubSource] }
|
||||
let result = fileService.runHermesCLI(args: args, timeout: 30)
|
||||
let parsed = Self.parseHubList(result.output)
|
||||
await MainActor.run {
|
||||
self.isHubLoading = false
|
||||
self.hubResults = parsed
|
||||
if parsed.isEmpty {
|
||||
self.hubMessage = result.exitCode == 0 ? "No results" : "Browse failed"
|
||||
} else {
|
||||
self.hubMessage = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func searchHub() {
|
||||
guard !hubQuery.isEmpty else {
|
||||
browseHub()
|
||||
return
|
||||
}
|
||||
isHubLoading = true
|
||||
Task.detached { [fileService, hubSource, hubQuery] in
|
||||
var args = ["skills", "search", hubQuery, "--limit", "40"]
|
||||
if hubSource != "all" { args += ["--source", hubSource] }
|
||||
let result = fileService.runHermesCLI(args: args, timeout: 30)
|
||||
let parsed = Self.parseHubList(result.output)
|
||||
await MainActor.run {
|
||||
self.isHubLoading = false
|
||||
self.hubResults = parsed
|
||||
if parsed.isEmpty {
|
||||
self.hubMessage = "No matches"
|
||||
} else {
|
||||
self.hubMessage = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func installHubSkill(_ skill: HermesHubSkill) {
|
||||
isHubLoading = true
|
||||
hubMessage = "Installing \(skill.identifier)…"
|
||||
Task.detached { [fileService] in
|
||||
// --yes skips confirmation since we're running non-interactively.
|
||||
let result = fileService.runHermesCLI(args: ["skills", "install", skill.identifier, "--yes"], timeout: 120)
|
||||
await MainActor.run {
|
||||
self.isHubLoading = false
|
||||
self.hubMessage = result.exitCode == 0 ? "Installed \(skill.identifier)" : "Install failed"
|
||||
self.load()
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
||||
self?.hubMessage = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func uninstallHubSkill(_ identifier: String) {
|
||||
Task.detached { [fileService] in
|
||||
let result = fileService.runHermesCLI(args: ["skills", "uninstall", identifier, "--yes"], timeout: 60)
|
||||
await MainActor.run {
|
||||
self.hubMessage = result.exitCode == 0 ? "Uninstalled" : "Uninstall failed"
|
||||
self.load()
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
|
||||
self?.hubMessage = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func checkForUpdates() {
|
||||
isHubLoading = true
|
||||
Task.detached { [fileService] in
|
||||
let result = fileService.runHermesCLI(args: ["skills", "check"], timeout: 60)
|
||||
let parsed = Self.parseUpdateList(result.output)
|
||||
await MainActor.run {
|
||||
self.isHubLoading = false
|
||||
self.updates = parsed
|
||||
self.hubMessage = parsed.isEmpty ? "No updates available" : "\(parsed.count) update(s)"
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
||||
self?.hubMessage = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func updateAll() {
|
||||
Task.detached { [fileService] in
|
||||
let result = fileService.runHermesCLI(args: ["skills", "update", "--yes"], timeout: 300)
|
||||
await MainActor.run {
|
||||
self.hubMessage = result.exitCode == 0 ? "Updated" : "Update failed"
|
||||
self.load()
|
||||
self.checkForUpdates()
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
||||
self?.hubMessage = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Parsers (best-effort, tolerant of format changes)
|
||||
// `nonisolated` so callers in `Task.detached` can run them off the main actor.
|
||||
|
||||
/// Parse `hermes skills browse|search` output.
|
||||
///
|
||||
/// Hermes emits a Rich box-drawn table with vertical bars as column separators:
|
||||
///
|
||||
/// │ # │ Name │ Description │ Source │ Trust │
|
||||
/// ├──────┼────────────────┼────────────────────────┼──────────────┼────────────┤
|
||||
/// │ 1 │ 1password │ Set up and use 1Pass… │ official │ ★ official │
|
||||
///
|
||||
/// Description cells can wrap across multiple rows — the continuation rows have
|
||||
/// an empty `#` column. We join consecutive rows with the same skill by checking
|
||||
/// if the first column (after `│`) is whitespace-only.
|
||||
nonisolated private static func parseHubList(_ output: String) -> [HermesHubSkill] {
|
||||
var results: [HermesHubSkill] = []
|
||||
for raw in output.components(separatedBy: "\n") {
|
||||
let line = raw
|
||||
// Skip everything that isn't a data row. Data rows start with `│` and
|
||||
// contain multiple `│` separators. Border rows (`┏`, `┡`, `├`, `└`, etc.)
|
||||
// are drawn with `━` or `─` and should be skipped.
|
||||
guard line.contains("│") else { continue }
|
||||
let cells = line.split(separator: "│", omittingEmptySubsequences: false).map { $0.trimmingCharacters(in: .whitespaces) }
|
||||
// Expect at least: leading empty, #, Name, Description, Source, Trust, trailing empty
|
||||
guard cells.count >= 6 else { continue }
|
||||
|
||||
let numCell = cells[1]
|
||||
let nameCell = cells[2]
|
||||
let descCell = cells[3]
|
||||
let sourceCell = cells[4]
|
||||
// Trust column (index 5) is informational only — we ignore it in the UI.
|
||||
|
||||
// Continuation row: `#` column is empty. Merge its description into the
|
||||
// last-added entry if present.
|
||||
if numCell.isEmpty {
|
||||
guard !results.isEmpty else { continue }
|
||||
let last = results.removeLast()
|
||||
let merged = [last.description, descCell].filter { !$0.isEmpty }.joined(separator: " ")
|
||||
results.append(HermesHubSkill(
|
||||
identifier: last.identifier,
|
||||
name: last.name,
|
||||
description: merged,
|
||||
source: last.source
|
||||
))
|
||||
continue
|
||||
}
|
||||
// Header row — first data-looking row whose number cell isn't a digit.
|
||||
if Int(numCell) == nil { continue }
|
||||
// Empty name cell shouldn't happen but guard anyway.
|
||||
guard !nameCell.isEmpty else { continue }
|
||||
|
||||
// Identifier: `hermes skills browse` shows the short name in the Name
|
||||
// column. For install we need the full identifier like
|
||||
// `<source>/<name>`. The CLI accepts just the name for official hub,
|
||||
// so we use that as the install target.
|
||||
let source = sourceCell
|
||||
.replacingOccurrences(of: "★", with: "")
|
||||
.trimmingCharacters(in: .whitespaces)
|
||||
results.append(HermesHubSkill(
|
||||
identifier: nameCell, // hermes skills install accepts the name for official/hub-indexed skills
|
||||
name: nameCell,
|
||||
description: descCell,
|
||||
source: source
|
||||
))
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
/// Parse `hermes skills check` output for available updates. Format is
|
||||
/// undocumented; we look for `→` (U+2192) or `->` arrow markers between
|
||||
/// version strings.
|
||||
nonisolated private static func parseUpdateList(_ output: String) -> [HermesSkillUpdate] {
|
||||
var results: [HermesSkillUpdate] = []
|
||||
for raw in output.components(separatedBy: "\n") {
|
||||
let line = raw.trimmingCharacters(in: .whitespaces)
|
||||
guard line.contains("→") || line.contains("->") else { continue }
|
||||
let marker = line.contains("→") ? "→" : "->"
|
||||
let parts = line.components(separatedBy: marker)
|
||||
guard parts.count == 2 else { continue }
|
||||
let left = parts[0].trimmingCharacters(in: .whitespaces)
|
||||
let available = parts[1].trimmingCharacters(in: .whitespaces)
|
||||
let leftTokens = left.split(separator: " ", omittingEmptySubsequences: true).map(String.init)
|
||||
guard leftTokens.count >= 2 else { continue }
|
||||
let identifier = leftTokens[0]
|
||||
let current = leftTokens[leftTokens.count - 1]
|
||||
results.append(HermesSkillUpdate(identifier: identifier, currentVersion: current, availableVersion: available))
|
||||
}
|
||||
return results
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,17 +2,62 @@ import SwiftUI
|
||||
|
||||
struct SkillsView: View {
|
||||
@State private var viewModel = SkillsViewModel()
|
||||
@State private var currentTab: Tab = .installed
|
||||
|
||||
enum Tab: String, CaseIterable, Identifiable {
|
||||
case installed = "Installed"
|
||||
case hub = "Browse Hub"
|
||||
case updates = "Updates"
|
||||
var id: String { rawValue }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
modePicker
|
||||
Divider()
|
||||
switch currentTab {
|
||||
case .installed: installedContent
|
||||
case .hub: hubContent
|
||||
case .updates: updatesContent
|
||||
}
|
||||
}
|
||||
.navigationTitle("Skills (\(viewModel.totalSkillCount))")
|
||||
.onAppear { viewModel.load() }
|
||||
}
|
||||
|
||||
private var modePicker: some View {
|
||||
HStack {
|
||||
Picker("", selection: $currentTab) {
|
||||
ForEach(Tab.allCases) { tab in
|
||||
Text(tab.rawValue).tag(tab)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.frame(maxWidth: 360)
|
||||
Spacer()
|
||||
if let msg = viewModel.hubMessage {
|
||||
Label(msg, systemImage: "info.circle")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if viewModel.isHubLoading {
|
||||
ProgressView().controlSize(.small)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
|
||||
// MARK: - Installed
|
||||
|
||||
private var installedContent: some View {
|
||||
HSplitView {
|
||||
skillsList
|
||||
.frame(minWidth: 250, idealWidth: 300)
|
||||
skillDetail
|
||||
.frame(minWidth: 400)
|
||||
}
|
||||
.navigationTitle("Skills (\(viewModel.totalSkillCount))")
|
||||
.searchable(text: $viewModel.searchText, prompt: "Filter skills...")
|
||||
.onAppear { viewModel.load() }
|
||||
}
|
||||
|
||||
private var skillsList: some View {
|
||||
@@ -103,6 +148,10 @@ struct SkillsView: View {
|
||||
Spacer()
|
||||
Button("Edit") { viewModel.startEditing() }
|
||||
.controlSize(.small)
|
||||
Button("Uninstall", role: .destructive) {
|
||||
viewModel.uninstallHubSkill(skill.id)
|
||||
}
|
||||
.controlSize(.small)
|
||||
}
|
||||
if viewModel.isMarkdownFile {
|
||||
MarkdownContentView(content: viewModel.skillContent)
|
||||
@@ -152,4 +201,141 @@ struct SkillsView: View {
|
||||
}
|
||||
.frame(minWidth: 800, minHeight: 500)
|
||||
}
|
||||
|
||||
// MARK: - Hub
|
||||
|
||||
private var hubContent: some View {
|
||||
VStack(spacing: 0) {
|
||||
hubToolbar
|
||||
Divider()
|
||||
if viewModel.hubResults.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"Browse the Hub",
|
||||
systemImage: "books.vertical",
|
||||
description: Text("Search or browse skills published to registries like skills.sh, GitHub, and the official hub.")
|
||||
)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 1) {
|
||||
ForEach(viewModel.hubResults) { hub in
|
||||
hubRow(hub)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var hubToolbar: some View {
|
||||
HStack(spacing: 8) {
|
||||
TextField("Search registries", text: $viewModel.hubQuery)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.onSubmit { viewModel.searchHub() }
|
||||
Picker("Source", selection: $viewModel.hubSource) {
|
||||
ForEach(viewModel.hubSources, id: \.self) { src in
|
||||
Text(src).tag(src)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: 160)
|
||||
Button("Search") { viewModel.searchHub() }
|
||||
.controlSize(.small)
|
||||
Button("Browse") { viewModel.browseHub() }
|
||||
.controlSize(.small)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
|
||||
private func hubRow(_ hub: HermesHubSkill) -> some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "books.vertical")
|
||||
.foregroundStyle(.blue)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(spacing: 6) {
|
||||
Text(hub.name)
|
||||
.font(.system(.body, design: .monospaced, weight: .medium))
|
||||
if !hub.source.isEmpty {
|
||||
Text(hub.source)
|
||||
.font(.caption2.bold())
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 1)
|
||||
.background(.quaternary)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
}
|
||||
Text(hub.identifier)
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
if !hub.description.isEmpty {
|
||||
Text(hub.description)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(3)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
Button {
|
||||
viewModel.installHubSkill(hub)
|
||||
} label: {
|
||||
Label("Install", systemImage: "arrow.down.to.line")
|
||||
}
|
||||
.controlSize(.small)
|
||||
.disabled(viewModel.isHubLoading)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
}
|
||||
|
||||
// MARK: - Updates
|
||||
|
||||
private var updatesContent: some View {
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
Button("Check for Updates") { viewModel.checkForUpdates() }
|
||||
.controlSize(.small)
|
||||
if !viewModel.updates.isEmpty {
|
||||
Button("Update All") { viewModel.updateAll() }
|
||||
.controlSize(.small)
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding()
|
||||
Divider()
|
||||
if viewModel.updates.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"No Updates",
|
||||
systemImage: "checkmark.circle",
|
||||
description: Text("All installed hub skills are up to date.")
|
||||
)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 1) {
|
||||
ForEach(viewModel.updates) { update in
|
||||
HStack {
|
||||
Image(systemName: "arrow.triangle.2.circlepath")
|
||||
.foregroundStyle(.orange)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(update.identifier)
|
||||
.font(.system(.body, design: .monospaced, weight: .medium))
|
||||
Text("\(update.currentVersion) → \(update.availableVersion)")
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
/// Connection/configuration status for a messaging platform, used for indicator dots in the picker.
|
||||
enum PlatformConnectivity: Sendable, Equatable {
|
||||
case connected // Gateway reports the platform online
|
||||
case configured // Platform has a config block but gateway isn't reporting it as connected
|
||||
case notConfigured // No signal that this platform has been set up
|
||||
case error(String) // Gateway reports an error for this platform
|
||||
}
|
||||
|
||||
@Observable
|
||||
final class ToolsViewModel {
|
||||
private let logger = Logger(subsystem: "com.scarf", category: "ToolsViewModel")
|
||||
@@ -10,6 +18,7 @@ final class ToolsViewModel {
|
||||
var mcpStatus: String = ""
|
||||
var isLoading = false
|
||||
var availablePlatforms: [HermesToolPlatform] = []
|
||||
var connectivity: [String: PlatformConnectivity] = [:]
|
||||
|
||||
@MainActor
|
||||
func load() async {
|
||||
@@ -42,47 +51,68 @@ final class ToolsViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
/// Enumerate all known platforms and compute a connectivity status per platform.
|
||||
///
|
||||
/// Source of truth:
|
||||
/// - `KnownPlatforms.all` defines every platform the app knows about (always show these).
|
||||
/// - `~/.hermes/gateway_state.json` tells us which are currently connected.
|
||||
/// - `~/.hermes/config.yaml` top-level keys (`discord:`, `whatsapp:`, etc.) tell us which have been configured.
|
||||
@MainActor
|
||||
private func loadPlatforms() async {
|
||||
let config: String
|
||||
do {
|
||||
config = try await Task.detached {
|
||||
try String(contentsOfFile: HermesPaths.configYAML, encoding: .utf8)
|
||||
}.value
|
||||
} catch {
|
||||
logger.error("Failed to read config.yaml: \(error.localizedDescription)")
|
||||
config = ""
|
||||
}
|
||||
var platforms: [HermesToolPlatform] = []
|
||||
var inSection = false
|
||||
for line in config.components(separatedBy: "\n") {
|
||||
if line.hasPrefix("platform_toolsets:") {
|
||||
inSection = true
|
||||
continue
|
||||
}
|
||||
if inSection {
|
||||
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||
if trimmed.isEmpty || (!line.hasPrefix(" ") && !line.hasPrefix("\t")) {
|
||||
if !trimmed.isEmpty { break }
|
||||
continue
|
||||
}
|
||||
if trimmed.hasSuffix(":") && !trimmed.hasPrefix("-") {
|
||||
let name = String(trimmed.dropLast()).trimmingCharacters(in: .whitespaces)
|
||||
if let known = KnownPlatforms.all.first(where: { $0.name == name }) {
|
||||
platforms.append(known)
|
||||
} else {
|
||||
platforms.append(HermesToolPlatform(name: name, displayName: name.capitalized, icon: "bubble.left"))
|
||||
}
|
||||
let yaml: String = await Task.detached {
|
||||
(try? String(contentsOfFile: HermesPaths.configYAML, encoding: .utf8)) ?? ""
|
||||
}.value
|
||||
|
||||
let gatewayState: GatewayState? = await Task.detached {
|
||||
HermesFileService().loadGatewayState()
|
||||
}.value
|
||||
|
||||
let configuredNames = Self.parseConfiguredPlatforms(yaml: yaml)
|
||||
var status: [String: PlatformConnectivity] = [:]
|
||||
|
||||
for platform in KnownPlatforms.all {
|
||||
if let pState = gatewayState?.platforms?[platform.name] {
|
||||
if let err = pState.error, !err.isEmpty {
|
||||
status[platform.name] = .error(err)
|
||||
} else if pState.connected == true {
|
||||
status[platform.name] = .connected
|
||||
} else if configuredNames.contains(platform.name) || platform.name == "cli" {
|
||||
status[platform.name] = .configured
|
||||
} else {
|
||||
status[platform.name] = .notConfigured
|
||||
}
|
||||
} else if configuredNames.contains(platform.name) || platform.name == "cli" {
|
||||
status[platform.name] = .configured
|
||||
} else {
|
||||
status[platform.name] = .notConfigured
|
||||
}
|
||||
}
|
||||
availablePlatforms = platforms.isEmpty ? [KnownPlatforms.cli] : platforms
|
||||
|
||||
connectivity = status
|
||||
availablePlatforms = KnownPlatforms.all
|
||||
if !availablePlatforms.contains(where: { $0.name == selectedPlatform.name }),
|
||||
let first = availablePlatforms.first {
|
||||
selectedPlatform = first
|
||||
}
|
||||
}
|
||||
|
||||
/// Find top-level YAML keys that look like messaging platform sections.
|
||||
/// Matches any known platform name followed by `:` at indent 0.
|
||||
private static func parseConfiguredPlatforms(yaml: String) -> Set<String> {
|
||||
var found: Set<String> = []
|
||||
let knownNames = Set(KnownPlatforms.all.map(\.name))
|
||||
for line in yaml.components(separatedBy: "\n") {
|
||||
guard !line.isEmpty, !line.hasPrefix(" "), !line.hasPrefix("\t") else { continue }
|
||||
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||
guard trimmed.hasSuffix(":") else { continue }
|
||||
let name = String(trimmed.dropLast()).trimmingCharacters(in: .whitespaces)
|
||||
if knownNames.contains(name) {
|
||||
found.insert(name)
|
||||
}
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func loadTools(for platform: HermesToolPlatform) async {
|
||||
let result = await runHermes(["tools", "list", "--platform", platform.name])
|
||||
|
||||
@@ -19,19 +19,46 @@ struct ToolsView: View {
|
||||
|
||||
private var platformPicker: some View {
|
||||
HStack(spacing: 12) {
|
||||
Picker("Platform", selection: Binding(
|
||||
get: { viewModel.selectedPlatform.name },
|
||||
set: { name in
|
||||
if let platform = viewModel.availablePlatforms.first(where: { $0.name == name }) {
|
||||
// macOS renders Menu items using NSMenu, which only honors text and
|
||||
// SF Symbol images — custom-drawn Circle() shapes don't appear in the
|
||||
// dropdown. We use a filled SF Symbol "circlebadge.fill" and the status
|
||||
// text suffix so users can tell offline from connected inside the menu.
|
||||
Menu {
|
||||
ForEach(viewModel.availablePlatforms) { platform in
|
||||
Button {
|
||||
Task { await viewModel.switchPlatform(platform) }
|
||||
} label: {
|
||||
let status = viewModel.connectivity[platform.name] ?? .notConfigured
|
||||
Label(
|
||||
menuLabel(platform: platform, status: status),
|
||||
systemImage: statusSymbol(status)
|
||||
)
|
||||
}
|
||||
}
|
||||
)) {
|
||||
ForEach(viewModel.availablePlatforms) { platform in
|
||||
Text(platform.displayName).tag(platform.name)
|
||||
} label: {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: KnownPlatforms.icon(for: viewModel.selectedPlatform.name))
|
||||
Text(viewModel.selectedPlatform.displayName)
|
||||
.fontWeight(.medium)
|
||||
statusDot(for: viewModel.connectivity[viewModel.selectedPlatform.name] ?? .notConfigured)
|
||||
Image(systemName: "chevron.down")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 6)
|
||||
.background(.quaternary.opacity(0.4))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.menuStyle(.borderlessButton)
|
||||
.fixedSize()
|
||||
|
||||
if let tooltip = statusDescription(viewModel.connectivity[viewModel.selectedPlatform.name] ?? .notConfigured) {
|
||||
Text(tooltip)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
Text("\(viewModel.toolsets.filter(\.enabled).count) of \(viewModel.toolsets.count) enabled")
|
||||
.font(.caption)
|
||||
@@ -41,6 +68,52 @@ struct ToolsView: View {
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func statusDot(for status: PlatformConnectivity) -> some View {
|
||||
Circle()
|
||||
.fill(statusColor(status))
|
||||
.frame(width: 8, height: 8)
|
||||
}
|
||||
|
||||
/// SF Symbol name used inside NSMenu (where Circle shapes don't render).
|
||||
private func statusSymbol(_ status: PlatformConnectivity) -> String {
|
||||
switch status {
|
||||
case .connected: return "circle.fill"
|
||||
case .configured: return "circle.dotted"
|
||||
case .notConfigured: return "circle"
|
||||
case .error: return "exclamationmark.circle.fill"
|
||||
}
|
||||
}
|
||||
|
||||
/// Menu-item label with an offline/connected suffix so status is readable even
|
||||
/// if the color of the SF Symbol doesn't come through NSMenu tinting.
|
||||
private func menuLabel(platform: HermesToolPlatform, status: PlatformConnectivity) -> String {
|
||||
switch status {
|
||||
case .connected: return platform.displayName
|
||||
case .configured: return "\(platform.displayName) (offline)"
|
||||
case .notConfigured: return "\(platform.displayName) (not configured)"
|
||||
case .error: return "\(platform.displayName) (error)"
|
||||
}
|
||||
}
|
||||
|
||||
private func statusColor(_ status: PlatformConnectivity) -> Color {
|
||||
switch status {
|
||||
case .connected: return .green
|
||||
case .configured: return .orange
|
||||
case .notConfigured: return .secondary.opacity(0.4)
|
||||
case .error: return .red
|
||||
}
|
||||
}
|
||||
|
||||
private func statusDescription(_ status: PlatformConnectivity) -> String? {
|
||||
switch status {
|
||||
case .connected: return "Connected"
|
||||
case .configured: return "Configured · not running"
|
||||
case .notConfigured: return "Not configured"
|
||||
case .error(let msg): return "Error: \(msg)"
|
||||
}
|
||||
}
|
||||
|
||||
private var toolsList: some View {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 1) {
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
struct HermesWebhook: Identifiable, Sendable, Equatable {
|
||||
var id: String { name }
|
||||
let name: String
|
||||
let description: String
|
||||
let deliver: String
|
||||
let events: [String]
|
||||
let routeSuffix: String // The URL suffix shown by hermes after subscription
|
||||
}
|
||||
|
||||
@Observable
|
||||
final class WebhooksViewModel {
|
||||
private let logger = Logger(subsystem: "com.scarf", category: "WebhooksViewModel")
|
||||
private let fileService = HermesFileService()
|
||||
|
||||
var webhooks: [HermesWebhook] = []
|
||||
var isLoading = false
|
||||
var message: String?
|
||||
|
||||
/// True when hermes's webhook gateway isn't configured. In that state,
|
||||
/// `hermes webhook list` returns setup instructions rather than a list of
|
||||
/// subscriptions — the UI should show a "Setup required" panel instead of
|
||||
/// trying to parse the output as webhook entries.
|
||||
var webhookPlatformNotEnabled: Bool = false
|
||||
|
||||
func load() {
|
||||
isLoading = true
|
||||
Task.detached { [fileService] in
|
||||
let result = fileService.runHermesCLI(args: ["webhook", "list"], timeout: 30)
|
||||
let notEnabled = Self.detectNotEnabled(result.output)
|
||||
let parsed = notEnabled ? [] : Self.parseWebhookList(result.output)
|
||||
await MainActor.run {
|
||||
self.isLoading = false
|
||||
self.webhookPlatformNotEnabled = notEnabled
|
||||
self.webhooks = parsed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Detect the "not enabled" state by the setup-instructions marker hermes emits.
|
||||
/// Checked before parsing so we don't synthesize bogus entries from instructional
|
||||
/// text.
|
||||
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")
|
||||
}
|
||||
|
||||
func subscribe(name: String, prompt: String, events: String, description: String, skills: String, deliver: String, chatID: String, secret: String) {
|
||||
guard !name.isEmpty else { return }
|
||||
var args = ["webhook", "subscribe", name]
|
||||
if !prompt.isEmpty { args += ["--prompt", prompt] }
|
||||
if !events.isEmpty { args += ["--events", events] }
|
||||
if !description.isEmpty { args += ["--description", description] }
|
||||
if !skills.isEmpty { args += ["--skills", skills] }
|
||||
if !deliver.isEmpty { args += ["--deliver", deliver] }
|
||||
if !chatID.isEmpty { args += ["--deliver-chat-id", chatID] }
|
||||
if !secret.isEmpty { args += ["--secret", secret] }
|
||||
runAndReload(args, success: "Subscribed /\(name)")
|
||||
}
|
||||
|
||||
func remove(_ webhook: HermesWebhook) {
|
||||
runAndReload(["webhook", "remove", webhook.name], success: "Removed")
|
||||
}
|
||||
|
||||
func test(_ webhook: HermesWebhook) {
|
||||
Task.detached { [fileService] in
|
||||
let result = fileService.runHermesCLI(args: ["webhook", "test", webhook.name], timeout: 30)
|
||||
await MainActor.run {
|
||||
self.message = result.exitCode == 0 ? "Test fired — check logs" : "Test failed"
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
||||
self?.message = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func runAndReload(_ args: [String], success: String) {
|
||||
Task.detached { [fileService] in
|
||||
let result = fileService.runHermesCLI(args: args, timeout: 60)
|
||||
await MainActor.run {
|
||||
self.message = result.exitCode == 0 ? success : "Failed"
|
||||
self.load()
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
|
||||
self?.message = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Tolerant parser for `hermes webhook list`. The CLI output format is evolving,
|
||||
/// so we extract what we can and degrade gracefully for unknown shapes.
|
||||
/// `nonisolated` so it can be invoked from `Task.detached`.
|
||||
nonisolated private static func parseWebhookList(_ output: String) -> [HermesWebhook] {
|
||||
var results: [HermesWebhook] = []
|
||||
var currentName = ""
|
||||
var currentDesc = ""
|
||||
var currentDeliver = ""
|
||||
var currentEvents: [String] = []
|
||||
var currentRoute = ""
|
||||
|
||||
func flush() {
|
||||
if !currentName.isEmpty {
|
||||
results.append(HermesWebhook(
|
||||
name: currentName,
|
||||
description: currentDesc,
|
||||
deliver: currentDeliver,
|
||||
events: currentEvents,
|
||||
routeSuffix: currentRoute.isEmpty ? "/webhooks/\(currentName)" : currentRoute
|
||||
))
|
||||
}
|
||||
currentName = ""; currentDesc = ""; currentDeliver = ""
|
||||
currentEvents = []; currentRoute = ""
|
||||
}
|
||||
|
||||
for raw in output.components(separatedBy: "\n") {
|
||||
let line = raw
|
||||
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||
if trimmed.isEmpty { continue }
|
||||
// New webhook block: non-indented, alphanumeric/underscore.
|
||||
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 {
|
||||
currentName = candidate
|
||||
}
|
||||
continue
|
||||
}
|
||||
if trimmed.lowercased().hasPrefix("description:") {
|
||||
currentDesc = String(trimmed.dropFirst("description:".count)).trimmingCharacters(in: .whitespaces)
|
||||
} else if trimmed.lowercased().hasPrefix("deliver:") {
|
||||
currentDeliver = String(trimmed.dropFirst("deliver:".count)).trimmingCharacters(in: .whitespaces)
|
||||
} else if trimmed.lowercased().hasPrefix("events:") {
|
||||
let list = String(trimmed.dropFirst("events:".count)).trimmingCharacters(in: .whitespaces)
|
||||
currentEvents = list.components(separatedBy: ",").map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty }
|
||||
} else if trimmed.lowercased().hasPrefix("url:") || trimmed.lowercased().hasPrefix("route:") {
|
||||
currentRoute = trimmed.components(separatedBy: ":").dropFirst().joined(separator: ":").trimmingCharacters(in: .whitespaces)
|
||||
}
|
||||
}
|
||||
flush()
|
||||
return results
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
import SwiftUI
|
||||
import AppKit
|
||||
|
||||
struct WebhooksView: View {
|
||||
@State private var viewModel = WebhooksViewModel()
|
||||
@State private var showAddSheet = false
|
||||
@State private var pendingRemove: HermesWebhook?
|
||||
|
||||
// Add form state
|
||||
@State private var addName = ""
|
||||
@State private var addPrompt = ""
|
||||
@State private var addEvents = ""
|
||||
@State private var addDescription = ""
|
||||
@State private var addSkills = ""
|
||||
@State private var addDeliver = "log"
|
||||
@State private var addChatID = ""
|
||||
@State private var addSecret = ""
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
header
|
||||
Divider()
|
||||
if viewModel.isLoading && viewModel.webhooks.isEmpty {
|
||||
ProgressView().padding()
|
||||
} else if viewModel.webhookPlatformNotEnabled {
|
||||
setupRequiredState
|
||||
} else if viewModel.webhooks.isEmpty {
|
||||
emptyState
|
||||
} else {
|
||||
list
|
||||
}
|
||||
}
|
||||
.navigationTitle("Webhooks")
|
||||
.onAppear { viewModel.load() }
|
||||
.sheet(isPresented: $showAddSheet) { addSheet }
|
||||
.confirmationDialog(
|
||||
pendingRemove.map { "Remove webhook \($0.name)?" } ?? "",
|
||||
isPresented: Binding(get: { pendingRemove != nil }, set: { if !$0 { pendingRemove = nil } })
|
||||
) {
|
||||
Button("Remove", role: .destructive) {
|
||||
if let w = pendingRemove { viewModel.remove(w) }
|
||||
pendingRemove = nil
|
||||
}
|
||||
Button("Cancel", role: .cancel) { pendingRemove = nil }
|
||||
}
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack {
|
||||
if let msg = viewModel.message {
|
||||
Label(msg, systemImage: "info.circle.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.green)
|
||||
}
|
||||
Spacer()
|
||||
Button {
|
||||
resetAddForm()
|
||||
showAddSheet = true
|
||||
} label: {
|
||||
Label("Subscribe", systemImage: "plus")
|
||||
}
|
||||
.controlSize(.small)
|
||||
Button("Reload") { viewModel.load() }
|
||||
.controlSize(.small)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
|
||||
/// Shown when hermes reports the webhook platform isn't enabled. Direct users
|
||||
/// to the interactive setup wizard instead of showing a misleading empty list.
|
||||
private var setupRequiredState: some View {
|
||||
VStack(spacing: 12) {
|
||||
Image(systemName: "exclamationmark.triangle")
|
||||
.font(.largeTitle)
|
||||
.foregroundStyle(.orange)
|
||||
Text("Webhook platform not enabled")
|
||||
.font(.title3.bold())
|
||||
Text("Hermes needs a global webhook secret and port before subscriptions can receive traffic. Run the gateway setup wizard or edit ~/.hermes/config.yaml manually.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: 500)
|
||||
HStack(spacing: 8) {
|
||||
Button {
|
||||
openGatewaySetupInTerminal()
|
||||
} label: {
|
||||
Label("Run Setup in Terminal", systemImage: "terminal")
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.small)
|
||||
Button {
|
||||
NSWorkspace.shared.open(URL(fileURLWithPath: HermesPaths.configYAML))
|
||||
} label: {
|
||||
Label("Edit config.yaml", systemImage: "doc.text")
|
||||
}
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.padding()
|
||||
}
|
||||
|
||||
private func openGatewaySetupInTerminal() {
|
||||
guard let hermes = HermesFileService().hermesBinaryPath() else { return }
|
||||
let script = "tell application \"Terminal\"\n activate\n do script \"\(hermes) gateway setup\"\nend tell"
|
||||
let appleScript = NSAppleScript(source: script)
|
||||
var err: NSDictionary?
|
||||
appleScript?.executeAndReturnError(&err)
|
||||
}
|
||||
|
||||
private var emptyState: some View {
|
||||
VStack(spacing: 8) {
|
||||
Image(systemName: "arrow.up.right.square")
|
||||
.font(.largeTitle)
|
||||
.foregroundStyle(.secondary)
|
||||
Text("No webhook subscriptions")
|
||||
.foregroundStyle(.secondary)
|
||||
Text("Webhooks let external services trigger agent responses. Each subscription gets its own URL endpoint.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: 440)
|
||||
Button("Create Subscription") {
|
||||
resetAddForm()
|
||||
showAddSheet = true
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.small)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.padding()
|
||||
}
|
||||
|
||||
private var list: some View {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 1) {
|
||||
ForEach(viewModel.webhooks) { webhook in
|
||||
row(webhook)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
private func row(_ webhook: HermesWebhook) -> some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "arrow.up.right.square")
|
||||
.foregroundStyle(.blue)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(webhook.name)
|
||||
.font(.system(.body, design: .monospaced, weight: .medium))
|
||||
if !webhook.description.isEmpty {
|
||||
Text(webhook.description)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
HStack(spacing: 6) {
|
||||
Text(webhook.routeSuffix)
|
||||
.font(.caption.monospaced())
|
||||
.textSelection(.enabled)
|
||||
.foregroundStyle(.tertiary)
|
||||
if !webhook.deliver.isEmpty {
|
||||
Text(webhook.deliver)
|
||||
.font(.caption2.bold())
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 1)
|
||||
.background(.quaternary)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
ForEach(webhook.events, id: \.self) { event in
|
||||
Text(event)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.blue)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 1)
|
||||
.background(.blue.opacity(0.12))
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
Button("Test") { viewModel.test(webhook) }
|
||||
.controlSize(.small)
|
||||
Button("Remove", role: .destructive) { pendingRemove = webhook }
|
||||
.controlSize(.small)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
}
|
||||
|
||||
private var addSheet: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("New Webhook Subscription")
|
||||
.font(.headline)
|
||||
formField("Name (URL suffix)", text: $addName, placeholder: "github_push", mono: true)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Prompt").font(.caption).foregroundStyle(.secondary)
|
||||
Text("Use {dot.notation} to reference fields in the webhook payload.")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
TextEditor(text: $addPrompt)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.frame(minHeight: 90)
|
||||
.padding(4)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
formField("Events (comma separated)", text: $addEvents, placeholder: "push, pull_request", mono: true)
|
||||
formField("Description", text: $addDescription, placeholder: "Optional human description")
|
||||
formField("Skills (comma separated)", text: $addSkills, placeholder: "github-auth, pr-review", mono: true)
|
||||
formField("Deliver", text: $addDeliver, placeholder: "log | telegram | discord | slack")
|
||||
formField("Chat ID", text: $addChatID, placeholder: "Required for cross-platform delivery")
|
||||
formField("Secret", text: $addSecret, placeholder: "HMAC secret (auto-generated if empty)", mono: true)
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("Cancel") { showAddSheet = false }
|
||||
Button("Subscribe") {
|
||||
viewModel.subscribe(
|
||||
name: addName,
|
||||
prompt: addPrompt,
|
||||
events: addEvents,
|
||||
description: addDescription,
|
||||
skills: addSkills,
|
||||
deliver: addDeliver,
|
||||
chatID: addChatID,
|
||||
secret: addSecret
|
||||
)
|
||||
showAddSheet = false
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(addName.trimmingCharacters(in: .whitespaces).isEmpty)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.frame(minWidth: 560, minHeight: 560)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func formField(_ label: String, text: Binding<String>, placeholder: String, mono: Bool = false) -> some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(label).font(.caption).foregroundStyle(.secondary)
|
||||
TextField(placeholder, text: text)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(mono ? .system(.caption, design: .monospaced) : .caption)
|
||||
}
|
||||
}
|
||||
|
||||
private func resetAddForm() {
|
||||
addName = ""; addPrompt = ""; addEvents = ""; addDescription = ""
|
||||
addSkills = ""; addDeliver = "log"; addChatID = ""; addSecret = ""
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,26 @@
|
||||
import Foundation
|
||||
|
||||
enum SidebarSection: String, CaseIterable, Identifiable {
|
||||
// Monitor
|
||||
case dashboard = "Dashboard"
|
||||
case insights = "Insights"
|
||||
case sessions = "Sessions"
|
||||
case activity = "Activity"
|
||||
// Projects
|
||||
case projects = "Projects"
|
||||
// Interact
|
||||
case chat = "Chat"
|
||||
case memory = "Memory"
|
||||
case skills = "Skills"
|
||||
// Configure (Phase 2/3 additions)
|
||||
case platforms = "Platforms"
|
||||
case personalities = "Personalities"
|
||||
case quickCommands = "Quick Commands"
|
||||
case credentialPools = "Credential Pools"
|
||||
case plugins = "Plugins"
|
||||
case webhooks = "Webhooks"
|
||||
case profiles = "Profiles"
|
||||
// Manage
|
||||
case tools = "Tools"
|
||||
case mcpServers = "MCP Servers"
|
||||
case gateway = "Gateway"
|
||||
@@ -29,6 +41,13 @@ enum SidebarSection: String, CaseIterable, Identifiable {
|
||||
case .chat: return "text.bubble"
|
||||
case .memory: return "brain"
|
||||
case .skills: return "lightbulb"
|
||||
case .platforms: return "dot.radiowaves.left.and.right"
|
||||
case .personalities: return "theatermasks"
|
||||
case .quickCommands: return "command.square"
|
||||
case .credentialPools: return "key.horizontal"
|
||||
case .plugins: return "app.badge.checkmark"
|
||||
case .webhooks: return "arrow.up.right.square"
|
||||
case .profiles: return "person.2.crop.square.stack"
|
||||
case .tools: return "wrench.and.screwdriver"
|
||||
case .mcpServers: return "puzzlepiece.extension"
|
||||
case .gateway: return "antenna.radiowaves.left.and.right"
|
||||
|
||||
@@ -24,6 +24,12 @@ struct SidebarView: View {
|
||||
.tag(section)
|
||||
}
|
||||
}
|
||||
Section("Configure") {
|
||||
ForEach([SidebarSection.platforms, .personalities, .quickCommands, .credentialPools, .plugins, .webhooks, .profiles]) { section in
|
||||
Label(section.rawValue, systemImage: section.icon)
|
||||
.tag(section)
|
||||
}
|
||||
}
|
||||
Section("Manage") {
|
||||
ForEach([SidebarSection.tools, .mcpServers, .gateway, .cron, .health, .logs, .settings]) { section in
|
||||
Label(section.rawValue, systemImage: section.icon)
|
||||
|
||||
Reference in New Issue
Block a user