From b6d9113579cc80cc9d8e966435283f6a761d50a0 Mon Sep 17 00:00:00 2001 From: Alan Wizemann Date: Thu, 16 Apr 2026 15:39:07 -0700 Subject: [PATCH] feat: Settings tabs, Platforms, Credential Pools, Model Picker, and Configure sidebar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- scarf/scarf/ContentView.swift | 52 +- scarf/scarf/Core/Models/HermesConfig.swift | 358 +++++++++++- .../Core/Services/HermesEnvService.swift | 215 ++++++++ .../Core/Services/HermesFileService.swift | 421 +++++++++++--- .../Core/Services/HermesFileWatcher.swift | 1 + .../Core/Services/ModelCatalogService.swift | 201 +++++++ .../ViewModels/CredentialPoolsViewModel.swift | 244 +++++++++ .../ViewModels/OAuthFlowController.swift | 251 +++++++++ .../Views/CredentialPoolsView.swift | 472 ++++++++++++++++ .../Cron/ViewModels/CronViewModel.swift | 82 +++ .../scarf/Features/Cron/Views/CronView.swift | 512 +++++++++++++----- .../Health/ViewModels/HealthViewModel.swift | 35 ++ .../Features/Health/Views/HealthView.swift | 64 ++- .../ViewModels/PersonalitiesViewModel.swift | 103 ++++ .../Views/PersonalitiesView.swift | 133 +++++ .../PlatformSetup/DiscordSetupViewModel.swift | 64 +++ .../PlatformSetup/EmailSetupViewModel.swift | 80 +++ .../PlatformSetup/FeishuSetupViewModel.swift | 47 ++ .../HomeAssistantSetupViewModel.swift | 67 +++ .../IMessageSetupViewModel.swift | 51 ++ .../PlatformSetup/MatrixSetupViewModel.swift | 62 +++ .../MattermostSetupViewModel.swift | 48 ++ .../PlatformSetup/PlatformSetupHelpers.swift | 91 ++++ .../PlatformSetup/SignalSetupViewModel.swift | 116 ++++ .../PlatformSetup/SlackSetupViewModel.swift | 58 ++ .../TelegramSetupViewModel.swift | 61 +++ .../PlatformSetup/WebhookSetupViewModel.swift | 34 ++ .../WhatsAppSetupViewModel.swift | 90 +++ .../ViewModels/PlatformsViewModel.swift | 96 ++++ .../PlatformSetup/DiscordSetupView.swift | 60 ++ .../Views/PlatformSetup/EmailSetupView.swift | 78 +++ .../PlatformSetup/EmbeddedSetupTerminal.swift | 158 ++++++ .../Views/PlatformSetup/FeishuSetupView.swift | 55 ++ .../HomeAssistantSetupView.swift | 75 +++ .../PlatformSetup/IMessageSetupView.swift | 64 +++ .../Views/PlatformSetup/MatrixSetupView.swift | 72 +++ .../PlatformSetup/MattermostSetupView.swift | 55 ++ .../Views/PlatformSetup/SignalSetupView.swift | 103 ++++ .../Views/PlatformSetup/SlackSetupView.swift | 59 ++ .../PlatformSetup/TelegramSetupView.swift | 61 +++ .../PlatformSetup/WebhookSetupView.swift | 56 ++ .../PlatformSetup/WhatsAppSetupView.swift | 83 +++ .../Platforms/Views/PlatformsView.swift | 165 ++++++ .../Plugins/ViewModels/PluginsViewModel.swift | 121 +++++ .../Features/Plugins/Views/PluginsView.swift | 152 ++++++ .../ViewModels/ProfilesViewModel.swift | 130 +++++ .../Profiles/Views/ProfilesView.swift | 252 +++++++++ .../ViewModels/QuickCommandsViewModel.swift | 94 ++++ .../Views/QuickCommandsView.swift | 180 ++++++ .../ViewModels/SettingsViewModel.swift | 190 ++++++- .../Views/Components/ModelPickerRow.swift | 73 +++ .../Views/Components/ModelPickerSheet.swift | 265 +++++++++ .../Views/Components/SettingsComponents.swift | 291 ++++++++++ .../Settings/Views/SettingsView.swift | 492 +++-------------- .../Settings/Views/Tabs/AdvancedTab.swift | 178 ++++++ .../Settings/Views/Tabs/AgentTab.swift | 27 + .../Settings/Views/Tabs/AuxiliaryTab.swift | 56 ++ .../Settings/Views/Tabs/BrowserTab.swift | 26 + .../Settings/Views/Tabs/DisplayTab.swift | 33 ++ .../Settings/Views/Tabs/GeneralTab.swift | 70 +++ .../Settings/Views/Tabs/MemoryTab.swift | 39 ++ .../Settings/Views/Tabs/SecurityTab.swift | 39 ++ .../Settings/Views/Tabs/TerminalTab.swift | 60 ++ .../Settings/Views/Tabs/VoiceTab.swift | 51 ++ .../Skills/ViewModels/SkillsViewModel.swift | 224 ++++++++ .../Features/Skills/Views/SkillsView.swift | 190 ++++++- .../Tools/ViewModels/ToolsViewModel.swift | 90 ++- .../Features/Tools/Views/ToolsView.swift | 89 ++- .../ViewModels/WebhooksViewModel.swift | 146 +++++ .../Webhooks/Views/WebhooksView.swift | 255 +++++++++ scarf/scarf/Navigation/AppCoordinator.swift | 19 + scarf/scarf/Navigation/SidebarView.swift | 6 + 72 files changed, 8348 insertions(+), 743 deletions(-) create mode 100644 scarf/scarf/Core/Services/HermesEnvService.swift create mode 100644 scarf/scarf/Core/Services/ModelCatalogService.swift create mode 100644 scarf/scarf/Features/CredentialPools/ViewModels/CredentialPoolsViewModel.swift create mode 100644 scarf/scarf/Features/CredentialPools/ViewModels/OAuthFlowController.swift create mode 100644 scarf/scarf/Features/CredentialPools/Views/CredentialPoolsView.swift create mode 100644 scarf/scarf/Features/Personalities/ViewModels/PersonalitiesViewModel.swift create mode 100644 scarf/scarf/Features/Personalities/Views/PersonalitiesView.swift create mode 100644 scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/DiscordSetupViewModel.swift create mode 100644 scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/EmailSetupViewModel.swift create mode 100644 scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/FeishuSetupViewModel.swift create mode 100644 scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/HomeAssistantSetupViewModel.swift create mode 100644 scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/IMessageSetupViewModel.swift create mode 100644 scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/MatrixSetupViewModel.swift create mode 100644 scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/MattermostSetupViewModel.swift create mode 100644 scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/PlatformSetupHelpers.swift create mode 100644 scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/SignalSetupViewModel.swift create mode 100644 scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/SlackSetupViewModel.swift create mode 100644 scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/TelegramSetupViewModel.swift create mode 100644 scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/WebhookSetupViewModel.swift create mode 100644 scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/WhatsAppSetupViewModel.swift create mode 100644 scarf/scarf/Features/Platforms/ViewModels/PlatformsViewModel.swift create mode 100644 scarf/scarf/Features/Platforms/Views/PlatformSetup/DiscordSetupView.swift create mode 100644 scarf/scarf/Features/Platforms/Views/PlatformSetup/EmailSetupView.swift create mode 100644 scarf/scarf/Features/Platforms/Views/PlatformSetup/EmbeddedSetupTerminal.swift create mode 100644 scarf/scarf/Features/Platforms/Views/PlatformSetup/FeishuSetupView.swift create mode 100644 scarf/scarf/Features/Platforms/Views/PlatformSetup/HomeAssistantSetupView.swift create mode 100644 scarf/scarf/Features/Platforms/Views/PlatformSetup/IMessageSetupView.swift create mode 100644 scarf/scarf/Features/Platforms/Views/PlatformSetup/MatrixSetupView.swift create mode 100644 scarf/scarf/Features/Platforms/Views/PlatformSetup/MattermostSetupView.swift create mode 100644 scarf/scarf/Features/Platforms/Views/PlatformSetup/SignalSetupView.swift create mode 100644 scarf/scarf/Features/Platforms/Views/PlatformSetup/SlackSetupView.swift create mode 100644 scarf/scarf/Features/Platforms/Views/PlatformSetup/TelegramSetupView.swift create mode 100644 scarf/scarf/Features/Platforms/Views/PlatformSetup/WebhookSetupView.swift create mode 100644 scarf/scarf/Features/Platforms/Views/PlatformSetup/WhatsAppSetupView.swift create mode 100644 scarf/scarf/Features/Platforms/Views/PlatformsView.swift create mode 100644 scarf/scarf/Features/Plugins/ViewModels/PluginsViewModel.swift create mode 100644 scarf/scarf/Features/Plugins/Views/PluginsView.swift create mode 100644 scarf/scarf/Features/Profiles/ViewModels/ProfilesViewModel.swift create mode 100644 scarf/scarf/Features/Profiles/Views/ProfilesView.swift create mode 100644 scarf/scarf/Features/QuickCommands/ViewModels/QuickCommandsViewModel.swift create mode 100644 scarf/scarf/Features/QuickCommands/Views/QuickCommandsView.swift create mode 100644 scarf/scarf/Features/Settings/Views/Components/ModelPickerRow.swift create mode 100644 scarf/scarf/Features/Settings/Views/Components/ModelPickerSheet.swift create mode 100644 scarf/scarf/Features/Settings/Views/Components/SettingsComponents.swift create mode 100644 scarf/scarf/Features/Settings/Views/Tabs/AdvancedTab.swift create mode 100644 scarf/scarf/Features/Settings/Views/Tabs/AgentTab.swift create mode 100644 scarf/scarf/Features/Settings/Views/Tabs/AuxiliaryTab.swift create mode 100644 scarf/scarf/Features/Settings/Views/Tabs/BrowserTab.swift create mode 100644 scarf/scarf/Features/Settings/Views/Tabs/DisplayTab.swift create mode 100644 scarf/scarf/Features/Settings/Views/Tabs/GeneralTab.swift create mode 100644 scarf/scarf/Features/Settings/Views/Tabs/MemoryTab.swift create mode 100644 scarf/scarf/Features/Settings/Views/Tabs/SecurityTab.swift create mode 100644 scarf/scarf/Features/Settings/Views/Tabs/TerminalTab.swift create mode 100644 scarf/scarf/Features/Settings/Views/Tabs/VoiceTab.swift create mode 100644 scarf/scarf/Features/Webhooks/ViewModels/WebhooksViewModel.swift create mode 100644 scarf/scarf/Features/Webhooks/Views/WebhooksView.swift diff --git a/scarf/scarf/ContentView.swift b/scarf/scarf/ContentView.swift index 0e7cfd9..18a2e64 100644 --- a/scarf/scarf/ContentView.swift +++ b/scarf/scarf/ContentView.swift @@ -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() } } } diff --git a/scarf/scarf/Core/Models/HermesConfig.swift b/scarf/scarf/Core/Models/HermesConfig.swift index 44adf51..06aaa2a 100644 --- a/scarf/scarf/Core/Models/HermesConfig.swift +++ b/scarf/scarf/Core/Models/HermesConfig.swift @@ -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 ) } diff --git a/scarf/scarf/Core/Services/HermesEnvService.swift b/scarf/scarf/Core/Services/HermesEnvService.swift new file mode 100644 index 0000000..408fa6d --- /dev/null +++ b/scarf/scarf/Core/Services/HermesEnvService.swift @@ -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.. 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.. 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 = [" ", "\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 + } +} diff --git a/scarf/scarf/Core/Services/HermesFileService.swift b/scarf/scarf/Core/Services/HermesFileService.swift index 72f4f9e..f17e1bc 100644 --- a/scarf/scarf/Core/Services/HermesFileService.swift +++ b/scarf/scarf/Core/Services/HermesFileService.swift @@ -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..= 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..= 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.." { + // 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 diff --git a/scarf/scarf/Core/Services/HermesFileWatcher.swift b/scarf/scarf/Core/Services/HermesFileWatcher.swift index e620330..69ab653 100644 --- a/scarf/scarf/Core/Services/HermesFileWatcher.swift +++ b/scarf/scarf/Core/Services/HermesFileWatcher.swift @@ -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, diff --git a/scarf/scarf/Core/Services/ModelCatalogService.swift b/scarf/scarf/Core/Services/ModelCatalogService.swift new file mode 100644 index 0000000..bfe262c --- /dev/null +++ b/scarf/scarf/Core/Services/ModelCatalogService.swift @@ -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.. 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? + } +} diff --git a/scarf/scarf/Features/CredentialPools/ViewModels/CredentialPoolsViewModel.swift b/scarf/scarf/Features/CredentialPools/ViewModels/CredentialPoolsViewModel.swift new file mode 100644 index 0000000..5d0f25a --- /dev/null +++ b/scarf/scarf/Features/CredentialPools/ViewModels/CredentialPoolsViewModel.swift @@ -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 `: `. + 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? +} diff --git a/scarf/scarf/Features/CredentialPools/ViewModels/OAuthFlowController.swift b/scarf/scarf/Features/CredentialPools/ViewModels/OAuthFlowController.swift new file mode 100644 index 0000000..e2e2af7 --- /dev/null +++ b/scarf/scarf/Features/CredentialPools/ViewModels/OAuthFlowController.swift @@ -0,0 +1,251 @@ +import Foundation +import AppKit +import os + +/// Drives the `hermes auth add --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 + } +} diff --git a/scarf/scarf/Features/CredentialPools/Views/CredentialPoolsView.swift b/scarf/scarf/Features/CredentialPools/Views/CredentialPoolsView.swift new file mode 100644 index 0000000..ad97eaa --- /dev/null +++ b/scarf/scarf/Features/CredentialPools/Views/CredentialPoolsView.swift @@ -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) + } + } + } + } +} diff --git a/scarf/scarf/Features/Cron/ViewModels/CronViewModel.swift b/scarf/scarf/Features/Cron/ViewModels/CronViewModel.swift index 4285544..7e1e34d 100644 --- a/scarf/scarf/Features/Cron/ViewModels/CronViewModel.swift +++ b/scarf/scarf/Features/Cron/ViewModels/CronViewModel.swift @@ -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 + } + } + } + } } diff --git a/scarf/scarf/Features/Cron/Views/CronView.swift b/scarf/scarf/Features/Cron/Views/CronView.swift index 0328058..92c0480 100644 --- a/scarf/scarf/Features/Cron/Views/CronView.swift +++ b/scarf/scarf/Features/Cron/Views/CronView.swift @@ -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, 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) + } + } } diff --git a/scarf/scarf/Features/Health/ViewModels/HealthViewModel.swift b/scarf/scarf/Features/Health/ViewModels/HealthViewModel.swift index 70ae9ec..a9b1359 100644 --- a/scarf/scarf/Features/Health/ViewModels/HealthViewModel.swift +++ b/scarf/scarf/Features/Health/ViewModels/HealthViewModel.swift @@ -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) diff --git a/scarf/scarf/Features/Health/Views/HealthView.swift b/scarf/scarf/Features/Health/Views/HealthView.swift index 436c487..3c95677 100644 --- a/scarf/scarf/Features/Health/Views/HealthView.swift +++ b/scarf/scarf/Features/Health/Views/HealthView.swift @@ -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 diff --git a/scarf/scarf/Features/Personalities/ViewModels/PersonalitiesViewModel.swift b/scarf/scarf/Features/Personalities/ViewModels/PersonalitiesViewModel.swift new file mode 100644 index 0000000..b8b646b --- /dev/null +++ b/scarf/scarf/Features/Personalities/ViewModels/PersonalitiesViewModel.swift @@ -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.[.subkey]" + var nameSet: Set = [] + 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) + } + } +} diff --git a/scarf/scarf/Features/Personalities/Views/PersonalitiesView.swift b/scarf/scarf/Features/Personalities/Views/PersonalitiesView.swift new file mode 100644 index 0000000..627652f --- /dev/null +++ b/scarf/scarf/Features/Personalities/Views/PersonalitiesView.swift @@ -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)) + } + } + } +} diff --git a/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/DiscordSetupViewModel.swift b/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/DiscordSetupViewModel.swift new file mode 100644 index 0000000..0d86793 --- /dev/null +++ b/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/DiscordSetupViewModel.swift @@ -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 + } + } +} diff --git a/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/EmailSetupViewModel.swift b/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/EmailSetupViewModel.swift new file mode 100644 index 0000000..d34a9fe --- /dev/null +++ b/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/EmailSetupViewModel.swift @@ -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 + } + } +} diff --git a/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/FeishuSetupViewModel.swift b/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/FeishuSetupViewModel.swift new file mode 100644 index 0000000..9917376 --- /dev/null +++ b/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/FeishuSetupViewModel.swift @@ -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 + } + } +} diff --git a/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/HomeAssistantSetupViewModel.swift b/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/HomeAssistantSetupViewModel.swift new file mode 100644 index 0000000..82c4f3a --- /dev/null +++ b/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/HomeAssistantSetupViewModel.swift @@ -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)) + } +} diff --git a/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/IMessageSetupViewModel.swift b/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/IMessageSetupViewModel.swift new file mode 100644 index 0000000..73fc4ba --- /dev/null +++ b/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/IMessageSetupViewModel.swift @@ -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 + } + } +} diff --git a/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/MatrixSetupViewModel.swift b/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/MatrixSetupViewModel.swift new file mode 100644 index 0000000..2b67d36 --- /dev/null +++ b/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/MatrixSetupViewModel.swift @@ -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 + } + } +} diff --git a/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/MattermostSetupViewModel.swift b/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/MattermostSetupViewModel.swift new file mode 100644 index 0000000..d89d1bf --- /dev/null +++ b/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/MattermostSetupViewModel.swift @@ -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 + } + } +} diff --git a/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/PlatformSetupHelpers.swift b/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/PlatformSetupHelpers.swift new file mode 100644 index 0000000..d7fbec4 --- /dev/null +++ b/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/PlatformSetupHelpers.swift @@ -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 ""` 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 + } + } +} diff --git a/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/SignalSetupViewModel.swift b/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/SignalSetupViewModel.swift new file mode 100644 index 0000000..8f23478 --- /dev/null +++ b/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/SignalSetupViewModel.swift @@ -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 + } + } +} diff --git a/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/SlackSetupViewModel.swift b/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/SlackSetupViewModel.swift new file mode 100644 index 0000000..38d552d --- /dev/null +++ b/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/SlackSetupViewModel.swift @@ -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 + } + } +} diff --git a/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/TelegramSetupViewModel.swift b/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/TelegramSetupViewModel.swift new file mode 100644 index 0000000..dcebfef --- /dev/null +++ b/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/TelegramSetupViewModel.swift @@ -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 + } + } +} diff --git a/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/WebhookSetupViewModel.swift b/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/WebhookSetupViewModel.swift new file mode 100644 index 0000000..d81e481 --- /dev/null +++ b/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/WebhookSetupViewModel.swift @@ -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 + } + } +} diff --git a/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/WhatsAppSetupViewModel.swift b/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/WhatsAppSetupViewModel.swift new file mode 100644 index 0000000..2445834 --- /dev/null +++ b/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/WhatsAppSetupViewModel.swift @@ -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 + } + } +} diff --git a/scarf/scarf/Features/Platforms/ViewModels/PlatformsViewModel.swift b/scarf/scarf/Features/Platforms/ViewModels/PlatformsViewModel.swift new file mode 100644 index 0000000..4e845b5 --- /dev/null +++ b/scarf/scarf/Features/Platforms/ViewModels/PlatformsViewModel.swift @@ -0,0 +1,96 @@ +import Foundation +import os + +/// Platform list/selection coordinator. Per-platform configuration now lives in +/// dedicated `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 + /// `:` 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 + } + } + } + } +} diff --git a/scarf/scarf/Features/Platforms/Views/PlatformSetup/DiscordSetupView.swift b/scarf/scarf/Features/Platforms/Views/PlatformSetup/DiscordSetupView.swift new file mode 100644 index 0000000..ca330bb --- /dev/null +++ b/scarf/scarf/Features/Platforms/Views/PlatformSetup/DiscordSetupView.swift @@ -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) + } + } +} diff --git a/scarf/scarf/Features/Platforms/Views/PlatformSetup/EmailSetupView.swift b/scarf/scarf/Features/Platforms/Views/PlatformSetup/EmailSetupView.swift new file mode 100644 index 0000000..225240e --- /dev/null +++ b/scarf/scarf/Features/Platforms/Views/PlatformSetup/EmailSetupView.swift @@ -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) + } + } +} diff --git a/scarf/scarf/Features/Platforms/Views/PlatformSetup/EmbeddedSetupTerminal.swift b/scarf/scarf/Features/Platforms/Views/PlatformSetup/EmbeddedSetupTerminal.swift new file mode 100644 index 0000000..654201f --- /dev/null +++ b/scarf/scarf/Features/Platforms/Views/PlatformSetup/EmbeddedSetupTerminal.swift @@ -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) } + } + } +} diff --git a/scarf/scarf/Features/Platforms/Views/PlatformSetup/FeishuSetupView.swift b/scarf/scarf/Features/Platforms/Views/PlatformSetup/FeishuSetupView.swift new file mode 100644 index 0000000..8bb8e98 --- /dev/null +++ b/scarf/scarf/Features/Platforms/Views/PlatformSetup/FeishuSetupView.swift @@ -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) + } + } +} diff --git a/scarf/scarf/Features/Platforms/Views/PlatformSetup/HomeAssistantSetupView.swift b/scarf/scarf/Features/Platforms/Views/PlatformSetup/HomeAssistantSetupView.swift new file mode 100644 index 0000000..ed5054e --- /dev/null +++ b/scarf/scarf/Features/Platforms/Views/PlatformSetup/HomeAssistantSetupView.swift @@ -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) + } + } +} diff --git a/scarf/scarf/Features/Platforms/Views/PlatformSetup/IMessageSetupView.swift b/scarf/scarf/Features/Platforms/Views/PlatformSetup/IMessageSetupView.swift new file mode 100644 index 0000000..ed34592 --- /dev/null +++ b/scarf/scarf/Features/Platforms/Views/PlatformSetup/IMessageSetupView.swift @@ -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) + } + } +} diff --git a/scarf/scarf/Features/Platforms/Views/PlatformSetup/MatrixSetupView.swift b/scarf/scarf/Features/Platforms/Views/PlatformSetup/MatrixSetupView.swift new file mode 100644 index 0000000..e478792 --- /dev/null +++ b/scarf/scarf/Features/Platforms/Views/PlatformSetup/MatrixSetupView.swift @@ -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) + } + } +} diff --git a/scarf/scarf/Features/Platforms/Views/PlatformSetup/MattermostSetupView.swift b/scarf/scarf/Features/Platforms/Views/PlatformSetup/MattermostSetupView.swift new file mode 100644 index 0000000..261b664 --- /dev/null +++ b/scarf/scarf/Features/Platforms/Views/PlatformSetup/MattermostSetupView.swift @@ -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) + } + } +} diff --git a/scarf/scarf/Features/Platforms/Views/PlatformSetup/SignalSetupView.swift b/scarf/scarf/Features/Platforms/Views/PlatformSetup/SignalSetupView.swift new file mode 100644 index 0000000..54f5b36 --- /dev/null +++ b/scarf/scarf/Features/Platforms/Views/PlatformSetup/SignalSetupView.swift @@ -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)) + } + } +} diff --git a/scarf/scarf/Features/Platforms/Views/PlatformSetup/SlackSetupView.swift b/scarf/scarf/Features/Platforms/Views/PlatformSetup/SlackSetupView.swift new file mode 100644 index 0000000..3b7a64e --- /dev/null +++ b/scarf/scarf/Features/Platforms/Views/PlatformSetup/SlackSetupView.swift @@ -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) + } + } +} diff --git a/scarf/scarf/Features/Platforms/Views/PlatformSetup/TelegramSetupView.swift b/scarf/scarf/Features/Platforms/Views/PlatformSetup/TelegramSetupView.swift new file mode 100644 index 0000000..1593e61 --- /dev/null +++ b/scarf/scarf/Features/Platforms/Views/PlatformSetup/TelegramSetupView.swift @@ -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) + } + } +} diff --git a/scarf/scarf/Features/Platforms/Views/PlatformSetup/WebhookSetupView.swift b/scarf/scarf/Features/Platforms/Views/PlatformSetup/WebhookSetupView.swift new file mode 100644 index 0000000..13fe3e6 --- /dev/null +++ b/scarf/scarf/Features/Platforms/Views/PlatformSetup/WebhookSetupView.swift @@ -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) + } + } +} diff --git a/scarf/scarf/Features/Platforms/Views/PlatformSetup/WhatsAppSetupView.swift b/scarf/scarf/Features/Platforms/Views/PlatformSetup/WhatsAppSetupView.swift new file mode 100644 index 0000000..f22f061 --- /dev/null +++ b/scarf/scarf/Features/Platforms/Views/PlatformSetup/WhatsAppSetupView.swift @@ -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)) + } + } +} diff --git a/scarf/scarf/Features/Platforms/Views/PlatformsView.swift b/scarf/scarf/Features/Platforms/Views/PlatformsView.swift new file mode 100644 index 0000000..beff5e3 --- /dev/null +++ b/scarf/scarf/Features/Platforms/Views/PlatformsView.swift @@ -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)" + } + } +} diff --git a/scarf/scarf/Features/Plugins/ViewModels/PluginsViewModel.swift b/scarf/scarf/Features/Plugins/ViewModels/PluginsViewModel.swift new file mode 100644 index 0000000..dcb54c6 --- /dev/null +++ b/scarf/scarf/Features/Plugins/ViewModels/PluginsViewModel.swift @@ -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 + } + } + } + } +} diff --git a/scarf/scarf/Features/Plugins/Views/PluginsView.swift b/scarf/scarf/Features/Plugins/Views/PluginsView.swift new file mode 100644 index 0000000..007c4eb --- /dev/null +++ b/scarf/scarf/Features/Plugins/Views/PluginsView.swift @@ -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) + } +} diff --git a/scarf/scarf/Features/Profiles/ViewModels/ProfilesViewModel.swift b/scarf/scarf/Features/Profiles/ViewModels/ProfilesViewModel.swift new file mode 100644 index 0000000..8945373 --- /dev/null +++ b/scarf/scarf/Features/Profiles/ViewModels/ProfilesViewModel.swift @@ -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) + } +} diff --git a/scarf/scarf/Features/Profiles/Views/ProfilesView.swift b/scarf/scarf/Features/Profiles/Views/ProfilesView.swift new file mode 100644 index 0000000..ecd7bd5 --- /dev/null +++ b/scarf/scarf/Features/Profiles/Views/ProfilesView.swift @@ -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) + } +} diff --git a/scarf/scarf/Features/QuickCommands/ViewModels/QuickCommandsViewModel.swift b/scarf/scarf/Features/QuickCommands/ViewModels/QuickCommandsViewModel.swift new file mode 100644 index 0000000..08f0308 --- /dev/null +++ b/scarf/scarf/Features/QuickCommands/ViewModels/QuickCommandsViewModel.swift @@ -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..type` + `quick_commands..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) + } + } +} diff --git a/scarf/scarf/Features/QuickCommands/Views/QuickCommandsView.swift b/scarf/scarf/Features/QuickCommands/Views/QuickCommandsView.swift new file mode 100644 index 0000000..9326bd1 --- /dev/null +++ b/scarf/scarf/Features/QuickCommands/Views/QuickCommandsView.swift @@ -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) + } +} diff --git a/scarf/scarf/Features/Settings/ViewModels/SettingsViewModel.swift b/scarf/scarf/Features/Settings/ViewModels/SettingsViewModel.swift index 526eb79..92a1bd0 100644 --- a/scarf/scarf/Features/Settings/ViewModels/SettingsViewModel.swift +++ b/scarf/scarf/Features/Settings/ViewModels/SettingsViewModel.swift @@ -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 ` 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) } } diff --git a/scarf/scarf/Features/Settings/Views/Components/ModelPickerRow.swift b/scarf/scarf/Features/Settings/Views/Components/ModelPickerRow.swift new file mode 100644 index 0000000..90128bc --- /dev/null +++ b/scarf/scarf/Features/Settings/Views/Components/ModelPickerRow.swift @@ -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 " / " 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…" + } + } +} diff --git a/scarf/scarf/Features/Settings/Views/Components/ModelPickerSheet.swift b/scarf/scarf/Features/Settings/Views/Components/ModelPickerSheet.swift new file mode 100644 index 0000000..b458717 --- /dev/null +++ b/scarf/scarf/Features/Settings/Views/Components/ModelPickerSheet.swift @@ -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.. some View { + Text(text) + .font(.caption2) + .foregroundStyle(.secondary) + .padding(.horizontal, 5) + .padding(.vertical, 1) + .background(.quaternary) + .clipShape(Capsule()) + } +} diff --git a/scarf/scarf/Features/Settings/Views/Components/SettingsComponents.swift b/scarf/scarf/Features/Settings/Views/Components/SettingsComponents.swift new file mode 100644 index 0000000..692d675 --- /dev/null +++ b/scarf/scarf/Features/Settings/Views/Components/SettingsComponents.swift @@ -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: 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 + let step: Int + let onChange: (Int) -> Void + + init(label: String, value: Int, range: ClosedRange, 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 + 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)) + } +} diff --git a/scarf/scarf/Features/Settings/Views/SettingsView.swift b/scarf/scarf/Features/Settings/Views/SettingsView.swift index a83d5c5..cfa3237 100644 --- a/scarf/scarf/Features/Settings/Views/SettingsView.swift +++ b/scarf/scarf/Features/Settings/Views/SettingsView.swift @@ -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: 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 - 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)) - } -} diff --git a/scarf/scarf/Features/Settings/Views/Tabs/AdvancedTab.swift b/scarf/scarf/Features/Settings/Views/Tabs/AdvancedTab.swift new file mode 100644 index 0000000..6c908f4 --- /dev/null +++ b/scarf/scarf/Features/Settings/Views/Tabs/AdvancedTab.swift @@ -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)) + } + } + } +} diff --git a/scarf/scarf/Features/Settings/Views/Tabs/AgentTab.swift b/scarf/scarf/Features/Settings/Views/Tabs/AgentTab.swift new file mode 100644 index 0000000..b6f8457 --- /dev/null +++ b/scarf/scarf/Features/Settings/Views/Tabs/AgentTab.swift @@ -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) } + } + } +} diff --git a/scarf/scarf/Features/Settings/Views/Tabs/AuxiliaryTab.swift b/scarf/scarf/Features/Settings/Views/Tabs/AuxiliaryTab.swift new file mode 100644 index 0000000..5d11d89 --- /dev/null +++ b/scarf/scarf/Features/Settings/Views/Tabs/AuxiliaryTab.swift @@ -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..*` 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 + } + } +} diff --git a/scarf/scarf/Features/Settings/Views/Tabs/BrowserTab.swift b/scarf/scarf/Features/Settings/Views/Tabs/BrowserTab.swift new file mode 100644 index 0000000..4fbaf55 --- /dev/null +++ b/scarf/scarf/Features/Settings/Views/Tabs/BrowserTab.swift @@ -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) } + } + } +} diff --git a/scarf/scarf/Features/Settings/Views/Tabs/DisplayTab.swift b/scarf/scarf/Features/Settings/Views/Tabs/DisplayTab.swift new file mode 100644 index 0000000..a67f251 --- /dev/null +++ b/scarf/scarf/Features/Settings/Views/Tabs/DisplayTab.swift @@ -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) } + } + } +} diff --git a/scarf/scarf/Features/Settings/Views/Tabs/GeneralTab.swift b/scarf/scarf/Features/Settings/Views/Tabs/GeneralTab.swift new file mode 100644 index 0000000..3ad1555 --- /dev/null +++ b/scarf/scarf/Features/Settings/Views/Tabs/GeneralTab.swift @@ -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)) + } +} diff --git a/scarf/scarf/Features/Settings/Views/Tabs/MemoryTab.swift b/scarf/scarf/Features/Settings/Views/Tabs/MemoryTab.swift new file mode 100644 index 0000000..8b33d47 --- /dev/null +++ b/scarf/scarf/Features/Settings/Views/Tabs/MemoryTab.swift @@ -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)) + } + } +} diff --git a/scarf/scarf/Features/Settings/Views/Tabs/SecurityTab.swift b/scarf/scarf/Features/Settings/Views/Tabs/SecurityTab.swift new file mode 100644 index 0000000..18a5323 --- /dev/null +++ b/scarf/scarf/Features/Settings/Views/Tabs/SecurityTab.swift @@ -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) } + } + } +} diff --git a/scarf/scarf/Features/Settings/Views/Tabs/TerminalTab.swift b/scarf/scarf/Features/Settings/Views/Tabs/TerminalTab.swift new file mode 100644 index 0000000..db09196 --- /dev/null +++ b/scarf/scarf/Features/Settings/Views/Tabs/TerminalTab.swift @@ -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) + } +} diff --git a/scarf/scarf/Features/Settings/Views/Tabs/VoiceTab.swift b/scarf/scarf/Features/Settings/Views/Tabs/VoiceTab.swift new file mode 100644 index 0000000..1222b7e --- /dev/null +++ b/scarf/scarf/Features/Settings/Views/Tabs/VoiceTab.swift @@ -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() + } + } + } +} diff --git a/scarf/scarf/Features/Skills/ViewModels/SkillsViewModel.swift b/scarf/scarf/Features/Skills/ViewModels/SkillsViewModel.swift index c52761f..550464f 100644 --- a/scarf/scarf/Features/Skills/ViewModels/SkillsViewModel.swift +++ b/scarf/scarf/Features/Skills/ViewModels/SkillsViewModel.swift @@ -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 + // `/`. 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 + } } diff --git a/scarf/scarf/Features/Skills/Views/SkillsView.swift b/scarf/scarf/Features/Skills/Views/SkillsView.swift index 9dd2aeb..84601ae 100644 --- a/scarf/scarf/Features/Skills/Views/SkillsView.swift +++ b/scarf/scarf/Features/Skills/Views/SkillsView.swift @@ -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() + } + } + } + } } diff --git a/scarf/scarf/Features/Tools/ViewModels/ToolsViewModel.swift b/scarf/scarf/Features/Tools/ViewModels/ToolsViewModel.swift index 9323fb9..7c8b78c 100644 --- a/scarf/scarf/Features/Tools/ViewModels/ToolsViewModel.swift +++ b/scarf/scarf/Features/Tools/ViewModels/ToolsViewModel.swift @@ -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 { + var found: Set = [] + 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]) diff --git a/scarf/scarf/Features/Tools/Views/ToolsView.swift b/scarf/scarf/Features/Tools/Views/ToolsView.swift index a9af803..bfc1bd8 100644 --- a/scarf/scarf/Features/Tools/Views/ToolsView.swift +++ b/scarf/scarf/Features/Tools/Views/ToolsView.swift @@ -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) { diff --git a/scarf/scarf/Features/Webhooks/ViewModels/WebhooksViewModel.swift b/scarf/scarf/Features/Webhooks/ViewModels/WebhooksViewModel.swift new file mode 100644 index 0000000..ecd8d3a --- /dev/null +++ b/scarf/scarf/Features/Webhooks/ViewModels/WebhooksViewModel.swift @@ -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 + } +} diff --git a/scarf/scarf/Features/Webhooks/Views/WebhooksView.swift b/scarf/scarf/Features/Webhooks/Views/WebhooksView.swift new file mode 100644 index 0000000..c12fcd8 --- /dev/null +++ b/scarf/scarf/Features/Webhooks/Views/WebhooksView.swift @@ -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, 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 = "" + } +} diff --git a/scarf/scarf/Navigation/AppCoordinator.swift b/scarf/scarf/Navigation/AppCoordinator.swift index 294c204..29dd911 100644 --- a/scarf/scarf/Navigation/AppCoordinator.swift +++ b/scarf/scarf/Navigation/AppCoordinator.swift @@ -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" diff --git a/scarf/scarf/Navigation/SidebarView.swift b/scarf/scarf/Navigation/SidebarView.swift index a1eba9b..d196cf9 100644 --- a/scarf/scarf/Navigation/SidebarView.swift +++ b/scarf/scarf/Navigation/SidebarView.swift @@ -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)