Compare commits

..

13 Commits

Author SHA1 Message Date
Alan Wizemann eb39dcfa61 docs: Restructure README + add v1.6.0 release binaries
Reorganize the Features section to match the app's sidebar (Monitor, Interact,
Configure, Manage, Project Dashboards, System) so readers find features the
same way they find them in the app. Add a "What's New in 1.6" callout with
links to the release notes.

Binaries: ARM64 (15 MB) and Universal (19 MB). Both signed with the Apple
Development identity (Team 3Q6X2L86C4). Universal contains both arm64 and
x86_64 slices verified with lipo.
2026-04-16 15:51:28 -07:00
Alan Wizemann 93ee194ba0 chore: Bump version to 1.6.0 2026-04-16 15:39:41 -07:00
Alan Wizemann b6d9113579 feat: Settings tabs, Platforms, Credential Pools, Model Picker, and Configure sidebar
Major expansion of Scarf's Hermes platform coverage. Settings is now a 10-tab
layout exposing ~60 previously hidden config fields. A new "Configure" sidebar
section groups per-platform setup, personality management, quick commands,
credential pools, plugins, webhooks, and profile switching.

## Highlights

- **Platforms feature** — Native GUI setup for all 13 messaging platforms
  (Telegram, Discord, Slack, WhatsApp, Signal, Email, Matrix, Mattermost,
  Feishu, iMessage, Home Assistant, Webhook, CLI). Per-platform forms write
  credentials to ~/.hermes/.env and behavior toggles to ~/.hermes/config.yaml.
  WhatsApp and Signal use an inline SwiftTerm terminal for QR/link pairing.

- **Credential Pools** — Provider-aware add/remove with proper type handling.
  OAuth flow uses Process + pipes to extract the authorization URL, open the
  browser explicitly, and accept the code via a form field. Fixes the Anthropic
  OAuth failure where the code had nowhere to be entered.

- **Model Picker** — Hierarchical provider -> model picker backed by
  ~/.hermes/models_dev_cache.json (111 providers, every major model). Used in
  Settings -> General and Delegation. "Custom..." escape hatch for unlisted IDs.

- **Settings as tabs** — 10 tabs (General, Display, Agent, Terminal, Browser,
  Voice, Memory, Aux Models, Security, Advanced). HermesConfig grew from 32 to
  ~90 fields via grouped sub-structs. All new fields round-trip through
  `hermes config set`.

- **Extended existing features** — Cron (create/edit/pause/resume/run-now/
  delete), Skills (Browse Hub + Updates tabs), Health (run `hermes dump` and
  `hermes debug share` with confirmation dialog), Sessions (rename/delete/
  export/export-all).

## Bug fixes

- Tools platform picker showed only CLI (was reading a nonexistent
  `platform_toolsets:` YAML section). Now enumerates KnownPlatforms.all with
  live connectivity dots from gateway_state.json.
- Credentials add with --api-key was triggering OAuth for providers like
  Anthropic because --type was missing. Now always passes --type api-key.
- Remove-by-index used 0-based indexing; hermes CLI expects 1-based. Fixed.
- Various CLI parser fragility issues (plugins, profiles, skills hub, webhooks)
  replaced with structured file reads or proper box-drawn table parsers.

## New core services

- HermesEnvService — reads/writes ~/.hermes/.env atomically, preserves
  comments, commented-out keys get enabled in-place on save, values with
  spaces/specials get quoted, unset commented out (non-destructive).
- ModelCatalogService — decodes the models.dev cache into typed providers and
  models with context/cost/release-date metadata.
- OAuthFlowController — manages the OAuth Process subprocess: extracts the
  auth URL via regex, opens the browser, pipes the code back via stdin,
  detects success/failure markers in output.

## New sidebar structure

Monitor / Projects / Interact / **Configure (new)** / Manage

The Configure section gathers the setup-style features that used to require
the CLI: Platforms, Personalities, Quick Commands, Credential Pools, Plugins,
Webhooks, Profiles.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 15:39:07 -07:00
Alan Wizemann b2a29ab68d fix: MCP test failures hidden as success; brew/nvm binaries not on PATH
Two related bugs surfaced when testing MCP servers that spawn npx, node,
python, etc. from Homebrew/nvm/asdf/mise installs.

1. MCP test reported success even when the connection failed.
   `hermes mcp test <server>` exits 0 even when the inner connection
   fails — it prints the error to stdout instead. Scarf trusted the
   exit code and rendered a green checkmark while the output said
   "✗ Connection failed: [Errno 2] No such file or directory: 'npx'".
   Fix: also scan output for ✗, "Connection failed", "No such file or
   directory", and "Error:" markers.

2. .app launches start with a minimal PATH that excludes Homebrew.
   When Scarf is launched from Finder/Dock, ProcessInfo's PATH is
   `/usr/bin:/bin:/usr/sbin:/sbin` — no /opt/homebrew/bin, no
   /usr/local/bin, no nvm/asdf/mise shims. Hermes inherits this and
   can't find npx/node/python when spawning MCP server subprocesses.
   Fix: query the user's login shell PATH once via `/bin/zsh -lc 'echo
   $PATH'`, cache it on HermesFileService, and inject it into both
   `runHermesCLI` and the ACP subprocess. Falls back to a sane default
   covering both Apple Silicon and Intel Homebrew if zsh query fails.

Bumps version to 1.5.8 (build 10). Includes signed Universal + ARM64
binaries.
2026-04-16 07:51:32 -07:00
Alan Wizemann 117a0ee9dd fix: MCP Servers — preserve all entries when patching config.yaml
Fixes a bug where adding a second MCP server caused the first to disappear
from the list view, and any args containing YAML reserved characters (e.g.
"@modelcontextprotocol/server-fetch") corrupted the config file.

Three root causes in HermesFileService MCP YAML patching:

1. extractMCPBlock extended through trailing comments to EOF when
   mcp_servers was the last top-level key in config.yaml. Trailing
   comments became part of the "block", so subsequent inserts landed
   at end-of-file rather than inside the entry.

2. patchMCPServerField's entry boundary similarly absorbed trailing
   blanks/comments, making the entry "own" everything until the next
   sibling — or until EOF for the last entry.

3. yamlScalar did not quote values starting with YAML reserved
   indicators (@ * & ? | > ! % , [ ] { } < ` ' "). Args like
   "@modelcontextprotocol/server-fetch" were written bare, producing
   invalid YAML that broke subsequent reads/writes.

Fix: trim trailing blanks/comments off both the block and the entry
in the locator/extractor; quote any scalar starting with a reserved
first character.

Bumps version to 1.5.7 (build 9). Includes signed Universal + ARM64
binaries.

Note: users with an already-corrupted ~/.hermes/config.yaml from the
1.5.6 bug should manually clean up their mcp_servers block (delete the
orphan args at end of file) before upgrading. New writes will be clean.
2026-04-16 07:38:49 -07:00
Alan Wizemann 61d59ba0e4 chore: Re-sign 1.5.6 release binaries
The first 1.5.6 zips contained `linker-signed` bundles with no Sealed
Resources, plus a stray nested scarf.app from a case-insensitive cp.
macOS Gatekeeper rejected the ARM64 download as "damaged"; the
Universal one ran only because the user had already trusted it.

Now both bundles are properly ad-hoc-signed (`Sealed Resources
version=2`) with the hardened runtime preserved. Sizes dropped
significantly (Universal 33MB→16MB, ARM64 27MB→13MB) because the
nested junk is gone.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 07:13:55 -07:00
Alan Wizemann 0a584f6722 chore: Bump version to 1.5.6 and add release binaries
Includes the MCP Servers management UI shipped in 219bca2:
- Add via curated presets (GitHub, Linear, Notion, Sentry, Stripe, …)
  or fully custom (stdio command + args, or HTTP URL with bearer auth)
- Per-server detail view: enable/disable, env + headers editor,
  tool include/exclude filters, resources/prompts toggles, request
  and connect timeouts, OAuth token detection + clearing
- One-click "Test Connection" runs `hermes mcp test` and surfaces
  the discovered tool list
- Gateway-restart banner after config changes that need a reload

README updated with the MCP Servers section, the new MCPServers/
feature module entry, and the `hermes mcp` + `mcp-tokens/` entries
in the Data Sources table.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 06:45:19 -07:00
Alan Wizemann 219bca264e feat: Add MCP Servers management UI
Full MCP server lifecycle: add (stdio + HTTP), edit, remove, test,
enable/disable. YAML config patching for args, env, headers, tool
filters, timeouts. OAuth token detection + deletion. Preset picker
for common MCP servers. Gateway restart banner after config changes.

New sidebar section "MCP Servers" under Manage.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 19:53:55 -07:00
Alan Wizemann c7e6a809ed chore: Bump version to 1.5.5 and add release binaries
Ship Hermes v0.9.0 compatibility plus new features (log component
filter, session pill, Fast Mode, Backup/Restore, iMessage, /compress,
Discord threads). README lists both universal and ARM64 downloads.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 11:32:16 -07:00
Alan Wizemann c5d6116f99 feat: Add Hermes v0.9.0 compatibility and new feature surfaces
- Log parser: session-ID tag in v0.9.0 log format is now an optional
  capture group; session pill renders inline and tap-filters the view.
- Logs: component filter (Gateway/Agent/Tools/CLI/Cron) and bounded
  logger column with middle truncation.
- Gateway stop: uses `hermes gateway stop` CLI (v0.9.0's launchctl
  bootout fix) with SIGTERM as fallback.
- HermesConfig: new keys for Fast Mode (service_tier), gateway notify
  interval, force IPv4, context engine, interim assistant messages,
  and Honcho eager init (camelCase per PR #6995).
- Settings: new Performance, Network, Advanced, and Backup & Restore
  sections that call `hermes backup` / `hermes import` off the main
  actor; robust zip-path extraction via regex.
- Platforms: iMessage (BlueBubbles) added to KnownPlatforms and
  icon map.
- Cron: Discord thread delivery (`discord:chat:thread`) renders as
  "Discord thread X in Y".
- Chat: `/compress <focus>` button appears when ACP advertises the
  command; optional focus sheet sends through existing prompt path.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 16:59:46 -07:00
Alan Wizemann 8672ed1e6c chore: Bump version to 1.5.2 and add universal release binary
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 02:53:21 -04:00
Alan Wizemann 46468890d5 feat: Track ACP token usage, improve chat scroll behavior, and show session costs
Add cumulative token tracking from ACP prompt results with fallback
display when DB has no data yet. Improve scroll-to-bottom reliability
with an external trigger for "Return to Active Session" and onAppear
auto-scroll. Show per-session cost in the dashboard session list.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 00:15:44 -04:00
Alan Wizemann cd503378e2 fix: Move Tools subprocess calls off main thread to fix toggle rendering
Synchronous Process.run()/waitUntilExit() calls on the main thread blocked
SwiftUI's render loop, causing toggle controls to appear as solid blue
rectangles instead of proper switches. All hermes subprocess and file I/O
calls are now async via Task.detached, toggle uses optimistic state update
for immediate visual feedback, and pipe file handles are properly closed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 23:16:52 -04:00
112 changed files with 11220 additions and 719 deletions
+4
View File
@@ -38,3 +38,7 @@ scarf/scarf/ Xcode project root (PBXFileSystemSynchronizedRootGroup
```bash
xcodebuild -project scarf/scarf.xcodeproj -scheme scarf -configuration Debug build
```
## Hermes Version
Targets Hermes v0.9.0 (v2026.4.13). Log lines may carry an optional `[session_id]` tag between the level and logger name — `HermesLogService.parseLine` treats the session tag as an optional capture group, so older untagged lines still parse.
+59 -13
View File
@@ -17,21 +17,59 @@
<a href="https://www.buymeacoffee.com/awizemann"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me a Coffee" height="28"></a>
</p>
## What's New in 1.6
- **Platforms** — Native GUI setup for all 13 messaging platforms, no more hand-editing `.env`
- **Credential Pools** — Fixed OAuth flow and API-key handling; pick providers from a catalog
- **Model Picker** — Hierarchical browser backed by the 111-provider models.dev cache
- **Settings tabs** — 10 organized tabs covering ~60 previously hidden config fields
- **Configure sidebar** — New section for Personalities, Quick Commands, Plugins, Webhooks, Profiles
See the full [v1.6.0 release notes](https://github.com/awizemann/scarf/releases/tag/v1.6.0).
## Features
Scarf mirrors Hermes's surface area through a sidebar-based UI. Sections below map 1:1 to the app's sidebar.
### Monitor
- **Dashboard** — System health, token usage, cost tracking, recent sessions with live refresh
- **Insights** — Usage analytics with token breakdown (including reasoning tokens), cost tracking, model/platform stats, top tools bar chart, activity heatmaps, notable sessions, and time period filtering (7/30/90 days or all time)
- **Sessions Browser** — Full conversation history with message rendering, model reasoning/thinking display, tool call inspection, full-text search, rename, delete, and JSONL export. Subagent sessions are filtered from the main list and accessible via parent session drill-down
- **Activity Feed** — Recent tool execution log with filtering by kind and session, detail inspector with pretty-printed arguments and tool output display
- **Live Chat** — Two modes: **Rich Chat** streams responses in real-time via the Agent Client Protocol (ACP) with iMessage-style bubbles, markdown rendering, tool call visualization, thinking/reasoning display, and permission request dialogs; **Terminal** runs `hermes chat` with full ANSI color and Rich formatting via [SwiftTerm](https://github.com/migueldeicaza/SwiftTerm). Both modes support session persistence, resume/continue previous sessions, auto-reconnection with session recovery, and voice mode controls
### Interact
- **Live Chat** — Two modes: **Rich Chat** streams responses in real-time via the Agent Client Protocol (ACP) with iMessage-style bubbles, markdown rendering, tool call visualization, thinking/reasoning display, permission request dialogs, and a one-click `/compress` focus sheet (when Hermes advertises the command); **Terminal** runs `hermes chat` with full ANSI color and Rich formatting via [SwiftTerm](https://github.com/migueldeicaza/SwiftTerm). Both modes support session persistence, resume/continue previous sessions, auto-reconnection with session recovery, and voice mode controls
- **Memory Viewer/Editor** — View and edit Hermes's MEMORY.md and USER.md with live file-watcher refresh, external memory provider awareness (Honcho, Supermemory, etc.), and profile-scoped memory support with profile picker
- **Skills Browser** — Browse and edit installed skills by category with file content viewer, file switcher, and required config warnings for skills that need specific settings
- **Tools Manager** — Enable/disable toolsets per platform (CLI, Telegram, Discord, Slack, WhatsApp, Signal, Email, Home Assistant, Webhook, Matrix, Feishu, Mattermost) with toggle switches and segmented platform picker, MCP server status
- **Skills Browser** — Browse installed skills by category with file content viewer and required config warnings. **New in 1.6:** Browse the Skills Hub, search by registry (official, skills.sh, well-known, GitHub, ClawHub, LobeHub), install, check for updates, and uninstall — all from the app
### Configure *(new in 1.6)*
- **Platforms** — 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 pairing use an inline SwiftTerm terminal for QR scan and signal-cli daemon management
- **Personalities** — List defined personalities, pick the active one, and edit `SOUL.md` inline with markdown preview
- **Quick Commands** — Editor for custom `/command_name` shell shortcuts with dangerous-pattern detection (`rm -rf`, `mkfs`, etc.)
- **Credential Pools** — Per-provider credential rotation with a fixed OAuth flow (URL extraction + browser open + code paste) and proper `--type api-key` handling. API keys never stored in UI state — only last-4 preview. Strategy picker (fill_first / round_robin / least_used / random)
- **Plugins** — Install via Git URL or `owner/repo`, update, remove, enable/disable. Reads `~/.hermes/plugins/` directly for reliable state
- **Webhooks** — Create, list, test-fire, and remove webhook subscriptions. Detects the "platform not enabled" state and links to gateway setup
- **Profiles** — Switch between multiple isolated Hermes instances. Create, rename, delete, export (zip), import. Safe-switch warning reminds users to restart Scarf after activating a different profile
### Manage
- **Tools** — Enable/disable toolsets per platform with a connectivity-aware platform menu (green/orange/grey/red dots for connected/configured/offline/error). **Fixed in 1.6:** all 13 platforms now appear (was previously stuck on CLI)
- **MCP Servers** — Manage Model Context Protocol servers Hermes connects to. Add via curated presets (GitHub, Linear, Notion, Sentry, Stripe, and more) or fully custom (stdio command + args, or HTTP URL with optional bearer auth). Per-server detail view with enable/disable toggle, environment variable + header editor, tool-include/exclude filters, resources/prompts toggles, request and connect timeouts, OAuth token detection + clearing, and one-click "Test Connection" that runs `hermes mcp test` and surfaces the discovered tool list. Gateway-restart banner appears after config changes that require a reload
- **Gateway Control** — Start/stop/restart the messaging gateway, view platform connection status, manage user pairing (approve/revoke)
- **Cron Manager** — View scheduled jobs with pre-run scripts, delivery failure tracking, timeout info, and `[SILENT]` job indicators
- **Log Viewer** — Real-time log tailing for agent.log, errors.log, and gateway.log with level filtering and text search
- **Project Dashboards** — Custom, agent-generated dashboards for any project. Define stat boxes, charts, tables, progress bars, checklists, rich text, and embedded web views in a simple JSON file — Scarf renders them with live refresh. Let your Hermes agent build and maintain project-specific visualizations automatically
- **Settings** — Structured config editor for all Hermes settings including model/provider selection, browser backend, reasoning effort, approval mode, cost display, Docker environment, command allowlist, credential management, and more
- **Cron Manager** — View scheduled jobs with pre-run scripts, delivery failure tracking, timeout info, and `[SILENT]` job indicators. **New in 1.6:** full write support — create, edit, pause, resume, run-now, and delete jobs from the app
- **Health** — Component-level status and diagnostics. **New in 1.6:** inline "Run Dump" and "Share Debug Report" buttons (the latter with an upload-confirmation dialog before sending to Nous support)
- **Log Viewer** — Real-time log tailing for agent.log, errors.log, and gateway.log with level filtering, component filter (Gateway / Agent / Tools / CLI / Cron), clickable session-ID pills that filter to a single session, and text search
- **Settings** — **Restructured in 1.6** into a 10-tab layout: General, Display, Agent, Terminal, Browser, Voice, Memory, Aux Models, Security, Advanced. Exposes ~60 previously hidden config fields including all 8 auxiliary model tasks, container limits, full TTS/STT provider settings, human-delay simulation, compression thresholds, logging rotation, checkpoints, website blocklist, Tirith sandbox, and delegation. One-click **Backup & Restore** via `hermes backup` / `hermes import`. Model picker replaces the old free-text model field, backed by the models.dev cache (111 providers, all major models) with a "Custom…" escape hatch
### Project Dashboards
Custom, agent-generated dashboards for any project. Define stat boxes, charts, tables, progress bars, checklists, rich text, and embedded web views in a simple JSON file — Scarf renders them with live refresh. Let your Hermes agent build and maintain project-specific visualizations automatically. See [Project Dashboards](#project-dashboards-1) below for the full schema.
### System
- **Hermes Process Control** — Start, stop, and restart the Hermes agent directly from Scarf
- **Menu Bar** — Status icon showing Hermes running state with quick actions
@@ -39,7 +77,7 @@
- macOS 14.6+ (Sonoma)
- Xcode 16.0+
- [Hermes agent](https://github.com/hermes-ai/hermes-agent) v0.6.0+ installed at `~/.hermes/` (v0.8.0 recommended for full feature support)
- [Hermes agent](https://github.com/hermes-ai/hermes-agent) v0.6.0+ installed at `~/.hermes/` (v0.9.0 recommended for full feature support)
### Compatibility
@@ -49,7 +87,10 @@ Scarf reads Hermes's SQLite database and parses CLI output from `hermes status`,
|----------------|--------|
| v0.6.0 (2026-03-30) | Verified |
| v0.7.0 (2026-04-03) | Verified |
| v0.8.0 (2026-04-08, latest) | Verified |
| v0.8.0 (2026-04-08) | Verified |
| v0.9.0 (2026-04-13) | Verified (recommended for full 1.6 feature support) |
Scarf 1.6 targets Hermes v0.9.0 specifically for the new Platforms, Credentials, Skills Hub, and Cron write features. Earlier Hermes versions remain supported for the monitoring and session features but may not expose every new setup form.
If a Hermes update changes the database schema or CLI output format, Scarf may need to be updated. Check the [Health](#features) view for compatibility warnings.
@@ -57,11 +98,13 @@ If a Hermes update changes the database schema or CLI output format, Scarf may n
### Pre-built Binary (no Xcode required)
Download the latest universal binary (Apple Silicon + Intel) from [Releases](https://github.com/awizemann/scarf/releases):
Download the latest build from [Releases](https://github.com/awizemann/scarf/releases):
1. Download `Scarf-vX.X.X-Universal.zip`
2. Unzip and drag **Scarf.app** to Applications
3. On first launch, right-click and choose **Open** (or go to System Settings → Privacy & Security → Open Anyway)
- `Scarf-vX.X.X-Universal.zip` — Apple Silicon + Intel (recommended)
- `Scarf-vX.X.X-ARM64.zip` — Apple Silicon only (smaller)
1. Unzip and drag **Scarf.app** to Applications
2. On first launch, right-click and choose **Open** (or go to System Settings → Privacy & Security → Open Anyway)
### Build from Source
@@ -96,6 +139,7 @@ scarf/
Memory/ Memory viewer and editor
Skills/ Skill browser by category
Tools/ Toolset management per platform
MCPServers/ MCP server registry, presets, OAuth, tool filters, test runner
Gateway/ Messaging gateway control and pairing
Cron/ Scheduled job viewer
Logs/ Real-time log viewer
@@ -122,6 +166,8 @@ Scarf reads Hermes data directly from `~/.hermes/`:
| `hermes sessions` | CLI commands | Rename/Delete/Export |
| `hermes gateway` | CLI commands | Start/Stop/Restart |
| `hermes pairing` | CLI commands | Approve/Revoke |
| `hermes mcp` | CLI commands | Add/Remove/Test MCP servers |
| `mcp-tokens/*.json` | JSON (per-server OAuth) | Detect/Delete |
| `.scarf/dashboard.json` | JSON (per-project) | Read-only |
| `scarf/projects.json` | JSON (registry) | Read/Write |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+4 -4
View File
@@ -407,7 +407,7 @@
CODE_SIGN_ENTITLEMENTS = scarf/scarf.entitlements;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 5;
CURRENT_PROJECT_VERSION = 11;
DEVELOPMENT_TEAM = 3Q6X2L86C4;
ENABLE_APP_SANDBOX = NO;
ENABLE_HARDENED_RUNTIME = YES;
@@ -422,7 +422,7 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 14.6;
MARKETING_VERSION = 1.5.1;
MARKETING_VERSION = 1.6.0;
PRODUCT_BUNDLE_IDENTIFIER = com.scarf;
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
@@ -444,7 +444,7 @@
CODE_SIGN_ENTITLEMENTS = scarf/scarf.entitlements;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 5;
CURRENT_PROJECT_VERSION = 11;
DEVELOPMENT_TEAM = 3Q6X2L86C4;
ENABLE_APP_SANDBOX = NO;
ENABLE_HARDENED_RUNTIME = YES;
@@ -459,7 +459,7 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 14.6;
MARKETING_VERSION = 1.5.1;
MARKETING_VERSION = 1.6.0;
PRODUCT_BUNDLE_IDENTIFIER = com.scarf;
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
+22 -28
View File
@@ -14,34 +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 .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()
}
}
}
+369 -1
View File
@@ -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
@@ -23,6 +321,43 @@ struct HermesConfig: Sendable {
var dockerEnv: [String: String]
var commandAllowlist: [String]
var memoryProfile: String
var serviceTier: String
var gatewayNotifyInterval: Int
var forceIPv4: Bool
var contextEngine: String
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",
@@ -46,7 +381,40 @@ struct HermesConfig: Sendable {
memoryProvider: "",
dockerEnv: [:],
commandAllowlist: [],
memoryProfile: ""
memoryProfile: "",
serviceTier: "normal",
gatewayNotifyInterval: 600,
forceIPv4: false,
contextEngine: "compressor",
interimAssistantMessages: true,
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
)
}
@@ -22,6 +22,7 @@ enum HermesPaths: Sendable {
nonisolated static let hermesBinary: String = userHome + "/.local/bin/hermes"
nonisolated static let scarfDir: String = home + "/scarf"
nonisolated static let projectsRegistry: String = scarfDir + "/projects.json"
nonisolated static let mcpTokensDir: String = home + "/mcp-tokens"
}
// MARK: - SQLite Constants
@@ -41,6 +41,21 @@ struct HermesCronJob: Identifiable, Sendable, Codable {
default: return "questionmark.circle"
}
}
var deliveryDisplay: String? {
guard let deliver, !deliver.isEmpty else { return nil }
// v0.9.0 extends Discord routing to threads: `discord:<chat>:<thread>`.
if deliver.hasPrefix("discord:") {
let parts = deliver.dropFirst("discord:".count).split(separator: ":", maxSplits: 1, omittingEmptySubsequences: false)
if parts.count == 2 {
return "Discord thread \(parts[1]) in \(parts[0])"
}
if parts.count == 1 {
return "Discord \(parts[0])"
}
}
return deliver
}
}
struct CronSchedule: Sendable, Codable {
@@ -0,0 +1,54 @@
import Foundation
enum MCPTransport: String, Sendable, Equatable, CaseIterable, Identifiable {
case stdio
case http
var id: String { rawValue }
var displayName: String {
switch self {
case .stdio: return "Local (stdio)"
case .http: return "Remote (HTTP)"
}
}
}
struct HermesMCPServer: Identifiable, Sendable, Equatable {
let name: String
let transport: MCPTransport
let command: String?
let args: [String]
let url: String?
let auth: String?
let env: [String: String]
let headers: [String: String]
let timeout: Int?
let connectTimeout: Int?
let enabled: Bool
let toolsInclude: [String]
let toolsExclude: [String]
let resourcesEnabled: Bool
let promptsEnabled: Bool
let hasOAuthToken: Bool
var id: String { name }
var summary: String {
switch transport {
case .stdio:
let argString = args.isEmpty ? "" : " " + args.joined(separator: " ")
return (command ?? "") + argString
case .http:
return url ?? ""
}
}
}
struct MCPTestResult: Sendable, Equatable {
let serverName: String
let succeeded: Bool
let output: String
let tools: [String]
let elapsed: TimeInterval
}
+2
View File
@@ -30,6 +30,7 @@ enum KnownPlatforms {
HermesToolPlatform(name: "matrix", displayName: "Matrix", icon: "lock.rectangle.stack"),
HermesToolPlatform(name: "feishu", displayName: "Feishu", icon: "message.badge.circle"),
HermesToolPlatform(name: "mattermost", displayName: "Mattermost", icon: "bubble.left.and.exclamationmark.bubble.right"),
HermesToolPlatform(name: "imessage", displayName: "iMessage", icon: "message.fill"),
]
static func icon(for platform: String) -> String {
@@ -46,6 +47,7 @@ enum KnownPlatforms {
case "matrix": return "lock.rectangle.stack"
case "feishu": return "message.badge.circle"
case "mattermost": return "bubble.left.and.exclamationmark.bubble.right"
case "imessage": return "message.fill"
default: return "bubble.left"
}
}
@@ -0,0 +1,174 @@
import Foundation
struct MCPServerPreset: Identifiable, Sendable, Equatable {
let id: String
let displayName: String
let description: String
let category: String
let iconSystemName: String
let transport: MCPTransport
let command: String?
let args: [String]
let url: String?
let auth: String?
let requiredEnvKeys: [String]
let optionalEnvKeys: [String]
let pathArgPrompt: String?
let docsURL: String
static let gallery: [MCPServerPreset] = [
MCPServerPreset(
id: "filesystem",
displayName: "Filesystem",
description: "Read and write files under a root directory you choose.",
category: "Built-in",
iconSystemName: "folder",
transport: .stdio,
command: "npx",
args: ["-y", "@modelcontextprotocol/server-filesystem"],
url: nil,
auth: nil,
requiredEnvKeys: [],
optionalEnvKeys: [],
pathArgPrompt: "Root directory (absolute path)",
docsURL: "https://github.com/modelcontextprotocol/servers/tree/main/src/filesystem"
),
MCPServerPreset(
id: "github",
displayName: "GitHub",
description: "Issues, pull requests, code search, and file operations via GitHub API.",
category: "Dev",
iconSystemName: "chevron.left.forwardslash.chevron.right",
transport: .stdio,
command: "npx",
args: ["-y", "@modelcontextprotocol/server-github"],
url: nil,
auth: nil,
requiredEnvKeys: ["GITHUB_PERSONAL_ACCESS_TOKEN"],
optionalEnvKeys: [],
pathArgPrompt: nil,
docsURL: "https://github.com/modelcontextprotocol/servers/tree/main/src/github"
),
MCPServerPreset(
id: "postgres",
displayName: "Postgres",
description: "Read-only SQL access against a Postgres database.",
category: "Data",
iconSystemName: "cylinder.split.1x2",
transport: .stdio,
command: "npx",
args: ["-y", "@modelcontextprotocol/server-postgres"],
url: nil,
auth: nil,
requiredEnvKeys: [],
optionalEnvKeys: [],
pathArgPrompt: "Connection URL (postgres://user:pass@host/db)",
docsURL: "https://github.com/modelcontextprotocol/servers/tree/main/src/postgres"
),
MCPServerPreset(
id: "slack",
displayName: "Slack",
description: "Read channels, post messages, and search your Slack workspace.",
category: "Productivity",
iconSystemName: "bubble.left.and.bubble.right",
transport: .stdio,
command: "npx",
args: ["-y", "@modelcontextprotocol/server-slack"],
url: nil,
auth: nil,
requiredEnvKeys: ["SLACK_BOT_TOKEN", "SLACK_TEAM_ID"],
optionalEnvKeys: [],
pathArgPrompt: nil,
docsURL: "https://github.com/modelcontextprotocol/servers/tree/main/src/slack"
),
MCPServerPreset(
id: "linear",
displayName: "Linear",
description: "Query and update Linear issues. Uses OAuth — no token needed.",
category: "Productivity",
iconSystemName: "list.bullet.rectangle",
transport: .http,
command: nil,
args: [],
url: "https://mcp.linear.app/sse",
auth: "oauth",
requiredEnvKeys: [],
optionalEnvKeys: [],
pathArgPrompt: nil,
docsURL: "https://linear.app/docs/mcp"
),
MCPServerPreset(
id: "sentry",
displayName: "Sentry",
description: "Investigate errors and performance issues from Sentry.",
category: "Dev",
iconSystemName: "exclamationmark.triangle",
transport: .stdio,
command: "npx",
args: ["-y", "@sentry/mcp-server"],
url: nil,
auth: nil,
requiredEnvKeys: ["SENTRY_AUTH_TOKEN", "SENTRY_ORG"],
optionalEnvKeys: [],
pathArgPrompt: nil,
docsURL: "https://docs.sentry.io/product/mcp/"
),
MCPServerPreset(
id: "puppeteer",
displayName: "Puppeteer",
description: "Headless browser automation — navigate pages, click, screenshot.",
category: "Automation",
iconSystemName: "safari",
transport: .stdio,
command: "npx",
args: ["-y", "@modelcontextprotocol/server-puppeteer"],
url: nil,
auth: nil,
requiredEnvKeys: [],
optionalEnvKeys: [],
pathArgPrompt: nil,
docsURL: "https://github.com/modelcontextprotocol/servers/tree/main/src/puppeteer"
),
MCPServerPreset(
id: "memory",
displayName: "Memory (Knowledge Graph)",
description: "Persistent knowledge graph of entities and relations across sessions.",
category: "Built-in",
iconSystemName: "brain",
transport: .stdio,
command: "npx",
args: ["-y", "@modelcontextprotocol/server-memory"],
url: nil,
auth: nil,
requiredEnvKeys: [],
optionalEnvKeys: ["MEMORY_FILE_PATH"],
pathArgPrompt: nil,
docsURL: "https://github.com/modelcontextprotocol/servers/tree/main/src/memory"
),
MCPServerPreset(
id: "fetch",
displayName: "Fetch",
description: "Retrieve and convert web pages to markdown.",
category: "Built-in",
iconSystemName: "arrow.down.circle",
transport: .stdio,
command: "npx",
args: ["-y", "@modelcontextprotocol/server-fetch"],
url: nil,
auth: nil,
requiredEnvKeys: [],
optionalEnvKeys: [],
pathArgPrompt: nil,
docsURL: "https://github.com/modelcontextprotocol/servers/tree/main/src/fetch"
)
]
static var categories: [String] {
var seen = Set<String>()
return gallery.compactMap { p in seen.insert(p.category).inserted ? p.category : nil }
}
static func byCategory(_ category: String) -> [MCPServerPreset] {
gallery.filter { $0.category == category }
}
}
+4 -2
View File
@@ -66,8 +66,10 @@ actor ACPClient {
proc.standardOutput = stdout
proc.standardError = stderr
// ACP uses JSON-RPC over pipes do NOT set TERM to avoid terminal escape pollution
var env = ProcessInfo.processInfo.environment
// ACP uses JSON-RPC over pipes do NOT set TERM to avoid terminal escape pollution.
// Use the enriched environment so any tools hermes spawns (MCP servers,
// shell commands) can find brew/nvm/asdf binaries on PATH.
var env = HermesFileService.enrichedEnvironment()
env.removeValue(forKey: "TERM")
proc.environment = env
@@ -0,0 +1,215 @@
import Foundation
import os
/// Read/write `~/.hermes/.env` while preserving comments, blank lines, and the
/// ordering of keys we don't touch.
///
/// Hermes treats `.env` as a traditional dotenv file: `KEY=value`, `#` comments,
/// and optional double-quoted values for strings with spaces or special chars.
/// We do NOT attempt to implement full shell-style escaping; the fields we write
/// from the GUI are bot tokens, user IDs, URLs, and on/off flags none of which
/// contain characters needing escaping beyond double-quoting.
///
/// Design choices:
/// - **Non-destructive "unset"**: clearing a field comments the line out rather
/// than deleting it, so users can restore a key by uncommenting without losing
/// their value.
/// - **Atomic write**: write to `.env.tmp`, then rename. Avoids a partially
/// written file if Scarf crashes mid-write.
/// - **Never logs values**: secrets flow through this service.
struct HermesEnvService: Sendable {
private let logger = Logger(subsystem: "com.scarf", category: "HermesEnvService")
/// Path to `~/.hermes/.env`. Kept configurable for tests.
let path: String
init(path: String = HermesPaths.home + "/.env") {
self.path = path
}
/// Read the .env file into a `[key: value]` dict. Comments and commented-out
/// assignments are ignored. Missing file returns an empty dict.
func load() -> [String: String] {
guard let content = try? String(contentsOfFile: path, encoding: .utf8) else {
return [:]
}
var result: [String: String] = [:]
for line in content.components(separatedBy: "\n") {
let trimmed = line.trimmingCharacters(in: .whitespaces)
// Skip blanks and comments. A line beginning with `#` is either a pure
// comment or a disabled assignment both should be treated as "unset".
if trimmed.isEmpty || trimmed.hasPrefix("#") { continue }
guard let eq = trimmed.firstIndex(of: "=") else { continue }
let key = String(trimmed[trimmed.startIndex..<eq]).trimmingCharacters(in: .whitespaces)
let raw = String(trimmed[trimmed.index(after: eq)...]).trimmingCharacters(in: .whitespaces)
result[key] = Self.stripEnvQuotes(raw)
}
return result
}
func get(_ key: String) -> String? {
load()[key]
}
/// Write/update a single key. Preserves the position of existing assignments
/// (even if they were commented out the new assignment replaces the comment
/// line in place). New keys are appended at the end.
@discardableResult
func set(_ key: String, value: String) -> Bool {
setMany([key: value])
}
/// Update multiple keys in one atomic rewrite. Use this when a form saves
/// several fields at once so the file doesn't get repeatedly rewritten.
///
/// Returns `true` on success, `false` if the atomic rewrite failed.
@discardableResult
func setMany(_ pairs: [String: String]) -> Bool {
var remaining = pairs
var lines: [String]
// Start from existing file contents, or a minimal header if creating new.
if let content = try? String(contentsOfFile: path, encoding: .utf8) {
lines = content.components(separatedBy: "\n")
// Trim a single trailing empty line from splitting the final newline;
// we'll re-add it on write.
if lines.last == "" { lines.removeLast() }
} else {
lines = ["# Hermes Agent Environment Configuration"]
}
// First pass: update in-place (handles both live and commented-out lines).
for (idx, line) in lines.enumerated() {
guard let match = Self.extractKey(fromLine: line) else { continue }
if let newValue = remaining.removeValue(forKey: match.key) {
// A commented-out `# KEY=...` becomes a live `KEY=...` with the new value.
lines[idx] = Self.formatLine(key: match.key, value: newValue)
}
}
// Second pass: append any keys that didn't match an existing line.
if !remaining.isEmpty {
// Leave a blank line before appending new keys for visual separation.
if let last = lines.last, !last.isEmpty {
lines.append("")
}
for key in remaining.keys.sorted() {
lines.append(Self.formatLine(key: key, value: remaining[key]!))
}
}
return atomicWrite(lines.joined(separator: "\n") + "\n")
}
/// Comment out a key. The value is preserved so the user can restore by
/// uncommenting. If the key doesn't exist, this is a no-op.
@discardableResult
func unset(_ key: String) -> Bool {
guard let content = try? String(contentsOfFile: path, encoding: .utf8) else {
return true
}
var lines = content.components(separatedBy: "\n")
if lines.last == "" { lines.removeLast() }
var changed = false
for (idx, line) in lines.enumerated() {
guard let match = Self.extractKey(fromLine: line), match.key == key else { continue }
// Skip lines that are already commented nothing to do.
if Self.isCommentedOutAssignment(line) { continue }
lines[idx] = "# " + line
changed = true
}
guard changed else { return true }
return atomicWrite(lines.joined(separator: "\n") + "\n")
}
// MARK: - Internals
/// Writes the entire file in one shot via a tmp + rename to avoid corrupting
/// `.env` if the process is killed mid-write. Preserves `0600` permissions
/// since `.env` typically holds secrets.
private func atomicWrite(_ content: String) -> Bool {
let tmp = path + ".tmp"
do {
try content.write(toFile: tmp, atomically: false, encoding: .utf8)
// Mirror the typical `.env` mode of `0600` (owner read/write only).
try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: tmp)
// Swap into place. FileManager.replaceItem handles the replacement
// atomically on the same volume; fall back to a two-step rename.
let destURL = URL(fileURLWithPath: path)
let tmpURL = URL(fileURLWithPath: tmp)
if FileManager.default.fileExists(atPath: path) {
_ = try FileManager.default.replaceItemAt(destURL, withItemAt: tmpURL)
} else {
try FileManager.default.moveItem(at: tmpURL, to: destURL)
}
return true
} catch {
logger.error("Failed to write .env: \(error.localizedDescription)")
try? FileManager.default.removeItem(atPath: tmp)
return false
}
}
/// Extract a key name and whether the line was active or commented-out.
/// Accepts both `KEY=value` and `# KEY=value` (any amount of whitespace after `#`).
private static func extractKey(fromLine line: String) -> (key: String, active: Bool)? {
var work = line.trimmingCharacters(in: .whitespaces)
var active = true
if work.hasPrefix("#") {
active = false
work = String(work.dropFirst()).trimmingCharacters(in: .whitespaces)
}
guard let eq = work.firstIndex(of: "=") else { return nil }
let key = String(work[work.startIndex..<eq]).trimmingCharacters(in: .whitespaces)
// Reject non-identifier looking keys to avoid matching prose in comments
// (e.g. "# This is a note about something = nice").
guard key.range(of: "^[A-Za-z_][A-Za-z0-9_]*$", options: .regularExpression) != nil else {
return nil
}
return (key, active)
}
private static func isCommentedOutAssignment(_ line: String) -> Bool {
guard let match = extractKey(fromLine: line) else { return false }
return !match.active
}
/// Format a single `KEY=value` line. Values containing whitespace or shell
/// metacharacters get double-quoted; simple tokens go in unquoted to match
/// hermes's own output style.
private static func formatLine(key: String, value: String) -> String {
if Self.needsQuoting(value) {
// Escape embedded backslashes and double quotes, then wrap.
let escaped = value
.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "\"", with: "\\\"")
return "\(key)=\"\(escaped)\""
}
return "\(key)=\(value)"
}
private static func needsQuoting(_ value: String) -> Bool {
if value.isEmpty { return false }
// Whitespace, shell metacharacters, or quotes trigger quoting.
let metacharacters: Set<Character> = [" ", "\t", "#", "$", "`", "\"", "'", "\\", "(", ")", "{", "}", "[", "]", "|", "&", ";", "<", ">", "*", "?"]
return value.contains(where: { metacharacters.contains($0) })
}
/// Strip one layer of matched double or single quotes from a loaded value.
private static func stripEnvQuotes(_ s: String) -> String {
guard s.count >= 2 else { return s }
let first = s.first!
let last = s.last!
if (first == "\"" && last == "\"") || (first == "'" && last == "'") {
var inner = String(s.dropFirst().dropLast())
if first == "\"" {
inner = inner
.replacingOccurrences(of: "\\\"", with: "\"")
.replacingOccurrences(of: "\\\\", with: "\\")
}
return inner
}
return s
}
}
File diff suppressed because it is too large Load Diff
@@ -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,
@@ -19,7 +20,8 @@ final class HermesFileWatcher {
HermesPaths.agentLog,
HermesPaths.errorsLog,
HermesPaths.gatewayLog,
HermesPaths.projectsRegistry
HermesPaths.projectsRegistry,
HermesPaths.mcpTokensDir
]
for path in paths {
@@ -4,6 +4,7 @@ struct LogEntry: Identifiable, Sendable {
let id: Int
let timestamp: String
let level: LogLevel
let sessionId: String?
let logger: String
let message: String
let raw: String
@@ -72,23 +73,30 @@ actor HermesLogService {
private func parseLine(_ line: String) -> LogEntry {
entryCounter += 1
// Format: YYYY-MM-DD HH:MM:SS,MMM LEVEL logger: message
let pattern = #"^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2},\d{3})\s+(DEBUG|INFO|WARNING|ERROR|CRITICAL)\s+(\S+?):\s+(.*)$"#
// Format (v0.9.0+): YYYY-MM-DD HH:MM:SS,MMM LEVEL [session_id] logger: message
// Session tag is optional earlier Hermes releases and out-of-session lines omit it.
let pattern = #"^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2},\d{3})\s+(DEBUG|INFO|WARNING|ERROR|CRITICAL)\s+(?:\[([^\]]+)\]\s+)?(\S+?):\s+(.*)$"#
if let regex = try? NSRegularExpression(pattern: pattern),
let match = regex.firstMatch(in: line, range: NSRange(line.startIndex..., in: line)) {
let timestamp = String(line[Range(match.range(at: 1), in: line)!])
let levelStr = String(line[Range(match.range(at: 2), in: line)!])
let logger = String(line[Range(match.range(at: 3), in: line)!])
let message = String(line[Range(match.range(at: 4), in: line)!])
let sessionId: String? = {
let range = match.range(at: 3)
guard range.location != NSNotFound, let r = Range(range, in: line) else { return nil }
return String(line[r])
}()
let logger = String(line[Range(match.range(at: 4), in: line)!])
let message = String(line[Range(match.range(at: 5), in: line)!])
return LogEntry(
id: entryCounter,
timestamp: timestamp,
level: LogEntry.LogLevel(rawValue: levelStr) ?? .info,
sessionId: sessionId,
logger: logger,
message: message,
raw: line
)
}
return LogEntry(id: entryCounter, timestamp: "", level: .info, logger: "", message: line, raw: line)
return LogEntry(id: entryCounter, timestamp: "", level: .info, sessionId: nil, logger: "", message: line, raw: line)
}
}
@@ -0,0 +1,201 @@
import Foundation
import os
/// A single model from the models.dev catalog shipped with hermes.
struct HermesModelInfo: Sendable, Identifiable, Hashable {
var id: String { providerID + ":" + modelID }
let providerID: String
let providerName: String
let modelID: String
let modelName: String
let contextWindow: Int?
let maxOutput: Int?
let costInput: Double? // USD per 1M input tokens
let costOutput: Double? // USD per 1M output tokens
let reasoning: Bool
let toolCall: Bool
let releaseDate: String?
/// Display-friendly cost string, or nil if cost is unknown.
var costDisplay: String? {
guard let input = costInput, let output = costOutput else { return nil }
return String(format: "$%.2f / $%.2f", input, output)
}
/// Display-friendly context window ("200K", "1M", etc.).
var contextDisplay: String? {
guard let ctx = contextWindow else { return nil }
if ctx >= 1_000_000 { return "\(ctx / 1_000_000)M" }
if ctx >= 1_000 { return "\(ctx / 1_000)K" }
return "\(ctx)"
}
}
/// Provider summary one row in the left column of the picker.
struct HermesProviderInfo: Sendable, Identifiable, Hashable {
var id: String { providerID }
let providerID: String
let providerName: String
let envVars: [String] // e.g. ["ANTHROPIC_API_KEY"]
let docURL: String?
let modelCount: Int
}
/// Reads the models.dev catalog that hermes caches at
/// `~/.hermes/models_dev_cache.json`. Offline-capable, fast enough to read per
/// call (~1500 models across ~110 providers).
///
/// We decode a trimmed subset so unknown fields don't break loading. Every
/// field we care about is optional on disk providers may omit cost, context
/// limits, etc.
struct ModelCatalogService: Sendable {
private let logger = Logger(subsystem: "com.scarf", category: "ModelCatalogService")
let path: String
init(path: String = HermesPaths.home + "/models_dev_cache.json") {
self.path = path
}
/// All providers, sorted by display name.
func loadProviders() -> [HermesProviderInfo] {
guard let catalog = loadCatalog() else { return [] }
return catalog
.map { (id, p) in
HermesProviderInfo(
providerID: id,
providerName: p.name ?? id,
envVars: p.env ?? [],
docURL: p.doc,
modelCount: p.models?.count ?? 0
)
}
.sorted { $0.providerName.localizedCaseInsensitiveCompare($1.providerName) == .orderedAscending }
}
/// Models for one provider, sorted by release date (newest first), then name.
func loadModels(for providerID: String) -> [HermesModelInfo] {
guard let catalog = loadCatalog(), let provider = catalog[providerID] else { return [] }
let providerName = provider.name ?? providerID
let models = (provider.models ?? [:]).map { (id, m) in
HermesModelInfo(
providerID: providerID,
providerName: providerName,
modelID: id,
modelName: m.name ?? id,
contextWindow: m.limit?.context,
maxOutput: m.limit?.output,
costInput: m.cost?.input,
costOutput: m.cost?.output,
reasoning: m.reasoning ?? false,
toolCall: m.tool_call ?? false,
releaseDate: m.release_date
)
}
return models.sorted { lhs, rhs in
// Newest-first by release date if both are known; otherwise fall
// back to alphabetical on display name.
if let lDate = lhs.releaseDate, let rDate = rhs.releaseDate, lDate != rDate {
return lDate > rDate
}
return lhs.modelName.localizedCaseInsensitiveCompare(rhs.modelName) == .orderedAscending
}
}
/// Find the provider that ships a given model ID. Useful for auto-syncing
/// provider when the user picks a model from a flat list or types one in.
func provider(for modelID: String) -> HermesProviderInfo? {
guard let catalog = loadCatalog() else { return nil }
for (providerID, p) in catalog {
if p.models?[modelID] != nil {
return HermesProviderInfo(
providerID: providerID,
providerName: p.name ?? providerID,
envVars: p.env ?? [],
docURL: p.doc,
modelCount: p.models?.count ?? 0
)
}
}
// Handle provider-prefixed IDs like "openai/gpt-4o" look up the
// prefix before the slash.
if let slash = modelID.firstIndex(of: "/") {
let prefix = String(modelID[modelID.startIndex..<slash])
if let p = catalog[prefix] {
return HermesProviderInfo(
providerID: prefix,
providerName: p.name ?? prefix,
envVars: p.env ?? [],
docURL: p.doc,
modelCount: p.models?.count ?? 0
)
}
}
return nil
}
/// Look up a specific model by provider + ID. Returns nil if not in the
/// catalog (e.g., free-typed custom model).
func model(providerID: String, modelID: String) -> HermesModelInfo? {
guard let catalog = loadCatalog(),
let provider = catalog[providerID],
let raw = provider.models?[modelID] else { return nil }
return HermesModelInfo(
providerID: providerID,
providerName: provider.name ?? providerID,
modelID: modelID,
modelName: raw.name ?? modelID,
contextWindow: raw.limit?.context,
maxOutput: raw.limit?.output,
costInput: raw.cost?.input,
costOutput: raw.cost?.output,
reasoning: raw.reasoning ?? false,
toolCall: raw.tool_call ?? false,
releaseDate: raw.release_date
)
}
// MARK: - Decoding
private func loadCatalog() -> [String: ProviderEntry]? {
guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)) else {
return nil
}
do {
return try JSONDecoder().decode([String: ProviderEntry].self, from: data)
} catch {
logger.error("Failed to decode models_dev_cache.json: \(error.localizedDescription)")
return nil
}
}
// Trimmed representations we decode a subset of fields and tolerate
// anything new hermes adds later. `snake_case` field names match the file.
private struct ProviderEntry: Decodable {
let id: String?
let name: String?
let env: [String]?
let doc: String?
let models: [String: ModelEntry]?
}
private struct ModelEntry: Decodable {
let name: String?
let reasoning: Bool?
let tool_call: Bool?
let release_date: String?
let cost: CostEntry?
let limit: LimitEntry?
}
private struct CostEntry: Decodable {
let input: Double?
let output: Double?
}
private struct LimitEntry: Decodable {
let context: Int?
let output: Int?
}
}
@@ -187,6 +187,8 @@ final class ChatViewModel {
richChatViewModel.handleACPEvent(
.promptComplete(sessionId: sessionId, response: result)
)
// Re-fetch session from DB to pick up cost/token data Hermes may have written
await richChatViewModel.refreshSessionFromDB()
} catch is CancellationError {
acpStatus = "Cancelled"
} catch {
@@ -32,9 +32,26 @@ final class RichChatViewModel {
var messageGroups: [MessageGroup] = []
var isAgentWorking = false
var pendingPermission: PendingPermission?
/// Mutated to trigger a scroll-to-bottom in the message list.
var scrollTrigger = UUID()
// Cumulative ACP token tracking (ACP returns tokens per prompt but DB has none)
private(set) var acpInputTokens = 0
private(set) var acpOutputTokens = 0
private(set) var acpThoughtTokens = 0
private(set) var acpCachedReadTokens = 0
/// Slash commands advertised by the ACP server via `available_commands_update`.
private(set) var availableCommandNames: Set<String> = []
var supportsCompress: Bool { availableCommandNames.contains("compress") }
var hasMessages: Bool { !messages.isEmpty }
func requestScrollToBottom() {
scrollTrigger = UUID()
}
private(set) var sessionId: String?
/// The original CLI session ID when resuming a CLI session via ACP.
/// Used to combine old CLI messages with new ACP messages.
@@ -77,6 +94,11 @@ final class RichChatViewModel {
streamingAssistantText = ""
streamingThinkingText = ""
streamingToolCalls = []
acpInputTokens = 0
acpOutputTokens = 0
acpThoughtTokens = 0
acpCachedReadTokens = 0
availableCommandNames = []
pendingPermission = nil
}
@@ -91,6 +113,17 @@ final class RichChatViewModel {
await dataService.close()
}
/// Re-fetch session metadata from DB to pick up cost/token updates.
func refreshSessionFromDB() async {
guard let sessionId else { return }
let opened = await dataService.open()
guard opened else { return }
if let session = await dataService.fetchSession(id: sessionId) {
currentSession = session
}
await dataService.close()
}
// MARK: - ACP Event Handling
/// Add a user message immediately (before DB write) for instant UI feedback.
@@ -136,11 +169,20 @@ final class RichChatViewModel {
kind: request.toolCallKind,
options: request.options
)
case .promptComplete:
handlePromptComplete()
case .promptComplete(_, let response):
handlePromptComplete(response: response)
case .connectionLost(let reason):
handleConnectionLost(reason: reason)
case .availableCommands, .unknown:
case .availableCommands(_, let commands):
var names: Set<String> = []
for entry in commands {
if let name = entry["name"] as? String {
// Hermes sends names either as "compress" or "/compress"
names.insert(name.trimmingCharacters(in: CharacterSet(charactersIn: "/")))
}
}
availableCommandNames = names
case .unknown:
break
}
}
@@ -188,9 +230,13 @@ final class RichChatViewModel {
buildMessageGroups()
}
private func handlePromptComplete() {
// Finalize any remaining streaming content
private func handlePromptComplete(response: ACPPromptResult) {
finalizeStreamingMessage()
// Accumulate token usage from this prompt
acpInputTokens += response.inputTokens
acpOutputTokens += response.outputTokens
acpThoughtTokens += response.thoughtTokens
acpCachedReadTokens += response.cachedReadTokens
isAgentWorking = false
buildMessageGroups()
}
@@ -91,9 +91,8 @@ struct ChatView: View {
Menu {
if viewModel.hasActiveProcess, let activeId = viewModel.richChatViewModel.sessionId {
Button("Return to Active Session (\(activeId.prefix(8))...)") {
// Already active just ensure we're showing it
viewModel.richChatViewModel.requestScrollToBottom()
}
.disabled(true)
Divider()
}
Button("New Session") {
@@ -105,6 +104,8 @@ struct ChatView: View {
if !viewModel.recentSessions.isEmpty {
Divider()
Text("Resume Session")
let activeSessionId = viewModel.richChatViewModel.sessionId
let originSessionId = viewModel.richChatViewModel.originSessionId
ForEach(viewModel.recentSessions) { session in
Button {
viewModel.resumeSession(session.id)
@@ -120,6 +121,7 @@ struct ChatView: View {
}
}
}
.disabled(session.id == activeSessionId || session.id == originSessionId)
}
}
} label: {
@@ -3,12 +3,29 @@ import SwiftUI
struct RichChatInputBar: View {
let onSend: (String) -> Void
let isEnabled: Bool
var supportsCompress: Bool = false
@State private var text = ""
@State private var showCompressSheet = false
@State private var compressFocus = ""
@FocusState private var isFocused: Bool
var body: some View {
HStack(alignment: .bottom, spacing: 8) {
if supportsCompress {
Button {
compressFocus = ""
showCompressSheet = true
} label: {
Image(systemName: "rectangle.compress.vertical")
.font(.title3)
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
.disabled(!isEnabled)
.help("Compress conversation (/compress)")
}
TextEditor(text: $text)
.font(.body)
.scrollContentBackground(.hidden)
@@ -50,6 +67,34 @@ struct RichChatInputBar: View {
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(.bar)
.sheet(isPresented: $showCompressSheet) {
compressSheet
}
}
private var compressSheet: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Compress Conversation")
.font(.headline)
Text("Optionally focus the summary on a specific topic. Leave blank to compress evenly.")
.font(.caption)
.foregroundStyle(.secondary)
TextField("Focus topic (optional)", text: $compressFocus)
.textFieldStyle(.roundedBorder)
HStack {
Spacer()
Button("Cancel") { showCompressSheet = false }
Button("Compress") {
let focus = compressFocus.trimmingCharacters(in: .whitespacesAndNewlines)
let command = focus.isEmpty ? "/compress" : "/compress \(focus)"
onSend(command)
showCompressSheet = false
}
.keyboardShortcut(.defaultAction)
}
}
.padding(20)
.frame(width: 360)
}
private var canSend: Bool {
@@ -3,6 +3,8 @@ import SwiftUI
struct RichChatMessageList: View {
let groups: [MessageGroup]
let isWorking: Bool
/// External trigger to force a scroll-to-bottom (e.g., from "Return to Active Session").
var scrollTrigger: UUID = UUID()
/// Track the last group's assistant content length to detect streaming updates.
private var scrollAnchor: String {
@@ -30,6 +32,14 @@ struct RichChatMessageList: View {
.padding()
}
.defaultScrollAnchor(.bottom)
// Scroll to bottom when view first appears with content
.onAppear {
if !groups.isEmpty {
DispatchQueue.main.async {
scrollToBottom(proxy: proxy, animated: false)
}
}
}
// Scroll on new groups
.onChange(of: groups.count) {
scrollToBottom(proxy: proxy)
@@ -50,6 +60,10 @@ struct RichChatMessageList: View {
.onChange(of: groups.last?.toolCallCount ?? 0) {
scrollToBottom(proxy: proxy)
}
// Scroll on external trigger (e.g., "Return to Active Session" button)
.onChange(of: scrollTrigger) {
scrollToBottom(proxy: proxy)
}
}
}
@@ -14,7 +14,10 @@ struct RichChatView: View {
VStack(spacing: 0) {
SessionInfoBar(
session: richChat.currentSession,
isWorking: richChat.isAgentWorking
isWorking: richChat.isAgentWorking,
acpInputTokens: richChat.acpInputTokens,
acpOutputTokens: richChat.acpOutputTokens,
acpThoughtTokens: richChat.acpThoughtTokens
)
Divider()
@@ -28,7 +31,8 @@ struct RichChatView: View {
} else {
RichChatMessageList(
groups: richChat.messageGroups,
isWorking: richChat.isAgentWorking
isWorking: richChat.isAgentWorking,
scrollTrigger: richChat.scrollTrigger
)
}
@@ -37,7 +41,8 @@ struct RichChatView: View {
onSend: { text in
onSend(text)
},
isEnabled: isEnabled
isEnabled: isEnabled,
supportsCompress: richChat.supportsCompress
)
}
// DB polling fallback for terminal mode only never overwrite ACP messages
@@ -3,6 +3,10 @@ import SwiftUI
struct SessionInfoBar: View {
let session: HermesSession?
let isWorking: Bool
/// Fallback token counts from ACP prompt results (DB may have zeros for ACP sessions).
var acpInputTokens: Int = 0
var acpOutputTokens: Int = 0
var acpThoughtTokens: Int = 0
var body: some View {
HStack(spacing: 16) {
@@ -30,11 +34,14 @@ struct SessionInfoBar: View {
Label(model, systemImage: "cpu")
}
Label("\(formatTokens(session.inputTokens)) in / \(formatTokens(session.outputTokens)) out", systemImage: "number")
let inputToks = session.inputTokens > 0 ? session.inputTokens : acpInputTokens
let outputToks = session.outputTokens > 0 ? session.outputTokens : acpOutputTokens
Label("\(formatTokens(inputToks)) in / \(formatTokens(outputToks)) out", systemImage: "number")
.contentTransition(.numericText())
if session.reasoningTokens > 0 {
Label("\(formatTokens(session.reasoningTokens)) reasoning", systemImage: "brain")
let reasonToks = session.reasoningTokens > 0 ? session.reasoningTokens : acpThoughtTokens
if reasonToks > 0 {
Label("\(formatTokens(reasonToks)) reasoning", systemImage: "brain")
}
if let cost = session.displayCostUSD {
@@ -0,0 +1,244 @@
import Foundation
import AppKit
import os
/// A single pooled credential for a provider (rotation entry).
struct HermesCredential: Identifiable, Sendable, Equatable {
var id: String { "\(provider):\(index):\(internalID)" }
let internalID: String // Stable id from auth.json (e.g. "9f8d9b")
let provider: String
let index: Int // 0-based index in the provider's pool
let label: String // Human label ("OPENROUTER_API_KEY")
let authType: String // "api_key" | "oauth"
let source: String // "env:OPENROUTER_API_KEY" | "gh_cli" | "file:..."
let tokenTail: String // Last 4 chars of the token NEVER store full token in UI state
let lastStatus: String // "ok" | "cooldown" | "exhausted" | ""
let requestCount: Int
}
/// Summary of one provider's pool with its rotation strategy.
struct HermesCredentialPool: Identifiable, Sendable {
var id: String { provider }
let provider: String
let strategy: String // "fill_first" | "round_robin" | "least_used" | "random"
let credentials: [HermesCredential]
}
@Observable
@MainActor
final class CredentialPoolsViewModel {
private let logger = Logger(subsystem: "com.scarf", category: "CredentialPoolsViewModel")
var pools: [HermesCredentialPool] = []
var isLoading = false
var message: String?
/// Driver for the OAuth flow. Uses Process + pipes (not SwiftTerm) so we
/// can extract the authorization URL, pop it open with an explicit button,
/// and feed the code back via stdin. See OAuthFlowController for why we
/// moved off the embedded-terminal approach.
let oauthFlow = OAuthFlowController()
var oauthProvider: String = ""
/// Convenience the sheet keys a lot of UI off "is the flow running?".
var oauthInProgress: Bool { oauthFlow.isRunning }
let strategyOptions = ["fill_first", "round_robin", "least_used", "random"]
/// Source of truth is `~/.hermes/auth.json`. Parsing box-drawn `hermes auth list`
/// output is fragile the JSON file is structured, stable, and already stores
/// exactly the pool data the UI needs. We never display full tokens.
func load() {
isLoading = true
defer { isLoading = false }
let authPath = HermesPaths.home + "/auth.json"
let strategies = parseStrategies()
guard let data = try? Data(contentsOf: URL(fileURLWithPath: authPath)) else {
pools = []
return
}
do {
let decoded = try JSONDecoder().decode(AuthFile.self, from: data)
pools = Self.buildPools(from: decoded, strategies: strategies)
} catch {
logger.error("Failed to decode auth.json: \(error.localizedDescription)")
pools = []
}
}
/// The `credential_pool_strategies:` map lives in config.yaml as `<provider>: <strategy>`.
private func parseStrategies() -> [String: String] {
guard let yaml = try? String(contentsOfFile: HermesPaths.configYAML, encoding: .utf8) else { return [:] }
let parsed = HermesFileService.parseNestedYAML(yaml)
return parsed.maps["credential_pool_strategies"] ?? [:]
}
private static func buildPools(from auth: AuthFile, strategies: [String: String]) -> [HermesCredentialPool] {
auth.credential_pool.keys.sorted().map { provider in
let entries = auth.credential_pool[provider] ?? []
let creds = entries.enumerated().map { index, entry in
HermesCredential(
internalID: entry.id ?? "",
provider: provider,
index: index,
label: entry.label ?? entry.source ?? "",
authType: entry.auth_type ?? "",
source: entry.source ?? "",
tokenTail: Self.tail(of: entry.access_token ?? ""),
lastStatus: entry.last_status ?? "",
requestCount: entry.request_count ?? 0
)
}
return HermesCredentialPool(
provider: provider,
strategy: strategies[provider] ?? "fill_first",
credentials: creds
)
}
}
/// Return last 4 chars prefixed with "", or "" if the token is too short.
/// Callers MUST NOT pass the full token anywhere user-visible beyond this.
private static func tail(of token: String) -> String {
guard token.count >= 4 else { return "" }
return "" + String(token.suffix(4))
}
// MARK: - Mutations (all routed through the hermes CLI so hermes stays authoritative)
func setStrategy(_ strategy: String, for provider: String) {
let result = runHermes(["config", "set", "credential_pool_strategies.\(provider)", strategy])
if result.exitCode == 0 {
message = "Strategy updated for \(provider)"
load()
} else {
message = "Failed to update strategy"
}
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
self?.message = nil
}
}
/// Add an API-key credential to a provider's pool. Runs non-interactively.
///
/// **Critical:** we must pass `--type api-key` in addition to `--api-key`.
/// Without `--type`, hermes falls back to the provider's default (OAuth for
/// Anthropic, etc.) and launches the browser flow even though the user
/// just gave us a key.
func addAPIKey(provider: String, apiKey: String, label: String) {
var args = ["auth", "add", provider, "--type", "api-key", "--api-key", apiKey]
let trimmedLabel = label.trimmingCharacters(in: .whitespaces)
if !trimmedLabel.isEmpty {
args += ["--label", trimmedLabel]
}
let result = runHermes(args)
if result.exitCode == 0 {
message = "Credential added"
load()
} else {
logger.warning("Add credential failed: \(result.output)")
message = "Add failed: \(result.output.prefix(160))"
}
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
self?.message = nil
}
}
/// Kick off the OAuth flow. Uses OAuthFlowController (Process + pipes) so
/// we can detect the authorization URL from hermes's output, open the
/// browser ourselves, and feed the code back via stdin avoiding the
/// subprocess-can't-open-browser problem SwiftTerm had.
func startOAuth(provider: String, label: String) {
guard !provider.isEmpty else { return }
oauthProvider = provider
oauthFlow.onExit = { [weak self] _ in
guard let self else { return }
self.message = self.oauthFlow.succeeded
? "OAuth login succeeded"
: (self.oauthFlow.errorMessage ?? "OAuth login failed or cancelled")
// Reload regardless hermes may have written a partial credential
// even on a soft failure, and we want the list to reflect truth.
self.load()
DispatchQueue.main.asyncAfter(deadline: .now() + 4) { [weak self] in
self?.message = nil
}
}
oauthFlow.start(provider: provider, label: label)
}
/// Submit the authorization code the user pasted into the form's text
/// field. Writes it to hermes's stdin.
func submitOAuthCode(_ code: String) {
oauthFlow.submitCode(code)
}
/// Cancel an in-progress OAuth attempt (e.g., user closed the sheet).
func cancelOAuth() {
oauthFlow.stop()
}
func removeCredential(provider: String, index: Int) {
// The CLI uses 1-based indexing ("#1", "#2" in `hermes auth list`); our
// stored `index` is 0-based, so add 1 when handing to the CLI.
let result = runHermes(["auth", "remove", provider, String(index + 1)])
if result.exitCode == 0 {
message = "Credential removed"
load()
} else {
message = "Remove failed"
}
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
self?.message = nil
}
}
func resetProvider(_ provider: String) {
let result = runHermes(["auth", "reset", provider])
message = result.exitCode == 0 ? "Cooldowns cleared for \(provider)" : "Reset failed"
load()
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
self?.message = nil
}
}
@discardableResult
private func runHermes(_ arguments: [String]) -> (output: String, exitCode: Int32) {
let process = Process()
process.executableURL = URL(fileURLWithPath: HermesPaths.hermesBinary)
process.arguments = arguments
process.environment = HermesFileService.enrichedEnvironment()
let pipe = Pipe()
process.standardOutput = pipe
process.standardError = Pipe()
do {
try process.run()
process.waitUntilExit()
let data = pipe.fileHandleForReading.readDataToEndOfFile()
return (String(data: data, encoding: .utf8) ?? "", process.terminationStatus)
} catch {
return ("", -1)
}
}
}
// MARK: - auth.json decoding
// Shape verified against a real `~/.hermes/auth.json` see sample in plan notes.
// All fields are optional because the format evolves and we want decoding to
// succeed even if hermes adds new keys or omits some for certain auth types.
private struct AuthFile: Decodable {
let credential_pool: [String: [AuthEntry]]
}
private struct AuthEntry: Decodable {
let id: String?
let label: String?
let auth_type: String?
let source: String?
let access_token: String?
let last_status: String?
let request_count: Int?
}
@@ -0,0 +1,251 @@
import Foundation
import AppKit
import os
/// Drives the `hermes auth add <provider> --type oauth` flow via `Process` +
/// pipes instead of SwiftTerm. The embedded terminal approach turned out to
/// have two problems:
///
/// 1. Python's `webbrowser.open` called from a subprocess doesn't reliably
/// open the user's browser the macOS `open` command can fail silently
/// depending on how the parent app was launched.
/// 2. Even when it works, users can't easily copy the URL from a terminal
/// emulator to click or share.
///
/// This controller runs hermes with `--no-browser`, captures stdout/stderr,
/// regex-extracts the authorization URL, and exposes it to the UI as a plain
/// string. The UI shows a real "Open in Browser" button (via NSWorkspace) and
/// a code input text field. Submitting writes the code + newline to hermes's
/// stdin pipe, which Python's `input()` reads normally verified in shell
/// testing that hermes accepts piped stdin when a TTY isn't available.
///
/// Hermes exits 0 even on "login did not return credentials" failures, so we
/// detect success by scanning output for failure markers AND by letting the
/// calling VM reload `auth.json` to see whether a new credential actually
/// landed.
@Observable
@MainActor
final class OAuthFlowController {
private let logger = Logger(subsystem: "com.scarf", category: "OAuthFlowController")
// MARK: - Observable state
/// Accumulated terminal output for display. Grows monotonically during
/// the flow; cleared on `start(...)`.
var output: String = ""
/// Authorization URL extracted from hermes's output. Shown as a prominent
/// "Open in Browser" button once detected.
var authorizationURL: String?
/// True once hermes has printed the "Authorization code:" prompt. Gates
/// the code submit button so users can't submit too early.
var awaitingCode: Bool = false
/// True between `start(...)` and process termination.
var isRunning: Bool = false
/// Set when the process exits with a success signal (both zero exit AND
/// no failure marker in output). The VM checks this + reloads auth.json.
var succeeded: Bool = false
/// Human-readable error message if start/submit failed mid-flow.
var errorMessage: String?
/// Fired when the process exits, with the raw exit code. Use this to
/// trigger a UI reload or close the sheet.
var onExit: ((Int32) -> Void)?
// MARK: - Private state
private var process: Process?
private var stdinPipe: Pipe?
private var stdoutPipe: Pipe?
// MARK: - Lifecycle
/// Start the OAuth flow. Any prior in-flight flow is terminated first.
func start(provider: String, label: String) {
stop()
output = ""
authorizationURL = nil
awaitingCode = false
succeeded = false
errorMessage = nil
// Pass --no-browser so hermes doesn't try (and potentially fail) to
// launch the browser itself we do it explicitly with the button.
var args = ["auth", "add", provider, "--type", "oauth", "--no-browser"]
let trimmedLabel = label.trimmingCharacters(in: .whitespaces)
if !trimmedLabel.isEmpty {
args += ["--label", trimmedLabel]
}
let proc = Process()
proc.executableURL = URL(fileURLWithPath: HermesPaths.hermesBinary)
proc.arguments = args
proc.environment = HermesFileService.enrichedEnvironment()
let outPipe = Pipe()
let inPipe = Pipe()
// Merge stderr into stdout: hermes prints the URL + prompt to stdout,
// but diagnostic messages can land on stderr; we want both interleaved
// in display order.
proc.standardOutput = outPipe
proc.standardError = outPipe
proc.standardInput = inPipe
outPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in
let data = handle.availableData
if data.isEmpty {
// EOF the peer closed its write end. Drop the handler so
// Foundation doesn't keep calling us with empty reads.
handle.readabilityHandler = nil
return
}
let chunk = String(data: data, encoding: .utf8) ?? ""
// Hop onto the main actor to mutate observable state.
Task { @MainActor [weak self] in
self?.handleOutputChunk(chunk)
}
}
proc.terminationHandler = { [weak self] p in
let code = p.terminationStatus
Task { @MainActor [weak self] in
outPipe.fileHandleForReading.readabilityHandler = nil
self?.handleTermination(exitCode: code)
}
}
do {
try proc.run()
process = proc
stdinPipe = inPipe
stdoutPipe = outPipe
isRunning = true
} catch {
errorMessage = "Failed to start hermes: \(error.localizedDescription)"
logger.error("Failed to start hermes: \(error.localizedDescription)")
}
}
/// Terminate the in-flight process (if any). Safe to call when nothing is running.
func stop() {
stdoutPipe?.fileHandleForReading.readabilityHandler = nil
process?.terminate()
process = nil
stdinPipe = nil
stdoutPipe = nil
isRunning = false
awaitingCode = false
}
/// Send the authorization code to hermes's stdin. Called when the user
/// taps "Submit" in the sheet's code input field.
func submitCode(_ code: String) {
let trimmed = code.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else {
errorMessage = "Authorization code is empty"
return
}
guard let stdinPipe else {
errorMessage = "Process is no longer accepting input"
return
}
let payload = trimmed + "\n"
guard let data = payload.data(using: .utf8) else {
errorMessage = "Could not encode code"
return
}
do {
try stdinPipe.fileHandleForWriting.write(contentsOf: data)
// After writing, we don't close stdin hermes might prompt again
// on failure. Instead we flip `awaitingCode` off so the UI can
// dim the submit button until another prompt appears.
awaitingCode = false
} catch {
errorMessage = "Failed to send code: \(error.localizedDescription)"
}
}
/// Explicitly open the detected authorization URL in the default browser.
/// Does nothing if no URL has been detected yet.
func openURLInBrowser() {
guard let url = authorizationURL, let parsed = URL(string: url) else { return }
NSWorkspace.shared.open(parsed)
}
// MARK: - Output handling
private func handleOutputChunk(_ chunk: String) {
output += chunk
if authorizationURL == nil, let url = Self.extractAuthURL(from: output) {
authorizationURL = url
// Auto-open the browser on first detection, since that's what a
// well-behaved hermes would have done. We keep the manual button
// available for retries / copy-paste.
if let parsed = URL(string: url) {
NSWorkspace.shared.open(parsed)
}
}
// The prompt may arrive in the same chunk as the URL. Checking
// cumulative output (rather than just this chunk) is safer.
if !awaitingCode, output.contains("Authorization code:") {
awaitingCode = true
}
}
private func handleTermination(exitCode: Int32) {
isRunning = false
// Hermes exits 0 even on "login did not return credentials" detect
// that failure marker explicitly so we don't report false success.
let failureMarkers = [
"did not return credentials",
"Token exchange failed",
"OAuth login failed",
"HTTP Error"
]
let outputFailed = failureMarkers.contains { output.localizedCaseInsensitiveContains($0) }
succeeded = exitCode == 0 && !outputFailed
if !succeeded, errorMessage == nil {
if outputFailed {
errorMessage = "OAuth did not complete — check the output above for details"
} else if exitCode != 0 {
errorMessage = "hermes exited with code \(exitCode)"
}
}
onExit?(exitCode)
}
// MARK: - URL extraction
/// Extract the OAuth authorization URL from hermes's output. Hermes prints
/// it on its own line in a Rich-rendered box; we want a plain https URL
/// that looks like a provider OAuth endpoint.
///
/// Priority order:
/// 1. URLs containing `client_id=` real OAuth auth URLs always have this.
/// 2. URLs containing `/authorize` fallback for providers that don't
/// include client_id in the query (unusual but possible).
/// 3. URLs containing `/oauth/` last resort.
///
/// Docs URLs and generic callback URLs are filtered out by these checks.
nonisolated static func extractAuthURL(from text: String) -> String? {
let pattern = #"https://[^\s\)\]\"'`<>]+"#
guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil }
let range = NSRange(text.startIndex..., in: text)
let urls: [String] = regex.matches(in: text, range: range).compactMap { match in
Range(match.range, in: text).map { String(text[$0]) }
}
// Prefer the strongest signal so we don't accidentally surface the
// redirect callback URL when both appear unencoded in output.
if let url = urls.first(where: { $0.contains("client_id=") }) { return url }
if let url = urls.first(where: { $0.contains("/authorize") }) { return url }
if let url = urls.first(where: { $0.contains("/oauth/") }) { return url }
return nil
}
}
@@ -0,0 +1,472 @@
import SwiftUI
struct CredentialPoolsView: View {
@State private var viewModel = CredentialPoolsViewModel()
@State private var showAddSheet = false
@State private var pendingRemove: HermesCredential?
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
header
safetyNotice
if viewModel.isLoading {
ProgressView().padding()
} else if viewModel.pools.isEmpty {
emptyState
} else {
ForEach(viewModel.pools) { pool in
poolSection(pool)
}
}
}
.padding()
.frame(maxWidth: .infinity, alignment: .topLeading)
}
.navigationTitle("Credential Pools")
.onAppear { viewModel.load() }
.sheet(isPresented: $showAddSheet) {
AddCredentialSheet(viewModel: viewModel) {
showAddSheet = false
}
}
.confirmationDialog(
pendingRemove.map { "Remove credential for \($0.provider)?" } ?? "",
isPresented: Binding(get: { pendingRemove != nil }, set: { if !$0 { pendingRemove = nil } })
) {
Button("Remove", role: .destructive) {
if let target = pendingRemove {
viewModel.removeCredential(provider: target.provider, index: target.index)
}
pendingRemove = nil
}
Button("Cancel", role: .cancel) { pendingRemove = nil }
} message: {
Text("This removes the credential from hermes. The upstream provider key is not revoked.")
}
}
private var header: some View {
HStack {
if let msg = viewModel.message {
Label(msg, systemImage: "info.circle.fill")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Button {
showAddSheet = true
} label: {
Label("Add Credential", systemImage: "plus")
}
.controlSize(.small)
Button("Reload") { viewModel.load() }
.controlSize(.small)
}
}
private var safetyNotice: some View {
HStack(alignment: .top, spacing: 8) {
Image(systemName: "lock.shield")
.foregroundStyle(.secondary)
Text("API keys are never displayed in full. Scarf only shows the last 4 characters for identification. Full key values are stored by hermes in ~/.hermes/auth.json.")
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(8)
.background(.quaternary.opacity(0.3))
.clipShape(RoundedRectangle(cornerRadius: 6))
}
private var emptyState: some View {
VStack(spacing: 8) {
Image(systemName: "key.horizontal")
.font(.largeTitle)
.foregroundStyle(.secondary)
Text("No credential pools configured")
.foregroundStyle(.secondary)
Text("Add rotation credentials so hermes can failover between keys when one hits rate limits.")
.font(.caption)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 40)
}
@ViewBuilder
private func poolSection(_ pool: HermesCredentialPool) -> some View {
SettingsSection(title: pool.provider, icon: "key.horizontal") {
PickerRow(label: "Rotation", selection: pool.strategy, options: viewModel.strategyOptions) { strategy in
viewModel.setStrategy(strategy, for: pool.provider)
}
ForEach(pool.credentials) { cred in
HStack(spacing: 12) {
Image(systemName: cred.authType == "oauth" ? "person.badge.key" : "key.fill")
.foregroundStyle(.secondary)
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 6) {
Text("#\(cred.index + 1)")
.font(.system(.caption, design: .monospaced, weight: .bold))
if !cred.label.isEmpty {
Text(cred.label).font(.caption)
}
if !cred.authType.isEmpty {
Text(cred.authType)
.font(.caption2)
.foregroundStyle(.secondary)
.padding(.horizontal, 5)
.padding(.vertical, 1)
.background(.quaternary)
.clipShape(Capsule())
}
if !cred.lastStatus.isEmpty {
Text(cred.lastStatus)
.font(.caption2)
.foregroundStyle(statusColor(cred.lastStatus))
}
}
HStack(spacing: 8) {
Text(cred.tokenTail.isEmpty ? "" : cred.tokenTail)
.font(.system(.caption, design: .monospaced))
.foregroundStyle(.secondary)
if !cred.source.isEmpty {
Text(cred.source)
.font(.caption2)
.foregroundStyle(.tertiary)
}
if cred.requestCount > 0 {
Text("\(cred.requestCount) req")
.font(.caption2)
.foregroundStyle(.tertiary)
}
}
}
Spacer()
Button("Remove", role: .destructive) { pendingRemove = cred }
.controlSize(.small)
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(.quaternary.opacity(0.3))
}
HStack {
Spacer()
Button("Reset Cooldowns") { viewModel.resetProvider(pool.provider) }
.controlSize(.small)
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(.quaternary.opacity(0.3))
}
}
private func statusColor(_ status: String) -> Color {
switch status {
case "ok", "active": return .green
case "cooldown": return .orange
case "exhausted": return .red
default: return .secondary
}
}
}
/// Two-step sheet for adding a credential:
/// 1. Provider picker (populated from the models catalog, falls back to free text)
/// + type selector (API Key vs OAuth) + optional label
/// 2. Either an immediate save (API key) or an embedded terminal running the
/// OAuth flow so the user can paste the authorization code back.
private struct AddCredentialSheet: View {
@Bindable var viewModel: CredentialPoolsViewModel
let onDismiss: () -> Void
enum AuthType: String, CaseIterable, Identifiable {
case apiKey = "API Key"
case oauth = "OAuth"
var id: String { rawValue }
}
@State private var providerID: String = ""
@State private var authType: AuthType = .apiKey
@State private var apiKey: String = ""
@State private var label: String = ""
@State private var providers: [HermesProviderInfo] = []
@State private var oauthStarted: Bool = false
@State private var authCode: String = ""
private let catalog = ModelCatalogService()
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Add Credential")
.font(.headline)
if !oauthStarted {
configSection
} else {
oauthSection
}
Divider()
footer
}
.padding()
.frame(minWidth: 600, minHeight: 460)
.onAppear {
providers = catalog.loadProviders()
}
// Auto-close the sheet once a credential is actually saved. We key
// off `succeeded` which the controller sets only when hermes exited
// zero AND the output has no failure markers. The 0.8s delay lets the
// user see the success banner before the sheet disappears.
.onChange(of: viewModel.oauthFlow.succeeded) { _, newValue in
guard newValue else { return }
DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) {
onDismiss()
}
}
}
// MARK: - Step 1: provider + type + label + optional API key
private var configSection: some View {
VStack(alignment: .leading, spacing: 10) {
VStack(alignment: .leading, spacing: 4) {
Text("Provider").font(.caption).foregroundStyle(.secondary)
HStack {
// Free-text first so providers missing from the catalog
// (e.g. "nous") are still addable.
TextField("e.g. anthropic", text: $providerID)
.textFieldStyle(.roundedBorder)
.font(.system(.caption, design: .monospaced))
Menu("Browse") {
ForEach(providers) { provider in
Button(provider.providerName + " (\(provider.providerID))") {
providerID = provider.providerID
}
}
}
.controlSize(.small)
}
}
VStack(alignment: .leading, spacing: 4) {
Text("Credential Type").font(.caption).foregroundStyle(.secondary)
Picker("", selection: $authType) {
ForEach(AuthType.allCases) { type in
Text(type.rawValue).tag(type)
}
}
.pickerStyle(.segmented)
.labelsHidden()
}
VStack(alignment: .leading, spacing: 4) {
Text("Label (optional)").font(.caption).foregroundStyle(.secondary)
TextField("e.g. team-prod", text: $label)
.textFieldStyle(.roundedBorder)
}
if authType == .apiKey {
VStack(alignment: .leading, spacing: 4) {
Text("API Key").font(.caption).foregroundStyle(.secondary)
SecureField("sk-…", text: $apiKey)
.textFieldStyle(.roundedBorder)
.font(.system(.caption, design: .monospaced))
}
} else {
oauthPreamble
}
}
}
/// Brief explanation shown before the user clicks "Start OAuth". Sets
/// expectations about the embedded-terminal flow so the browser window
/// and code-paste step aren't surprises.
private var oauthPreamble: some View {
VStack(alignment: .leading, spacing: 6) {
Text("Clicking Start OAuth opens the provider's authorization page in your browser. After you approve, copy the code the provider displays and paste it back into the terminal that appears next.")
.font(.caption)
.foregroundStyle(.secondary)
Text("The terminal is a real TTY — paste with ⌘V, press Return, and wait for the process to exit with \"login succeeded\".")
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(8)
.background(.quaternary.opacity(0.3))
.clipShape(RoundedRectangle(cornerRadius: 6))
}
// MARK: - Step 2: OAuth URL button, code field, live output log
private var oauthSection: some View {
// Pull the observable controller into a local so the view redraws
// when its @Observable properties change.
let flow = viewModel.oauthFlow
return VStack(alignment: .leading, spacing: 10) {
oauthHeader(flow: flow)
urlBlock(flow: flow)
codeEntryBlock(flow: flow)
outputLogBlock(flow: flow)
}
}
@ViewBuilder
private func oauthHeader(flow: OAuthFlowController) -> some View {
HStack(spacing: 8) {
Image(systemName: "person.badge.key")
Text("OAuth login for \(viewModel.oauthProvider)")
.font(.headline)
Spacer()
if flow.isRunning {
ProgressView().controlSize(.small)
} else if flow.succeeded {
Label("Succeeded", systemImage: "checkmark.circle.fill")
.font(.caption)
.foregroundStyle(.green)
} else if let err = flow.errorMessage {
Label(err, systemImage: "exclamationmark.triangle.fill")
.font(.caption)
.foregroundStyle(.orange)
.lineLimit(1)
}
}
}
/// Authorization URL block. Hermes prints the URL on startup; we detect
/// it via regex and expose a prominent Open + Copy pair. The URL keeps
/// showing even after the browser is opened so users can paste it into
/// a different browser profile if needed.
@ViewBuilder
private func urlBlock(flow: OAuthFlowController) -> some View {
if let url = flow.authorizationURL {
VStack(alignment: .leading, spacing: 6) {
Label("Authorization URL", systemImage: "link")
.font(.caption.bold())
.foregroundStyle(.secondary)
HStack(spacing: 6) {
Text(url)
.font(.caption.monospaced())
.textSelection(.enabled)
.lineLimit(2)
.truncationMode(.middle)
Spacer()
Button {
flow.openURLInBrowser()
} label: {
Label("Open in Browser", systemImage: "safari")
}
.controlSize(.small)
.buttonStyle(.borderedProminent)
Button {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(url, forType: .string)
} label: {
Label("Copy", systemImage: "doc.on.doc")
}
.controlSize(.small)
}
}
.padding(8)
.background(.blue.opacity(0.08))
.clipShape(RoundedRectangle(cornerRadius: 6))
} else if flow.isRunning {
// Still waiting for hermes to print the URL usually <1s.
HStack(spacing: 6) {
ProgressView().controlSize(.small)
Text("Waiting for authorization URL…")
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
/// Authorization code input. Only active once hermes has printed its
/// "Authorization code:" prompt so users can't submit before hermes is
/// ready to receive input.
@ViewBuilder
private func codeEntryBlock(flow: OAuthFlowController) -> some View {
VStack(alignment: .leading, spacing: 4) {
Label("Authorization Code", systemImage: "keyboard")
.font(.caption.bold())
.foregroundStyle(.secondary)
Text("After approving in your browser, the provider shows a code. Paste it below and submit.")
.font(.caption)
.foregroundStyle(.secondary)
HStack(spacing: 6) {
TextField("Paste code here…", text: $authCode)
.textFieldStyle(.roundedBorder)
.font(.system(.caption, design: .monospaced))
.disabled(!flow.awaitingCode)
.onSubmit { submitCode(flow: flow) }
Button("Submit") { submitCode(flow: flow) }
.controlSize(.small)
.buttonStyle(.borderedProminent)
.disabled(!flow.awaitingCode || authCode.trimmingCharacters(in: .whitespaces).isEmpty)
}
if !flow.awaitingCode && flow.isRunning {
Text("Waiting for hermes to prompt for the code…")
.font(.caption2)
.foregroundStyle(.tertiary)
}
}
}
/// Live output log useful for diagnostics if the flow stalls or errors.
@ViewBuilder
private func outputLogBlock(flow: OAuthFlowController) -> some View {
VStack(alignment: .leading, spacing: 4) {
Label("Output", systemImage: "text.alignleft")
.font(.caption.bold())
.foregroundStyle(.secondary)
ScrollView {
Text(flow.output.isEmpty ? "(no output yet)" : flow.output)
.font(.system(.caption, design: .monospaced))
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(8)
}
.frame(minHeight: 120, maxHeight: 200)
.background(.quaternary.opacity(0.3))
.clipShape(RoundedRectangle(cornerRadius: 6))
}
}
private func submitCode(flow: OAuthFlowController) {
let trimmed = authCode.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
viewModel.submitOAuthCode(trimmed)
authCode = ""
}
// MARK: - Footer (buttons)
private var footer: some View {
HStack {
Spacer()
if oauthStarted {
Button("Close") {
// Closing mid-flow terminates hermes so we don't leave a
// zombie process waiting for stdin forever.
viewModel.cancelOAuth()
onDismiss()
}
} else {
Button("Cancel") { onDismiss() }
if authType == .apiKey {
Button("Add") {
viewModel.addAPIKey(provider: providerID, apiKey: apiKey, label: label)
onDismiss()
}
.buttonStyle(.borderedProminent)
.disabled(providerID.trimmingCharacters(in: .whitespaces).isEmpty || apiKey.trimmingCharacters(in: .whitespaces).isEmpty)
} else {
Button("Start OAuth") {
viewModel.startOAuth(provider: providerID, label: label)
oauthStarted = true
}
.buttonStyle(.borderedProminent)
.disabled(providerID.trimmingCharacters(in: .whitespaces).isEmpty)
}
}
}
}
}
@@ -1,19 +1,101 @@
import Foundation
import AppKit
import os
@Observable
final class CronViewModel {
private let logger = Logger(subsystem: "com.scarf", category: "CronViewModel")
private let fileService = HermesFileService()
var jobs: [HermesCronJob] = []
var selectedJob: HermesCronJob?
var jobOutput: String?
var availableSkills: [String] = []
var message: String?
var showCreateSheet = false
var editingJob: HermesCronJob?
func load() {
jobs = fileService.loadCronJobs()
availableSkills = fileService.loadSkills().flatMap { $0.skills.map(\.id) }.sorted()
if let selected = selectedJob, let refreshed = jobs.first(where: { $0.id == selected.id }) {
selectedJob = refreshed
jobOutput = fileService.loadCronOutput(jobId: refreshed.id)
}
}
func selectJob(_ job: HermesCronJob) {
selectedJob = job
jobOutput = fileService.loadCronOutput(jobId: job.id)
}
// MARK: - CLI wrappers
func pauseJob(_ job: HermesCronJob) {
runAndReload(["cron", "pause", job.id], success: "Paused")
}
func resumeJob(_ job: HermesCronJob) {
runAndReload(["cron", "resume", job.id], success: "Resumed")
}
func runNow(_ job: HermesCronJob) {
runAndReload(["cron", "run", job.id], success: "Scheduled for next tick")
}
func deleteJob(_ job: HermesCronJob) {
runAndReload(["cron", "remove", job.id], success: "Removed")
if selectedJob?.id == job.id {
selectedJob = nil
jobOutput = nil
}
}
func createJob(schedule: String, prompt: String, name: String, deliver: String, skills: [String], script: String, repeatCount: String) {
var args = ["cron", "create"]
if !name.isEmpty { args += ["--name", name] }
if !deliver.isEmpty { args += ["--deliver", deliver] }
if !repeatCount.isEmpty { args += ["--repeat", repeatCount] }
for skill in skills where !skill.isEmpty { args += ["--skill", skill] }
if !script.isEmpty { args += ["--script", script] }
args.append(schedule)
if !prompt.isEmpty { args.append(prompt) }
runAndReload(args, success: "Job created")
}
func updateJob(id: String, schedule: String?, prompt: String?, name: String?, deliver: String?, repeatCount: String?, newSkills: [String]?, clearSkills: Bool, script: String?) {
var args = ["cron", "edit", id]
if let schedule, !schedule.isEmpty { args += ["--schedule", schedule] }
if let prompt, !prompt.isEmpty { args += ["--prompt", prompt] }
if let name, !name.isEmpty { args += ["--name", name] }
if let deliver { args += ["--deliver", deliver] }
if let repeatCount, !repeatCount.isEmpty { args += ["--repeat", repeatCount] }
if clearSkills {
args.append("--clear-skills")
} else if let newSkills {
for skill in newSkills where !skill.isEmpty { args += ["--skill", skill] }
}
if let script { args += ["--script", script] }
runAndReload(args, success: "Updated")
}
// MARK: - Private
private func runAndReload(_ arguments: [String], success: String) {
Task.detached { [fileService] in
let result = fileService.runHermesCLI(args: arguments, timeout: 60)
await MainActor.run {
if result.exitCode == 0 {
self.message = success
} else {
self.message = "Failed: \(result.output.prefix(200))"
self.logger.warning("cron command failed: args=\(arguments) output=\(result.output)")
}
self.load()
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
self?.message = nil
}
}
}
}
}
+374 -138
View File
@@ -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.deliver {
Label("Deliver: \(deliver)", systemImage: "paperplane")
}
}
.font(.caption)
.foregroundStyle(.secondary)
}
detailHeader(job)
actionBar(job)
Divider()
VStack(alignment: .leading, spacing: 4) {
Text("Prompt")
.font(.caption.bold())
.foregroundStyle(.secondary)
Text(job.prompt)
.textSelection(.enabled)
.padding(8)
.frame(maxWidth: .infinity, alignment: .leading)
.background(.quaternary.opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: 6))
}
if let script = job.preRunScript, !script.isEmpty {
VStack(alignment: .leading, spacing: 4) {
Text("Pre-Run Script")
.font(.caption.bold())
.foregroundStyle(.secondary)
Text(script)
.font(.system(.caption, design: .monospaced))
.textSelection(.enabled)
.padding(8)
.frame(maxWidth: .infinity, alignment: .leading)
.background(.quaternary.opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: 6))
}
}
if let skills = job.skills, !skills.isEmpty {
VStack(alignment: .leading, spacing: 4) {
Text("Skills")
.font(.caption.bold())
.foregroundStyle(.secondary)
HStack {
ForEach(skills, id: \.self) { skill in
Text(skill)
.font(.caption.monospaced())
.padding(.horizontal, 8)
.padding(.vertical, 2)
.background(.quaternary)
.clipShape(Capsule())
}
}
}
}
if let nextRun = job.nextRunAt {
Label("Next run: \(nextRun)", systemImage: "arrow.forward.circle")
.font(.caption)
.foregroundStyle(.secondary)
}
if let lastRun = job.lastRunAt {
Label("Last run: \(lastRun)", systemImage: "arrow.backward.circle")
.font(.caption)
.foregroundStyle(.secondary)
}
if let error = job.lastError {
Label(error, systemImage: "exclamationmark.triangle")
.font(.caption)
.foregroundStyle(.red)
}
if let timeout = job.timeoutSeconds {
Label("Timeout: \(timeout)s (\(job.timeoutType ?? "wall_clock"))", systemImage: "timer")
.font(.caption)
.foregroundStyle(.secondary)
}
if let failures = job.deliveryFailures, failures > 0 {
Label("\(failures) delivery failure\(failures == 1 ? "" : "s")", systemImage: "exclamationmark.triangle")
.font(.caption)
.foregroundStyle(.orange)
}
if let deliveryError = job.lastDeliveryError {
Label(deliveryError, systemImage: "paperplane.circle")
.font(.caption)
.foregroundStyle(.orange)
}
if let output = viewModel.jobOutput {
Divider()
VStack(alignment: .leading, spacing: 4) {
Text("Last Output")
.font(.caption.bold())
.foregroundStyle(.secondary)
Text(output)
.font(.system(.caption, design: .monospaced))
.textSelection(.enabled)
.padding(8)
.frame(maxWidth: .infinity, alignment: .leading)
.background(.quaternary.opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: 6))
}
}
detailBody(job)
}
.padding()
.frame(maxWidth: .infinity, alignment: .topLeading)
@@ -176,4 +159,257 @@ struct CronView: View {
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
private func detailHeader(_ job: HermesCronJob) -> some View {
VStack(alignment: .leading, spacing: 8) {
Text(job.name)
.font(.title2.bold())
HStack(spacing: 16) {
Label(job.state, systemImage: job.stateIcon)
Label(job.schedule.display ?? job.schedule.kind, systemImage: "clock")
Label(job.enabled ? "Enabled" : "Disabled", systemImage: job.enabled ? "checkmark.circle" : "xmark.circle")
if let deliver = job.deliveryDisplay {
Label("Deliver: \(deliver)", systemImage: "paperplane")
}
}
.font(.caption)
.foregroundStyle(.secondary)
}
}
private func actionBar(_ job: HermesCronJob) -> some View {
HStack(spacing: 8) {
Button {
if job.enabled { viewModel.pauseJob(job) } else { viewModel.resumeJob(job) }
} label: {
Label(job.enabled ? "Pause" : "Resume", systemImage: job.enabled ? "pause" : "play")
}
Button {
viewModel.runNow(job)
} label: {
Label("Run Now", systemImage: "bolt")
}
Button {
viewModel.editingJob = job
} label: {
Label("Edit", systemImage: "pencil")
}
Spacer()
Button(role: .destructive) {
pendingDelete = job
} label: {
Label("Delete", systemImage: "trash")
}
}
.controlSize(.small)
}
@ViewBuilder
private func detailBody(_ job: HermesCronJob) -> some View {
VStack(alignment: .leading, spacing: 4) {
Text("Prompt")
.font(.caption.bold())
.foregroundStyle(.secondary)
Text(job.prompt)
.textSelection(.enabled)
.padding(8)
.frame(maxWidth: .infinity, alignment: .leading)
.background(.quaternary.opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: 6))
}
if let script = job.preRunScript, !script.isEmpty {
VStack(alignment: .leading, spacing: 4) {
Text("Pre-Run Script")
.font(.caption.bold())
.foregroundStyle(.secondary)
Text(script)
.font(.system(.caption, design: .monospaced))
.textSelection(.enabled)
.padding(8)
.frame(maxWidth: .infinity, alignment: .leading)
.background(.quaternary.opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: 6))
}
}
if let skills = job.skills, !skills.isEmpty {
VStack(alignment: .leading, spacing: 4) {
Text("Skills")
.font(.caption.bold())
.foregroundStyle(.secondary)
HStack {
ForEach(skills, id: \.self) { skill in
Text(skill)
.font(.caption.monospaced())
.padding(.horizontal, 8)
.padding(.vertical, 2)
.background(.quaternary)
.clipShape(Capsule())
}
}
}
}
if let nextRun = job.nextRunAt {
Label("Next run: \(nextRun)", systemImage: "arrow.forward.circle")
.font(.caption)
.foregroundStyle(.secondary)
}
if let lastRun = job.lastRunAt {
Label("Last run: \(lastRun)", systemImage: "arrow.backward.circle")
.font(.caption)
.foregroundStyle(.secondary)
}
if let error = job.lastError {
Label(error, systemImage: "exclamationmark.triangle")
.font(.caption)
.foregroundStyle(.red)
}
if let timeout = job.timeoutSeconds {
Label("Timeout: \(timeout)s (\(job.timeoutType ?? "wall_clock"))", systemImage: "timer")
.font(.caption)
.foregroundStyle(.secondary)
}
if let failures = job.deliveryFailures, failures > 0 {
Label("\(failures) delivery failure\(failures == 1 ? "" : "s")", systemImage: "exclamationmark.triangle")
.font(.caption)
.foregroundStyle(.orange)
}
if let deliveryError = job.lastDeliveryError {
Label(deliveryError, systemImage: "paperplane.circle")
.font(.caption)
.foregroundStyle(.orange)
}
if let output = viewModel.jobOutput {
Divider()
VStack(alignment: .leading, spacing: 4) {
Text("Last Output")
.font(.caption.bold())
.foregroundStyle(.secondary)
Text(output)
.font(.system(.caption, design: .monospaced))
.textSelection(.enabled)
.padding(8)
.frame(maxWidth: .infinity, alignment: .leading)
.background(.quaternary.opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: 6))
}
}
}
}
/// Create/edit sheet. Form fields mirror `hermes cron create|edit` flags.
struct CronJobEditor: View {
enum Mode {
case create
case edit(HermesCronJob)
}
struct FormState {
var name: String = ""
var schedule: String = ""
var prompt: String = ""
var deliver: String = ""
var repeatCount: String = ""
var skills: [String] = []
var clearSkills: Bool = false
var script: String = ""
}
let mode: Mode
let availableSkills: [String]
let onSave: (FormState) -> Void
let onCancel: () -> Void
@State private var form = FormState()
@State private var isEditMode = false
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text(headerText)
.font(.headline)
formField("Name", text: $form.name, placeholder: "Friendly label")
formField("Schedule", text: $form.schedule, placeholder: "0 9 * * * or 30m or every 2h", mono: true)
VStack(alignment: .leading, spacing: 4) {
Text("Prompt")
.font(.caption).foregroundStyle(.secondary)
TextEditor(text: $form.prompt)
.font(.system(.caption, design: .monospaced))
.frame(minHeight: 100)
.padding(4)
.background(.quaternary.opacity(0.3))
.clipShape(RoundedRectangle(cornerRadius: 6))
}
formField("Deliver", text: $form.deliver, placeholder: "origin | local | discord:CHANNEL | telegram:CHAT", mono: true)
formField("Repeat", text: $form.repeatCount, placeholder: "Optional count")
formField("Script path", text: $form.script, placeholder: "Python script whose stdout is injected", mono: true)
if !availableSkills.isEmpty {
VStack(alignment: .leading, spacing: 4) {
Text("Skills")
.font(.caption).foregroundStyle(.secondary)
ScrollView {
VStack(alignment: .leading, spacing: 2) {
ForEach(availableSkills, id: \.self) { skill in
Toggle(skill, isOn: Binding(
get: { form.skills.contains(skill) },
set: { on in
if on {
form.skills.append(skill)
} else {
form.skills.removeAll { $0 == skill }
}
}
))
.font(.caption.monospaced())
.toggleStyle(.checkbox)
}
}
}
.frame(maxHeight: 120)
.padding(6)
.background(.quaternary.opacity(0.3))
.clipShape(RoundedRectangle(cornerRadius: 6))
if isEditMode {
Toggle("Clear all skills on save", isOn: $form.clearSkills)
.font(.caption)
}
}
}
HStack {
Spacer()
Button("Cancel") { onCancel() }
Button("Save") { onSave(form) }
.buttonStyle(.borderedProminent)
.disabled(form.schedule.isEmpty)
}
}
.padding()
.frame(minWidth: 560, minHeight: 560)
.onAppear {
if case .edit(let job) = mode {
isEditMode = true
form.name = job.name
form.schedule = job.schedule.expression ?? job.schedule.display ?? ""
form.prompt = job.prompt
form.deliver = job.deliver ?? ""
form.skills = job.skills ?? []
form.script = job.preRunScript ?? ""
}
}
}
private var headerText: String {
switch mode {
case .create: return "Create Cron Job"
case .edit(let job): return "Edit \(job.name)"
}
}
@ViewBuilder
private func formField(_ label: String, text: Binding<String>, placeholder: String, mono: Bool = false) -> some View {
VStack(alignment: .leading, spacing: 4) {
Text(label).font(.caption).foregroundStyle(.secondary)
TextField(placeholder, text: text)
.textFieldStyle(.roundedBorder)
.font(mono ? .system(.caption, design: .monospaced) : .caption)
}
}
}
@@ -164,6 +164,9 @@ struct SessionRow: View {
HStack(spacing: 12) {
Label("\(session.messageCount)", systemImage: "bubble.left")
Label("\(session.toolCallCount)", systemImage: "wrench")
if let cost = session.displayCostUSD, cost > 0 {
Label(String(format: "$%.4f", cost), systemImage: "dollarsign.circle")
}
}
.font(.caption)
.foregroundStyle(.secondary)
@@ -37,6 +37,10 @@ final class HealthViewModel {
var hermesPID: pid_t?
var actionMessage: String?
/// Text output from `hermes dump` / `hermes debug share`. Shown in an expandable panel.
var diagnosticsOutput: String = ""
var isSharingDebug = false
func load() {
isLoading = true
refreshProcessStatus()
@@ -201,6 +205,37 @@ final class HealthViewModel {
}
}
/// Capture `hermes dump` output a setup summary used for debugging / support.
/// Does NOT upload anything.
func runDump() {
actionMessage = "Running dump…"
let result = runHermes(["dump"])
diagnosticsOutput = result.output
actionMessage = result.exitCode == 0 ? "Dump captured" : "Dump failed"
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
self?.actionMessage = nil
}
}
/// Upload a debug report via `hermes debug share`. THIS UPLOADS DATA to Nous
/// Research support infrastructure caller must confirm with the user first.
func runDebugShare() {
isSharingDebug = true
actionMessage = "Uploading debug report…"
Task.detached { [fileService] in
let result = fileService.runHermesCLI(args: ["debug", "share"], timeout: 120)
await MainActor.run {
self.isSharingDebug = false
self.diagnosticsOutput = result.output
self.actionMessage = result.exitCode == 0 ? "Upload complete" : "Upload failed"
DispatchQueue.main.asyncAfter(deadline: .now() + 4) { [weak self] in
self?.actionMessage = nil
}
}
}
}
@discardableResult
private func runHermes(_ arguments: [String]) -> (output: String, exitCode: Int32) {
let process = Process()
process.executableURL = URL(fileURLWithPath: HermesPaths.hermesBinary)
@@ -4,18 +4,38 @@ struct HealthView: View {
@State private var viewModel = HealthViewModel()
@State private var expandedSection: UUID?
@State private var selectedTab = 0
@State private var showShareConfirm = false
@State private var showDiagnostics = false
var body: some View {
VStack(spacing: 0) {
headerBar
Divider()
Picker("", selection: $selectedTab) {
Text("Status").tag(0)
Text("Diagnostics").tag(1)
HStack {
Picker("", selection: $selectedTab) {
Text("Status").tag(0)
Text("Diagnostics").tag(1)
}
.pickerStyle(.segmented)
.frame(maxWidth: 300)
Spacer()
Button("Run Dump") {
viewModel.runDump()
showDiagnostics = true
}
.controlSize(.small)
Button("Share Debug Report…") {
showShareConfirm = true
}
.controlSize(.small)
.disabled(viewModel.isSharingDebug)
}
.pickerStyle(.segmented)
.frame(maxWidth: 300)
.padding(.vertical, 8)
.padding(.horizontal)
if showDiagnostics && !viewModel.diagnosticsOutput.isEmpty {
Divider()
diagnosticsPanel
}
Divider()
ScrollView {
sectionGrid(selectedTab == 0 ? viewModel.statusSections : viewModel.doctorSections)
@@ -24,6 +44,40 @@ struct HealthView: View {
}
.navigationTitle("Health")
.onAppear { viewModel.load() }
.confirmationDialog("Upload debug report?", isPresented: $showShareConfirm) {
Button("Upload", role: .destructive) {
viewModel.runDebugShare()
showDiagnostics = true
}
Button("Cancel", role: .cancel) {}
} message: {
Text("This uploads logs, config (with secrets redacted), and system info to Nous Research support infrastructure. Review the output below before sharing the returned URL.")
}
}
private var diagnosticsPanel: some View {
VStack(alignment: .leading, spacing: 6) {
HStack {
Text("Diagnostic Output")
.font(.caption.bold())
.foregroundStyle(.secondary)
Spacer()
Button("Hide") { showDiagnostics = false }
.controlSize(.mini)
}
ScrollView {
Text(viewModel.diagnosticsOutput)
.font(.system(.caption, design: .monospaced))
.textSelection(.enabled)
.padding(8)
.frame(maxWidth: .infinity, alignment: .leading)
}
.frame(maxHeight: 240)
.background(.quaternary.opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: 6))
}
.padding(.horizontal)
.padding(.vertical, 8)
}
// MARK: - Header
@@ -7,6 +7,7 @@ final class LogsViewModel {
var entries: [LogEntry] = []
var selectedLogFile: LogFile = .agent
var filterLevel: LogEntry.LogLevel?
var selectedComponent: LogComponent = .all
var searchText = ""
private var pollTimer: Timer?
@@ -26,11 +27,37 @@ final class LogsViewModel {
}
}
enum LogComponent: String, CaseIterable, Identifiable {
case all = "All"
case gateway = "Gateway"
case agent = "Agent"
case tools = "Tools"
case cli = "CLI"
case cron = "Cron"
var id: String { rawValue }
var loggerPrefix: String? {
switch self {
case .all: return nil
case .gateway: return "gateway"
case .agent: return "agent"
case .tools: return "tools"
case .cli: return "cli"
case .cron: return "cron"
}
}
}
var filteredEntries: [LogEntry] {
entries.filter { entry in
let levelOk = filterLevel == nil || entry.level == filterLevel
let searchOk = searchText.isEmpty || entry.raw.localizedCaseInsensitiveContains(searchText)
return levelOk && searchOk
let componentOk: Bool = {
guard let prefix = selectedComponent.loggerPrefix else { return true }
return entry.logger.hasPrefix(prefix)
}()
return levelOk && searchOk && componentOk
}
}
@@ -28,6 +28,13 @@ struct LogsView: View {
.pickerStyle(.segmented)
.frame(maxWidth: 300)
Picker("Component", selection: $viewModel.selectedComponent) {
ForEach(LogsViewModel.LogComponent.allCases) { component in
Text(component.rawValue).tag(component)
}
}
.frame(maxWidth: 140)
Spacer()
Picker("Level", selection: $viewModel.filterLevel) {
@@ -58,6 +65,27 @@ struct LogsView: View {
.font(.caption.monospaced().bold())
.foregroundStyle(colorForLevel(entry.level))
.frame(width: 60, alignment: .leading)
if let sessionId = entry.sessionId {
Button {
viewModel.searchText = sessionId
} label: {
Text(sessionId)
.font(.system(.caption2, design: .monospaced))
.padding(.horizontal, 4)
.padding(.vertical, 1)
.background(Color.accentColor.opacity(0.15))
.clipShape(RoundedRectangle(cornerRadius: 3))
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
.help("Filter to session \(sessionId)")
}
Text(entry.logger)
.font(.system(.caption2, design: .monospaced))
.foregroundStyle(.tertiary)
.lineLimit(1)
.truncationMode(.middle)
.frame(maxWidth: 140, alignment: .leading)
Text(entry.message)
.font(.system(.caption, design: .monospaced))
.textSelection(.enabled)
@@ -0,0 +1,111 @@
import Foundation
@Observable
final class MCPServerEditorViewModel {
struct KeyValueRow: Identifiable, Equatable {
let id = UUID()
var key: String
var value: String
}
private let fileService = HermesFileService()
let server: HermesMCPServer
var envDraft: [KeyValueRow]
var headersDraft: [KeyValueRow]
var includeDraft: String
var excludeDraft: String
var resourcesEnabled: Bool
var promptsEnabled: Bool
var timeoutDraft: String
var connectTimeoutDraft: String
var showSecrets: Bool = false
var isSaving: Bool = false
var saveError: String?
init(server: HermesMCPServer) {
self.server = server
self.envDraft = server.env.keys.sorted().map { KeyValueRow(key: $0, value: server.env[$0] ?? "") }
self.headersDraft = server.headers.keys.sorted().map { KeyValueRow(key: $0, value: server.headers[$0] ?? "") }
self.includeDraft = server.toolsInclude.joined(separator: ", ")
self.excludeDraft = server.toolsExclude.joined(separator: ", ")
self.resourcesEnabled = server.resourcesEnabled
self.promptsEnabled = server.promptsEnabled
self.timeoutDraft = server.timeout.map { String($0) } ?? ""
self.connectTimeoutDraft = server.connectTimeout.map { String($0) } ?? ""
}
func appendEnvRow() {
envDraft.append(KeyValueRow(key: "", value: ""))
}
func removeEnvRow(id: UUID) {
envDraft.removeAll { $0.id == id }
}
func appendHeaderRow() {
headersDraft.append(KeyValueRow(key: "", value: ""))
}
func removeHeaderRow(id: UUID) {
headersDraft.removeAll { $0.id == id }
}
func save(completion: @escaping (Bool) -> Void) {
isSaving = true
saveError = nil
let envMap = Dictionary(uniqueKeysWithValues: envDraft
.filter { !$0.key.trimmingCharacters(in: .whitespaces).isEmpty }
.map { ($0.key.trimmingCharacters(in: .whitespaces), $0.value) })
let headerMap = Dictionary(uniqueKeysWithValues: headersDraft
.filter { !$0.key.trimmingCharacters(in: .whitespaces).isEmpty }
.map { ($0.key.trimmingCharacters(in: .whitespaces), $0.value) })
let include = includeDraft.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty }
let exclude = excludeDraft.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty }
let timeoutValue = Int(timeoutDraft.trimmingCharacters(in: .whitespaces))
let connectValue = Int(connectTimeoutDraft.trimmingCharacters(in: .whitespaces))
let service = fileService
let transport = server.transport
let name = server.name
let resources = resourcesEnabled
let prompts = promptsEnabled
Task.detached {
var success = true
switch transport {
case .stdio:
if !service.setMCPServerEnv(name: name, env: envMap) { success = false }
case .http:
if !service.setMCPServerHeaders(name: name, headers: headerMap) { success = false }
}
if !service.updateMCPToolFilters(
name: name,
include: include,
exclude: exclude,
resources: resources,
prompts: prompts
) { success = false }
if !service.setMCPServerTimeouts(name: name, timeout: timeoutValue, connectTimeout: connectValue) {
success = false
}
await MainActor.run {
self.isSaving = false
if !success {
self.saveError = "One or more fields could not be written. Check \(HermesPaths.configYAML)."
}
completion(success)
}
}
}
func clearOAuthToken(completion: @escaping (Bool) -> Void) {
let service = fileService
let name = server.name
Task.detached {
let ok = service.deleteMCPOAuthToken(name: name)
await MainActor.run { completion(ok) }
}
}
}
@@ -0,0 +1,223 @@
import Foundation
@Observable
final class MCPServersViewModel {
private let fileService = HermesFileService()
var servers: [HermesMCPServer] = []
var selectedServerName: String?
var searchText = ""
var isLoading = false
var statusMessage: String?
var showPresetPicker = false
var showAddCustom = false
var showRestartBanner = false
var testResults: [String: MCPTestResult] = [:]
var testingNames: Set<String> = []
var activeError: String?
var editingServer: HermesMCPServer?
var filteredServers: [HermesMCPServer] {
guard !searchText.isEmpty else { return servers }
let query = searchText.lowercased()
return servers.filter { server in
server.name.lowercased().contains(query) ||
server.summary.lowercased().contains(query)
}
}
var stdioServers: [HermesMCPServer] {
filteredServers.filter { $0.transport == .stdio }
}
var httpServers: [HermesMCPServer] {
filteredServers.filter { $0.transport == .http }
}
var selectedServer: HermesMCPServer? {
guard let name = selectedServerName else { return nil }
return servers.first(where: { $0.name == name })
}
func load() {
isLoading = true
servers = fileService.loadMCPServers()
isLoading = false
if let name = selectedServerName, !servers.contains(where: { $0.name == name }) {
selectedServerName = nil
}
}
func selectServer(name: String?) {
selectedServerName = name
}
func beginEdit() {
editingServer = selectedServer
}
func finishEdit(reload: Bool) {
editingServer = nil
if reload {
load()
showRestartBanner = true
}
}
func deleteServer(name: String) {
let fileService = self.fileService
Task.detached {
let result = fileService.removeMCPServer(name: name)
await MainActor.run {
if result.exitCode == 0 {
self.flashStatus("Removed \(name)")
if self.selectedServerName == name {
self.selectedServerName = nil
}
self.testResults.removeValue(forKey: name)
self.load()
self.showRestartBanner = true
} else {
self.activeError = "Remove failed: \(result.output)"
}
}
}
}
func toggleEnabled(name: String) {
guard let server = servers.first(where: { $0.name == name }) else { return }
let newValue = !server.enabled
let fileService = self.fileService
Task.detached {
let ok = fileService.toggleMCPServerEnabled(name: name, enabled: newValue)
await MainActor.run {
if ok {
self.flashStatus(newValue ? "Enabled \(name)" : "Disabled \(name)")
self.load()
self.showRestartBanner = true
} else {
self.activeError = "Could not update \(name)"
}
}
}
}
func testServer(name: String) {
guard !testingNames.contains(name) else { return }
testingNames.insert(name)
let fileService = self.fileService
Task.detached {
let result = await fileService.testMCPServer(name: name)
await MainActor.run {
self.testingNames.remove(name)
self.testResults[name] = result
}
}
}
func testAll() {
let targets = servers.map(\.name)
let fileService = self.fileService
Task.detached {
for name in targets {
let result = await fileService.testMCPServer(name: name)
await MainActor.run {
self.testResults[name] = result
}
}
}
}
func addFromPreset(preset: MCPServerPreset, name: String, pathArg: String?, envValues: [String: String]) {
let fileService = self.fileService
let allArgs: [String] = {
var base = preset.args
if let pathArg, !pathArg.isEmpty { base.append(pathArg) }
return base
}()
Task.detached {
let addResult: (exitCode: Int32, output: String)
switch preset.transport {
case .stdio:
addResult = fileService.addMCPServerStdio(
name: name,
command: preset.command ?? "",
args: allArgs
)
case .http:
addResult = fileService.addMCPServerHTTP(
name: name,
url: preset.url ?? "",
auth: preset.auth
)
}
guard addResult.exitCode == 0 else {
await MainActor.run {
self.activeError = "Add failed: \(addResult.output)"
}
return
}
if !envValues.isEmpty {
_ = fileService.setMCPServerEnv(name: name, env: envValues)
}
await MainActor.run {
self.flashStatus("Added \(name)")
self.load()
self.selectedServerName = name
self.showRestartBanner = true
self.showPresetPicker = false
}
}
}
func addCustom(name: String, transport: MCPTransport, command: String, args: [String], url: String, auth: String?) {
let fileService = self.fileService
Task.detached {
let result: (exitCode: Int32, output: String)
switch transport {
case .stdio:
result = fileService.addMCPServerStdio(name: name, command: command, args: args)
case .http:
result = fileService.addMCPServerHTTP(name: name, url: url, auth: auth)
}
await MainActor.run {
if result.exitCode == 0 {
self.flashStatus("Added \(name)")
self.load()
self.selectedServerName = name
self.showRestartBanner = true
self.showAddCustom = false
} else {
self.activeError = "Add failed: \(result.output)"
}
}
}
}
func restartGateway() {
let fileService = self.fileService
Task.detached {
let result = fileService.restartGateway()
await MainActor.run {
if result.exitCode == 0 {
self.flashStatus("Gateway restarted")
self.showRestartBanner = false
} else {
self.activeError = "Restart failed: \(result.output)"
}
}
}
}
func flashStatus(_ message: String) {
statusMessage = message
Task {
try? await Task.sleep(nanoseconds: 3_000_000_000)
await MainActor.run {
if self.statusMessage == message {
self.statusMessage = nil
}
}
}
}
}
@@ -0,0 +1,154 @@
import SwiftUI
struct MCPServerAddCustomView: View {
let viewModel: MCPServersViewModel
@Environment(\.dismiss) private var dismiss
@State private var name: String = ""
@State private var transport: MCPTransport = .stdio
@State private var command: String = "npx"
@State private var argsText: String = ""
@State private var url: String = ""
@State private var auth: String = "none"
var body: some View {
VStack(spacing: 0) {
HStack {
Text("Add Custom MCP Server")
.font(.headline)
Spacer()
Button("Cancel") { dismiss() }
Button("Add") {
submit()
}
.buttonStyle(.borderedProminent)
.disabled(!canSubmit)
}
.padding()
Divider()
ScrollView {
VStack(alignment: .leading, spacing: 16) {
sectionBox(title: "Identity") {
VStack(alignment: .leading, spacing: 6) {
Text("Name").font(.caption.bold())
TextField("my_server", text: $name)
.textFieldStyle(.roundedBorder)
.font(.system(.body, design: .monospaced))
Text("Becomes the key under mcp_servers: in config.yaml.")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
sectionBox(title: "Transport") {
Picker("", selection: $transport) {
ForEach(MCPTransport.allCases) { t in
Text(t.displayName).tag(t)
}
}
.pickerStyle(.segmented)
.labelsHidden()
}
if transport == .stdio {
stdioSection
} else {
httpSection
}
Text("Env vars, headers, and tool filters can be edited after the server is added.")
.font(.caption)
.foregroundStyle(.secondary)
}
.padding()
}
}
.frame(minWidth: 560, minHeight: 500)
}
private var stdioSection: some View {
sectionBox(title: "Command") {
VStack(alignment: .leading, spacing: 8) {
VStack(alignment: .leading, spacing: 4) {
Text("Command").font(.caption.bold())
TextField("npx", text: $command)
.textFieldStyle(.roundedBorder)
.font(.system(.body, design: .monospaced))
}
VStack(alignment: .leading, spacing: 4) {
Text("Args (one per line)").font(.caption.bold())
TextEditor(text: $argsText)
.font(.system(.body, design: .monospaced))
.frame(minHeight: 80)
.padding(4)
.overlay(
RoundedRectangle(cornerRadius: 6).stroke(Color.secondary.opacity(0.25))
)
}
}
}
}
private var httpSection: some View {
sectionBox(title: "Endpoint") {
VStack(alignment: .leading, spacing: 8) {
VStack(alignment: .leading, spacing: 4) {
Text("URL").font(.caption.bold())
TextField("https://...", text: $url)
.textFieldStyle(.roundedBorder)
.font(.system(.body, design: .monospaced))
}
VStack(alignment: .leading, spacing: 4) {
Text("Auth").font(.caption.bold())
Picker("", selection: $auth) {
Text("None").tag("none")
Text("OAuth 2.1").tag("oauth")
Text("Header").tag("header")
}
.labelsHidden()
.pickerStyle(.segmented)
}
}
}
}
private var canSubmit: Bool {
let trimmedName = name.trimmingCharacters(in: .whitespaces)
guard !trimmedName.isEmpty else { return false }
switch transport {
case .stdio:
return !command.trimmingCharacters(in: .whitespaces).isEmpty
case .http:
return !url.trimmingCharacters(in: .whitespaces).isEmpty
}
}
private func submit() {
let trimmedName = name.trimmingCharacters(in: .whitespaces)
let args = argsText
.split(separator: "\n")
.map { $0.trimmingCharacters(in: .whitespaces) }
.filter { !$0.isEmpty }
let resolvedAuth: String? = (auth == "none") ? nil : auth
viewModel.addCustom(
name: trimmedName,
transport: transport,
command: command.trimmingCharacters(in: .whitespaces),
args: args,
url: url.trimmingCharacters(in: .whitespaces),
auth: resolvedAuth
)
dismiss()
}
@ViewBuilder
private func sectionBox<Content: View>(title: String, @ViewBuilder content: () -> Content) -> some View {
VStack(alignment: .leading, spacing: 8) {
Text(title)
.font(.subheadline.bold())
content()
}
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.secondary.opacity(0.06))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
@@ -0,0 +1,227 @@
import SwiftUI
struct MCPServerDetailView: View {
let server: HermesMCPServer
let testResult: MCPTestResult?
let isTesting: Bool
let onTest: () -> Void
let onToggleEnabled: () -> Void
let onEdit: () -> Void
let onDelete: () -> Void
@State private var showDeleteConfirm = false
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
header
overview
if server.transport == .stdio {
envSection
} else {
headersSection
}
toolsSection
timeoutsSection
if let result = testResult {
MCPServerTestResultView(result: result)
}
}
.padding()
.frame(maxWidth: .infinity, alignment: .topLeading)
}
.confirmationDialog(
"Remove \(server.name)?",
isPresented: $showDeleteConfirm,
titleVisibility: .visible
) {
Button("Remove", role: .destructive) { onDelete() }
Button("Cancel", role: .cancel) {}
} message: {
Text("This removes the server from config.yaml and deletes any OAuth token.")
}
}
private var header: some View {
HStack(alignment: .top) {
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 8) {
Image(systemName: server.transport == .http ? "network" : "terminal")
.foregroundStyle(.secondary)
Text(server.name)
.font(.title2.bold())
if !server.enabled {
Text("Disabled")
.font(.caption)
.padding(.horizontal, 8)
.padding(.vertical, 2)
.background(Color.secondary.opacity(0.2))
.clipShape(Capsule())
}
if server.hasOAuthToken {
Label("OAuth", systemImage: "key.fill")
.font(.caption)
.padding(.horizontal, 8)
.padding(.vertical, 2)
.background(Color.green.opacity(0.15))
.clipShape(Capsule())
}
}
Text(server.transport.displayName)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
HStack(spacing: 8) {
Button {
onTest()
} label: {
if isTesting {
ProgressView().controlSize(.small)
} else {
Label("Test", systemImage: "bolt.horizontal")
}
}
.disabled(isTesting)
Button {
onToggleEnabled()
} label: {
Label(server.enabled ? "Disable" : "Enable", systemImage: server.enabled ? "pause.circle" : "play.circle")
}
Button {
onEdit()
} label: {
Label("Edit", systemImage: "pencil")
}
.buttonStyle(.borderedProminent)
Button(role: .destructive) {
showDeleteConfirm = true
} label: {
Label("Remove", systemImage: "trash")
}
}
}
}
private var overview: some View {
VStack(alignment: .leading, spacing: 6) {
Text("Connection")
.font(.caption.bold())
.foregroundStyle(.secondary)
switch server.transport {
case .stdio:
summaryRow(label: "Command", value: server.command ?? "")
if !server.args.isEmpty {
summaryRow(label: "Args", value: server.args.joined(separator: " "))
}
case .http:
summaryRow(label: "URL", value: server.url ?? "")
if let auth = server.auth, !auth.isEmpty {
summaryRow(label: "Auth", value: auth)
}
}
}
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.secondary.opacity(0.06))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
private func summaryRow(label: String, value: String) -> some View {
HStack(alignment: .top) {
Text(label)
.font(.caption)
.foregroundStyle(.secondary)
.frame(width: 80, alignment: .leading)
Text(value)
.font(.system(.caption, design: .monospaced))
.textSelection(.enabled)
}
}
private var envSection: some View {
VStack(alignment: .leading, spacing: 6) {
Text("Environment Variables")
.font(.caption.bold())
.foregroundStyle(.secondary)
if server.env.isEmpty {
Text("No env vars configured.")
.font(.caption)
.foregroundStyle(.secondary)
} else {
ForEach(server.env.keys.sorted(), id: \.self) { key in
HStack {
Text(key)
.font(.system(.caption, design: .monospaced))
Spacer()
Text(String(repeating: "", count: 10))
.font(.caption.monospaced())
.foregroundStyle(.secondary)
}
}
}
}
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.secondary.opacity(0.06))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
private var headersSection: some View {
VStack(alignment: .leading, spacing: 6) {
Text("Headers")
.font(.caption.bold())
.foregroundStyle(.secondary)
if server.headers.isEmpty {
Text("No headers configured.")
.font(.caption)
.foregroundStyle(.secondary)
} else {
ForEach(server.headers.keys.sorted(), id: \.self) { key in
HStack {
Text(key)
.font(.system(.caption, design: .monospaced))
Spacer()
Text(String(repeating: "", count: 10))
.font(.caption.monospaced())
.foregroundStyle(.secondary)
}
}
}
}
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.secondary.opacity(0.06))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
private var toolsSection: some View {
VStack(alignment: .leading, spacing: 6) {
Text("Tool Filters")
.font(.caption.bold())
.foregroundStyle(.secondary)
summaryRow(label: "Include", value: server.toolsInclude.isEmpty ? "(all)" : server.toolsInclude.joined(separator: ", "))
summaryRow(label: "Exclude", value: server.toolsExclude.isEmpty ? "" : server.toolsExclude.joined(separator: ", "))
summaryRow(label: "Resources", value: server.resourcesEnabled ? "enabled" : "disabled")
summaryRow(label: "Prompts", value: server.promptsEnabled ? "enabled" : "disabled")
}
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.secondary.opacity(0.06))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
private var timeoutsSection: some View {
VStack(alignment: .leading, spacing: 6) {
Text("Timeouts")
.font(.caption.bold())
.foregroundStyle(.secondary)
summaryRow(label: "Connect", value: server.connectTimeout.map { "\($0)s" } ?? "default")
summaryRow(label: "Call", value: server.timeout.map { "\($0)s" } ?? "default")
}
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.secondary.opacity(0.06))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
@@ -0,0 +1,218 @@
import SwiftUI
struct MCPServerEditorView: View {
@State var viewModel: MCPServerEditorViewModel
let onSave: (Bool) -> Void
let onCancel: () -> Void
var body: some View {
VStack(spacing: 0) {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text("Edit \(viewModel.server.name)")
.font(.headline)
Text(viewModel.server.transport.displayName)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Button("Cancel") { onCancel() }
.keyboardShortcut(.cancelAction)
Button {
viewModel.save { changed in
if changed { onSave(true) }
}
} label: {
if viewModel.isSaving {
ProgressView().controlSize(.small)
} else {
Text("Save")
}
}
.buttonStyle(.borderedProminent)
.keyboardShortcut(.defaultAction)
.disabled(viewModel.isSaving)
}
.padding()
Divider()
ScrollView {
VStack(alignment: .leading, spacing: 20) {
if let error = viewModel.saveError {
Text(error)
.font(.caption)
.foregroundStyle(.red)
.padding(10)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.red.opacity(0.12))
.clipShape(RoundedRectangle(cornerRadius: 6))
}
if viewModel.server.transport == .stdio {
envSection
} else {
headersSection
}
toolsSection
timeoutsSection
if viewModel.server.hasOAuthToken {
oauthSection
}
}
.padding()
}
}
.frame(minWidth: 640, minHeight: 560)
}
private var envSection: some View {
sectionBox(title: "Environment Variables") {
VStack(alignment: .leading, spacing: 8) {
if viewModel.envDraft.isEmpty {
Text("No env vars. Add one with the button below.")
.font(.caption)
.foregroundStyle(.secondary)
}
ForEach($viewModel.envDraft) { $row in
HStack(spacing: 8) {
TextField("KEY", text: $row.key)
.textFieldStyle(.roundedBorder)
.font(.system(.body, design: .monospaced))
.frame(maxWidth: 240)
if viewModel.showSecrets {
TextField("value", text: $row.value)
.textFieldStyle(.roundedBorder)
} else {
SecureField("value", text: $row.value)
.textFieldStyle(.roundedBorder)
}
Button(role: .destructive) {
viewModel.removeEnvRow(id: row.id)
} label: {
Image(systemName: "minus.circle")
}
.buttonStyle(.borderless)
}
}
HStack {
Button {
viewModel.appendEnvRow()
} label: {
Label("Add", systemImage: "plus.circle")
}
Spacer()
Toggle("Show values", isOn: $viewModel.showSecrets)
.toggleStyle(.switch)
.controlSize(.small)
}
}
}
}
private var headersSection: some View {
sectionBox(title: "Headers") {
VStack(alignment: .leading, spacing: 8) {
if viewModel.headersDraft.isEmpty {
Text("No headers. Add one with the button below.")
.font(.caption)
.foregroundStyle(.secondary)
}
ForEach($viewModel.headersDraft) { $row in
HStack(spacing: 8) {
TextField("Header", text: $row.key)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: 240)
TextField("value", text: $row.value)
.textFieldStyle(.roundedBorder)
Button(role: .destructive) {
viewModel.removeHeaderRow(id: row.id)
} label: {
Image(systemName: "minus.circle")
}
.buttonStyle(.borderless)
}
}
Button {
viewModel.appendHeaderRow()
} label: {
Label("Add", systemImage: "plus.circle")
}
}
}
}
private var toolsSection: some View {
sectionBox(title: "Tool Filters") {
VStack(alignment: .leading, spacing: 10) {
VStack(alignment: .leading, spacing: 4) {
Text("Include (comma-separated — if set, only these are exposed)")
.font(.caption)
.foregroundStyle(.secondary)
TextField("tool_a, tool_b", text: $viewModel.includeDraft)
.textFieldStyle(.roundedBorder)
.font(.system(.body, design: .monospaced))
}
VStack(alignment: .leading, spacing: 4) {
Text("Exclude")
.font(.caption)
.foregroundStyle(.secondary)
TextField("tool_c", text: $viewModel.excludeDraft)
.textFieldStyle(.roundedBorder)
.font(.system(.body, design: .monospaced))
}
Toggle("Expose resources", isOn: $viewModel.resourcesEnabled)
Toggle("Expose prompts", isOn: $viewModel.promptsEnabled)
}
}
}
private var timeoutsSection: some View {
sectionBox(title: "Timeouts (seconds)") {
HStack(spacing: 16) {
VStack(alignment: .leading, spacing: 4) {
Text("Connect timeout")
.font(.caption)
.foregroundStyle(.secondary)
TextField("default", text: $viewModel.connectTimeoutDraft)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: 140)
}
VStack(alignment: .leading, spacing: 4) {
Text("Call timeout")
.font(.caption)
.foregroundStyle(.secondary)
TextField("default", text: $viewModel.timeoutDraft)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: 140)
}
Spacer()
}
}
}
private var oauthSection: some View {
sectionBox(title: "OAuth Token") {
HStack {
Text("Token on disk. Clear to re-authenticate next time the gateway connects.")
.font(.caption)
.foregroundStyle(.secondary)
Spacer()
Button("Clear Token", role: .destructive) {
viewModel.clearOAuthToken { _ in }
}
}
}
}
@ViewBuilder
private func sectionBox<Content: View>(title: String, @ViewBuilder content: () -> Content) -> some View {
VStack(alignment: .leading, spacing: 8) {
Text(title)
.font(.subheadline.bold())
content()
}
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.secondary.opacity(0.06))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
@@ -0,0 +1,240 @@
import SwiftUI
struct MCPServerPresetPickerView: View {
let viewModel: MCPServersViewModel
@Environment(\.dismiss) private var dismiss
@State private var selectedPreset: MCPServerPreset?
@State private var nameOverride: String = ""
@State private var pathArg: String = ""
@State private var envValues: [String: String] = [:]
@State private var showSecrets: Bool = false
var body: some View {
VStack(spacing: 0) {
header
Divider()
if let preset = selectedPreset {
configureStep(preset: preset)
} else {
galleryStep
}
}
.frame(minWidth: 720, minHeight: 560)
}
private var header: some View {
HStack {
if selectedPreset != nil {
Button {
selectedPreset = nil
} label: {
Label("Back", systemImage: "chevron.left")
}
}
VStack(alignment: .leading, spacing: 2) {
Text(selectedPreset?.displayName ?? "Add from Preset")
.font(.headline)
Text(selectedPreset?.description ?? "Pick an MCP server to add.")
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
Spacer()
Button("Close") { dismiss() }
}
.padding()
}
private var galleryStep: some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
ForEach(MCPServerPreset.categories, id: \.self) { category in
VStack(alignment: .leading, spacing: 8) {
Text(category)
.font(.subheadline.bold())
LazyVGrid(
columns: [GridItem(.adaptive(minimum: 200), spacing: 12)],
spacing: 12
) {
ForEach(MCPServerPreset.byCategory(category)) { preset in
presetCard(preset)
}
}
}
}
}
.padding()
}
}
private func presetCard(_ preset: MCPServerPreset) -> some View {
Button {
selectedPreset = preset
nameOverride = preset.id
pathArg = ""
envValues = Dictionary(uniqueKeysWithValues: preset.requiredEnvKeys.map { ($0, "") })
for key in preset.optionalEnvKeys {
envValues[key] = ""
}
} label: {
VStack(alignment: .leading, spacing: 6) {
HStack {
Image(systemName: preset.iconSystemName)
.font(.title3)
.foregroundStyle(Color.accentColor)
Text(preset.displayName)
.font(.body.bold())
Spacer()
Image(systemName: preset.transport == .http ? "network" : "terminal")
.font(.caption)
.foregroundStyle(.secondary)
}
Text(preset.description)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(3)
.frame(maxWidth: .infinity, alignment: .leading)
if !preset.requiredEnvKeys.isEmpty {
Text("Requires: \(preset.requiredEnvKeys.joined(separator: ", "))")
.font(.caption2.monospaced())
.foregroundStyle(.orange)
}
}
.padding(12)
.frame(maxWidth: .infinity, alignment: .topLeading)
.background(Color.secondary.opacity(0.08))
.clipShape(RoundedRectangle(cornerRadius: 10))
.contentShape(RoundedRectangle(cornerRadius: 10))
}
.buttonStyle(.plain)
}
private func configureStep(preset: MCPServerPreset) -> some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
nameField
if let prompt = preset.pathArgPrompt {
pathArgField(prompt: prompt)
}
if !preset.requiredEnvKeys.isEmpty || !preset.optionalEnvKeys.isEmpty {
envFields(preset: preset)
}
if !preset.docsURL.isEmpty {
Link(destination: URL(string: preset.docsURL) ?? URL(string: "https://modelcontextprotocol.io")!) {
Label("Docs", systemImage: "book")
.font(.caption)
}
}
HStack {
Spacer()
Button("Add Server") {
submit(preset: preset)
}
.buttonStyle(.borderedProminent)
.disabled(!canSubmit(preset: preset))
}
}
.padding()
}
}
private var nameField: some View {
VStack(alignment: .leading, spacing: 4) {
Text("Server name")
.font(.caption.bold())
TextField("e.g. github", text: $nameOverride)
.textFieldStyle(.roundedBorder)
.font(.system(.body, design: .monospaced))
Text("Used as the YAML key. Lowercase, no spaces.")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
private func pathArgField(prompt: String) -> some View {
VStack(alignment: .leading, spacing: 4) {
Text(prompt)
.font(.caption.bold())
TextField(prompt, text: $pathArg)
.textFieldStyle(.roundedBorder)
.font(.system(.body, design: .monospaced))
}
}
private func envFields(preset: MCPServerPreset) -> some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text("Environment Variables")
.font(.caption.bold())
Spacer()
Toggle("Show values", isOn: $showSecrets)
.toggleStyle(.switch)
.controlSize(.small)
}
ForEach(preset.requiredEnvKeys, id: \.self) { key in
envRow(key: key, required: true)
}
ForEach(preset.optionalEnvKeys, id: \.self) { key in
envRow(key: key, required: false)
}
}
}
private func envRow(key: String, required: Bool) -> some View {
HStack(spacing: 8) {
VStack(alignment: .leading, spacing: 2) {
Text(key)
.font(.system(.caption, design: .monospaced))
if required {
Text("required")
.font(.caption2)
.foregroundStyle(.orange)
}
}
.frame(width: 240, alignment: .leading)
if showSecrets {
TextField("value", text: bindingForEnv(key))
.textFieldStyle(.roundedBorder)
} else {
SecureField("value", text: bindingForEnv(key))
.textFieldStyle(.roundedBorder)
}
}
}
private func bindingForEnv(_ key: String) -> Binding<String> {
Binding(
get: { envValues[key] ?? "" },
set: { envValues[key] = $0 }
)
}
private func canSubmit(preset: MCPServerPreset) -> Bool {
let trimmedName = nameOverride.trimmingCharacters(in: .whitespaces)
guard !trimmedName.isEmpty else { return false }
if preset.pathArgPrompt != nil && pathArg.trimmingCharacters(in: .whitespaces).isEmpty {
return false
}
for key in preset.requiredEnvKeys {
if (envValues[key] ?? "").trimmingCharacters(in: .whitespaces).isEmpty { return false }
}
return true
}
private func submit(preset: MCPServerPreset) {
let finalName = nameOverride.trimmingCharacters(in: .whitespaces)
let finalPath = pathArg.trimmingCharacters(in: .whitespaces)
let trimmedEnv = envValues.reduce(into: [String: String]()) { acc, pair in
let trimmedValue = pair.value.trimmingCharacters(in: .whitespaces)
if !trimmedValue.isEmpty { acc[pair.key] = pair.value }
}
viewModel.addFromPreset(
preset: preset,
name: finalName,
pathArg: preset.pathArgPrompt != nil ? finalPath : nil,
envValues: trimmedEnv
)
dismiss()
}
}
@@ -0,0 +1,66 @@
import SwiftUI
struct MCPServerTestResultView: View {
let result: MCPTestResult
@State private var showOutput = false
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 8) {
Image(systemName: result.succeeded ? "checkmark.seal.fill" : "xmark.seal.fill")
.foregroundStyle(result.succeeded ? .green : .red)
VStack(alignment: .leading, spacing: 2) {
Text(result.succeeded ? "Test passed" : "Test failed")
.font(.subheadline.bold())
Text(String(format: "%.1fs · %d tools", result.elapsed, result.tools.count))
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Button {
showOutput.toggle()
} label: {
Label(showOutput ? "Hide Output" : "Show Output", systemImage: showOutput ? "chevron.up" : "chevron.down")
.font(.caption)
}
.buttonStyle(.borderless)
}
if !result.tools.isEmpty {
WrapChips(items: result.tools)
}
if showOutput {
ScrollView {
Text(result.output.isEmpty ? "(no output)" : result.output)
.font(.system(.caption, design: .monospaced))
.textSelection(.enabled)
.padding(8)
.frame(maxWidth: .infinity, alignment: .leading)
}
.frame(maxHeight: 220)
.background(Color.black.opacity(0.05))
.clipShape(RoundedRectangle(cornerRadius: 6))
}
}
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.background((result.succeeded ? Color.green : Color.red).opacity(0.08))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
private struct WrapChips: View {
let items: [String]
var body: some View {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 120), spacing: 6)], spacing: 6) {
ForEach(items, id: \.self) { item in
Text(item)
.font(.caption.monospaced())
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(Color.secondary.opacity(0.15))
.clipShape(Capsule())
}
}
}
}
@@ -0,0 +1,163 @@
import SwiftUI
struct MCPServersView: View {
@State private var viewModel = MCPServersViewModel()
var body: some View {
HSplitView {
serversList
.frame(minWidth: 260, idealWidth: 300)
serverDetail
.frame(minWidth: 500)
}
.navigationTitle("MCP Servers (\(viewModel.servers.count))")
.searchable(text: $viewModel.searchText, prompt: "Filter servers...")
.toolbar {
ToolbarItemGroup(placement: .primaryAction) {
Button {
viewModel.showPresetPicker = true
} label: {
Label("Add from Preset", systemImage: "square.grid.2x2")
}
Button {
viewModel.showAddCustom = true
} label: {
Label("Add Custom", systemImage: "plus")
}
Button {
viewModel.testAll()
} label: {
Label("Test All", systemImage: "bolt.horizontal")
}
.disabled(viewModel.servers.isEmpty)
Button {
viewModel.load()
} label: {
Label("Reload", systemImage: "arrow.clockwise")
}
}
}
.onAppear { viewModel.load() }
.sheet(isPresented: $viewModel.showPresetPicker) {
MCPServerPresetPickerView(viewModel: viewModel)
}
.sheet(isPresented: $viewModel.showAddCustom) {
MCPServerAddCustomView(viewModel: viewModel)
}
.sheet(isPresented: Binding(
get: { viewModel.editingServer != nil },
set: { if !$0 { viewModel.editingServer = nil } }
)) {
if let server = viewModel.editingServer {
MCPServerEditorView(
viewModel: MCPServerEditorViewModel(server: server),
onSave: { changed in viewModel.finishEdit(reload: changed) },
onCancel: { viewModel.finishEdit(reload: false) }
)
}
}
.alert("Error", isPresented: Binding(
get: { viewModel.activeError != nil },
set: { if !$0 { viewModel.activeError = nil } }
)) {
Button("OK") { viewModel.activeError = nil }
} message: {
Text(viewModel.activeError ?? "")
}
}
private var serversList: some View {
List(selection: Binding(
get: { viewModel.selectedServerName },
set: { viewModel.selectServer(name: $0) }
)) {
if !viewModel.stdioServers.isEmpty {
Section("Local (stdio)") {
ForEach(viewModel.stdioServers) { server in
serverRow(server)
.tag(server.name as String?)
}
}
}
if !viewModel.httpServers.isEmpty {
Section("Remote (HTTP)") {
ForEach(viewModel.httpServers) { server in
serverRow(server)
.tag(server.name as String?)
}
}
}
if viewModel.servers.isEmpty && !viewModel.isLoading {
Section {
Text("No servers configured yet")
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
.listStyle(.sidebar)
}
@ViewBuilder
private func serverRow(_ server: HermesMCPServer) -> some View {
HStack(spacing: 8) {
Image(systemName: server.transport == .http ? "network" : "terminal")
.foregroundStyle(server.enabled ? Color.accentColor : .secondary)
VStack(alignment: .leading, spacing: 2) {
Text(server.name)
.font(.body)
if !server.enabled {
Text("Disabled")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
Spacer()
if viewModel.testingNames.contains(server.name) {
ProgressView().controlSize(.small)
} else if let result = viewModel.testResults[server.name] {
Image(systemName: result.succeeded ? "checkmark.circle.fill" : "xmark.circle.fill")
.foregroundStyle(result.succeeded ? .green : .red)
.help(result.succeeded ? "\(result.tools.count) tools" : "Test failed")
}
}
}
@ViewBuilder
private var serverDetail: some View {
VStack(spacing: 0) {
if viewModel.showRestartBanner {
RestartGatewayBanner(
onRestart: { viewModel.restartGateway() },
onDismiss: { viewModel.showRestartBanner = false }
)
}
if let status = viewModel.statusMessage {
Text(status)
.font(.caption)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.accentColor.opacity(0.12))
}
if let server = viewModel.selectedServer {
MCPServerDetailView(
server: server,
testResult: viewModel.testResults[server.name],
isTesting: viewModel.testingNames.contains(server.name),
onTest: { viewModel.testServer(name: server.name) },
onToggleEnabled: { viewModel.toggleEnabled(name: server.name) },
onEdit: { viewModel.beginEdit() },
onDelete: { viewModel.deleteServer(name: server.name) }
)
} else {
ContentUnavailableView(
"Select an MCP Server",
systemImage: "puzzlepiece.extension",
description: Text("Pick one from the list, or add a new server from the toolbar.")
)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
}
}
@@ -0,0 +1,33 @@
import SwiftUI
struct RestartGatewayBanner: View {
let onRestart: () -> Void
let onDismiss: () -> Void
var body: some View {
HStack(spacing: 10) {
Image(systemName: "arrow.triangle.2.circlepath.circle.fill")
.foregroundStyle(.orange)
VStack(alignment: .leading, spacing: 1) {
Text("Gateway restart required")
.font(.caption.bold())
Text("Changes won't take effect until Hermes reloads the config.")
.font(.caption2)
.foregroundStyle(.secondary)
}
Spacer()
Button("Restart Now") { onRestart() }
.controlSize(.small)
.buttonStyle(.borderedProminent)
Button {
onDismiss()
} label: {
Image(systemName: "xmark")
}
.buttonStyle(.borderless)
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(Color.orange.opacity(0.14))
}
}
@@ -0,0 +1,103 @@
import Foundation
import AppKit
import os
/// A personality defined under the `personalities:` block in config.yaml.
/// Each entry may have a free-form `prompt` string plus arbitrary extra fields.
struct HermesPersonality: Identifiable, Sendable, Equatable {
var id: String { name }
let name: String
let prompt: String
}
@Observable
final class PersonalitiesViewModel {
private let logger = Logger(subsystem: "com.scarf", category: "PersonalitiesViewModel")
private let fileService = HermesFileService()
var personalities: [HermesPersonality] = []
var activeName: String = ""
var soulMarkdown: String = ""
var soulPath: String { HermesPaths.home + "/SOUL.md" }
var message: String?
func load() {
let config = fileService.loadConfig()
activeName = config.personality
personalities = parsePersonalitiesBlock()
soulMarkdown = (try? String(contentsOfFile: soulPath, encoding: .utf8)) ?? ""
}
/// Parse the `personalities:` section of config.yaml using the nested parser.
/// Each personality is a top-level key under `personalities`, optionally with
/// a `prompt:` child.
private func parsePersonalitiesBlock() -> [HermesPersonality] {
guard let yaml = try? String(contentsOfFile: HermesPaths.configYAML, encoding: .utf8) else { return [] }
let parsed = HermesFileService.parseNestedYAML(yaml)
// Find all keys "personalities.<name>[.subkey]"
var nameSet: Set<String> = []
for key in parsed.values.keys where key.hasPrefix("personalities.") {
let parts = key.split(separator: ".", maxSplits: 2, omittingEmptySubsequences: false)
if parts.count >= 2 { nameSet.insert(String(parts[1])) }
}
for key in parsed.lists.keys where key.hasPrefix("personalities.") {
let parts = key.split(separator: ".", maxSplits: 2, omittingEmptySubsequences: false)
if parts.count >= 2 { nameSet.insert(String(parts[1])) }
}
return nameSet.sorted().map { name in
let prompt = parsed.values["personalities.\(name).prompt"] ?? ""
return HermesPersonality(name: name, prompt: HermesFileService.stripYAMLQuotes(prompt))
}
}
func setActive(_ name: String) {
let result = runHermes(["config", "set", "display.personality", name])
if result.exitCode == 0 {
activeName = name
message = "Active personality set to \(name)"
} else {
logger.warning("Failed to set personality: \(result.output)")
message = "Failed to set personality"
}
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
self?.message = nil
}
}
func saveSOUL(_ content: String) {
do {
try content.write(toFile: soulPath, atomically: true, encoding: .utf8)
soulMarkdown = content
message = "SOUL.md saved"
} catch {
logger.error("Failed to write SOUL.md: \(error.localizedDescription)")
message = "Save failed"
}
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
self?.message = nil
}
}
func openConfigInEditor() {
NSWorkspace.shared.open(URL(fileURLWithPath: HermesPaths.configYAML))
}
@discardableResult
private func runHermes(_ arguments: [String]) -> (output: String, exitCode: Int32) {
let process = Process()
process.executableURL = URL(fileURLWithPath: HermesPaths.hermesBinary)
process.arguments = arguments
process.environment = HermesFileService.enrichedEnvironment()
let pipe = Pipe()
process.standardOutput = pipe
process.standardError = Pipe()
do {
try process.run()
process.waitUntilExit()
let data = pipe.fileHandleForReading.readDataToEndOfFile()
return (String(data: data, encoding: .utf8) ?? "", process.terminationStatus)
} catch {
return ("", -1)
}
}
}
@@ -0,0 +1,133 @@
import SwiftUI
struct PersonalitiesView: View {
@State private var viewModel = PersonalitiesViewModel()
@State private var soulDraft = ""
@State private var editingSOUL = false
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 20) {
header
activeSection
listSection
soulSection
}
.padding()
.frame(maxWidth: .infinity, alignment: .topLeading)
}
.navigationTitle("Personalities")
.onAppear {
viewModel.load()
soulDraft = viewModel.soulMarkdown
}
}
private var header: some View {
HStack {
if let msg = viewModel.message {
Label(msg, systemImage: "checkmark.circle.fill")
.font(.caption)
.foregroundStyle(.green)
}
Spacer()
Button("Edit config.yaml") { viewModel.openConfigInEditor() }
.controlSize(.small)
Button("Reload") { viewModel.load(); soulDraft = viewModel.soulMarkdown }
.controlSize(.small)
}
}
private var activeSection: some View {
SettingsSection(title: "Active Personality", icon: "theatermasks.fill") {
if viewModel.personalities.isEmpty {
ReadOnlyRow(label: "Current", value: viewModel.activeName.isEmpty ? "default" : viewModel.activeName)
ReadOnlyRow(label: "Defined", value: "None in config.yaml — add under `personalities:` to customize.")
} else {
PickerRow(label: "Active", selection: viewModel.activeName, options: viewModel.personalities.map(\.name)) { viewModel.setActive($0) }
}
}
}
@ViewBuilder
private var listSection: some View {
if !viewModel.personalities.isEmpty {
SettingsSection(title: "Defined Personalities", icon: "list.bullet") {
ForEach(viewModel.personalities) { personality in
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(personality.name)
.font(.system(.body, design: .monospaced, weight: .medium))
if personality.name == viewModel.activeName {
Text("active")
.font(.caption2.bold())
.foregroundStyle(.green)
.padding(.horizontal, 6)
.padding(.vertical, 1)
.background(.green.opacity(0.15))
.clipShape(Capsule())
}
Spacer()
}
if !personality.prompt.isEmpty {
Text(personality.prompt)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(6)
.textSelection(.enabled)
}
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(.quaternary.opacity(0.3))
}
}
}
}
private var soulSection: some View {
VStack(alignment: .leading, spacing: 10) {
HStack {
Label("SOUL.md", systemImage: "sparkles")
.font(.headline)
Spacer()
if editingSOUL {
Button("Cancel") {
editingSOUL = false
soulDraft = viewModel.soulMarkdown
}
.controlSize(.small)
Button("Save") {
viewModel.saveSOUL(soulDraft)
editingSOUL = false
}
.controlSize(.small)
.keyboardShortcut("s", modifiers: .command)
} else {
Button("Edit") { editingSOUL = true }
.controlSize(.small)
}
}
Text("SOUL.md describes the agent's voice, values, and personality at ~/.hermes/SOUL.md. It is injected into every session's context.")
.font(.caption)
.foregroundStyle(.secondary)
if editingSOUL {
TextEditor(text: $soulDraft)
.font(.system(.caption, design: .monospaced))
.frame(minHeight: 220)
.padding(6)
.background(.quaternary.opacity(0.3))
.clipShape(RoundedRectangle(cornerRadius: 6))
} else {
Text(viewModel.soulMarkdown.isEmpty ? "(empty)" : viewModel.soulMarkdown)
.font(.system(.caption, design: .monospaced))
.foregroundStyle(viewModel.soulMarkdown.isEmpty ? .secondary : .primary)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(8)
.background(.quaternary.opacity(0.3))
.clipShape(RoundedRectangle(cornerRadius: 6))
}
}
}
}
@@ -0,0 +1,64 @@
import Foundation
import os
/// Discord setup. Bot token + user IDs in `.env`, behavior knobs in `discord.*`.
/// Field reference: https://hermes-agent.nousresearch.com/docs/user-guide/messaging/discord
@Observable
@MainActor
final class DiscordSetupViewModel {
var botToken: String = ""
var allowedUsers: String = ""
var homeChannel: String = ""
var homeChannelName: String = ""
var allowBots: String = "none" // "none" | "mentions" | "all"
var replyToMode: String = "first" // "off" | "first" | "all"
// config.yaml these mirror the existing `HermesConfig.discord` block so we
// stay consistent with whatever the Settings UI shows.
var requireMention: Bool = true
var freeResponseChannels: String = ""
var autoThread: Bool = true
var reactions: Bool = true
var message: String?
let allowBotsOptions = ["none", "mentions", "all"]
let replyToModeOptions = ["off", "first", "all"]
func load() {
let env = HermesEnvService().load()
botToken = env["DISCORD_BOT_TOKEN"] ?? ""
allowedUsers = env["DISCORD_ALLOWED_USERS"] ?? ""
homeChannel = env["DISCORD_HOME_CHANNEL"] ?? ""
homeChannelName = env["DISCORD_HOME_CHANNEL_NAME"] ?? ""
allowBots = env["DISCORD_ALLOW_BOTS"] ?? "none"
replyToMode = env["DISCORD_REPLY_TO_MODE"] ?? "first"
let cfg = HermesFileService().loadConfig().discord
requireMention = cfg.requireMention
freeResponseChannels = cfg.freeResponseChannels
autoThread = cfg.autoThread
reactions = cfg.reactions
}
func save() {
let envPairs: [String: String] = [
"DISCORD_BOT_TOKEN": botToken,
"DISCORD_ALLOWED_USERS": allowedUsers,
"DISCORD_HOME_CHANNEL": homeChannel,
"DISCORD_HOME_CHANNEL_NAME": homeChannelName,
"DISCORD_ALLOW_BOTS": allowBots == "none" ? "" : allowBots, // default is "none", don't persist
"DISCORD_REPLY_TO_MODE": replyToMode == "first" ? "" : replyToMode
]
let configKV: [String: String] = [
"discord.require_mention": PlatformSetupHelpers.envBool(requireMention),
"discord.free_response_channels": freeResponseChannels,
"discord.auto_thread": PlatformSetupHelpers.envBool(autoThread),
"discord.reactions": PlatformSetupHelpers.envBool(reactions)
]
message = PlatformSetupHelpers.saveForm(envPairs: envPairs, configKV: configKV)
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
self?.message = nil
}
}
}
@@ -0,0 +1,80 @@
import Foundation
/// Email setup. IMAP/SMTP with app passwords no OAuth.
/// Field reference: https://hermes-agent.nousresearch.com/docs/user-guide/messaging/email
@Observable
@MainActor
final class EmailSetupViewModel {
var address: String = ""
var password: String = ""
var imapHost: String = ""
var smtpHost: String = ""
var imapPort: String = "993"
var smtpPort: String = "587"
var pollInterval: String = "15"
var allowedUsers: String = ""
var homeAddress: String = ""
var allowAllUsers: Bool = false
var skipAttachments: Bool = false
var message: String?
/// Common provider presets so users don't have to look up IMAP/SMTP servers.
struct Preset {
let name: String
let imap: String
let smtp: String
}
let presets: [Preset] = [
Preset(name: "Gmail", imap: "imap.gmail.com", smtp: "smtp.gmail.com"),
Preset(name: "Outlook", imap: "outlook.office365.com", smtp: "smtp.office365.com"),
Preset(name: "iCloud", imap: "imap.mail.me.com", smtp: "smtp.mail.me.com"),
Preset(name: "Fastmail", imap: "imap.fastmail.com", smtp: "smtp.fastmail.com"),
Preset(name: "Yahoo", imap: "imap.mail.yahoo.com", smtp: "smtp.mail.yahoo.com")
]
func load() {
let env = HermesEnvService().load()
address = env["EMAIL_ADDRESS"] ?? ""
password = env["EMAIL_PASSWORD"] ?? ""
imapHost = env["EMAIL_IMAP_HOST"] ?? ""
smtpHost = env["EMAIL_SMTP_HOST"] ?? ""
imapPort = env["EMAIL_IMAP_PORT"] ?? "993"
smtpPort = env["EMAIL_SMTP_PORT"] ?? "587"
pollInterval = env["EMAIL_POLL_INTERVAL"] ?? "15"
allowedUsers = env["EMAIL_ALLOWED_USERS"] ?? ""
homeAddress = env["EMAIL_HOME_ADDRESS"] ?? ""
allowAllUsers = PlatformSetupHelpers.parseEnvBool(env["EMAIL_ALLOW_ALL_USERS"])
// skip_attachments lives in config.yaml.
let yaml = (try? String(contentsOfFile: HermesPaths.configYAML, encoding: .utf8)) ?? ""
let parsed = HermesFileService.parseNestedYAML(yaml)
skipAttachments = (parsed.values["platforms.email.skip_attachments"] ?? "false") == "true"
}
func applyPreset(_ preset: Preset) {
imapHost = preset.imap
smtpHost = preset.smtp
}
func save() {
let envPairs: [String: String] = [
"EMAIL_ADDRESS": address,
"EMAIL_PASSWORD": password,
"EMAIL_IMAP_HOST": imapHost,
"EMAIL_SMTP_HOST": smtpHost,
"EMAIL_IMAP_PORT": imapPort,
"EMAIL_SMTP_PORT": smtpPort,
"EMAIL_POLL_INTERVAL": pollInterval,
"EMAIL_ALLOWED_USERS": allowAllUsers ? "" : allowedUsers,
"EMAIL_HOME_ADDRESS": homeAddress,
"EMAIL_ALLOW_ALL_USERS": allowAllUsers ? "true" : ""
]
let configKV: [String: String] = [
"platforms.email.skip_attachments": PlatformSetupHelpers.envBool(skipAttachments)
]
message = PlatformSetupHelpers.saveForm(envPairs: envPairs, configKV: configKV)
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
self?.message = nil
}
}
}
@@ -0,0 +1,47 @@
import Foundation
/// Feishu/Lark setup. Choose domain (feishu = China, lark = international).
/// Field reference: https://hermes-agent.nousresearch.com/docs/user-guide/messaging/feishu
@Observable
@MainActor
final class FeishuSetupViewModel {
var appID: String = ""
var appSecret: String = ""
var domain: String = "lark"
var encryptKey: String = ""
var verificationToken: String = ""
var allowedUsers: String = ""
var connectionMode: String = "websocket" // "websocket" | "webhook"
var message: String?
let domainOptions = ["feishu", "lark"]
let connectionOptions = ["websocket", "webhook"]
func load() {
let env = HermesEnvService().load()
appID = env["FEISHU_APP_ID"] ?? ""
appSecret = env["FEISHU_APP_SECRET"] ?? ""
domain = env["FEISHU_DOMAIN"] ?? "lark"
encryptKey = env["FEISHU_ENCRYPT_KEY"] ?? ""
verificationToken = env["FEISHU_VERIFICATION_TOKEN"] ?? ""
allowedUsers = env["FEISHU_ALLOWED_USERS"] ?? ""
connectionMode = env["FEISHU_CONNECTION_MODE"] ?? "websocket"
}
func save() {
let envPairs: [String: String] = [
"FEISHU_APP_ID": appID,
"FEISHU_APP_SECRET": appSecret,
"FEISHU_DOMAIN": domain,
"FEISHU_ENCRYPT_KEY": encryptKey,
"FEISHU_VERIFICATION_TOKEN": verificationToken,
"FEISHU_ALLOWED_USERS": allowedUsers,
"FEISHU_CONNECTION_MODE": connectionMode == "websocket" ? "" : connectionMode
]
message = PlatformSetupHelpers.saveForm(envPairs: envPairs, configKV: [:])
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
self?.message = nil
}
}
}
@@ -0,0 +1,67 @@
import Foundation
import AppKit
/// Home Assistant setup. Long-lived access token in `.env`, scalar filters via
/// `hermes config set` under `platforms.homeassistant.extra.*`.
///
/// **List fields** (`watch_domains`, `watch_entities`, `ignore_entities`) are
/// NOT editable in the form. `hermes config set` stores array arguments as
/// quoted strings instead of YAML lists, which hermes would then reject as
/// invalid. Users edit these directly in config.yaml the view shows the
/// current values (read-only) and an "Edit in config.yaml" button that opens
/// the file.
///
/// Field reference: https://hermes-agent.nousresearch.com/docs/user-guide/messaging/homeassistant
@Observable
@MainActor
final class HomeAssistantSetupViewModel {
var url: String = "http://homeassistant.local:8123"
var token: String = ""
// Scalar filters writable via hermes config set.
var watchAll: Bool = false
var cooldownSeconds: Int = 30
// List filters read-only; user must edit config.yaml manually.
var watchDomains: [String] = []
var watchEntities: [String] = []
var ignoreEntities: [String] = []
var message: String?
func load() {
let env = HermesEnvService().load()
url = env["HASS_URL"] ?? "http://homeassistant.local:8123"
token = env["HASS_TOKEN"] ?? ""
let cfg = HermesFileService().loadConfig().homeAssistant
watchAll = cfg.watchAll
cooldownSeconds = cfg.cooldownSeconds
watchDomains = cfg.watchDomains
watchEntities = cfg.watchEntities
ignoreEntities = cfg.ignoreEntities
}
func save() {
let envPairs: [String: String] = [
"HASS_URL": url,
"HASS_TOKEN": token
]
// Only scalar config values lists are skipped intentionally; see
// file header comment for rationale.
let configKV: [String: String] = [
"platforms.homeassistant.extra.watch_all": PlatformSetupHelpers.envBool(watchAll),
"platforms.homeassistant.extra.cooldown_seconds": String(cooldownSeconds)
]
message = PlatformSetupHelpers.saveForm(envPairs: envPairs, configKV: configKV)
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
self?.message = nil
}
}
/// Open config.yaml in the user's default editor so they can manually edit
/// the list-valued filter fields.
func openConfigForLists() {
NSWorkspace.shared.open(URL(fileURLWithPath: HermesPaths.configYAML))
}
}
@@ -0,0 +1,51 @@
import Foundation
/// iMessage via BlueBubbles. Requires a BlueBubbles Server running on a Mac
/// that's always on, with an Apple ID signed into Messages.app.
/// Field reference: https://hermes-agent.nousresearch.com/docs/user-guide/messaging/bluebubbles
@Observable
@MainActor
final class IMessageSetupViewModel {
var serverURL: String = ""
var password: String = ""
var webhookHost: String = "127.0.0.1"
var webhookPort: String = "8645"
var webhookPath: String = ""
var allowedUsers: String = ""
var homeChannel: String = ""
var allowAllUsers: Bool = false
var sendReadReceipts: Bool = false
var message: String?
func load() {
let env = HermesEnvService().load()
serverURL = env["BLUEBUBBLES_SERVER_URL"] ?? ""
password = env["BLUEBUBBLES_PASSWORD"] ?? ""
webhookHost = env["BLUEBUBBLES_WEBHOOK_HOST"] ?? "127.0.0.1"
webhookPort = env["BLUEBUBBLES_WEBHOOK_PORT"] ?? "8645"
webhookPath = env["BLUEBUBBLES_WEBHOOK_PATH"] ?? ""
allowedUsers = env["BLUEBUBBLES_ALLOWED_USERS"] ?? ""
homeChannel = env["BLUEBUBBLES_HOME_CHANNEL"] ?? ""
allowAllUsers = PlatformSetupHelpers.parseEnvBool(env["BLUEBUBBLES_ALLOW_ALL_USERS"])
sendReadReceipts = PlatformSetupHelpers.parseEnvBool(env["BLUEBUBBLES_SEND_READ_RECEIPTS"])
}
func save() {
let envPairs: [String: String] = [
"BLUEBUBBLES_SERVER_URL": serverURL,
"BLUEBUBBLES_PASSWORD": password,
"BLUEBUBBLES_WEBHOOK_HOST": webhookHost,
"BLUEBUBBLES_WEBHOOK_PORT": webhookPort,
"BLUEBUBBLES_WEBHOOK_PATH": webhookPath,
"BLUEBUBBLES_ALLOWED_USERS": allowAllUsers ? "" : allowedUsers,
"BLUEBUBBLES_HOME_CHANNEL": homeChannel,
"BLUEBUBBLES_ALLOW_ALL_USERS": allowAllUsers ? "true" : "",
"BLUEBUBBLES_SEND_READ_RECEIPTS": sendReadReceipts ? "true" : ""
]
message = PlatformSetupHelpers.saveForm(envPairs: envPairs, configKV: [:])
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
self?.message = nil
}
}
}
@@ -0,0 +1,62 @@
import Foundation
/// Matrix setup. Supports both access-token and password auth. No SSO.
/// Field reference: https://hermes-agent.nousresearch.com/docs/user-guide/messaging/matrix
@Observable
@MainActor
final class MatrixSetupViewModel {
var homeserver: String = ""
var accessToken: String = "" // preferred
var userID: String = ""
var password: String = "" // alternative to accessToken
var allowedUsers: String = ""
var homeRoom: String = ""
var recoveryKey: String = ""
var encryption: Bool = false
// config.yaml
var requireMention: Bool = true
var autoThread: Bool = true
var dmMentionThreads: Bool = false
var message: String?
func load() {
let env = HermesEnvService().load()
homeserver = env["MATRIX_HOMESERVER"] ?? ""
accessToken = env["MATRIX_ACCESS_TOKEN"] ?? ""
userID = env["MATRIX_USER_ID"] ?? ""
password = env["MATRIX_PASSWORD"] ?? ""
allowedUsers = env["MATRIX_ALLOWED_USERS"] ?? ""
homeRoom = env["MATRIX_HOME_ROOM"] ?? ""
recoveryKey = env["MATRIX_RECOVERY_KEY"] ?? ""
encryption = PlatformSetupHelpers.parseEnvBool(env["MATRIX_ENCRYPTION"])
let cfg = HermesFileService().loadConfig().matrix
requireMention = cfg.requireMention
autoThread = cfg.autoThread
dmMentionThreads = cfg.dmMentionThreads
}
func save() {
let envPairs: [String: String] = [
"MATRIX_HOMESERVER": homeserver,
"MATRIX_ACCESS_TOKEN": accessToken,
"MATRIX_USER_ID": userID,
"MATRIX_PASSWORD": password,
"MATRIX_ALLOWED_USERS": allowedUsers,
"MATRIX_HOME_ROOM": homeRoom,
"MATRIX_RECOVERY_KEY": recoveryKey,
"MATRIX_ENCRYPTION": encryption ? "true" : ""
]
let configKV: [String: String] = [
"matrix.require_mention": PlatformSetupHelpers.envBool(requireMention),
"matrix.auto_thread": PlatformSetupHelpers.envBool(autoThread),
"matrix.dm_mention_threads": PlatformSetupHelpers.envBool(dmMentionThreads)
]
message = PlatformSetupHelpers.saveForm(envPairs: envPairs, configKV: configKV)
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
self?.message = nil
}
}
}
@@ -0,0 +1,48 @@
import Foundation
/// Mattermost setup. Server URL + personal access token (or bot token).
/// Field reference: https://hermes-agent.nousresearch.com/docs/user-guide/messaging/mattermost
@Observable
@MainActor
final class MattermostSetupViewModel {
var serverURL: String = ""
var token: String = ""
var allowedUsers: String = ""
var homeChannel: String = ""
var freeResponseChannels: String = ""
var replyMode: String = "off"
var requireMention: Bool = true
var message: String?
let replyModeOptions = ["off", "thread"]
func load() {
let env = HermesEnvService().load()
serverURL = env["MATTERMOST_URL"] ?? ""
token = env["MATTERMOST_TOKEN"] ?? ""
allowedUsers = env["MATTERMOST_ALLOWED_USERS"] ?? ""
homeChannel = env["MATTERMOST_HOME_CHANNEL"] ?? ""
freeResponseChannels = env["MATTERMOST_FREE_RESPONSE_CHANNELS"] ?? ""
replyMode = env["MATTERMOST_REPLY_MODE"] ?? "off"
let cfg = HermesFileService().loadConfig().mattermost
requireMention = cfg.requireMention
}
func save() {
let envPairs: [String: String] = [
"MATTERMOST_URL": serverURL,
"MATTERMOST_TOKEN": token,
"MATTERMOST_ALLOWED_USERS": allowedUsers,
"MATTERMOST_HOME_CHANNEL": homeChannel,
"MATTERMOST_FREE_RESPONSE_CHANNELS": freeResponseChannels,
"MATTERMOST_REPLY_MODE": replyMode == "off" ? "" : replyMode,
"MATTERMOST_REQUIRE_MENTION": PlatformSetupHelpers.envBool(requireMention)
]
message = PlatformSetupHelpers.saveForm(envPairs: envPairs, configKV: [:])
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
self?.message = nil
}
}
}
@@ -0,0 +1,91 @@
import Foundation
import AppKit
import os
/// Shared helpers used by every per-platform setup view model.
///
/// Each platform form follows the same pattern:
/// 1. Load current values from `.env` + config.yaml into local `@Observable` state.
/// 2. Present them in a form where changes happen in-memory.
/// 3. On save, write env vars via `HermesEnvService.setMany` and config.yaml keys
/// via `hermes config set`, then surface a success/error toast.
///
/// Putting the save logic here keeps each per-platform VM focused on its own
/// field set without re-implementing the write plumbing 12 times.
@MainActor
enum PlatformSetupHelpers {
static let logger = Logger(subsystem: "com.scarf", category: "PlatformSetup")
static let envService = HermesEnvService()
/// Apply a form save in one atomic batch.
///
/// - `envPairs`: values to write into `.env`. Empty strings trigger `unset()`
/// (commenting the line out) rather than storing a literal empty value.
/// - `configKV`: scalar config.yaml paths to set via `hermes config set`.
/// Empty strings still produce a `config set <key> ""` call because
/// some fields accept an explicit empty string (e.g., `display.skin: ""`).
///
/// Returns a user-facing summary message.
@discardableResult
static func saveForm(envPairs: [String: String], configKV: [String: String]) -> String {
// Split env pairs into set vs. unset.
var toSet: [String: String] = [:]
var toUnset: [String] = []
for (k, v) in envPairs {
if v.isEmpty {
toUnset.append(k)
} else {
toSet[k] = v
}
}
var envOK = true
if !toSet.isEmpty {
envOK = envService.setMany(toSet)
}
for key in toUnset {
_ = envService.unset(key)
}
var configFailures: [String] = []
for (key, value) in configKV {
let result = runHermesCLI(args: ["config", "set", key, value])
if result.exitCode != 0 {
configFailures.append(key)
logger.warning("hermes config set \(key) failed: \(result.output)")
}
}
if !envOK { return "Failed to write .env" }
if !configFailures.isEmpty { return "Saved, but failed to update: \(configFailures.joined(separator: ", "))" }
return "Saved — restart gateway to apply"
}
/// Synchronous hermes CLI invocation. Use only for fast commands like
/// `config set`; longer commands should use `HermesFileService.runHermesCLI`
/// from a `Task.detached`.
static func runHermesCLI(args: [String], timeout: TimeInterval = 15) -> (exitCode: Int32, output: String) {
HermesFileService().runHermesCLI(args: args, timeout: timeout)
}
/// Ask the user's default browser to open a URL (typically a hermes doc page
/// or a platform developer portal).
static func openURL(_ string: String) {
guard let url = URL(string: string) else { return }
NSWorkspace.shared.open(url)
}
/// Bool <-> "true"/"false" round-trip for env vars. Hermes accepts both
/// "true"/"false" and "1"/"0"; we emit the string form for readability.
static func envBool(_ on: Bool) -> String { on ? "true" : "false" }
/// Parse an env string as a bool. Treats missing/empty as `false`.
/// "true", "1", "yes", "on" (case-insensitive) are true.
static func parseEnvBool(_ s: String?) -> Bool {
guard let s else { return false }
switch s.lowercased() {
case "true", "1", "yes", "on": return true
default: return false
}
}
}
@@ -0,0 +1,116 @@
import Foundation
/// Signal setup. Users must install `signal-cli` externally (needs Java), link
/// their account via `signal-cli link -n ...`, and run a daemon on an HTTP port
/// that hermes talks to. We expose an embedded terminal for both the link and
/// daemon commands.
///
/// Field reference: https://hermes-agent.nousresearch.com/docs/user-guide/messaging/signal
@Observable
@MainActor
final class SignalSetupViewModel {
var httpURL: String = "http://127.0.0.1:8080"
var account: String = "" // E.164 phone, e.g. +15551234567
var allowedUsers: String = ""
var groupAllowedUsers: String = ""
var homeChannel: String = ""
var allowAllUsers: Bool = false
var message: String?
let terminalController = EmbeddedSetupTerminalController()
var signalCLIInstalled: Bool = false
var activeTask: SignalTerminalTask = .none
enum SignalTerminalTask: Equatable {
case none
case link
case daemon
}
func load() {
let env = HermesEnvService().load()
httpURL = env["SIGNAL_HTTP_URL"] ?? "http://127.0.0.1:8080"
account = env["SIGNAL_ACCOUNT"] ?? ""
allowedUsers = env["SIGNAL_ALLOWED_USERS"] ?? ""
groupAllowedUsers = env["SIGNAL_GROUP_ALLOWED_USERS"] ?? ""
homeChannel = env["SIGNAL_HOME_CHANNEL"] ?? ""
allowAllUsers = PlatformSetupHelpers.parseEnvBool(env["SIGNAL_ALLOW_ALL_USERS"])
signalCLIInstalled = Self.detectSignalCLI()
}
/// Best-effort `signal-cli` binary lookup on the login-shell PATH.
private static func detectSignalCLI() -> Bool {
let env = HermesFileService.enrichedEnvironment()
let paths = env["PATH"]?.split(separator: ":").map(String.init) ?? []
for dir in paths {
if FileManager.default.isExecutableFile(atPath: dir + "/signal-cli") {
return true
}
}
return false
}
func save() {
let envPairs: [String: String] = [
"SIGNAL_HTTP_URL": httpURL,
"SIGNAL_ACCOUNT": account,
"SIGNAL_ALLOWED_USERS": allowAllUsers ? "" : allowedUsers,
"SIGNAL_GROUP_ALLOWED_USERS": groupAllowedUsers,
"SIGNAL_HOME_CHANNEL": homeChannel,
"SIGNAL_ALLOW_ALL_USERS": allowAllUsers ? "true" : ""
]
message = PlatformSetupHelpers.saveForm(envPairs: envPairs, configKV: [:])
clearMessageAfterDelay()
}
/// Run `signal-cli link -n HermesAgent` to generate a QR code.
func startLink() {
guard signalCLIInstalled else {
message = "signal-cli not found on PATH — install it first"
clearMessageAfterDelay()
return
}
activeTask = .link
terminalController.onExit = { [weak self] _ in
self?.activeTask = .none
self?.message = "Link step exited — save credentials and start the daemon next"
self?.clearMessageAfterDelay()
}
terminalController.start(executable: "/usr/bin/env", arguments: ["signal-cli", "link", "-n", "HermesAgent"])
}
/// Run the signal-cli daemon. Users can stop it by closing the panel.
func startDaemon() {
guard !account.isEmpty else {
message = "Enter your Signal account (E.164 format) first"
clearMessageAfterDelay()
return
}
guard signalCLIInstalled else {
message = "signal-cli not found on PATH"
clearMessageAfterDelay()
return
}
activeTask = .daemon
let bind = httpURL.replacingOccurrences(of: "http://", with: "").replacingOccurrences(of: "https://", with: "")
terminalController.onExit = { [weak self] _ in
self?.activeTask = .none
}
terminalController.start(
executable: "/usr/bin/env",
arguments: ["signal-cli", "--account", account, "daemon", "--http", bind]
)
}
func stopTerminal() {
terminalController.stop()
activeTask = .none
}
private func clearMessageAfterDelay() {
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
self?.message = nil
}
}
}
@@ -0,0 +1,58 @@
import Foundation
/// Slack setup. Requires two tokens (bot + app-level for Socket Mode).
/// Field reference: https://hermes-agent.nousresearch.com/docs/user-guide/messaging/slack
@Observable
@MainActor
final class SlackSetupViewModel {
var botToken: String = "" // xoxb-...
var appToken: String = "" // xapp-...
var allowedUsers: String = ""
var homeChannel: String = ""
var homeChannelName: String = ""
var replyToMode: String = "first"
var requireMention: Bool = true
var replyInThread: Bool = true
var replyBroadcast: Bool = false
var message: String?
let replyToModeOptions = ["off", "first", "all"]
func load() {
let env = HermesEnvService().load()
botToken = env["SLACK_BOT_TOKEN"] ?? ""
appToken = env["SLACK_APP_TOKEN"] ?? ""
allowedUsers = env["SLACK_ALLOWED_USERS"] ?? ""
homeChannel = env["SLACK_HOME_CHANNEL"] ?? ""
homeChannelName = env["SLACK_HOME_CHANNEL_NAME"] ?? ""
let cfg = HermesFileService().loadConfig().slack
replyToMode = cfg.replyToMode
requireMention = cfg.requireMention
replyInThread = cfg.replyInThread
replyBroadcast = cfg.replyBroadcast
}
func save() {
let envPairs: [String: String] = [
"SLACK_BOT_TOKEN": botToken,
"SLACK_APP_TOKEN": appToken,
"SLACK_ALLOWED_USERS": allowedUsers,
"SLACK_HOME_CHANNEL": homeChannel,
"SLACK_HOME_CHANNEL_NAME": homeChannelName
]
// Slack uses the modern `platforms.slack.*` schema.
let configKV: [String: String] = [
"platforms.slack.reply_to_mode": replyToMode,
"platforms.slack.require_mention": PlatformSetupHelpers.envBool(requireMention),
"platforms.slack.extra.reply_in_thread": PlatformSetupHelpers.envBool(replyInThread),
"platforms.slack.extra.reply_broadcast": PlatformSetupHelpers.envBool(replyBroadcast)
]
message = PlatformSetupHelpers.saveForm(envPairs: envPairs, configKV: configKV)
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
self?.message = nil
}
}
}
@@ -0,0 +1,61 @@
import Foundation
import os
/// Telegram platform setup. Credentials live in `.env` (`TELEGRAM_*`); mention /
/// reactions toggles live in `config.yaml` under `telegram.*`.
///
/// Field reference: https://hermes-agent.nousresearch.com/docs/user-guide/messaging/telegram
@Observable
@MainActor
final class TelegramSetupViewModel {
// Required
var botToken: String = ""
var allowedUsers: String = ""
// Optional
var homeChannel: String = ""
var webhookURL: String = ""
var webhookPort: String = ""
var webhookSecret: String = ""
// Config.yaml toggles
var requireMention: Bool = true
var reactions: Bool = false
var message: String?
func load() {
let env = HermesEnvService().load()
botToken = env["TELEGRAM_BOT_TOKEN"] ?? ""
allowedUsers = env["TELEGRAM_ALLOWED_USERS"] ?? ""
homeChannel = env["TELEGRAM_HOME_CHANNEL"] ?? ""
webhookURL = env["TELEGRAM_WEBHOOK_URL"] ?? ""
webhookPort = env["TELEGRAM_WEBHOOK_PORT"] ?? ""
webhookSecret = env["TELEGRAM_WEBHOOK_SECRET"] ?? ""
let cfg = HermesFileService().loadConfig()
requireMention = cfg.telegram.requireMention
reactions = cfg.telegram.reactions
}
func save() {
let envPairs: [String: String] = [
"TELEGRAM_BOT_TOKEN": botToken,
"TELEGRAM_ALLOWED_USERS": allowedUsers,
"TELEGRAM_HOME_CHANNEL": homeChannel,
"TELEGRAM_WEBHOOK_URL": webhookURL,
"TELEGRAM_WEBHOOK_PORT": webhookPort,
"TELEGRAM_WEBHOOK_SECRET": webhookSecret
]
let configKV: [String: String] = [
"telegram.require_mention": PlatformSetupHelpers.envBool(requireMention),
"telegram.reactions": PlatformSetupHelpers.envBool(reactions)
]
message = PlatformSetupHelpers.saveForm(envPairs: envPairs, configKV: configKV)
clearMessageAfterDelay()
}
private func clearMessageAfterDelay() {
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
self?.message = nil
}
}
}
@@ -0,0 +1,34 @@
import Foundation
/// Webhook platform setup. Just the global enable/port/secret per-subscription
/// routes live in the Webhooks sidebar feature.
///
/// Field reference: https://hermes-agent.nousresearch.com/docs/user-guide/messaging/webhooks
@Observable
@MainActor
final class WebhookSetupViewModel {
var enabled: Bool = false
var port: String = "8644"
var secret: String = ""
var message: String?
func load() {
let env = HermesEnvService().load()
enabled = PlatformSetupHelpers.parseEnvBool(env["WEBHOOK_ENABLED"])
port = env["WEBHOOK_PORT"] ?? "8644"
secret = env["WEBHOOK_SECRET"] ?? ""
}
func save() {
let envPairs: [String: String] = [
"WEBHOOK_ENABLED": enabled ? "true" : "",
"WEBHOOK_PORT": port,
"WEBHOOK_SECRET": secret
]
message = PlatformSetupHelpers.saveForm(envPairs: envPairs, configKV: [:])
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
self?.message = nil
}
}
}
@@ -0,0 +1,90 @@
import Foundation
/// WhatsApp setup. Unlike other platforms, pairing requires scanning a QR code
/// via the `hermes whatsapp` CLI wizard we expose that as an embedded
/// terminal below the config form.
///
/// Field reference: https://hermes-agent.nousresearch.com/docs/user-guide/messaging/whatsapp
@Observable
@MainActor
final class WhatsAppSetupViewModel {
var enabled: Bool = false
var mode: String = "bot" // "bot" | "self-chat"
var allowedUsers: String = "" // Comma-separated phone numbers (no +)
var allowAllUsers: Bool = false
// config.yaml knobs
var unauthorizedDMBehavior: String = "pair" // "pair" | "ignore"
var replyPrefix: String = ""
var message: String?
let modeOptions = ["bot", "self-chat"]
let unauthorizedOptions = ["pair", "ignore"]
/// The embedded terminal for the pairing step. Owned here so we can
/// `stop()` it cleanly when the user navigates away.
let terminalController = EmbeddedSetupTerminalController()
var pairingInProgress: Bool = false
func load() {
let env = HermesEnvService().load()
enabled = PlatformSetupHelpers.parseEnvBool(env["WHATSAPP_ENABLED"])
mode = env["WHATSAPP_MODE"] ?? "bot"
allowedUsers = env["WHATSAPP_ALLOWED_USERS"] ?? ""
allowAllUsers = PlatformSetupHelpers.parseEnvBool(env["WHATSAPP_ALLOW_ALL_USERS"])
// Hermes accepts two equivalent ways to mean "allow everyone":
// WHATSAPP_ALLOW_ALL_USERS=true OR WHATSAPP_ALLOWED_USERS=*
// Normalize so the checkbox reflects either form.
if allowedUsers == "*" {
allowAllUsers = true
allowedUsers = ""
}
let cfg = HermesFileService().loadConfig().whatsapp
unauthorizedDMBehavior = cfg.unauthorizedDMBehavior
replyPrefix = cfg.replyPrefix
}
func save() {
let envPairs: [String: String] = [
"WHATSAPP_ENABLED": PlatformSetupHelpers.envBool(enabled),
"WHATSAPP_MODE": mode,
// If "allow all" is set, the allowlist becomes "*" per hermes docs.
"WHATSAPP_ALLOWED_USERS": allowAllUsers ? "*" : allowedUsers,
"WHATSAPP_ALLOW_ALL_USERS": allowAllUsers ? "true" : ""
]
let configKV: [String: String] = [
"whatsapp.unauthorized_dm_behavior": unauthorizedDMBehavior,
"whatsapp.reply_prefix": replyPrefix
]
message = PlatformSetupHelpers.saveForm(envPairs: envPairs, configKV: configKV)
clearMessageAfterDelay()
}
/// Launch `hermes whatsapp` in the embedded terminal. The user scans the QR
/// code; hermes writes the session to `~/.hermes/platforms/whatsapp/session`
/// and exits when pairing is complete.
func startPairing() {
pairingInProgress = true
terminalController.onExit = { [weak self] _ in
self?.pairingInProgress = false
self?.message = "Pairing terminal exited — check output for status"
self?.clearMessageAfterDelay()
}
terminalController.start(
executable: HermesPaths.hermesBinary,
arguments: ["whatsapp"]
)
}
func stopPairing() {
terminalController.stop()
pairingInProgress = false
}
private func clearMessageAfterDelay() {
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
self?.message = nil
}
}
}
@@ -0,0 +1,96 @@
import Foundation
import os
/// Platform list/selection coordinator. Per-platform configuration now lives in
/// dedicated `<Platform>SetupViewModel` classes under `ViewModels/PlatformSetup/`.
/// This VM only manages the sidebar list, connectivity detection, and the
/// "Restart Gateway" action.
@Observable
@MainActor
final class PlatformsViewModel {
private let logger = Logger(subsystem: "com.scarf", category: "PlatformsViewModel")
private let fileService = HermesFileService()
var gatewayState: GatewayState?
var selected: HermesToolPlatform = KnownPlatforms.cli
var message: String?
var restartInProgress: Bool = false
var platforms: [HermesToolPlatform] { KnownPlatforms.all }
func load() {
gatewayState = fileService.loadGatewayState()
}
func connectivity(for platform: HermesToolPlatform) -> PlatformConnectivity {
if let pState = gatewayState?.platforms?[platform.name] {
if let err = pState.error, !err.isEmpty { return .error(err) }
if pState.connected == true { return .connected }
}
return hasConfigBlock(for: platform) ? .configured : .notConfigured
}
/// Does the platform have any configuration on disk either a top-level
/// `<platform>:` block in config.yaml, or an "identifying" env var in
/// `.env` (e.g. `TELEGRAM_BOT_TOKEN`, `DISCORD_BOT_TOKEN`)?
///
/// We need the env-var check because the new per-platform setup forms
/// write credentials to `.env` primarily; most platforms don't create a
/// YAML block until the user saves a behavior toggle. Without this,
/// platforms configured via the new flow would display as "Not configured"
/// until the first YAML edit.
func hasConfigBlock(for platform: HermesToolPlatform) -> Bool {
if platform.name == "cli" { return true }
let yaml = (try? String(contentsOfFile: HermesPaths.configYAML, encoding: .utf8)) ?? ""
for line in yaml.components(separatedBy: "\n") where !line.hasPrefix(" ") && !line.hasPrefix("\t") {
if line.trimmingCharacters(in: .whitespaces) == "\(platform.name):" { return true }
}
// Env-var fallback: any identifying env var for this platform counts
// as "configured". Uses the shared `identifyingEnvVar(for:)` mapping.
if let key = Self.identifyingEnvVar(for: platform.name) {
let env = HermesEnvService().load()
if let value = env[key], !value.isEmpty { return true }
}
return false
}
/// Primary credential env var for a platform the one whose presence
/// signals that the user has started setup. Centralized here so both the
/// connectivity detector and future diagnostics agree on the check.
private static func identifyingEnvVar(for platformName: String) -> String? {
switch platformName {
case "telegram": return "TELEGRAM_BOT_TOKEN"
case "discord": return "DISCORD_BOT_TOKEN"
case "slack": return "SLACK_BOT_TOKEN"
case "whatsapp": return "WHATSAPP_ENABLED"
case "signal": return "SIGNAL_ACCOUNT"
case "email": return "EMAIL_ADDRESS"
case "matrix": return "MATRIX_HOMESERVER"
case "mattermost": return "MATTERMOST_URL"
case "feishu": return "FEISHU_APP_ID"
case "imessage": return "BLUEBUBBLES_SERVER_URL"
case "homeassistant": return "HASS_TOKEN"
case "webhook": return "WEBHOOK_ENABLED"
default: return nil
}
}
/// Restart the hermes gateway so newly-saved config takes effect. Runs on a
/// background task so the UI stays responsive during the ~second or two
/// `hermes gateway restart` takes.
func restartGateway() {
restartInProgress = true
message = "Restarting gateway…"
Task.detached { [fileService] in
let result = fileService.runHermesCLI(args: ["gateway", "restart"], timeout: 30)
await MainActor.run {
self.restartInProgress = false
self.message = result.exitCode == 0 ? "Gateway restarted" : "Restart failed"
self.load()
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
self?.message = nil
}
}
}
}
}
@@ -0,0 +1,60 @@
import SwiftUI
struct DiscordSetupView: View {
@State private var viewModel = DiscordSetupViewModel()
var body: some View {
VStack(alignment: .leading, spacing: 16) {
instructions
SettingsSection(title: "Required", icon: "key") {
SecretTextField(label: "Bot Token", value: viewModel.botToken) { viewModel.botToken = $0 }
EditableTextField(label: "Allowed User IDs", value: viewModel.allowedUsers) { viewModel.allowedUsers = $0 }
}
SettingsSection(title: "Home Channel", icon: "house") {
EditableTextField(label: "Home Channel ID", value: viewModel.homeChannel) { viewModel.homeChannel = $0 }
EditableTextField(label: "Display Name", value: viewModel.homeChannelName) { viewModel.homeChannelName = $0 }
}
SettingsSection(title: "Behavior", icon: "slider.horizontal.3") {
ToggleRow(label: "Require @mention", isOn: viewModel.requireMention) { viewModel.requireMention = $0 }
EditableTextField(label: "Free-Response Channels", value: viewModel.freeResponseChannels) { viewModel.freeResponseChannels = $0 }
ToggleRow(label: "Auto-thread on mention", isOn: viewModel.autoThread) { viewModel.autoThread = $0 }
ToggleRow(label: "Reactions", isOn: viewModel.reactions) { viewModel.reactions = $0 }
PickerRow(label: "Allow Other Bots", selection: viewModel.allowBots, options: viewModel.allowBotsOptions) { viewModel.allowBots = $0 }
PickerRow(label: "Reply Mode", selection: viewModel.replyToMode, options: viewModel.replyToModeOptions) { viewModel.replyToMode = $0 }
}
saveBar
}
.onAppear { viewModel.load() }
}
private var instructions: some View {
VStack(alignment: .leading, spacing: 6) {
Text("Create an app in Discord's Developer Portal, enable Message Content and Server Members intents, and copy the bot token. Invite the bot to your server via the OAuth2 URL generator.")
.font(.caption)
.foregroundStyle(.secondary)
HStack(spacing: 12) {
Button("Open Developer Portal") { PlatformSetupHelpers.openURL("https://discord.com/developers/applications") }
.controlSize(.small)
Button("Discord Setup Docs") { PlatformSetupHelpers.openURL("https://hermes-agent.nousresearch.com/docs/user-guide/messaging/discord") }
.controlSize(.small)
}
}
}
private var saveBar: some View {
HStack {
if let msg = viewModel.message {
Label(msg, systemImage: "checkmark.circle.fill")
.font(.caption)
.foregroundStyle(.green)
}
Spacer()
Button("Reload") { viewModel.load() }.controlSize(.small)
Button("Save") { viewModel.save() }.buttonStyle(.borderedProminent).controlSize(.small)
}
}
}
@@ -0,0 +1,78 @@
import SwiftUI
struct EmailSetupView: View {
@State private var viewModel = EmailSetupViewModel()
var body: some View {
VStack(alignment: .leading, spacing: 16) {
instructions
presetBar
SettingsSection(title: "Credentials", icon: "envelope") {
EditableTextField(label: "Email Address", value: viewModel.address) { viewModel.address = $0 }
SecretTextField(label: "App Password", value: viewModel.password) { viewModel.password = $0 }
}
SettingsSection(title: "Servers", icon: "server.rack") {
EditableTextField(label: "IMAP Host", value: viewModel.imapHost) { viewModel.imapHost = $0 }
EditableTextField(label: "SMTP Host", value: viewModel.smtpHost) { viewModel.smtpHost = $0 }
EditableTextField(label: "IMAP Port", value: viewModel.imapPort) { viewModel.imapPort = $0 }
EditableTextField(label: "SMTP Port", value: viewModel.smtpPort) { viewModel.smtpPort = $0 }
EditableTextField(label: "Poll Interval (s)", value: viewModel.pollInterval) { viewModel.pollInterval = $0 }
}
SettingsSection(title: "Access Control", icon: "person.badge.shield.checkmark") {
ToggleRow(label: "Allow All Senders", isOn: viewModel.allowAllUsers) { viewModel.allowAllUsers = $0 }
if !viewModel.allowAllUsers {
EditableTextField(label: "Allowed Senders", value: viewModel.allowedUsers) { viewModel.allowedUsers = $0 }
}
EditableTextField(label: "Home Address", value: viewModel.homeAddress) { viewModel.homeAddress = $0 }
}
SettingsSection(title: "Behavior", icon: "slider.horizontal.3") {
ToggleRow(label: "Skip Attachments", isOn: viewModel.skipAttachments) { viewModel.skipAttachments = $0 }
}
saveBar
}
.onAppear { viewModel.load() }
}
private var instructions: some View {
VStack(alignment: .leading, spacing: 6) {
Text("Enable 2FA on your email account and generate an app password. Regular account passwords will fail. Always set allowed senders — otherwise anyone knowing the address can message the agent.")
.font(.caption)
.foregroundStyle(.secondary)
HStack {
Button("Email Setup Docs") { PlatformSetupHelpers.openURL("https://hermes-agent.nousresearch.com/docs/user-guide/messaging/email") }
.controlSize(.small)
}
}
}
private var presetBar: some View {
HStack(spacing: 8) {
Text("Preset:")
.font(.caption)
.foregroundStyle(.secondary)
ForEach(viewModel.presets, id: \.name) { preset in
Button(preset.name) { viewModel.applyPreset(preset) }
.controlSize(.small)
}
Spacer()
}
}
private var saveBar: some View {
HStack {
if let msg = viewModel.message {
Label(msg, systemImage: "checkmark.circle.fill")
.font(.caption)
.foregroundStyle(.green)
}
Spacer()
Button("Reload") { viewModel.load() }.controlSize(.small)
Button("Save") { viewModel.save() }.buttonStyle(.borderedProminent).controlSize(.small)
}
}
}
@@ -0,0 +1,158 @@
import SwiftUI
import AppKit
import SwiftTerm
import os
/// Inline SwiftTerm terminal for platform pairing wizards that genuinely require
/// a TTY (WhatsApp QR, Signal `signal-cli link`). This is a lightweight sibling
/// to `PersistentTerminalView` in the Chat feature scoped to run a single
/// command, show its output, and notify when the process exits.
///
/// Usage:
/// EmbeddedSetupTerminal(controller: viewModel.terminalController)
/// // Controller exposes start()/terminate() that the view model owns.
struct EmbeddedSetupTerminal: NSViewRepresentable {
let controller: EmbeddedSetupTerminalController
func makeNSView(context: Context) -> NSView {
let container = NSView()
controller.attach(to: container)
return container
}
func updateNSView(_ nsView: NSView, context: Context) {
// If the view model recreated its terminal view (e.g., after re-launching
// the pairing command), re-attach it to the container.
controller.reattachIfNeeded(to: nsView)
}
}
/// Owns the `LocalProcessTerminalView` so it survives SwiftUI body redraws.
/// Lives on the view model (one per platform that uses it).
@MainActor
final class EmbeddedSetupTerminalController {
private let logger = Logger(subsystem: "com.scarf", category: "EmbeddedSetupTerminal")
/// The hosting NSView from the `NSViewRepresentable`. Weak because SwiftUI
/// owns the container's lifetime; we just attach our terminal view inside it.
private weak var container: NSView?
/// The actual terminal emulator. Recreated per launch so a terminated
/// process doesn't leave stale buffer state mixed with new output.
private var terminalView: LocalProcessTerminalView?
private var coordinator: Coordinator?
/// Invoked when the spawned process exits. The `Int32` is the exit code
/// (`0` success, non-zero failure). Runs on the main actor.
var onExit: ((Int32) -> Void)?
var isRunning: Bool { terminalView != nil }
/// Start a process in the embedded terminal. If a process is already running,
/// it is terminated first to avoid orphans.
func start(executable: String, arguments: [String], environment: [String: String] = [:]) {
stop()
guard let container else {
logger.warning("start() called before terminal was attached to a container")
return
}
let terminal = LocalProcessTerminalView(frame: .zero)
terminal.font = NSFont.monospacedSystemFont(ofSize: 12, weight: .regular)
terminal.nativeBackgroundColor = NSColor(red: 0.11, green: 0.12, blue: 0.14, alpha: 1.0)
terminal.nativeForegroundColor = NSColor(red: 0.85, green: 0.87, blue: 0.91, alpha: 1.0)
let coord = Coordinator { [weak self] exitCode in
self?.onExit?(exitCode ?? -1)
}
terminal.processDelegate = coord
coordinator = coord
// Merge caller-provided env over the enriched shell env so `npx`, `node`,
// `signal-cli`, etc. resolve from PATH.
var env = HermesFileService.enrichedEnvironment()
env["TERM"] = "xterm-256color"
env["COLORTERM"] = "truecolor"
for (k, v) in environment { env[k] = v }
let envArray = env.map { "\($0.key)=\($0.value)" }
terminal.startProcess(
executable: executable,
args: arguments,
environment: envArray,
execName: nil
)
// Attach with AutoLayout constraints matches the pattern used by
// Features/Chat/Views/TerminalRepresentable.swift. Relying on
// autoresizingMask is unreliable when SwiftUI hosts the NSView,
// because SwiftUI drives layout via AutoLayout.
container.subviews.forEach { $0.removeFromSuperview() }
terminal.translatesAutoresizingMaskIntoConstraints = false
container.addSubview(terminal)
NSLayoutConstraint.activate([
terminal.leadingAnchor.constraint(equalTo: container.leadingAnchor),
terminal.trailingAnchor.constraint(equalTo: container.trailingAnchor),
terminal.topAnchor.constraint(equalTo: container.topAnchor),
terminal.bottomAnchor.constraint(equalTo: container.bottomAnchor),
])
terminalView = terminal
}
/// Kill the running process (if any). Safe to call when nothing is running.
func stop() {
terminalView?.terminate()
terminalView?.removeFromSuperview()
terminalView = nil
}
// MARK: - NSViewRepresentable plumbing
func attach(to container: NSView) {
self.container = container
if let tv = terminalView, tv.superview !== container {
container.subviews.forEach { $0.removeFromSuperview() }
tv.translatesAutoresizingMaskIntoConstraints = false
container.addSubview(tv)
NSLayoutConstraint.activate([
tv.leadingAnchor.constraint(equalTo: container.leadingAnchor),
tv.trailingAnchor.constraint(equalTo: container.trailingAnchor),
tv.topAnchor.constraint(equalTo: container.topAnchor),
tv.bottomAnchor.constraint(equalTo: container.bottomAnchor),
])
}
}
func reattachIfNeeded(to container: NSView) {
self.container = container
guard let tv = terminalView, tv.superview !== container else { return }
container.subviews.forEach { $0.removeFromSuperview() }
tv.translatesAutoresizingMaskIntoConstraints = false
container.addSubview(tv)
NSLayoutConstraint.activate([
tv.leadingAnchor.constraint(equalTo: container.leadingAnchor),
tv.trailingAnchor.constraint(equalTo: container.trailingAnchor),
tv.topAnchor.constraint(equalTo: container.topAnchor),
tv.bottomAnchor.constraint(equalTo: container.bottomAnchor),
])
}
final class Coordinator: NSObject, LocalProcessTerminalViewDelegate {
let onTerminated: (Int32?) -> Void
init(onTerminated: @escaping (Int32?) -> Void) {
self.onTerminated = onTerminated
}
func sizeChanged(source: LocalProcessTerminalView, newCols: Int, newRows: Int) {}
func setTerminalTitle(source: LocalProcessTerminalView, title: String) {}
func hostCurrentDirectoryUpdate(source: TerminalView, directory: String?) {}
func processTerminated(source: TerminalView, exitCode: Int32?) {
let terminal = source.getTerminal()
terminal.feed(text: "\r\n[Process exited with code \(exitCode ?? -1)]\r\n")
let code = exitCode
DispatchQueue.main.async { self.onTerminated(code) }
}
}
}
@@ -0,0 +1,55 @@
import SwiftUI
struct FeishuSetupView: View {
@State private var viewModel = FeishuSetupViewModel()
var body: some View {
VStack(alignment: .leading, spacing: 16) {
instructions
SettingsSection(title: "App Credentials", icon: "key") {
EditableTextField(label: "App ID", value: viewModel.appID) { viewModel.appID = $0 }
SecretTextField(label: "App Secret", value: viewModel.appSecret) { viewModel.appSecret = $0 }
PickerRow(label: "Domain", selection: viewModel.domain, options: viewModel.domainOptions) { viewModel.domain = $0 }
}
SettingsSection(title: "Webhook Security", icon: "lock.shield") {
SecretTextField(label: "Encrypt Key", value: viewModel.encryptKey) { viewModel.encryptKey = $0 }
SecretTextField(label: "Verification Token", value: viewModel.verificationToken) { viewModel.verificationToken = $0 }
}
SettingsSection(title: "Access Control", icon: "person.badge.shield.checkmark") {
EditableTextField(label: "Allowed Users", value: viewModel.allowedUsers) { viewModel.allowedUsers = $0 }
PickerRow(label: "Connection Mode", selection: viewModel.connectionMode, options: viewModel.connectionOptions) { viewModel.connectionMode = $0 }
}
saveBar
}
.onAppear { viewModel.load() }
}
private var instructions: some View {
VStack(alignment: .leading, spacing: 6) {
Text("Create an app in the Feishu/Lark Developer Console, enable Interactive Card if you need button responses, and copy the App ID and App Secret. WebSocket mode (recommended) doesn't need a public endpoint.")
.font(.caption)
.foregroundStyle(.secondary)
HStack {
Button("Feishu Setup Docs") { PlatformSetupHelpers.openURL("https://hermes-agent.nousresearch.com/docs/user-guide/messaging/feishu") }
.controlSize(.small)
}
}
}
private var saveBar: some View {
HStack {
if let msg = viewModel.message {
Label(msg, systemImage: "checkmark.circle.fill")
.font(.caption)
.foregroundStyle(.green)
}
Spacer()
Button("Reload") { viewModel.load() }.controlSize(.small)
Button("Save") { viewModel.save() }.buttonStyle(.borderedProminent).controlSize(.small)
}
}
}
@@ -0,0 +1,75 @@
import SwiftUI
struct HomeAssistantSetupView: View {
@State private var viewModel = HomeAssistantSetupViewModel()
var body: some View {
VStack(alignment: .leading, spacing: 16) {
instructions
SettingsSection(title: "Connection", icon: "network") {
EditableTextField(label: "URL", value: viewModel.url) { viewModel.url = $0 }
SecretTextField(label: "Long-Lived Token", value: viewModel.token) { viewModel.token = $0 }
}
SettingsSection(title: "Event Filters", icon: "line.3.horizontal.decrease.circle") {
ToggleRow(label: "Watch All Changes", isOn: viewModel.watchAll) { viewModel.watchAll = $0 }
StepperRow(label: "Cooldown (s)", value: viewModel.cooldownSeconds, range: 0...3600, step: 5) { viewModel.cooldownSeconds = $0 }
}
listFiltersSection
saveBar
}
.onAppear { viewModel.load() }
}
private var instructions: some View {
VStack(alignment: .leading, spacing: 6) {
Text("Create a long-lived access token in Home Assistant (Profile → Security → Long-Lived Access Tokens). By default, no events are forwarded — enable Watch All Changes, or add entity filters below.")
.font(.caption)
.foregroundStyle(.secondary)
HStack {
Button("Home Assistant Docs") { PlatformSetupHelpers.openURL("https://hermes-agent.nousresearch.com/docs/user-guide/messaging/homeassistant") }
.controlSize(.small)
}
}
}
/// Read-only display of list-valued filters (watch_domains, watch_entities,
/// ignore_entities). Editing requires hand-modifying config.yaml because
/// the `hermes config set` CLI can't produce YAML lists it stores
/// arrays as quoted strings, which hermes rejects.
private var listFiltersSection: some View {
SettingsSection(title: "Entity Filters (config.yaml only)", icon: "list.bullet") {
ReadOnlyRow(label: "Watch Domains", value: viewModel.watchDomains.joined(separator: ", "))
ReadOnlyRow(label: "Watch Entities", value: viewModel.watchEntities.joined(separator: ", "))
ReadOnlyRow(label: "Ignore Entities", value: viewModel.ignoreEntities.joined(separator: ", "))
HStack {
Text("")
.frame(width: 160, alignment: .trailing)
Text("These list fields must be edited directly in config.yaml.")
.font(.caption2)
.foregroundStyle(.secondary)
Button("Edit config.yaml") { viewModel.openConfigForLists() }
.controlSize(.mini)
Spacer()
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(.quaternary.opacity(0.3))
}
}
private var saveBar: some View {
HStack {
if let msg = viewModel.message {
Label(msg, systemImage: "checkmark.circle.fill")
.font(.caption)
.foregroundStyle(.green)
}
Spacer()
Button("Reload") { viewModel.load() }.controlSize(.small)
Button("Save") { viewModel.save() }.buttonStyle(.borderedProminent).controlSize(.small)
}
}
}
@@ -0,0 +1,64 @@
import SwiftUI
struct IMessageSetupView: View {
@State private var viewModel = IMessageSetupViewModel()
var body: some View {
VStack(alignment: .leading, spacing: 16) {
instructions
SettingsSection(title: "BlueBubbles Server", icon: "server.rack") {
EditableTextField(label: "Server URL", value: viewModel.serverURL) { viewModel.serverURL = $0 }
SecretTextField(label: "Server Password", value: viewModel.password) { viewModel.password = $0 }
}
SettingsSection(title: "Webhook (hermes side)", icon: "arrow.up.right.square") {
EditableTextField(label: "Host", value: viewModel.webhookHost) { viewModel.webhookHost = $0 }
EditableTextField(label: "Port", value: viewModel.webhookPort) { viewModel.webhookPort = $0 }
EditableTextField(label: "Path", value: viewModel.webhookPath) { viewModel.webhookPath = $0 }
}
SettingsSection(title: "Access Control", icon: "person.badge.shield.checkmark") {
ToggleRow(label: "Allow All Users", isOn: viewModel.allowAllUsers) { viewModel.allowAllUsers = $0 }
if !viewModel.allowAllUsers {
EditableTextField(label: "Allowed Users", value: viewModel.allowedUsers) { viewModel.allowedUsers = $0 }
}
EditableTextField(label: "Home Channel", value: viewModel.homeChannel) { viewModel.homeChannel = $0 }
}
SettingsSection(title: "Behavior", icon: "slider.horizontal.3") {
ToggleRow(label: "Send Read Receipts", isOn: viewModel.sendReadReceipts) { viewModel.sendReadReceipts = $0 }
}
saveBar
}
.onAppear { viewModel.load() }
}
private var instructions: some View {
VStack(alignment: .leading, spacing: 6) {
Text("iMessage integration runs through BlueBubbles Server. You need a Mac that stays on with Messages.app signed in — install BlueBubbles Server on it, then point hermes at that server's URL.")
.font(.caption)
.foregroundStyle(.secondary)
HStack(spacing: 12) {
Button("Install BlueBubbles Server") { PlatformSetupHelpers.openURL("https://bluebubbles.app/") }
.controlSize(.small)
Button("BlueBubbles Docs") { PlatformSetupHelpers.openURL("https://hermes-agent.nousresearch.com/docs/user-guide/messaging/bluebubbles") }
.controlSize(.small)
}
}
}
private var saveBar: some View {
HStack {
if let msg = viewModel.message {
Label(msg, systemImage: "checkmark.circle.fill")
.font(.caption)
.foregroundStyle(.green)
}
Spacer()
Button("Reload") { viewModel.load() }.controlSize(.small)
Button("Save") { viewModel.save() }.buttonStyle(.borderedProminent).controlSize(.small)
}
}
}
@@ -0,0 +1,72 @@
import SwiftUI
struct MatrixSetupView: View {
@State private var viewModel = MatrixSetupViewModel()
var body: some View {
VStack(alignment: .leading, spacing: 16) {
instructions
SettingsSection(title: "Homeserver", icon: "network") {
EditableTextField(label: "Homeserver URL", value: viewModel.homeserver) { viewModel.homeserver = $0 }
}
SettingsSection(title: "Authentication", icon: "person.badge.key") {
SecretTextField(label: "Access Token", value: viewModel.accessToken) { viewModel.accessToken = $0 }
Text("— or use user/password login —")
.font(.caption2)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity)
.padding(.vertical, 2)
EditableTextField(label: "User ID", value: viewModel.userID) { viewModel.userID = $0 }
SecretTextField(label: "Password", value: viewModel.password) { viewModel.password = $0 }
}
SettingsSection(title: "Access Control", icon: "person.badge.shield.checkmark") {
EditableTextField(label: "Allowed Users", value: viewModel.allowedUsers) { viewModel.allowedUsers = $0 }
EditableTextField(label: "Home Room", value: viewModel.homeRoom) { viewModel.homeRoom = $0 }
}
SettingsSection(title: "Behavior", icon: "slider.horizontal.3") {
ToggleRow(label: "Require @mention", isOn: viewModel.requireMention) { viewModel.requireMention = $0 }
ToggleRow(label: "Auto-thread on mention", isOn: viewModel.autoThread) { viewModel.autoThread = $0 }
ToggleRow(label: "DM mention threads", isOn: viewModel.dmMentionThreads) { viewModel.dmMentionThreads = $0 }
}
SettingsSection(title: "End-to-End Encryption (experimental)", icon: "lock.shield") {
ToggleRow(label: "Enable E2EE", isOn: viewModel.encryption) { viewModel.encryption = $0 }
if viewModel.encryption {
SecretTextField(label: "Recovery Key", value: viewModel.recoveryKey) { viewModel.recoveryKey = $0 }
}
}
saveBar
}
.onAppear { viewModel.load() }
}
private var instructions: some View {
VStack(alignment: .leading, spacing: 6) {
Text("Matrix uses either an access token (preferred) or username/password. Get an access token from Element: Settings → Help & About → Access Token.")
.font(.caption)
.foregroundStyle(.secondary)
HStack {
Button("Matrix Setup Docs") { PlatformSetupHelpers.openURL("https://hermes-agent.nousresearch.com/docs/user-guide/messaging/matrix") }
.controlSize(.small)
}
}
}
private var saveBar: some View {
HStack {
if let msg = viewModel.message {
Label(msg, systemImage: "checkmark.circle.fill")
.font(.caption)
.foregroundStyle(.green)
}
Spacer()
Button("Reload") { viewModel.load() }.controlSize(.small)
Button("Save") { viewModel.save() }.buttonStyle(.borderedProminent).controlSize(.small)
}
}
}
@@ -0,0 +1,55 @@
import SwiftUI
struct MattermostSetupView: View {
@State private var viewModel = MattermostSetupViewModel()
var body: some View {
VStack(alignment: .leading, spacing: 16) {
instructions
SettingsSection(title: "Server", icon: "network") {
EditableTextField(label: "Server URL", value: viewModel.serverURL) { viewModel.serverURL = $0 }
SecretTextField(label: "Token", value: viewModel.token) { viewModel.token = $0 }
}
SettingsSection(title: "Access Control", icon: "person.badge.shield.checkmark") {
EditableTextField(label: "Allowed Users", value: viewModel.allowedUsers) { viewModel.allowedUsers = $0 }
EditableTextField(label: "Home Channel", value: viewModel.homeChannel) { viewModel.homeChannel = $0 }
EditableTextField(label: "Free-Response Channels", value: viewModel.freeResponseChannels) { viewModel.freeResponseChannels = $0 }
}
SettingsSection(title: "Behavior", icon: "slider.horizontal.3") {
ToggleRow(label: "Require @mention", isOn: viewModel.requireMention) { viewModel.requireMention = $0 }
PickerRow(label: "Reply Mode", selection: viewModel.replyMode, options: viewModel.replyModeOptions) { viewModel.replyMode = $0 }
}
saveBar
}
.onAppear { viewModel.load() }
}
private var instructions: some View {
VStack(alignment: .leading, spacing: 6) {
Text("Create a personal access token under Profile → Security → Personal Access Tokens, or create a bot account. Use the token as the MATTERMOST_TOKEN value.")
.font(.caption)
.foregroundStyle(.secondary)
HStack {
Button("Mattermost Setup Docs") { PlatformSetupHelpers.openURL("https://hermes-agent.nousresearch.com/docs/user-guide/messaging/mattermost") }
.controlSize(.small)
}
}
}
private var saveBar: some View {
HStack {
if let msg = viewModel.message {
Label(msg, systemImage: "checkmark.circle.fill")
.font(.caption)
.foregroundStyle(.green)
}
Spacer()
Button("Reload") { viewModel.load() }.controlSize(.small)
Button("Save") { viewModel.save() }.buttonStyle(.borderedProminent).controlSize(.small)
}
}
}
@@ -0,0 +1,103 @@
import SwiftUI
struct SignalSetupView: View {
@State private var viewModel = SignalSetupViewModel()
var body: some View {
VStack(alignment: .leading, spacing: 16) {
instructions
prerequisiteStatus
SettingsSection(title: "Daemon Endpoint", icon: "network") {
EditableTextField(label: "HTTP URL", value: viewModel.httpURL) { viewModel.httpURL = $0 }
EditableTextField(label: "Account (E.164)", value: viewModel.account) { viewModel.account = $0 }
}
SettingsSection(title: "Access Control", icon: "person.badge.shield.checkmark") {
ToggleRow(label: "Allow All Users", isOn: viewModel.allowAllUsers) { viewModel.allowAllUsers = $0 }
if !viewModel.allowAllUsers {
EditableTextField(label: "Allowed Users", value: viewModel.allowedUsers) { viewModel.allowedUsers = $0 }
}
EditableTextField(label: "Group Allowed Users", value: viewModel.groupAllowedUsers) { viewModel.groupAllowedUsers = $0 }
EditableTextField(label: "Home Channel", value: viewModel.homeChannel) { viewModel.homeChannel = $0 }
}
saveBar
Divider()
terminalSection
}
.onAppear { viewModel.load() }
.onDisappear { viewModel.stopTerminal() }
}
private var instructions: some View {
VStack(alignment: .leading, spacing: 6) {
Text("Signal integration requires signal-cli (Java-based) installed locally. Link this Mac as a Signal device, then keep the daemon running so hermes can send/receive messages.")
.font(.caption)
.foregroundStyle(.secondary)
HStack(spacing: 12) {
Button("Install signal-cli") { PlatformSetupHelpers.openURL("https://github.com/AsamK/signal-cli/wiki/Quickstart") }
.controlSize(.small)
Button("Signal Setup Docs") { PlatformSetupHelpers.openURL("https://hermes-agent.nousresearch.com/docs/user-guide/messaging/signal") }
.controlSize(.small)
}
}
}
@ViewBuilder
private var prerequisiteStatus: some View {
HStack(spacing: 8) {
Image(systemName: viewModel.signalCLIInstalled ? "checkmark.circle.fill" : "exclamationmark.triangle.fill")
.foregroundStyle(viewModel.signalCLIInstalled ? .green : .orange)
Text(viewModel.signalCLIInstalled ? "signal-cli is available on PATH" : "signal-cli not found on PATH — install it first")
.font(.caption)
.foregroundStyle(viewModel.signalCLIInstalled ? Color.primary : Color.orange)
Spacer()
}
.padding(8)
.background(.quaternary.opacity(0.3))
.clipShape(RoundedRectangle(cornerRadius: 6))
}
private var saveBar: some View {
HStack {
if let msg = viewModel.message {
Label(msg, systemImage: "info.circle")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Button("Reload") { viewModel.load() }.controlSize(.small)
Button("Save") { viewModel.save() }.buttonStyle(.borderedProminent).controlSize(.small)
}
}
private var terminalSection: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Label("signal-cli Terminal", systemImage: "terminal")
.font(.headline)
Spacer()
switch viewModel.activeTask {
case .none:
Button("Link Device") { viewModel.startLink() }.controlSize(.small)
.disabled(!viewModel.signalCLIInstalled)
Button("Start Daemon") { viewModel.startDaemon() }.buttonStyle(.borderedProminent).controlSize(.small)
.disabled(!viewModel.signalCLIInstalled || viewModel.account.isEmpty)
case .link:
Text("Linking…").font(.caption).foregroundStyle(.secondary)
Button("Stop") { viewModel.stopTerminal() }.controlSize(.small)
case .daemon:
Text("Daemon running").font(.caption).foregroundStyle(.green)
Button("Stop") { viewModel.stopTerminal() }.controlSize(.small)
}
}
Text("Link the device first to generate and scan a QR code. Once linked, start the daemon — it must keep running for hermes to send/receive messages.")
.font(.caption)
.foregroundStyle(.secondary)
EmbeddedSetupTerminal(controller: viewModel.terminalController)
.frame(minHeight: 260, maxHeight: 360)
.clipShape(RoundedRectangle(cornerRadius: 6))
}
}
}
@@ -0,0 +1,59 @@
import SwiftUI
struct SlackSetupView: View {
@State private var viewModel = SlackSetupViewModel()
var body: some View {
VStack(alignment: .leading, spacing: 16) {
instructions
SettingsSection(title: "Required Tokens", icon: "key") {
SecretTextField(label: "Bot Token (xoxb-)", value: viewModel.botToken) { viewModel.botToken = $0 }
SecretTextField(label: "App Token (xapp-)", value: viewModel.appToken) { viewModel.appToken = $0 }
EditableTextField(label: "Allowed User IDs", value: viewModel.allowedUsers) { viewModel.allowedUsers = $0 }
}
SettingsSection(title: "Home Channel", icon: "house") {
EditableTextField(label: "Channel ID", value: viewModel.homeChannel) { viewModel.homeChannel = $0 }
EditableTextField(label: "Display Name", value: viewModel.homeChannelName) { viewModel.homeChannelName = $0 }
}
SettingsSection(title: "Behavior", icon: "slider.horizontal.3") {
ToggleRow(label: "Require @mention", isOn: viewModel.requireMention) { viewModel.requireMention = $0 }
PickerRow(label: "Reply Mode", selection: viewModel.replyToMode, options: viewModel.replyToModeOptions) { viewModel.replyToMode = $0 }
ToggleRow(label: "Reply in thread", isOn: viewModel.replyInThread) { viewModel.replyInThread = $0 }
ToggleRow(label: "Reply broadcast", isOn: viewModel.replyBroadcast) { viewModel.replyBroadcast = $0 }
}
saveBar
}
.onAppear { viewModel.load() }
}
private var instructions: some View {
VStack(alignment: .leading, spacing: 6) {
Text("Create a Slack app at api.slack.com/apps, enable Socket Mode, grant bot scopes (chat:write, app_mentions:read, channels:history, etc.), then copy the Bot User OAuth Token (xoxb-) and the App-Level Token (xapp-).")
.font(.caption)
.foregroundStyle(.secondary)
HStack(spacing: 12) {
Button("Open Slack API") { PlatformSetupHelpers.openURL("https://api.slack.com/apps") }
.controlSize(.small)
Button("Slack Setup Docs") { PlatformSetupHelpers.openURL("https://hermes-agent.nousresearch.com/docs/user-guide/messaging/slack") }
.controlSize(.small)
}
}
}
private var saveBar: some View {
HStack {
if let msg = viewModel.message {
Label(msg, systemImage: "checkmark.circle.fill")
.font(.caption)
.foregroundStyle(.green)
}
Spacer()
Button("Reload") { viewModel.load() }.controlSize(.small)
Button("Save") { viewModel.save() }.buttonStyle(.borderedProminent).controlSize(.small)
}
}
}
@@ -0,0 +1,61 @@
import SwiftUI
struct TelegramSetupView: View {
@State private var viewModel = TelegramSetupViewModel()
var body: some View {
VStack(alignment: .leading, spacing: 16) {
instructions
SettingsSection(title: "Required", icon: "key") {
SecretTextField(label: "Bot Token", value: viewModel.botToken) { viewModel.botToken = $0 }
EditableTextField(label: "Allowed Users", value: viewModel.allowedUsers) { viewModel.allowedUsers = $0 }
}
SettingsSection(title: "Optional", icon: "slider.horizontal.3") {
EditableTextField(label: "Home Channel", value: viewModel.homeChannel) { viewModel.homeChannel = $0 }
ToggleRow(label: "Require @mention", isOn: viewModel.requireMention) { viewModel.requireMention = $0 }
ToggleRow(label: "Reactions", isOn: viewModel.reactions) { viewModel.reactions = $0 }
}
SettingsSection(title: "Webhook (advanced)", icon: "arrow.up.right.square") {
EditableTextField(label: "Webhook URL", value: viewModel.webhookURL) { viewModel.webhookURL = $0 }
EditableTextField(label: "Webhook Port", value: viewModel.webhookPort) { viewModel.webhookPort = $0 }
SecretTextField(label: "Webhook Secret", value: viewModel.webhookSecret) { viewModel.webhookSecret = $0 }
}
saveBar
}
.onAppear { viewModel.load() }
}
private var instructions: some View {
VStack(alignment: .leading, spacing: 6) {
Text("Create a bot via @BotFather and get your numeric user ID from @userinfobot. Paste the token and your user ID below — the bot will only respond to allowed users.")
.font(.caption)
.foregroundStyle(.secondary)
HStack(spacing: 12) {
Button("Open BotFather") { PlatformSetupHelpers.openURL("https://t.me/BotFather") }
.controlSize(.small)
Button("Telegram Setup Docs") { PlatformSetupHelpers.openURL("https://hermes-agent.nousresearch.com/docs/user-guide/messaging/telegram") }
.controlSize(.small)
}
}
}
private var saveBar: some View {
HStack {
if let msg = viewModel.message {
Label(msg, systemImage: "checkmark.circle.fill")
.font(.caption)
.foregroundStyle(.green)
}
Spacer()
Button("Reload") { viewModel.load() }
.controlSize(.small)
Button("Save") { viewModel.save() }
.buttonStyle(.borderedProminent)
.controlSize(.small)
}
}
}
@@ -0,0 +1,56 @@
import SwiftUI
struct WebhookSetupView: View {
@State private var viewModel = WebhookSetupViewModel()
var body: some View {
VStack(alignment: .leading, spacing: 16) {
instructions
SettingsSection(title: "Global Settings", icon: "arrow.up.right.square") {
ToggleRow(label: "Webhook Enabled", isOn: viewModel.enabled) { viewModel.enabled = $0 }
EditableTextField(label: "Port", value: viewModel.port) { viewModel.port = $0 }
SecretTextField(label: "HMAC Secret", value: viewModel.secret) { viewModel.secret = $0 }
}
HStack(alignment: .top, spacing: 8) {
Image(systemName: "info.circle")
.foregroundStyle(.secondary)
Text("Per-route subscriptions (events, prompt template, delivery target) are managed in the Webhooks sidebar — not here. This panel only controls whether the webhook platform is listening at all.")
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(10)
.background(.quaternary.opacity(0.3))
.clipShape(RoundedRectangle(cornerRadius: 6))
saveBar
}
.onAppear { viewModel.load() }
}
private var instructions: some View {
VStack(alignment: .leading, spacing: 6) {
Text("Enable the webhook platform to accept event-driven agent triggers. The HMAC secret is used as a fallback when individual routes don't provide their own.")
.font(.caption)
.foregroundStyle(.secondary)
HStack {
Button("Webhook Setup Docs") { PlatformSetupHelpers.openURL("https://hermes-agent.nousresearch.com/docs/user-guide/messaging/webhooks") }
.controlSize(.small)
}
}
}
private var saveBar: some View {
HStack {
if let msg = viewModel.message {
Label(msg, systemImage: "checkmark.circle.fill")
.font(.caption)
.foregroundStyle(.green)
}
Spacer()
Button("Reload") { viewModel.load() }.controlSize(.small)
Button("Save") { viewModel.save() }.buttonStyle(.borderedProminent).controlSize(.small)
}
}
}
@@ -0,0 +1,83 @@
import SwiftUI
struct WhatsAppSetupView: View {
@State private var viewModel = WhatsAppSetupViewModel()
var body: some View {
VStack(alignment: .leading, spacing: 16) {
instructions
SettingsSection(title: "Status", icon: "power") {
ToggleRow(label: "WhatsApp Enabled", isOn: viewModel.enabled) { viewModel.enabled = $0 }
PickerRow(label: "Mode", selection: viewModel.mode, options: viewModel.modeOptions) { viewModel.mode = $0 }
}
SettingsSection(title: "Access Control", icon: "person.badge.shield.checkmark") {
ToggleRow(label: "Allow All Users", isOn: viewModel.allowAllUsers) { viewModel.allowAllUsers = $0 }
if !viewModel.allowAllUsers {
EditableTextField(label: "Allowed Numbers", value: viewModel.allowedUsers) { viewModel.allowedUsers = $0 }
}
}
SettingsSection(title: "Behavior", icon: "slider.horizontal.3") {
PickerRow(label: "Unauthorized DM", selection: viewModel.unauthorizedDMBehavior, options: viewModel.unauthorizedOptions) { viewModel.unauthorizedDMBehavior = $0 }
EditableTextField(label: "Reply Prefix", value: viewModel.replyPrefix) { viewModel.replyPrefix = $0 }
}
saveBar
Divider()
pairingSection
}
.onAppear { viewModel.load() }
.onDisappear { viewModel.stopPairing() }
}
private var instructions: some View {
VStack(alignment: .leading, spacing: 6) {
Text("WhatsApp uses the Baileys library to emulate a WhatsApp Web session. Pair this Mac as a linked device by running the pairing wizard and scanning the QR code with your phone (Settings → Linked Devices → Link a Device).")
.font(.caption)
.foregroundStyle(.secondary)
HStack(spacing: 12) {
Button("WhatsApp Setup Docs") { PlatformSetupHelpers.openURL("https://hermes-agent.nousresearch.com/docs/user-guide/messaging/whatsapp") }
.controlSize(.small)
}
}
}
private var saveBar: some View {
HStack {
if let msg = viewModel.message {
Label(msg, systemImage: "checkmark.circle.fill")
.font(.caption)
.foregroundStyle(.green)
}
Spacer()
Button("Reload") { viewModel.load() }.controlSize(.small)
Button("Save") { viewModel.save() }.buttonStyle(.borderedProminent).controlSize(.small)
}
}
private var pairingSection: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Label("Pair Device", systemImage: "qrcode")
.font(.headline)
Spacer()
if viewModel.pairingInProgress {
Button("Stop") { viewModel.stopPairing() }
.controlSize(.small)
} else {
Button("Start Pairing") { viewModel.startPairing() }
.buttonStyle(.borderedProminent)
.controlSize(.small)
}
}
Text("A QR code will appear below. Scan it with WhatsApp on your phone. The session is saved to ~/.hermes/platforms/whatsapp/ so you won't need to scan again after restarts.")
.font(.caption)
.foregroundStyle(.secondary)
EmbeddedSetupTerminal(controller: viewModel.terminalController)
.frame(minHeight: 260, maxHeight: 360)
.clipShape(RoundedRectangle(cornerRadius: 6))
}
}
}
@@ -0,0 +1,165 @@
import SwiftUI
struct PlatformsView: View {
@State private var viewModel = PlatformsViewModel()
@Environment(HermesFileWatcher.self) private var fileWatcher
// HSplitView (not nested NavigationSplitView) because ContentView already
// hosts the outer NavigationSplitView nesting them breaks layout on macOS.
var body: some View {
HSplitView {
platformList
.frame(minWidth: 220, idealWidth: 240, maxWidth: 300)
detail
.frame(minWidth: 480)
}
.navigationTitle("Platforms")
.onAppear { viewModel.load() }
// Re-read config.yaml / .env / gateway_state.json when any of them
// changes on disk. This is how the left-side connectivity dots refresh
// after the user saves in a per-platform setup form.
.onChange(of: fileWatcher.lastChangeDate) { viewModel.load() }
}
private var platformList: some View {
VStack(spacing: 0) {
List(selection: Binding(
get: { viewModel.selected.name },
set: { name in
if let p = viewModel.platforms.first(where: { $0.name == name }) {
viewModel.selected = p
}
}
)) {
ForEach(viewModel.platforms) { platform in
HStack(spacing: 8) {
Image(systemName: KnownPlatforms.icon(for: platform.name))
.frame(width: 20)
Text(platform.displayName)
Spacer()
Circle()
.fill(statusColor(viewModel.connectivity(for: platform)))
.frame(width: 8, height: 8)
}
.tag(platform.name)
}
}
.listStyle(.inset)
Divider()
VStack(spacing: 4) {
Button {
viewModel.restartGateway()
} label: {
Label("Restart Gateway", systemImage: "arrow.triangle.2.circlepath")
.frame(maxWidth: .infinity, alignment: .leading)
}
.buttonStyle(.borderless)
.disabled(viewModel.restartInProgress)
}
.font(.caption)
.padding(.horizontal, 12)
.padding(.vertical, 8)
}
}
@ViewBuilder
private var detail: some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
header
connectivitySection
platformForm
}
.padding()
.frame(maxWidth: .infinity, alignment: .topLeading)
}
.id(viewModel.selected.name) // Force view rebuild when platform changes so per-platform state resets.
}
private var header: some View {
HStack(spacing: 12) {
Image(systemName: KnownPlatforms.icon(for: viewModel.selected.name))
.font(.title)
VStack(alignment: .leading) {
Text(viewModel.selected.displayName)
.font(.title2.bold())
Text(statusDescription(viewModel.connectivity(for: viewModel.selected)))
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
if let msg = viewModel.message {
Label(msg, systemImage: "checkmark.circle.fill")
.font(.caption)
.foregroundStyle(.green)
}
if viewModel.restartInProgress {
ProgressView().controlSize(.small)
}
}
}
private var connectivitySection: some View {
SettingsSection(title: "Connection", icon: "dot.radiowaves.left.and.right") {
let status = viewModel.connectivity(for: viewModel.selected)
ReadOnlyRow(label: "Status", value: statusDescription(status))
if case .error(let msg) = status {
ReadOnlyRow(label: "Error", value: msg)
}
ReadOnlyRow(label: "Configured", value: viewModel.hasConfigBlock(for: viewModel.selected) ? "Yes" : "No")
}
}
/// Dispatch to the right per-platform setup view based on the selection.
/// Each setup view owns its own `@State` view model and handles load/save
/// independently; we don't push state down from this container.
@ViewBuilder
private var platformForm: some View {
switch viewModel.selected.name {
case "cli": cliPanel
case "telegram": TelegramSetupView()
case "discord": DiscordSetupView()
case "slack": SlackSetupView()
case "whatsapp": WhatsAppSetupView()
case "signal": SignalSetupView()
case "email": EmailSetupView()
case "matrix": MatrixSetupView()
case "mattermost": MattermostSetupView()
case "feishu": FeishuSetupView()
case "imessage": IMessageSetupView()
case "homeassistant": HomeAssistantSetupView()
case "webhook": WebhookSetupView()
default:
SettingsSection(title: viewModel.selected.displayName, icon: KnownPlatforms.icon(for: viewModel.selected.name)) {
ReadOnlyRow(label: "Setup", value: "No setup form for this platform yet.")
}
}
}
private var cliPanel: some View {
SettingsSection(title: "CLI", icon: "terminal") {
ReadOnlyRow(label: "Scope", value: "Local terminal sessions")
ReadOnlyRow(label: "Note", value: "CLI uses the main app — no platform-specific config.")
}
}
private func statusColor(_ status: PlatformConnectivity) -> Color {
switch status {
case .connected: return .green
case .configured: return .orange
case .notConfigured: return .secondary.opacity(0.4)
case .error: return .red
}
}
private func statusDescription(_ status: PlatformConnectivity) -> String {
switch status {
case .connected: return "Connected"
case .configured: return "Configured · not running"
case .notConfigured: return "Not configured"
case .error(let msg): return "Error: \(msg)"
}
}
}
@@ -0,0 +1,121 @@
import Foundation
import os
struct HermesPlugin: Identifiable, Sendable, Equatable {
var id: String { name }
let name: String
let source: String // Git URL or `owner/repo` (read from plugin manifest if present)
let enabled: Bool // True unless a `.disabled` marker exists
let version: String // From plugin.json / manifest if present
let path: String // Absolute directory path
}
@Observable
final class PluginsViewModel {
private let logger = Logger(subsystem: "com.scarf", category: "PluginsViewModel")
private let fileService = HermesFileService()
var plugins: [HermesPlugin] = []
var isLoading = false
var message: String?
private var pluginsDir: String { HermesPaths.home + "/plugins" }
/// Source of truth is the `~/.hermes/plugins/` directory. Each plugin is a
/// subdirectory we read its `plugin.json` (if present) for source/version
/// metadata. Parsing `hermes plugins list` box-drawn output is fragile.
func load() {
isLoading = true
defer { isLoading = false }
let fm = FileManager.default
guard let entries = try? fm.contentsOfDirectory(atPath: pluginsDir) else {
plugins = []
return
}
var result: [HermesPlugin] = []
for entry in entries.sorted() where !entry.hasPrefix(".") {
let path = pluginsDir + "/" + entry
var isDir: ObjCBool = false
guard fm.fileExists(atPath: path, isDirectory: &isDir), isDir.boolValue else { continue }
let manifest = Self.readManifest(path: path)
let disabled = fm.fileExists(atPath: path + "/.disabled")
result.append(HermesPlugin(
name: entry,
source: manifest.source,
enabled: !disabled,
version: manifest.version,
path: path
))
}
plugins = result
}
/// Best-effort manifest read. Supports both plugin.json and plugin.yaml shapes.
private static func readManifest(path: String) -> (source: String, version: String) {
let fm = FileManager.default
let jsonPath = path + "/plugin.json"
if fm.fileExists(atPath: jsonPath),
let data = try? Data(contentsOf: URL(fileURLWithPath: jsonPath)),
let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
let source = (obj["source"] as? String) ?? (obj["repository"] as? String) ?? (obj["url"] as? String) ?? ""
let version = (obj["version"] as? String) ?? ""
return (source, version)
}
let yamlPath = path + "/plugin.yaml"
if fm.fileExists(atPath: yamlPath),
let yaml = try? String(contentsOfFile: yamlPath, encoding: .utf8) {
let parsed = HermesFileService.parseNestedYAML(yaml)
let source = HermesFileService.stripYAMLQuotes(parsed.values["source"] ?? parsed.values["repository"] ?? parsed.values["url"] ?? "")
let version = HermesFileService.stripYAMLQuotes(parsed.values["version"] ?? "")
return (source, version)
}
return ("", "")
}
func install(_ identifier: String) {
isLoading = true
message = "Installing \(identifier)"
Task.detached { [fileService] in
let result = fileService.runHermesCLI(args: ["plugins", "install", identifier], timeout: 180)
await MainActor.run {
self.isLoading = false
self.message = result.exitCode == 0 ? "Installed" : "Install failed"
self.load()
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
self?.message = nil
}
}
}
}
func update(_ plugin: HermesPlugin) {
runAndReload(["plugins", "update", plugin.name], success: "Updated")
}
func remove(_ plugin: HermesPlugin) {
runAndReload(["plugins", "remove", plugin.name], success: "Removed")
}
func enable(_ plugin: HermesPlugin) {
runAndReload(["plugins", "enable", plugin.name], success: "Enabled")
}
func disable(_ plugin: HermesPlugin) {
runAndReload(["plugins", "disable", plugin.name], success: "Disabled")
}
private func runAndReload(_ args: [String], success: String) {
Task.detached { [fileService] in
let result = fileService.runHermesCLI(args: args, timeout: 60)
await MainActor.run {
self.message = result.exitCode == 0 ? success : "Failed"
self.load()
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
self?.message = nil
}
}
}
}
}
@@ -0,0 +1,152 @@
import SwiftUI
struct PluginsView: View {
@State private var viewModel = PluginsViewModel()
@State private var installIdentifier = ""
@State private var showInstall = false
@State private var pendingRemove: HermesPlugin?
var body: some View {
VStack(spacing: 0) {
header
Divider()
if viewModel.isLoading && viewModel.plugins.isEmpty {
ProgressView().padding()
} else if viewModel.plugins.isEmpty {
emptyState
} else {
list
}
}
.navigationTitle("Plugins")
.onAppear { viewModel.load() }
.sheet(isPresented: $showInstall) { installSheet }
.confirmationDialog(
pendingRemove.map { "Remove \($0.name)?" } ?? "",
isPresented: Binding(get: { pendingRemove != nil }, set: { if !$0 { pendingRemove = nil } })
) {
Button("Remove", role: .destructive) {
if let plugin = pendingRemove { viewModel.remove(plugin) }
pendingRemove = nil
}
Button("Cancel", role: .cancel) { pendingRemove = nil }
}
}
private var header: some View {
HStack {
if let msg = viewModel.message {
Label(msg, systemImage: "info.circle.fill")
.font(.caption)
.foregroundStyle(.green)
}
Spacer()
Button {
installIdentifier = ""
showInstall = true
} label: {
Label("Install", systemImage: "plus")
}
.controlSize(.small)
Button("Reload") { viewModel.load() }
.controlSize(.small)
}
.padding(.horizontal)
.padding(.vertical, 8)
}
private var emptyState: some View {
VStack(spacing: 8) {
Image(systemName: "app.badge.checkmark")
.font(.largeTitle)
.foregroundStyle(.secondary)
Text("No plugins installed")
.foregroundStyle(.secondary)
Text("Plugins extend hermes with custom tools, providers, or memory backends.")
.font(.caption)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.frame(maxWidth: 400)
Button("Install a Plugin") {
installIdentifier = ""
showInstall = true
}
.buttonStyle(.borderedProminent)
.controlSize(.small)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding()
}
private var list: some View {
ScrollView {
LazyVStack(spacing: 1) {
ForEach(viewModel.plugins) { plugin in
row(plugin)
}
}
.padding()
}
}
private func row(_ plugin: HermesPlugin) -> some View {
HStack(spacing: 12) {
Image(systemName: plugin.enabled ? "app.badge.checkmark.fill" : "app.badge")
.foregroundStyle(plugin.enabled ? .green : .secondary)
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 6) {
Text(plugin.name)
.font(.system(.body, design: .monospaced, weight: .medium))
if !plugin.version.isEmpty {
Text(plugin.version)
.font(.caption2.monospaced())
.foregroundStyle(.secondary)
}
}
if !plugin.source.isEmpty {
Text(plugin.source)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.textSelection(.enabled)
}
}
Spacer()
Button(plugin.enabled ? "Disable" : "Enable") {
if plugin.enabled { viewModel.disable(plugin) } else { viewModel.enable(plugin) }
}
.controlSize(.small)
Button("Update") { viewModel.update(plugin) }
.controlSize(.small)
Button("Remove", role: .destructive) { pendingRemove = plugin }
.controlSize(.small)
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(.quaternary.opacity(0.3))
}
private var installSheet: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Install Plugin")
.font(.headline)
Text("Provide a Git URL (https://github.com/...) or a shorthand like `owner/repo`.")
.font(.caption)
.foregroundStyle(.secondary)
TextField("github.com/owner/plugin-repo or owner/repo", text: $installIdentifier)
.textFieldStyle(.roundedBorder)
.font(.system(.caption, design: .monospaced))
HStack {
Spacer()
Button("Cancel") { showInstall = false }
Button("Install") {
viewModel.install(installIdentifier)
showInstall = false
}
.buttonStyle(.borderedProminent)
.disabled(installIdentifier.trimmingCharacters(in: .whitespaces).isEmpty)
}
}
.padding()
.frame(minWidth: 500, minHeight: 200)
}
}
@@ -0,0 +1,130 @@
import Foundation
import os
struct HermesProfile: Identifiable, Sendable, Equatable {
var id: String { name }
let name: String
let isActive: Bool
let path: String
}
@Observable
final class ProfilesViewModel {
private let logger = Logger(subsystem: "com.scarf", category: "ProfilesViewModel")
private let fileService = HermesFileService()
var profiles: [HermesProfile] = []
var activeName: String = "default"
var isLoading = false
var message: String?
var detailOutput: String = ""
func load() {
isLoading = true
Task.detached { [fileService] in
let result = fileService.runHermesCLI(args: ["profile", "list"], timeout: 20)
let (parsed, active) = Self.parseProfileList(result.output)
await MainActor.run {
self.isLoading = false
self.profiles = parsed
self.activeName = active
}
}
}
func showDetail(_ profile: HermesProfile) {
detailOutput = "Loading…"
Task.detached { [fileService] in
let result = fileService.runHermesCLI(args: ["profile", "show", profile.name], timeout: 15)
await MainActor.run {
self.detailOutput = result.output
}
}
}
func switchTo(_ profile: HermesProfile) {
runAndReload(["profile", "use", profile.name], success: "Active profile set to \(profile.name)")
}
func create(name: String, cloneConfig: Bool, cloneAll: Bool) {
var args = ["profile", "create", name]
if cloneAll { args.append("--clone-all") }
else if cloneConfig { args.append("--clone") }
runAndReload(args, success: "Profile '\(name)' created")
}
func rename(_ profile: HermesProfile, to newName: String) {
runAndReload(["profile", "rename", profile.name, newName], success: "Renamed")
}
func delete(_ profile: HermesProfile) {
runAndReload(["profile", "delete", profile.name], success: "Deleted \(profile.name)")
}
func export(_ profile: HermesProfile, to path: String) {
runAndReload(["profile", "export", profile.name, "--output", path], success: "Exported")
}
func `import`(from path: String) {
runAndReload(["profile", "import", path], success: "Imported")
}
private func runAndReload(_ args: [String], success: String) {
Task.detached { [fileService] in
let result = fileService.runHermesCLI(args: args, timeout: 60)
await MainActor.run {
self.message = result.exitCode == 0 ? success : "Failed: \(result.output.prefix(120))"
self.load()
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
self?.message = nil
}
}
}
}
/// Parse `hermes profile list` output. Hermes emits a box-drawn Rich table:
///
/// Profile Model Gateway Alias
///
/// default running
/// experimental gpt-4 stopped hermes-exp
///
/// Active profiles are prefixed with `` (U+25C6). Columns are separated by
/// whitespace; there are no vertical bars. We ignore box-drawing lines and
/// the header row, then extract the name from column 0 of each data row.
nonisolated private static func parseProfileList(_ output: String) -> (profiles: [HermesProfile], active: String) {
var results: [HermesProfile] = []
var active = "default"
var sawHeader = false
for raw in output.components(separatedBy: "\n") {
let line = raw.trimmingCharacters(in: .whitespaces)
if line.isEmpty { continue }
// Box-drawing separator rows: contain only (U+2500) and whitespace.
if line.unicodeScalars.allSatisfy({ $0.value == 0x2500 || $0.properties.isWhitespace }) { continue }
// Header row (first non-empty, non-separator line in the table).
if !sawHeader && line.lowercased().contains("profile") && line.lowercased().contains("gateway") {
sawHeader = true
continue
}
// Data row. Strip active marker first.
var working = line
var isActive = false
if working.hasPrefix("") {
isActive = true
working = String(working.dropFirst()).trimmingCharacters(in: .whitespaces)
} else if working.hasPrefix("*") {
isActive = true
working = String(working.dropFirst()).trimmingCharacters(in: .whitespaces)
}
let tokens = working.split(whereSeparator: { $0.isWhitespace }).map(String.init)
guard let nameStr = tokens.first else { continue }
// Reject rows whose first token is something like "Tip:" or a localized
// label real profile names are lowercase alphanumeric with - or _.
guard nameStr.range(of: "^[a-zA-Z0-9_-]+$", options: .regularExpression) != nil else { continue }
if isActive { active = nameStr }
results.append(HermesProfile(name: nameStr, isActive: isActive, path: ""))
}
return (results, active)
}
}
@@ -0,0 +1,252 @@
import SwiftUI
import AppKit
import UniformTypeIdentifiers
struct ProfilesView: View {
@State private var viewModel = ProfilesViewModel()
@State private var selected: HermesProfile?
@State private var showCreate = false
@State private var createName = ""
@State private var createCloneConfig = true
@State private var createCloneAll = false
@State private var showRename = false
@State private var renameTarget: HermesProfile?
@State private var renameNewName = ""
@State private var pendingDelete: HermesProfile?
var body: some View {
HSplitView {
listSection
.frame(minWidth: 260, idealWidth: 300)
detailSection
.frame(minWidth: 400)
}
.navigationTitle("Profiles")
.onAppear { viewModel.load() }
.sheet(isPresented: $showCreate) { createSheet }
.sheet(isPresented: Binding(get: { renameTarget != nil }, set: { if !$0 { renameTarget = nil } })) {
renameSheet
}
.confirmationDialog(
pendingDelete.map { "Delete profile '\($0.name)'?" } ?? "",
isPresented: Binding(get: { pendingDelete != nil }, set: { if !$0 { pendingDelete = nil } })
) {
Button("Delete", role: .destructive) {
if let profile = pendingDelete { viewModel.delete(profile) }
pendingDelete = nil
}
Button("Cancel", role: .cancel) { pendingDelete = nil }
} message: {
Text("This removes the profile directory and all data within it. This cannot be undone.")
}
}
private var listSection: some View {
VStack(spacing: 0) {
HStack {
if let msg = viewModel.message {
Label(msg, systemImage: "info.circle")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Button {
createName = ""; createCloneConfig = true; createCloneAll = false
showCreate = true
} label: {
Label("Create", systemImage: "plus")
}
.controlSize(.small)
Button {
let panel = NSOpenPanel()
panel.allowedContentTypes = [.zip]
panel.canChooseFiles = true
panel.canChooseDirectories = false
panel.allowsMultipleSelection = false
if panel.runModal() == .OK, let url = panel.url {
viewModel.import(from: url.path)
}
} label: {
Label("Import", systemImage: "square.and.arrow.down")
}
.controlSize(.small)
}
.padding(.horizontal)
.padding(.vertical, 6)
Divider()
List(selection: Binding(
get: { selected?.id },
set: { id in
if let id, let profile = viewModel.profiles.first(where: { $0.id == id }) {
selected = profile
viewModel.showDetail(profile)
}
}
)) {
ForEach(viewModel.profiles) { profile in
HStack {
Image(systemName: profile.isActive ? "checkmark.circle.fill" : "person.crop.square")
.foregroundStyle(profile.isActive ? .green : .secondary)
Text(profile.name)
.font(.system(.body, design: .monospaced))
Spacer()
if profile.isActive {
Text("active")
.font(.caption2.bold())
.foregroundStyle(.green)
}
}
.tag(profile.id)
.contextMenu {
Button("Use") { viewModel.switchTo(profile) }
.disabled(profile.isActive)
Button("Rename") {
renameTarget = profile
renameNewName = profile.name
}
Button("Export…") {
let panel = NSSavePanel()
panel.allowedContentTypes = [.zip]
panel.nameFieldStringValue = "\(profile.name)-profile.zip"
if panel.runModal() == .OK, let url = panel.url {
viewModel.export(profile, to: url.path)
}
}
Divider()
Button("Delete", role: .destructive) { pendingDelete = profile }
.disabled(profile.isActive)
}
}
}
.listStyle(.inset)
.overlay {
if viewModel.profiles.isEmpty && !viewModel.isLoading {
ContentUnavailableView("No Profiles", systemImage: "person.2.crop.square.stack", description: Text("Create a profile to isolate config and skills."))
}
}
}
}
@ViewBuilder
private var detailSection: some View {
if let profile = selected {
ScrollView {
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 12) {
Image(systemName: "person.crop.square.filled.and.at.rectangle")
.font(.title)
VStack(alignment: .leading) {
Text(profile.name).font(.title2.bold())
Text(profile.isActive ? "Active profile" : "Inactive")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
if !profile.isActive {
Button {
viewModel.switchTo(profile)
} label: {
Label("Switch to This Profile", systemImage: "arrow.triangle.swap")
}
.buttonStyle(.borderedProminent)
.controlSize(.small)
}
}
if !profile.isActive {
profileSwitchWarning
}
SettingsSection(title: "Details", icon: "info.circle") {
if !profile.path.isEmpty {
ReadOnlyRow(label: "Path", value: profile.path)
}
}
if !viewModel.detailOutput.isEmpty {
VStack(alignment: .leading, spacing: 4) {
Text("hermes profile show")
.font(.caption.bold())
.foregroundStyle(.secondary)
Text(viewModel.detailOutput)
.font(.system(.caption, design: .monospaced))
.textSelection(.enabled)
.padding(8)
.frame(maxWidth: .infinity, alignment: .leading)
.background(.quaternary.opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: 6))
}
}
}
.padding()
.frame(maxWidth: .infinity, alignment: .topLeading)
}
} else {
ContentUnavailableView("Select a Profile", systemImage: "person.2.crop.square.stack", description: Text("Choose a profile to inspect."))
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
private var profileSwitchWarning: some View {
HStack(alignment: .top, spacing: 8) {
Image(systemName: "exclamationmark.triangle")
.foregroundStyle(.orange)
Text("Switching the active profile changes the `~/.hermes` directory hermes uses. Restart Scarf after switching so it re-reads from the new profile's files.")
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(10)
.background(.orange.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 6))
}
private var createSheet: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Create Profile").font(.headline)
VStack(alignment: .leading, spacing: 4) {
Text("Name").font(.caption).foregroundStyle(.secondary)
TextField("e.g. experimental", text: $createName)
.textFieldStyle(.roundedBorder)
}
Toggle("Clone config, .env, SOUL.md from active profile", isOn: $createCloneConfig)
.disabled(createCloneAll)
Toggle("Full copy of active profile (all state)", isOn: $createCloneAll)
HStack {
Spacer()
Button("Cancel") { showCreate = false }
Button("Create") {
viewModel.create(name: createName, cloneConfig: createCloneConfig, cloneAll: createCloneAll)
showCreate = false
}
.buttonStyle(.borderedProminent)
.disabled(createName.trimmingCharacters(in: .whitespaces).isEmpty)
}
}
.padding()
.frame(minWidth: 460, minHeight: 240)
}
private var renameSheet: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Rename Profile").font(.headline)
if let target = renameTarget {
VStack(alignment: .leading, spacing: 4) {
Text("New name for '\(target.name)'").font(.caption).foregroundStyle(.secondary)
TextField("new-name", text: $renameNewName)
.textFieldStyle(.roundedBorder)
}
}
HStack {
Spacer()
Button("Cancel") { renameTarget = nil }
Button("Rename") {
if let target = renameTarget {
viewModel.rename(target, to: renameNewName)
}
renameTarget = nil
}
.buttonStyle(.borderedProminent)
.disabled(renameNewName.trimmingCharacters(in: .whitespaces).isEmpty)
}
}
.padding()
.frame(minWidth: 440, minHeight: 180)
}
}
@@ -0,0 +1,94 @@
import Foundation
import AppKit
import os
/// A user-defined shell shortcut that hermes exposes in chat (e.g. `/my_cmd`).
struct HermesQuickCommand: Identifiable, Sendable, Equatable {
var id: String { name }
let name: String
let type: String // "exec" is the only supported type today
let command: String
}
@Observable
final class QuickCommandsViewModel {
private let logger = Logger(subsystem: "com.scarf", category: "QuickCommandsViewModel")
var commands: [HermesQuickCommand] = []
var message: String?
func load() {
guard let yaml = try? String(contentsOfFile: HermesPaths.configYAML, encoding: .utf8) else {
commands = []
return
}
let parsed = HermesFileService.parseNestedYAML(yaml)
// Each quick command is `quick_commands.<name>.type` + `quick_commands.<name>.command`.
var byName: [String: (type: String, command: String)] = [:]
for (key, value) in parsed.values where key.hasPrefix("quick_commands.") {
let parts = key.split(separator: ".", maxSplits: 2, omittingEmptySubsequences: false)
guard parts.count == 3 else { continue }
let name = String(parts[1])
let field = String(parts[2])
var existing = byName[name] ?? (type: "exec", command: "")
let stripped = HermesFileService.stripYAMLQuotes(value)
if field == "type" { existing.type = stripped }
if field == "command" { existing.command = stripped }
byName[name] = existing
}
commands = byName.map { HermesQuickCommand(name: $0.key, type: $0.value.type, command: $0.value.command) }
.sorted { $0.name < $1.name }
}
/// Check for obviously destructive shell strings. Display-only; we do not block.
static func isDangerous(_ command: String) -> Bool {
let lowered = command.lowercased()
let patterns = ["rm -rf /", "rm -rf ~", ":(){", "mkfs", "dd if=", "> /dev/sd", "shutdown", "reboot"]
return patterns.contains { lowered.contains($0) }
}
func addOrUpdate(name: String, command: String) {
guard !name.isEmpty, !command.isEmpty else {
message = "Name and command are required"
return
}
let sanitizedName = name.replacingOccurrences(of: " ", with: "_")
let typeResult = runHermes(["config", "set", "quick_commands.\(sanitizedName).type", "exec"])
let cmdResult = runHermes(["config", "set", "quick_commands.\(sanitizedName).command", command])
if typeResult.exitCode == 0 && cmdResult.exitCode == 0 {
message = "Saved /\(sanitizedName)"
load()
} else {
logger.warning("Failed to save quick command: type=\(typeResult.output) cmd=\(cmdResult.output)")
message = "Save failed"
}
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
self?.message = nil
}
}
/// Removal requires editing config.yaml directly `hermes config set` has no
/// unset for nested keys. Open the file in the editor for manual removal.
func openConfigForRemoval() {
NSWorkspace.shared.open(URL(fileURLWithPath: HermesPaths.configYAML))
}
@discardableResult
private func runHermes(_ arguments: [String]) -> (output: String, exitCode: Int32) {
let process = Process()
process.executableURL = URL(fileURLWithPath: HermesPaths.hermesBinary)
process.arguments = arguments
process.environment = HermesFileService.enrichedEnvironment()
let pipe = Pipe()
process.standardOutput = pipe
process.standardError = Pipe()
do {
try process.run()
process.waitUntilExit()
let data = pipe.fileHandleForReading.readDataToEndOfFile()
return (String(data: data, encoding: .utf8) ?? "", process.terminationStatus)
} catch {
return ("", -1)
}
}
}
@@ -0,0 +1,180 @@
import SwiftUI
struct QuickCommandsView: View {
@State private var viewModel = QuickCommandsViewModel()
@State private var showAddSheet = false
@State private var editTarget: HermesQuickCommand?
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
header
intro
if viewModel.commands.isEmpty {
emptyState
} else {
list
}
}
.padding()
.frame(maxWidth: .infinity, alignment: .topLeading)
}
.navigationTitle("Quick Commands")
.onAppear { viewModel.load() }
.sheet(isPresented: $showAddSheet) {
QuickCommandEditor(initial: nil) { name, cmd in
viewModel.addOrUpdate(name: name, command: cmd)
showAddSheet = false
} onCancel: {
showAddSheet = false
}
}
.sheet(item: $editTarget) { target in
QuickCommandEditor(initial: target) { name, cmd in
viewModel.addOrUpdate(name: name, command: cmd)
editTarget = nil
} onCancel: {
editTarget = nil
}
}
}
private var header: some View {
HStack {
if let msg = viewModel.message {
Label(msg, systemImage: "checkmark.circle.fill")
.font(.caption)
.foregroundStyle(.green)
}
Spacer()
Button {
showAddSheet = true
} label: {
Label("Add Command", systemImage: "plus")
}
.controlSize(.small)
Button("Reload") { viewModel.load() }
.controlSize(.small)
}
}
private var intro: some View {
Text("Quick commands are shell shortcuts hermes exposes in chat as `/command_name`. They live under `quick_commands:` in config.yaml.")
.font(.caption)
.foregroundStyle(.secondary)
}
private var emptyState: some View {
VStack(spacing: 8) {
Image(systemName: "command.square")
.font(.largeTitle)
.foregroundStyle(.secondary)
Text("No quick commands configured")
.foregroundStyle(.secondary)
Button("Add your first command") { showAddSheet = true }
.buttonStyle(.borderedProminent)
.controlSize(.small)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 40)
}
private var list: some View {
VStack(spacing: 1) {
ForEach(viewModel.commands) { cmd in
HStack(spacing: 12) {
Image(systemName: "command.square")
.foregroundStyle(.blue)
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 6) {
Text("/\(cmd.name)")
.font(.system(.body, design: .monospaced, weight: .medium))
if QuickCommandsViewModel.isDangerous(cmd.command) {
Label("dangerous", systemImage: "exclamationmark.triangle.fill")
.font(.caption2.bold())
.foregroundStyle(.red)
}
}
Text(cmd.command)
.font(.caption)
.foregroundStyle(.secondary)
.textSelection(.enabled)
.lineLimit(3)
}
Spacer()
Button("Edit") { editTarget = cmd }
.controlSize(.small)
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(.quaternary.opacity(0.3))
}
HStack {
Spacer()
Button("Remove via config.yaml…") { viewModel.openConfigForRemoval() }
.controlSize(.small)
.foregroundStyle(.secondary)
}
.padding(.top, 8)
}
}
}
/// Inline editor for add/update. Removal requires hand-editing config.yaml because
/// `hermes config set` has no unset primitive for nested keys.
private struct QuickCommandEditor: View {
let initial: HermesQuickCommand?
let onSave: (String, String) -> Void
let onCancel: () -> Void
@State private var name: String
@State private var command: String
init(initial: HermesQuickCommand?, onSave: @escaping (String, String) -> Void, onCancel: @escaping () -> Void) {
self.initial = initial
self.onSave = onSave
self.onCancel = onCancel
_name = State(initialValue: initial?.name ?? "")
_command = State(initialValue: initial?.command ?? "")
}
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text(initial == nil ? "Add Quick Command" : "Edit /\(initial!.name)")
.font(.headline)
VStack(alignment: .leading, spacing: 4) {
Text("Name (no leading slash)")
.font(.caption)
.foregroundStyle(.secondary)
TextField("e.g. deploy", text: $name)
.textFieldStyle(.roundedBorder)
.disabled(initial != nil)
}
VStack(alignment: .leading, spacing: 4) {
Text("Shell Command")
.font(.caption)
.foregroundStyle(.secondary)
TextEditor(text: $command)
.font(.system(.caption, design: .monospaced))
.frame(minHeight: 100)
.padding(4)
.background(.quaternary.opacity(0.3))
.clipShape(RoundedRectangle(cornerRadius: 6))
}
if QuickCommandsViewModel.isDangerous(command) {
Label("Command looks destructive. Double-check before saving.", systemImage: "exclamationmark.triangle.fill")
.font(.caption)
.foregroundStyle(.red)
}
HStack {
Spacer()
Button("Cancel") { onCancel() }
Button("Save") { onSave(name, command) }
.buttonStyle(.borderedProminent)
.disabled(name.trimmingCharacters(in: .whitespaces).isEmpty || command.trimmingCharacters(in: .whitespaces).isEmpty)
}
}
.padding()
.frame(minWidth: 500, minHeight: 320)
}
}
@@ -1,8 +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
@@ -13,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()
@@ -23,12 +28,14 @@ final class SettingsViewModel {
do {
rawConfigYAML = try String(contentsOfFile: HermesPaths.configYAML, encoding: .utf8)
} catch {
print("[Scarf] Failed to read config.yaml: \(error.localizedDescription)")
logger.error("Failed to read config.yaml: \(error.localizedDescription)")
rawConfigYAML = ""
}
personalities = parsePersonalities()
}
/// Set a scalar config value via `hermes config set <key> <value>` and reload
/// the config on success so the UI reflects the new state.
func setSetting(_ key: String, value: String) {
let result = runHermes(["config", "set", key, value])
if result.exitCode == 0 {
@@ -37,40 +44,239 @@ 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 removeAuth() {
let result = runHermes(["auth", "remove"])
if result.exitCode == 0 {
saveMessage = "Credentials removed"
/// 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 {
saveMessage = "Failed to remove credentials"
setSetting("memory.provider", value: value)
}
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
self?.saveMessage = nil
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
func runBackup() {
backupInProgress = true
Task.detached { [fileService] in
let result = fileService.runHermesCLI(args: ["backup"], timeout: 300)
let zipPath = Self.extractZipPath(from: result.output)
await MainActor.run {
self.backupInProgress = false
if result.exitCode == 0 {
if let zipPath {
NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: zipPath)])
self.saveMessage = "Backup saved"
} else {
self.saveMessage = "Backup complete"
}
} else {
self.saveMessage = "Backup failed"
}
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
self?.saveMessage = nil
}
}
}
}
func runRestore(from url: URL) {
backupInProgress = true
Task.detached { [fileService] in
let result = fileService.runHermesCLI(args: ["import", url.path], timeout: 300)
await MainActor.run {
self.backupInProgress = false
self.saveMessage = result.exitCode == 0 ? "Restore complete — restart Scarf" : "Restore failed"
if result.exitCode == 0 {
self.load()
}
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
self?.saveMessage = nil
}
}
}
}
/// Pull the first absolute `.zip` path out of `hermes backup` stdout.
/// Hermes prints a line like "Backup saved to /Users/foo/.hermes-backups/hermes-2026-04-14.zip (5.4 MB)".
nonisolated static func extractZipPath(from output: String) -> String? {
let pattern = #"(/[^\s]+\.zip)"#
guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil }
let range = NSRange(output.startIndex..., in: output)
guard let match = regex.firstMatch(in: output, range: range),
let r = Range(match.range(at: 1), in: output) else { return nil }
return String(output[r])
}
func presentRestorePicker() -> URL? {
let panel = NSOpenPanel()
panel.allowedContentTypes = [.zip]
panel.canChooseFiles = true
panel.canChooseDirectories = false
panel.allowsMultipleSelection = false
panel.message = "Choose a Hermes backup archive to restore"
guard panel.runModal() == .OK, let url = panel.url else { return nil }
return url
}
func openConfigInEditor() {
NSWorkspace.shared.open(URL(fileURLWithPath: HermesPaths.configYAML))
}
@@ -105,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()
@@ -114,6 +321,7 @@ final class SettingsViewModel {
let data = pipe.fileHandleForReading.readDataToEndOfFile()
return (String(data: data, encoding: .utf8) ?? "", process.terminationStatus)
} catch {
logger.error("Failed to run hermes \(arguments.joined(separator: " ")): \(error.localizedDescription)")
return ("", -1)
}
}
@@ -0,0 +1,73 @@
import SwiftUI
/// Row-style model picker that mirrors the visual style of `PickerRow`/`EditableTextField`
/// but opens a dedicated sheet browsing providers + models from the catalog.
///
/// The caller receives (modelID, providerID) and decides how to persist them
/// Settings General saves both; Delegation saves both to its own keys; aux
/// fields that only take a model can ignore the provider parameter.
struct ModelPickerRow: View {
let label: String
let currentModel: String
let currentProvider: String
let onChange: (_ modelID: String, _ providerID: String) -> Void
@State private var showSheet = false
var body: some View {
HStack {
Text(label)
.font(.caption)
.foregroundStyle(.secondary)
.frame(width: 160, alignment: .trailing)
Button {
showSheet = true
} label: {
HStack(spacing: 6) {
Image(systemName: "cpu")
Text(displayValue)
.font(.system(.caption, design: .monospaced))
Image(systemName: "chevron.down")
.font(.caption2)
.foregroundStyle(.secondary)
}
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(.quaternary.opacity(0.4))
.clipShape(RoundedRectangle(cornerRadius: 5))
}
.buttonStyle(.plain)
Spacer()
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(.quaternary.opacity(0.3))
.sheet(isPresented: $showSheet) {
ModelPickerSheet(
initialProvider: currentProvider,
initialModel: currentModel,
onSelect: { modelID, providerID in
onChange(modelID, providerID)
showSheet = false
},
onCancel: { showSheet = false }
)
}
}
/// Format as "<provider> / <model>" when both are known; fall back to
/// whichever side exists; fall back to a dim "Select model" placeholder
/// when nothing has been set yet.
private var displayValue: String {
let hasProvider = !currentProvider.isEmpty && currentProvider != "unknown"
let hasModel = !currentModel.isEmpty && currentModel != "unknown"
switch (hasProvider, hasModel) {
case (true, true): return "\(currentProvider) / \(currentModel)"
case (false, true): return currentModel
case (true, false): return "\(currentProvider) / (none)"
case (false, false): return "Select model…"
}
}
}
@@ -0,0 +1,265 @@
import SwiftUI
/// Two-column model browser sheet. Left column lists providers, right column
/// lists models for the selected provider. Supports filtering and a "Custom"
/// option for free-form model IDs not in the catalog.
struct ModelPickerSheet: View {
let initialProvider: String
let initialModel: String
let onSelect: (_ modelID: String, _ providerID: String) -> Void
let onCancel: () -> Void
@State private var providers: [HermesProviderInfo] = []
@State private var selectedProviderID: String = ""
@State private var models: [HermesModelInfo] = []
@State private var selectedModelID: String = ""
@State private var searchText: String = ""
// Custom model entry used when the catalog doesn't have the exact model
// the user needs (e.g., provider-prefixed IDs like "openrouter/some/model").
@State private var customMode: Bool = false
@State private var customModelID: String = ""
@State private var customProviderID: String = ""
private let catalog = ModelCatalogService()
var body: some View {
VStack(spacing: 0) {
header
Divider()
if customMode {
customEntry
} else {
HSplitView {
providerColumn.frame(minWidth: 220, idealWidth: 240)
modelColumn.frame(minWidth: 340)
}
}
Divider()
footer
}
.frame(minWidth: 720, minHeight: 520)
.onAppear {
providers = catalog.loadProviders()
selectedProviderID = initialProvider.isEmpty ? (providers.first?.providerID ?? "") : initialProvider
selectedModelID = initialModel
loadModelsForSelection()
}
}
private var header: some View {
HStack(spacing: 8) {
Image(systemName: "cpu")
Text("Select Model")
.font(.headline)
Spacer()
if !customMode {
TextField("Search…", text: $searchText)
.textFieldStyle(.roundedBorder)
.frame(width: 240)
}
Button(customMode ? "Back to Catalog" : "Custom…") {
customMode.toggle()
if customMode {
customModelID = initialModel
customProviderID = initialProvider
}
}
.controlSize(.small)
}
.padding()
}
private var providerColumn: some View {
List(selection: Binding(
get: { selectedProviderID },
set: { newValue in
selectedProviderID = newValue
loadModelsForSelection()
}
)) {
ForEach(filteredProviders) { provider in
HStack {
Text(provider.providerName)
Spacer()
Text("\(provider.modelCount)")
.font(.caption2.monospaced())
.foregroundStyle(.secondary)
}
.tag(provider.providerID)
}
}
.listStyle(.inset)
}
private var modelColumn: some View {
List(selection: $selectedModelID) {
ForEach(filteredModels) { model in
VStack(alignment: .leading, spacing: 2) {
HStack {
Text(model.modelName)
.font(.system(.body, design: .default, weight: .medium))
Spacer()
if let ctx = model.contextDisplay {
Text(ctx + " ctx")
.font(.caption2.monospaced())
.foregroundStyle(.secondary)
}
}
HStack(spacing: 6) {
Text(model.modelID)
.font(.caption2.monospaced())
.foregroundStyle(.tertiary)
if let cost = model.costDisplay {
Text("·")
.foregroundStyle(.tertiary)
Text(cost)
.font(.caption2.monospaced())
.foregroundStyle(.secondary)
}
if model.toolCall {
capsuleTag("tools")
}
if model.reasoning {
capsuleTag("reasoning")
}
}
}
.padding(.vertical, 2)
.tag(model.modelID)
}
}
.listStyle(.inset)
.overlay {
if filteredModels.isEmpty {
ContentUnavailableView("No Models", systemImage: "cpu", description: Text("This provider has no catalogued models."))
}
}
}
private var customEntry: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Use a model not in the catalog. Hermes accepts any string the provider recognizes, including provider-prefixed forms like \"openrouter/anthropic/claude-opus-4.6\".")
.font(.caption)
.foregroundStyle(.secondary)
VStack(alignment: .leading, spacing: 4) {
Text("Model ID").font(.caption).foregroundStyle(.secondary)
TextField("e.g. openai/gpt-4o", text: $customModelID)
.textFieldStyle(.roundedBorder)
.font(.system(.caption, design: .monospaced))
}
VStack(alignment: .leading, spacing: 4) {
Text("Provider").font(.caption).foregroundStyle(.secondary)
TextField("e.g. openai", text: $customProviderID)
.textFieldStyle(.roundedBorder)
.font(.system(.caption, design: .monospaced))
Text("Leave blank to infer from the model ID's prefix (\"openai/...\" → openai).")
.font(.caption2)
.foregroundStyle(.tertiary)
}
Spacer()
}
.padding()
}
private var footer: some View {
HStack {
if customMode {
Text(customProviderPreview)
.font(.caption2)
.foregroundStyle(.secondary)
} else if let preview = selectedPreview {
Text(preview)
.font(.caption2)
.foregroundStyle(.secondary)
}
Spacer()
Button("Cancel") { onCancel() }
Button("Select") { submitSelection() }
.buttonStyle(.borderedProminent)
.disabled(!canSubmit)
}
.padding()
}
// MARK: - Helpers
private var filteredProviders: [HermesProviderInfo] {
guard !searchText.isEmpty else { return providers }
let q = searchText.lowercased()
return providers.filter {
$0.providerName.lowercased().contains(q) || $0.providerID.lowercased().contains(q)
}
}
private var filteredModels: [HermesModelInfo] {
guard !searchText.isEmpty else { return models }
let q = searchText.lowercased()
return models.filter {
$0.modelName.lowercased().contains(q) || $0.modelID.lowercased().contains(q)
}
}
private var canSubmit: Bool {
if customMode {
return !customModelID.trimmingCharacters(in: .whitespaces).isEmpty
}
return !selectedModelID.isEmpty
}
private var selectedPreview: String? {
guard !selectedModelID.isEmpty, !selectedProviderID.isEmpty else { return nil }
return "\(selectedProviderID) / \(selectedModelID)"
}
private var customProviderPreview: String {
let resolved = resolvedCustomProvider()
return resolved.isEmpty ? "Provider will not be changed" : "Provider → \(resolved)"
}
private func loadModelsForSelection() {
guard !selectedProviderID.isEmpty else {
models = []
return
}
models = catalog.loadModels(for: selectedProviderID)
// If the current selection is not in the new list, don't try to keep
// stale highlight state clear unless the user originally had this model.
if !models.contains(where: { $0.modelID == selectedModelID }) {
selectedModelID = models.first?.modelID ?? ""
}
}
/// When the user enters a custom model ID without explicitly naming a
/// provider, infer from a `provider/model` prefix if present. Otherwise
/// fall back to whatever is currently selected (we never blank out the
/// existing provider silently).
private func resolvedCustomProvider() -> String {
let explicit = customProviderID.trimmingCharacters(in: .whitespaces)
if !explicit.isEmpty { return explicit }
if let slash = customModelID.firstIndex(of: "/") {
return String(customModelID[customModelID.startIndex..<slash])
}
return ""
}
private func submitSelection() {
if customMode {
let model = customModelID.trimmingCharacters(in: .whitespaces)
let provider = resolvedCustomProvider()
onSelect(model, provider)
} else {
onSelect(selectedModelID, selectedProviderID)
}
}
private func capsuleTag(_ text: String) -> some View {
Text(text)
.font(.caption2)
.foregroundStyle(.secondary)
.padding(.horizontal, 5)
.padding(.vertical, 1)
.background(.quaternary)
.clipShape(Capsule())
}
}
@@ -0,0 +1,291 @@
import SwiftUI
import AppKit
/// Shared form-row components used across the Settings tabs. Extracting these keeps
/// individual tab views small and avoids triggering SwiftUI's type-checker timeout
/// on large view bodies (per project guidance in CLAUDE.md).
struct SettingsSection<Content: View>: View {
let title: String
let icon: String
@ViewBuilder let content: Content
var body: some View {
VStack(alignment: .leading, spacing: 10) {
Label(title, systemImage: icon)
.font(.headline)
VStack(spacing: 1) {
content
}
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
}
struct EditableTextField: View {
let label: String
let value: String
let onCommit: (String) -> Void
@State private var text: String = ""
@State private var isEditing = false
var body: some View {
HStack {
Text(label)
.font(.caption)
.foregroundStyle(.secondary)
.frame(width: 160, alignment: .trailing)
if isEditing {
TextField(label, text: $text, onCommit: {
if text != value { onCommit(text) }
isEditing = false
})
.textFieldStyle(.roundedBorder)
.font(.system(.caption, design: .monospaced))
Button("Cancel") { isEditing = false }
.controlSize(.mini)
} else {
Text(value.isEmpty ? "" : value)
.font(.system(.caption, design: .monospaced))
.foregroundStyle(value.isEmpty ? .secondary : .primary)
Spacer()
Button("Edit") {
text = value
isEditing = true
}
.controlSize(.mini)
}
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(.quaternary.opacity(0.3))
}
}
/// Masked text field for API keys, tokens, etc. Shows until the user taps reveal.
struct SecretTextField: View {
let label: String
let value: String
let onCommit: (String) -> Void
@State private var text: String = ""
@State private var isEditing = false
@State private var isRevealed = false
var body: some View {
HStack {
Text(label)
.font(.caption)
.foregroundStyle(.secondary)
.frame(width: 160, alignment: .trailing)
if isEditing {
TextField(label, text: $text, onCommit: {
if text != value { onCommit(text) }
isEditing = false
isRevealed = false
})
.textFieldStyle(.roundedBorder)
.font(.system(.caption, design: .monospaced))
Button("Cancel") {
isEditing = false
isRevealed = false
}
.controlSize(.mini)
} else {
Text(displayValue)
.font(.system(.caption, design: .monospaced))
.foregroundStyle(value.isEmpty ? .secondary : .primary)
Spacer()
if !value.isEmpty {
Button(isRevealed ? "Hide" : "Reveal") { isRevealed.toggle() }
.controlSize(.mini)
}
Button("Edit") {
text = value
isEditing = true
}
.controlSize(.mini)
}
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(.quaternary.opacity(0.3))
}
private var displayValue: String {
if value.isEmpty { return "" }
if isRevealed { return value }
let tail = value.suffix(4)
return String(repeating: "", count: max(0, min(12, value.count - 4))) + tail
}
}
struct PickerRow: View {
let label: String
let selection: String
let options: [String]
let onChange: (String) -> Void
var body: some View {
HStack {
Text(label)
.font(.caption)
.foregroundStyle(.secondary)
.frame(width: 160, alignment: .trailing)
Picker("", selection: Binding(
get: { selection },
set: { onChange($0) }
)) {
ForEach(options, id: \.self) { option in
Text(option.isEmpty ? "(none)" : option).tag(option)
}
}
.frame(maxWidth: 250)
Spacer()
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(.quaternary.opacity(0.3))
}
}
struct ToggleRow: View {
let label: String
let isOn: Bool
let onChange: (Bool) -> Void
var body: some View {
HStack {
Text(label)
.font(.caption)
.foregroundStyle(.secondary)
.frame(width: 160, alignment: .trailing)
Toggle("", isOn: Binding(
get: { isOn },
set: { onChange($0) }
))
.toggleStyle(.switch)
.labelsHidden()
Spacer()
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(.quaternary.opacity(0.3))
}
}
struct StepperRow: View {
let label: String
let value: Int
let range: ClosedRange<Int>
let step: Int
let onChange: (Int) -> Void
init(label: String, value: Int, range: ClosedRange<Int>, step: Int = 1, onChange: @escaping (Int) -> Void) {
self.label = label
self.value = value
self.range = range
self.step = step
self.onChange = onChange
}
var body: some View {
HStack {
Text(label)
.font(.caption)
.foregroundStyle(.secondary)
.frame(width: 160, alignment: .trailing)
Text("\(value)")
.font(.system(.caption, design: .monospaced))
.frame(width: 70, alignment: .leading)
Stepper("", value: Binding(
get: { value },
set: { onChange($0) }
), in: range, step: step)
.labelsHidden()
Spacer()
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(.quaternary.opacity(0.3))
}
}
/// Double stepper that increments by a fractional step (e.g. 0.05 for thresholds).
struct DoubleStepperRow: View {
let label: String
let value: Double
let range: ClosedRange<Double>
let step: Double
let onChange: (Double) -> Void
var body: some View {
HStack {
Text(label)
.font(.caption)
.foregroundStyle(.secondary)
.frame(width: 160, alignment: .trailing)
Text(String(format: "%.2f", value))
.font(.system(.caption, design: .monospaced))
.frame(width: 70, alignment: .leading)
Stepper("", value: Binding(
get: { value },
set: { onChange($0) }
), in: range, step: step)
.labelsHidden()
Spacer()
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(.quaternary.opacity(0.3))
}
}
struct ReadOnlyRow: View {
let label: String
let value: String
var body: some View {
HStack {
Text(label)
.font(.caption)
.foregroundStyle(.secondary)
.frame(width: 160, alignment: .trailing)
Text(value.isEmpty ? "" : value)
.font(.system(.caption, design: .monospaced))
.foregroundStyle(value.isEmpty ? .secondary : .primary)
.textSelection(.enabled)
Spacer()
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(.quaternary.opacity(0.3))
}
}
struct PathRow: View {
let label: String
let path: String
var body: some View {
HStack {
Text(label)
.font(.caption)
.foregroundStyle(.secondary)
.frame(width: 160, alignment: .trailing)
Text(path)
.font(.system(.caption, design: .monospaced))
.textSelection(.enabled)
Spacer()
Button {
NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: path)
} label: {
Image(systemName: "folder")
.font(.caption)
}
.buttonStyle(.plain)
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(.quaternary.opacity(0.3))
}
}
@@ -1,38 +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
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 {
@@ -48,325 +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: "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) }
}
}
// MARK: - Paths
private var pathsSection: some View {
SettingsSection(title: "Paths", icon: "folder") {
PathRow(label: "Hermes Home", path: HermesPaths.home)
PathRow(label: "State DB", path: HermesPaths.stateDB)
PathRow(label: "Config", path: HermesPaths.configYAML)
PathRow(label: "Memory", path: HermesPaths.memoriesDir)
PathRow(label: "Sessions", path: HermesPaths.sessionsDir)
PathRow(label: "Skills", path: HermesPaths.skillsDir)
PathRow(label: "Agent Log", path: HermesPaths.agentLog)
PathRow(label: "Error Log", path: HermesPaths.errorsLog)
}
}
// MARK: - Raw Config
private var rawConfigSection: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text("Raw Config")
.font(.headline)
Button(showRawConfig ? "Hide" : "Show") {
showRawConfig.toggle()
}
.controlSize(.small)
}
if showRawConfig {
Text(viewModel.rawConfigYAML)
.font(.system(.caption, design: .monospaced))
.textSelection(.enabled)
.padding(8)
.frame(maxWidth: .infinity, alignment: .leading)
.background(.quaternary.opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: 6))
}
@ViewBuilder
private func tabContent(_ tab: SettingsTab) -> some View {
switch tab {
case .general: GeneralTab(viewModel: viewModel)
case .display: DisplayTab(viewModel: viewModel)
case .agent: AgentTab(viewModel: viewModel)
case .terminal: TerminalTab(viewModel: viewModel)
case .browser: BrowserTab(viewModel: viewModel)
case .voice: VoiceTab(viewModel: viewModel)
case .memory: MemoryTab(viewModel: viewModel)
case .auxiliary: AuxiliaryTab(viewModel: viewModel)
case .security: SecurityTab(viewModel: viewModel)
case .advanced: AdvancedTab(viewModel: viewModel)
}
}
}
// MARK: - Reusable Components
struct SettingsSection<Content: View>: View {
let title: String
let icon: String
@ViewBuilder let content: Content
var body: some View {
VStack(alignment: .leading, spacing: 10) {
Label(title, systemImage: icon)
.font(.headline)
VStack(spacing: 1) {
content
}
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
}
struct EditableTextField: View {
let label: String
let value: String
let onCommit: (String) -> Void
@State private var text: String = ""
@State private var isEditing = false
var body: some View {
HStack {
Text(label)
.font(.caption)
.foregroundStyle(.secondary)
.frame(width: 130, alignment: .trailing)
if isEditing {
TextField(label, text: $text, onCommit: {
if text != value { onCommit(text) }
isEditing = false
})
.textFieldStyle(.roundedBorder)
.font(.system(.caption, design: .monospaced))
Button("Cancel") { isEditing = false }
.controlSize(.mini)
} else {
Text(value)
.font(.system(.caption, design: .monospaced))
Spacer()
Button("Edit") {
text = value
isEditing = true
}
.controlSize(.mini)
}
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(.quaternary.opacity(0.3))
}
}
struct PickerRow: View {
let label: String
let selection: String
let options: [String]
let onChange: (String) -> Void
var body: some View {
HStack {
Text(label)
.font(.caption)
.foregroundStyle(.secondary)
.frame(width: 130, alignment: .trailing)
Picker("", selection: Binding(
get: { selection },
set: { onChange($0) }
)) {
ForEach(options, id: \.self) { option in
Text(option).tag(option)
}
}
.frame(maxWidth: 250)
Spacer()
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(.quaternary.opacity(0.3))
}
}
struct ToggleRow: View {
let label: String
let isOn: Bool
let onChange: (Bool) -> Void
var body: some View {
HStack {
Text(label)
.font(.caption)
.foregroundStyle(.secondary)
.frame(width: 130, alignment: .trailing)
Toggle("", isOn: Binding(
get: { isOn },
set: { onChange($0) }
))
.toggleStyle(.switch)
.labelsHidden()
Spacer()
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(.quaternary.opacity(0.3))
}
}
struct StepperRow: View {
let label: String
let value: Int
let range: ClosedRange<Int>
let onChange: (Int) -> Void
var body: some View {
HStack {
Text(label)
.font(.caption)
.foregroundStyle(.secondary)
.frame(width: 130, alignment: .trailing)
Text("\(value)")
.font(.system(.caption, design: .monospaced))
.frame(width: 50)
Stepper("", value: Binding(
get: { value },
set: { onChange($0) }
), in: range)
.labelsHidden()
Spacer()
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(.quaternary.opacity(0.3))
}
}
struct ReadOnlyRow: View {
let label: String
let value: String
var body: some View {
HStack {
Text(label)
.font(.caption)
.foregroundStyle(.secondary)
.frame(width: 130, alignment: .trailing)
Text(value)
.font(.system(.caption, design: .monospaced))
.textSelection(.enabled)
Spacer()
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(.quaternary.opacity(0.3))
}
}
struct PathRow: View {
let label: String
let path: String
var body: some View {
HStack {
Text(label)
.font(.caption)
.foregroundStyle(.secondary)
.frame(width: 130, alignment: .trailing)
Text(path)
.font(.system(.caption, design: .monospaced))
.textSelection(.enabled)
Spacer()
Button {
NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: path)
} label: {
Image(systemName: "folder")
.font(.caption)
}
.buttonStyle(.plain)
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(.quaternary.opacity(0.3))
}
}
@@ -0,0 +1,178 @@
import SwiftUI
/// Advanced tab network, compression, checkpoints, logging, delegation, file read cap,
/// cron wrap, config diagnostics, backup/restore, paths, raw config.
struct AdvancedTab: View {
@Bindable var viewModel: SettingsViewModel
@State private var showRawConfig = false
@State private var showRestoreConfirm = false
@State private var pendingRestoreURL: URL?
@State private var diagnosticsOutput: String = ""
@State private var showDiagnostics = false
var body: some View {
SettingsSection(title: "Network", icon: "network") {
ToggleRow(label: "Force IPv4", isOn: viewModel.config.forceIPv4) { viewModel.setForceIPv4($0) }
}
SettingsSection(title: "Context & Compression", icon: "arrow.down.right.and.arrow.up.left") {
ReadOnlyRow(label: "Context Engine", value: viewModel.config.contextEngine)
StepperRow(label: "File Read Max", value: viewModel.config.fileReadMaxChars, range: 1000...1_000_000, step: 1000) { viewModel.setFileReadMaxChars($0) }
ToggleRow(label: "Compression Enabled", isOn: viewModel.config.compression.enabled) { viewModel.setCompressionEnabled($0) }
DoubleStepperRow(label: "Threshold", value: viewModel.config.compression.threshold, range: 0.1...1.0, step: 0.05) { viewModel.setCompressionThreshold($0) }
DoubleStepperRow(label: "Target Ratio", value: viewModel.config.compression.targetRatio, range: 0.05...0.9, step: 0.05) { viewModel.setCompressionTargetRatio($0) }
StepperRow(label: "Protect Last N", value: viewModel.config.compression.protectLastN, range: 0...100) { viewModel.setCompressionProtectLastN($0) }
}
SettingsSection(title: "Checkpoints", icon: "clock.arrow.circlepath") {
ToggleRow(label: "Enabled", isOn: viewModel.config.checkpoints.enabled) { viewModel.setCheckpointsEnabled($0) }
StepperRow(label: "Max Snapshots", value: viewModel.config.checkpoints.maxSnapshots, range: 1...500, step: 5) { viewModel.setCheckpointsMaxSnapshots($0) }
}
SettingsSection(title: "Logging", icon: "doc.text") {
PickerRow(label: "Level", selection: viewModel.config.logging.level, options: ["DEBUG", "INFO", "WARNING", "ERROR"]) { viewModel.setLoggingLevel($0) }
StepperRow(label: "Max Size (MB)", value: viewModel.config.logging.maxSizeMB, range: 1...100) { viewModel.setLoggingMaxSizeMB($0) }
StepperRow(label: "Backup Count", value: viewModel.config.logging.backupCount, range: 0...20) { viewModel.setLoggingBackupCount($0) }
}
SettingsSection(title: "Delegation", icon: "arrow.triangle.branch") {
// Delegation has its own model/provider pair (tasks spawned by the
// agent use this instead of the main model). The picker keeps the
// two in sync just like Settings General.
ModelPickerRow(
label: "Model",
currentModel: viewModel.config.delegation.model,
currentProvider: viewModel.config.delegation.provider
) { modelID, providerID in
viewModel.setDelegationModel(modelID)
if !providerID.isEmpty {
viewModel.setDelegationProvider(providerID)
}
}
ReadOnlyRow(label: "Provider", value: viewModel.config.delegation.provider)
EditableTextField(label: "Base URL", value: viewModel.config.delegation.baseURL) { viewModel.setDelegationBaseURL($0) }
StepperRow(label: "Max Iterations", value: viewModel.config.delegation.maxIterations, range: 1...500, step: 5) { viewModel.setDelegationMaxIterations($0) }
}
SettingsSection(title: "Cron", icon: "clock") {
ToggleRow(label: "Wrap Response", isOn: viewModel.config.cronWrapResponse) { viewModel.setCronWrapResponse($0) }
}
SettingsSection(title: "Config Diagnostics", icon: "stethoscope") {
HStack {
Text("Actions")
.font(.caption)
.foregroundStyle(.secondary)
.frame(width: 160, alignment: .trailing)
Button("Check") {
diagnosticsOutput = viewModel.runConfigCheck()
showDiagnostics = true
}
.controlSize(.small)
Button("Migrate") {
diagnosticsOutput = viewModel.runConfigMigrate()
showDiagnostics = true
}
.controlSize(.small)
Spacer()
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(.quaternary.opacity(0.3))
if showDiagnostics {
Text(diagnosticsOutput.isEmpty ? "(no output)" : diagnosticsOutput)
.font(.system(.caption, design: .monospaced))
.textSelection(.enabled)
.padding(8)
.frame(maxWidth: .infinity, alignment: .leading)
.background(.quaternary.opacity(0.5))
}
}
backupSection
pathsSection
rawConfigSection
}
private var backupSection: some View {
SettingsSection(title: "Backup & Restore", icon: "externaldrive") {
HStack {
Text("Archive")
.font(.caption)
.foregroundStyle(.secondary)
.frame(width: 160, alignment: .trailing)
Button {
viewModel.runBackup()
} label: {
Label("Backup Now", systemImage: "arrow.down.doc")
}
.controlSize(.small)
.disabled(viewModel.backupInProgress)
Button {
if let url = viewModel.presentRestorePicker() {
pendingRestoreURL = url
showRestoreConfirm = true
}
} label: {
Label("Restore…", systemImage: "arrow.up.doc")
}
.controlSize(.small)
.disabled(viewModel.backupInProgress)
if viewModel.backupInProgress {
ProgressView().controlSize(.small)
}
Spacer()
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(.quaternary.opacity(0.3))
}
.confirmationDialog("Restore from backup?", isPresented: $showRestoreConfirm) {
Button("Restore", role: .destructive) {
if let url = pendingRestoreURL {
viewModel.runRestore(from: url)
}
pendingRestoreURL = nil
}
Button("Cancel", role: .cancel) { pendingRestoreURL = nil }
} message: {
Text("This will overwrite files under ~/.hermes/ with the archive contents.")
}
}
private var pathsSection: some View {
SettingsSection(title: "Paths", icon: "folder") {
PathRow(label: "Hermes Home", path: HermesPaths.home)
PathRow(label: "State DB", path: HermesPaths.stateDB)
PathRow(label: "Config", path: HermesPaths.configYAML)
PathRow(label: "Memory", path: HermesPaths.memoriesDir)
PathRow(label: "Sessions", path: HermesPaths.sessionsDir)
PathRow(label: "Skills", path: HermesPaths.skillsDir)
PathRow(label: "Agent Log", path: HermesPaths.agentLog)
PathRow(label: "Error Log", path: HermesPaths.errorsLog)
}
}
private var rawConfigSection: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text("Raw Config")
.font(.headline)
Button(showRawConfig ? "Hide" : "Show") {
showRawConfig.toggle()
}
.controlSize(.small)
}
if showRawConfig {
Text(viewModel.rawConfigYAML)
.font(.system(.caption, design: .monospaced))
.textSelection(.enabled)
.padding(8)
.frame(maxWidth: .infinity, alignment: .leading)
.background(.quaternary.opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: 6))
}
}
}
}
@@ -0,0 +1,27 @@
import SwiftUI
/// Agent tab turns, reasoning effort, tool use enforcement, approvals, gateway timing, service tier.
struct AgentTab: View {
@Bindable var viewModel: SettingsViewModel
var body: some View {
SettingsSection(title: "Turns & Reasoning", icon: "arrow.2.circlepath") {
StepperRow(label: "Max Turns", value: viewModel.config.maxTurns, range: 1...200) { viewModel.setMaxTurns($0) }
PickerRow(label: "Reasoning Effort", selection: viewModel.config.reasoningEffort, options: ["none", "minimal", "low", "medium", "high", "xhigh"]) { viewModel.setReasoningEffort($0) }
PickerRow(label: "Tool Use Enforcement", selection: viewModel.config.toolUseEnforcement, options: ["auto", "true", "false"]) { viewModel.setToolUseEnforcement($0) }
}
SettingsSection(title: "Approvals", icon: "checkmark.shield") {
PickerRow(label: "Approval Mode", selection: viewModel.config.approvalMode, options: ["auto", "manual", "smart", "off"]) { viewModel.setApprovalMode($0) }
StepperRow(label: "Approval Timeout (s)", value: viewModel.config.approvalTimeout, range: 5...600, step: 5) { viewModel.setApprovalTimeout($0) }
}
SettingsSection(title: "Gateway", icon: "antenna.radiowaves.left.and.right") {
ToggleRow(label: "Fast Mode", isOn: viewModel.config.serviceTier == "fast") { on in
viewModel.setServiceTier(on ? "fast" : "normal")
}
StepperRow(label: "Gateway Timeout (s)", value: viewModel.config.gatewayTimeout, range: 60...7200, step: 60) { viewModel.setGatewayTimeout($0) }
StepperRow(label: "Notify Interval (s)", value: viewModel.config.gatewayNotifyInterval, range: 0...3600, step: 30) { viewModel.setGatewayNotifyInterval($0) }
}
}
}
@@ -0,0 +1,56 @@
import SwiftUI
/// Auxiliary tab the 8 sub-model tasks hermes delegates to cheaper models.
/// Each follows the same provider/model/base_url/api_key/timeout pattern.
struct AuxiliaryTab: View {
@Bindable var viewModel: SettingsViewModel
// Keyed by the config path name matches `auxiliary.<task>.*` in config.yaml.
private let tasks: [(key: String, title: String, icon: String)] = [
("vision", "Vision", "eye"),
("web_extract", "Web Extract", "doc.richtext"),
("compression", "Compression", "arrow.down.right.and.arrow.up.left.circle"),
("session_search", "Session Search", "magnifyingglass"),
("skills_hub", "Skills Hub", "books.vertical"),
("approval", "Approval", "checkmark.seal"),
("mcp", "MCP", "puzzlepiece"),
("flush_memories", "Flush Memories", "trash.slash")
]
var body: some View {
Text("Auxiliary tasks use separate, typically cheaper models. Leave Provider as `auto` to inherit the main provider.")
.font(.caption)
.foregroundStyle(.secondary)
.padding(.bottom, 4)
ForEach(tasks, id: \.key) { task in
SettingsSection(title: task.title, icon: task.icon) {
auxRows(for: task.key)
}
}
}
@ViewBuilder
private func auxRows(for key: String) -> some View {
let model = auxModel(for: key)
EditableTextField(label: "Provider", value: model.provider) { viewModel.setAuxiliary(key, field: "provider", value: $0) }
EditableTextField(label: "Model", value: model.model) { viewModel.setAuxiliary(key, field: "model", value: $0) }
EditableTextField(label: "Base URL", value: model.baseURL) { viewModel.setAuxiliary(key, field: "base_url", value: $0) }
SecretTextField(label: "API Key", value: model.apiKey) { viewModel.setAuxiliary(key, field: "api_key", value: $0) }
StepperRow(label: "Timeout (s)", value: model.timeout, range: 5...3600, step: 5) { viewModel.setAuxiliaryTimeout(key, value: $0) }
}
private func auxModel(for key: String) -> AuxiliaryModel {
switch key {
case "vision": return viewModel.config.auxiliary.vision
case "web_extract": return viewModel.config.auxiliary.webExtract
case "compression": return viewModel.config.auxiliary.compression
case "session_search": return viewModel.config.auxiliary.sessionSearch
case "skills_hub": return viewModel.config.auxiliary.skillsHub
case "approval": return viewModel.config.auxiliary.approval
case "mcp": return viewModel.config.auxiliary.mcp
case "flush_memories": return viewModel.config.auxiliary.flushMemories
default: return .empty
}
}
}
@@ -0,0 +1,26 @@
import SwiftUI
/// Browser tab browser backend + automation timeouts + camofox.
struct BrowserTab: View {
@Bindable var viewModel: SettingsViewModel
var body: some View {
SettingsSection(title: "Backend", icon: "globe") {
PickerRow(label: "Backend", selection: viewModel.config.browserBackend, options: viewModel.browserBackends) { viewModel.setBrowserBackend($0) }
}
SettingsSection(title: "Timeouts", icon: "hourglass") {
StepperRow(label: "Inactivity (s)", value: viewModel.config.browser.inactivityTimeout, range: 10...3600, step: 10) { viewModel.setBrowserInactivityTimeout($0) }
StepperRow(label: "Command (s)", value: viewModel.config.browser.commandTimeout, range: 5...600, step: 5) { viewModel.setBrowserCommandTimeout($0) }
}
SettingsSection(title: "Behavior", icon: "slider.horizontal.below.rectangle") {
ToggleRow(label: "Record Sessions", isOn: viewModel.config.browser.recordSessions) { viewModel.setBrowserRecordSessions($0) }
ToggleRow(label: "Allow Private URLs", isOn: viewModel.config.browser.allowPrivateURLs) { viewModel.setBrowserAllowPrivateURLs($0) }
}
SettingsSection(title: "Camofox", icon: "eye.slash") {
ToggleRow(label: "Managed Persistence", isOn: viewModel.config.browser.camofoxManagedPersistence) { viewModel.setCamofoxManagedPersistence($0) }
}
}
}
@@ -0,0 +1,33 @@
import SwiftUI
/// Display tab streaming, reasoning, cost, skin, compact mode, inline diffs, bell, etc.
struct DisplayTab: View {
@Bindable var viewModel: SettingsViewModel
var body: some View {
SettingsSection(title: "Output", icon: "doc.plaintext") {
ToggleRow(label: "Streaming", isOn: viewModel.config.streaming) { viewModel.setStreaming($0) }
ToggleRow(label: "Show Reasoning", isOn: viewModel.config.showReasoning) { viewModel.setShowReasoning($0) }
ToggleRow(label: "Show Cost", isOn: viewModel.config.showCost) { viewModel.setShowCost($0) }
ToggleRow(label: "Interim Messages", isOn: viewModel.config.interimAssistantMessages) { viewModel.setInterimAssistantMessages($0) }
ToggleRow(label: "Verbose", isOn: viewModel.config.verbose) { viewModel.setVerbose($0) }
ToggleRow(label: "Inline Diffs", isOn: viewModel.config.display.inlineDiffs) { viewModel.setInlineDiffs($0) }
}
SettingsSection(title: "Layout", icon: "rectangle.3.group") {
EditableTextField(label: "Skin", value: viewModel.config.display.skin) { viewModel.setSkin($0) }
ToggleRow(label: "Compact", isOn: viewModel.config.display.compact) { viewModel.setDisplayCompact($0) }
PickerRow(label: "Resume Display", selection: viewModel.config.display.resumeDisplay, options: ["full", "minimal"]) { viewModel.setResumeDisplay($0) }
PickerRow(label: "Busy Input Mode", selection: viewModel.config.display.busyInputMode, options: ["interrupt", "queue"]) { viewModel.setBusyInputMode($0) }
}
SettingsSection(title: "Tool Progress", icon: "gauge") {
ToggleRow(label: "Tool Progress Command", isOn: viewModel.config.display.toolProgressCommand) { viewModel.setToolProgressCommand($0) }
StepperRow(label: "Preview Length", value: viewModel.config.display.toolPreviewLength, range: 0...500, step: 10) { viewModel.setToolPreviewLength($0) }
}
SettingsSection(title: "Feedback", icon: "bell") {
ToggleRow(label: "Bell on Complete", isOn: viewModel.config.display.bellOnComplete) { viewModel.setBellOnComplete($0) }
}
}
}
@@ -0,0 +1,70 @@
import SwiftUI
/// General tab model picker (provider auto-follows), personality, locale.
/// Credential management lives in the Credential Pools sidebar item; a hint
/// row in this tab deep-links there so users don't have to hunt for it.
struct GeneralTab: View {
@Bindable var viewModel: SettingsViewModel
@Environment(AppCoordinator.self) private var coordinator
var body: some View {
SettingsSection(title: "Model", icon: "cpu") {
ModelPickerRow(
label: "Model",
currentModel: viewModel.config.model,
currentProvider: viewModel.config.provider
) { modelID, providerID in
// Selecting a model auto-syncs the provider so the two stay in
// lockstep. If the picker returns an empty provider (custom
// entry without a prefix), keep the current one.
viewModel.setModel(modelID)
if !providerID.isEmpty {
viewModel.setProvider(providerID)
}
}
// Provider is shown read-only for clarity; users change it via the
// Model picker, which presents providers and models together.
ReadOnlyRow(label: "Provider", value: viewModel.config.provider)
credentialsHint
}
SettingsSection(title: "Personality", icon: "theatermasks") {
if !viewModel.personalities.isEmpty {
PickerRow(label: "Personality", selection: viewModel.config.personality, options: viewModel.personalities) { viewModel.setPersonality($0) }
} else {
EditableTextField(label: "Personality", value: viewModel.config.personality) { viewModel.setPersonality($0) }
}
}
SettingsSection(title: "Locale", icon: "globe.americas") {
EditableTextField(label: "Timezone (IANA)", value: viewModel.config.timezone) { viewModel.setTimezone($0) }
}
}
/// Breadcrumb-style row that points users to the Credential Pools sidebar
/// item. Replaces the old "Remove Credentials" button that action lived
/// here historically but duplicated Credential Pools' per-credential UI.
private var credentialsHint: some View {
HStack {
Text("Credentials")
.font(.caption)
.foregroundStyle(.secondary)
.frame(width: 160, alignment: .trailing)
Button {
coordinator.selectedSection = .credentialPools
} label: {
HStack(spacing: 4) {
Text("Manage in Credential Pools")
.font(.caption)
Image(systemName: "arrow.right")
.font(.caption2)
}
}
.buttonStyle(.link)
Spacer()
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(.quaternary.opacity(0.3))
}
}

Some files were not shown because too many files have changed in this diff Show More