mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 18:44:45 +00:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b7ad01f9da | |||
| 868e61979e | |||
| 9bdd928469 | |||
| 75e489e39c | |||
| 41ea3aeb83 | |||
| eb39dcfa61 | |||
| 93ee194ba0 | |||
| b6d9113579 | |||
| b2a29ab68d |
@@ -1,5 +1,6 @@
|
||||
# Xcode
|
||||
build/
|
||||
.gh-pages-worktree/
|
||||
DerivedData/
|
||||
*.pbxuser
|
||||
!default.pbxuser
|
||||
|
||||
@@ -17,22 +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
|
||||
|
||||
### 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, iMessage, 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
|
||||
- **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
|
||||
- **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, Fast Mode service tier, interim assistant messages, gateway notify interval, force IPv4, context engine, Honcho eager init, Docker environment, command allowlist, credential management, and one-click **Backup & Restore** via `hermes backup` / `hermes import`
|
||||
- **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
|
||||
|
||||
@@ -51,7 +88,9 @@ 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) | Verified |
|
||||
| v0.9.0 (2026-04-13, latest) | 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.
|
||||
|
||||
@@ -62,10 +101,11 @@ If a Hermes update changes the database schema or CLI output format, Scarf may n
|
||||
Download the latest build from [Releases](https://github.com/awizemann/scarf/releases):
|
||||
|
||||
- `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)
|
||||
2. Launch normally — builds are Developer ID signed and notarized, so Gatekeeper accepts them on first launch
|
||||
|
||||
Scarf checks for updates automatically on launch via [Sparkle](https://sparkle-project.org) and daily thereafter. You can disable automatic checks or trigger a manual check from **Settings → General → Updates** or the menu bar icon.
|
||||
|
||||
### Build from Source
|
||||
|
||||
@@ -139,6 +179,7 @@ The app opens `state.db` in read-only mode to avoid WAL contention with Hermes.
|
||||
| Package | Purpose |
|
||||
|---------|---------|
|
||||
| [SwiftTerm](https://github.com/migueldeicaza/SwiftTerm) | Terminal emulator for the Chat feature |
|
||||
| [Sparkle](https://github.com/sparkle-project/Sparkle) | Auto-updates from the GitHub-hosted appcast |
|
||||
|
||||
Everything else uses system frameworks: SQLite3 C API, Foundation JSON, AttributedString markdown, SwiftUI Charts, GCD file watching.
|
||||
|
||||
@@ -288,6 +329,20 @@ Your agent can update the dashboard as part of cron jobs, after builds, or whene
|
||||
|
||||
Each section defines a grid with 1–4 columns. Widgets flow left-to-right, wrapping to new rows. See [DASHBOARD_SCHEMA.md](scarf/docs/DASHBOARD_SCHEMA.md) for the full schema reference with examples of every widget type.
|
||||
|
||||
## Releases
|
||||
|
||||
Scarf ships through GitHub releases — the App Store is not supported because Scarf spawns the user-installed `hermes` binary and reads `~/.hermes/` directly, both of which App Sandbox forbids.
|
||||
|
||||
Each release goes through a single local script: [scripts/release.sh](scripts/release.sh). The script archives a universal binary, signs it with the Developer ID Application cert, submits to `notarytool`, staples the ticket, produces the distribution zip, signs an appcast entry with Sparkle's EdDSA key, pushes an updated `appcast.xml` to the `gh-pages` branch, creates the GitHub release, and tags `main`.
|
||||
|
||||
The Sparkle appcast is served from [awizemann.github.io/scarf/appcast.xml](https://awizemann.github.io/scarf/appcast.xml).
|
||||
|
||||
Signing prerequisites (one-time):
|
||||
|
||||
- `Developer ID Application` certificate in the login Keychain
|
||||
- `scarf-notary` keychain profile registered via `xcrun notarytool store-credentials`
|
||||
- Sparkle EdDSA private key in Keychain item `https://sparkle-project.org` (back this up — without it, shipped apps can never receive updates)
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome. Please open an issue to discuss what you'd like to change before submitting a PR.
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,25 @@
|
||||
## What's New in 1.6.1
|
||||
|
||||
### Auto-updates
|
||||
|
||||
Scarf now ships with [Sparkle](https://sparkle-project.org). On launch (and daily thereafter) it checks an EdDSA-signed appcast at [awizemann.github.io/scarf/appcast.xml](https://awizemann.github.io/scarf/appcast.xml). When a new version is available you'll get an in-app update prompt — no more manually downloading zips and dragging into Applications.
|
||||
|
||||
You can disable automatic checks or trigger a manual one from **Settings → General → Updates**, the menu bar icon, or the **Scarf → Check for Updates…** menu item.
|
||||
|
||||
### Notarized & Developer ID signed
|
||||
|
||||
This is the first release that's properly Developer ID signed and notarized by Apple. Gatekeeper accepts it on first launch — no more right-click → Open dance, no more "Scarf cannot be opened because the developer cannot be verified" warnings.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Chat works correctly when no terminal hermes session is running, and surfaces the real error when it can't reach the agent (b6df…)
|
||||
|
||||
### Under the hood
|
||||
|
||||
- Tracked `Info.plist` (replacing auto-generation) so signing-relevant keys live in version control
|
||||
- New `UpdaterService` wraps Sparkle and is injected via SwiftUI `.environment()`
|
||||
- One-command release pipeline at [scripts/release.sh](https://github.com/awizemann/scarf/blob/main/scripts/release.sh) handles archive → sign → notarize → staple → appcast → GitHub release → tag
|
||||
|
||||
---
|
||||
|
||||
**Migrating from 1.6.0:** unzip and replace your existing `Scarf.app` in `/Applications`. After this release, future updates install in-place via Sparkle.
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
53495AB62F7B992C00BD31AD /* SwiftTerm in Frameworks */ = {isa = PBXBuildFile; productRef = 53SWIFTTERM0001 /* SwiftTerm */; };
|
||||
53SPARKLE00010 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = 53SPARKLE00011 /* Sparkle */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
@@ -33,9 +34,22 @@
|
||||
534959592F7B83B700BD31AD /* scarfUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = scarfUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
534959AA2F7B83B600BD31AD /* Exceptions for "scarf" folder in "scarf" target */ = {
|
||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||
membershipExceptions = (
|
||||
Info.plist,
|
||||
);
|
||||
target = 5349593F2F7B83B600BD31AD /* scarf */;
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||
534959422F7B83B600BD31AD /* scarf */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
534959AA2F7B83B600BD31AD /* Exceptions for "scarf" folder in "scarf" target */,
|
||||
);
|
||||
path = scarf;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@@ -57,6 +71,7 @@
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
53495AB62F7B992C00BD31AD /* SwiftTerm in Frameworks */,
|
||||
53SPARKLE00010 /* Sparkle in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -118,6 +133,7 @@
|
||||
name = scarf;
|
||||
packageProductDependencies = (
|
||||
53SWIFTTERM0001 /* SwiftTerm */,
|
||||
53SPARKLE00011 /* Sparkle */,
|
||||
);
|
||||
productName = scarf;
|
||||
productReference = 534959402F7B83B600BD31AD /* scarf.app */;
|
||||
@@ -203,6 +219,7 @@
|
||||
minimizedProjectReferenceProxies = 1;
|
||||
packageReferences = (
|
||||
53SWIFTTERM0002 /* XCRemoteSwiftPackageReference "SwiftTerm" */,
|
||||
53SPARKLE00012 /* XCRemoteSwiftPackageReference "Sparkle" */,
|
||||
);
|
||||
preferredProjectObjectVersion = 77;
|
||||
productRefGroup = 534959412F7B83B600BD31AD /* Products */;
|
||||
@@ -407,23 +424,20 @@
|
||||
CODE_SIGN_ENTITLEMENTS = scarf/scarf.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 9;
|
||||
CURRENT_PROJECT_VERSION = 17;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
ENABLE_APP_SANDBOX = NO;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = Scarf;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||
INFOPLIST_KEY_NSMicrophoneUsageDescription = "Scarf uses the microphone for Hermes voice chat.";
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = scarf/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 14.6;
|
||||
MARKETING_VERSION = 1.5.7;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarf;
|
||||
MARKETING_VERSION = 1.6.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarf.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
@@ -444,23 +458,20 @@
|
||||
CODE_SIGN_ENTITLEMENTS = scarf/scarf.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 9;
|
||||
CURRENT_PROJECT_VERSION = 17;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
ENABLE_APP_SANDBOX = NO;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = Scarf;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||
INFOPLIST_KEY_NSMicrophoneUsageDescription = "Scarf uses the microphone for Hermes voice chat.";
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = scarf/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 14.6;
|
||||
MARKETING_VERSION = 1.5.7;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarf;
|
||||
MARKETING_VERSION = 1.6.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarf.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
@@ -477,11 +488,11 @@
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 4;
|
||||
CURRENT_PROJECT_VERSION = 17;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 26.2;
|
||||
MARKETING_VERSION = 1.5.0;
|
||||
MARKETING_VERSION = 1.6.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarfTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||
@@ -498,11 +509,11 @@
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 4;
|
||||
CURRENT_PROJECT_VERSION = 17;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 26.2;
|
||||
MARKETING_VERSION = 1.5.0;
|
||||
MARKETING_VERSION = 1.6.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarfTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||
@@ -518,10 +529,10 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 4;
|
||||
CURRENT_PROJECT_VERSION = 17;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.5.0;
|
||||
MARKETING_VERSION = 1.6.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarfUITests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||
@@ -537,10 +548,10 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 4;
|
||||
CURRENT_PROJECT_VERSION = 17;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.5.0;
|
||||
MARKETING_VERSION = 1.6.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarfUITests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||
@@ -594,6 +605,14 @@
|
||||
/* End XCConfigurationList section */
|
||||
|
||||
/* Begin XCRemoteSwiftPackageReference section */
|
||||
53SPARKLE00012 /* XCRemoteSwiftPackageReference "Sparkle" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/sparkle-project/Sparkle";
|
||||
requirement = {
|
||||
kind = upToNextMajorVersion;
|
||||
minimumVersion = 2.6.0;
|
||||
};
|
||||
};
|
||||
53SWIFTTERM0002 /* XCRemoteSwiftPackageReference "SwiftTerm" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/migueldeicaza/SwiftTerm.git";
|
||||
@@ -605,6 +624,11 @@
|
||||
/* End XCRemoteSwiftPackageReference section */
|
||||
|
||||
/* Begin XCSwiftPackageProductDependency section */
|
||||
53SPARKLE00011 /* Sparkle */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = 53SPARKLE00012 /* XCRemoteSwiftPackageReference "Sparkle" */;
|
||||
productName = Sparkle;
|
||||
};
|
||||
53SWIFTTERM0001 /* SwiftTerm */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = 53SWIFTTERM0002 /* XCRemoteSwiftPackageReference "SwiftTerm" */;
|
||||
|
||||
@@ -14,36 +14,28 @@ struct ContentView: View {
|
||||
@ViewBuilder
|
||||
private var detailView: some View {
|
||||
switch coordinator.selectedSection {
|
||||
case .dashboard:
|
||||
DashboardView()
|
||||
case .insights:
|
||||
InsightsView()
|
||||
case .sessions:
|
||||
SessionsView()
|
||||
case .activity:
|
||||
ActivityView()
|
||||
case .projects:
|
||||
ProjectsView()
|
||||
case .chat:
|
||||
ChatView()
|
||||
case .memory:
|
||||
MemoryView()
|
||||
case .skills:
|
||||
SkillsView()
|
||||
case .tools:
|
||||
ToolsView()
|
||||
case .mcpServers:
|
||||
MCPServersView()
|
||||
case .gateway:
|
||||
GatewayView()
|
||||
case .cron:
|
||||
CronView()
|
||||
case .health:
|
||||
HealthView()
|
||||
case .logs:
|
||||
LogsView()
|
||||
case .settings:
|
||||
SettingsView()
|
||||
case .dashboard: DashboardView()
|
||||
case .insights: InsightsView()
|
||||
case .sessions: SessionsView()
|
||||
case .activity: ActivityView()
|
||||
case .projects: ProjectsView()
|
||||
case .chat: ChatView()
|
||||
case .memory: MemoryView()
|
||||
case .skills: SkillsView()
|
||||
case .platforms: PlatformsView()
|
||||
case .personalities: PersonalitiesView()
|
||||
case .quickCommands: QuickCommandsView()
|
||||
case .credentialPools: CredentialPoolsView()
|
||||
case .plugins: PluginsView()
|
||||
case .webhooks: WebhooksView()
|
||||
case .profiles: ProfilesView()
|
||||
case .tools: ToolsView()
|
||||
case .mcpServers: MCPServersView()
|
||||
case .gateway: GatewayView()
|
||||
case .cron: CronView()
|
||||
case .health: HealthView()
|
||||
case .logs: LogsView()
|
||||
case .settings: SettingsView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,304 @@
|
||||
import Foundation
|
||||
|
||||
/// Settings for one of hermes's auxiliary model tasks (vision, compression, approvals, etc.).
|
||||
/// Every auxiliary task follows the same provider/model/base_url/api_key/timeout pattern.
|
||||
struct AuxiliaryModel: Sendable, Equatable {
|
||||
var provider: String
|
||||
var model: String
|
||||
var baseURL: String
|
||||
var apiKey: String
|
||||
var timeout: Int
|
||||
|
||||
static let empty = AuxiliaryModel(provider: "auto", model: "", baseURL: "", apiKey: "", timeout: 30)
|
||||
}
|
||||
|
||||
/// Group of display-related settings mirroring the `display:` block in config.yaml.
|
||||
struct DisplaySettings: Sendable, Equatable {
|
||||
var skin: String
|
||||
var compact: Bool
|
||||
var resumeDisplay: String // "full" | "minimal"
|
||||
var bellOnComplete: Bool
|
||||
var inlineDiffs: Bool
|
||||
var toolProgressCommand: Bool
|
||||
var toolPreviewLength: Int
|
||||
var busyInputMode: String // e.g. "interrupt"
|
||||
|
||||
static let empty = DisplaySettings(
|
||||
skin: "default",
|
||||
compact: false,
|
||||
resumeDisplay: "full",
|
||||
bellOnComplete: false,
|
||||
inlineDiffs: true,
|
||||
toolProgressCommand: false,
|
||||
toolPreviewLength: 0,
|
||||
busyInputMode: "interrupt"
|
||||
)
|
||||
}
|
||||
|
||||
/// Container/terminal backend options. These map to `terminal.*` keys in config.yaml.
|
||||
struct TerminalSettings: Sendable, Equatable {
|
||||
var cwd: String
|
||||
var timeout: Int
|
||||
var envPassthrough: [String]
|
||||
var persistentShell: Bool
|
||||
var dockerImage: String
|
||||
var dockerMountCwdToWorkspace: Bool
|
||||
var dockerForwardEnv: [String]
|
||||
var dockerVolumes: [String]
|
||||
var containerCPU: Int // 0 = unlimited
|
||||
var containerMemory: Int // MB, 0 = unlimited
|
||||
var containerDisk: Int // MB, 0 = unlimited
|
||||
var containerPersistent: Bool
|
||||
var modalImage: String
|
||||
var modalMode: String // "auto" | other
|
||||
var daytonaImage: String
|
||||
var singularityImage: String
|
||||
|
||||
static let empty = TerminalSettings(
|
||||
cwd: ".",
|
||||
timeout: 180,
|
||||
envPassthrough: [],
|
||||
persistentShell: true,
|
||||
dockerImage: "",
|
||||
dockerMountCwdToWorkspace: false,
|
||||
dockerForwardEnv: [],
|
||||
dockerVolumes: [],
|
||||
containerCPU: 0,
|
||||
containerMemory: 0,
|
||||
containerDisk: 0,
|
||||
containerPersistent: false,
|
||||
modalImage: "",
|
||||
modalMode: "auto",
|
||||
daytonaImage: "",
|
||||
singularityImage: ""
|
||||
)
|
||||
}
|
||||
|
||||
/// Browser automation tuning (`browser.*`).
|
||||
struct BrowserSettings: Sendable, Equatable {
|
||||
var inactivityTimeout: Int
|
||||
var commandTimeout: Int
|
||||
var recordSessions: Bool
|
||||
var allowPrivateURLs: Bool
|
||||
var camofoxManagedPersistence: Bool
|
||||
|
||||
static let empty = BrowserSettings(
|
||||
inactivityTimeout: 120,
|
||||
commandTimeout: 30,
|
||||
recordSessions: false,
|
||||
allowPrivateURLs: false,
|
||||
camofoxManagedPersistence: false
|
||||
)
|
||||
}
|
||||
|
||||
/// Voice push-to-talk plus TTS/STT provider settings.
|
||||
struct VoiceSettings: Sendable, Equatable {
|
||||
var recordKey: String
|
||||
var maxRecordingSeconds: Int
|
||||
var silenceDuration: Double
|
||||
|
||||
// TTS
|
||||
var ttsProvider: String
|
||||
var ttsEdgeVoice: String
|
||||
var ttsElevenLabsVoiceID: String
|
||||
var ttsElevenLabsModelID: String
|
||||
var ttsOpenAIModel: String
|
||||
var ttsOpenAIVoice: String
|
||||
var ttsNeuTTSModel: String
|
||||
var ttsNeuTTSDevice: String
|
||||
|
||||
// STT
|
||||
var sttEnabled: Bool
|
||||
var sttProvider: String
|
||||
var sttLocalModel: String
|
||||
var sttLocalLanguage: String
|
||||
var sttOpenAIModel: String
|
||||
var sttMistralModel: String
|
||||
|
||||
static let empty = VoiceSettings(
|
||||
recordKey: "ctrl+b",
|
||||
maxRecordingSeconds: 120,
|
||||
silenceDuration: 3.0,
|
||||
ttsProvider: "edge",
|
||||
ttsEdgeVoice: "en-US-AriaNeural",
|
||||
ttsElevenLabsVoiceID: "",
|
||||
ttsElevenLabsModelID: "eleven_multilingual_v2",
|
||||
ttsOpenAIModel: "gpt-4o-mini-tts",
|
||||
ttsOpenAIVoice: "alloy",
|
||||
ttsNeuTTSModel: "neuphonic/neutts-air-q4-gguf",
|
||||
ttsNeuTTSDevice: "cpu",
|
||||
sttEnabled: true,
|
||||
sttProvider: "local",
|
||||
sttLocalModel: "base",
|
||||
sttLocalLanguage: "",
|
||||
sttOpenAIModel: "whisper-1",
|
||||
sttMistralModel: "voxtral-mini-latest"
|
||||
)
|
||||
}
|
||||
|
||||
/// Eight sub-models that share the same provider/model/base_url/api_key/timeout shape.
|
||||
struct AuxiliarySettings: Sendable, Equatable {
|
||||
var vision: AuxiliaryModel
|
||||
var webExtract: AuxiliaryModel
|
||||
var compression: AuxiliaryModel
|
||||
var sessionSearch: AuxiliaryModel
|
||||
var skillsHub: AuxiliaryModel
|
||||
var approval: AuxiliaryModel
|
||||
var mcp: AuxiliaryModel
|
||||
var flushMemories: AuxiliaryModel
|
||||
|
||||
static let empty = AuxiliarySettings(
|
||||
vision: .empty,
|
||||
webExtract: .empty,
|
||||
compression: .empty,
|
||||
sessionSearch: .empty,
|
||||
skillsHub: .empty,
|
||||
approval: .empty,
|
||||
mcp: .empty,
|
||||
flushMemories: .empty
|
||||
)
|
||||
}
|
||||
|
||||
/// Security/redaction/firewall config. Website blocklist is nested in YAML.
|
||||
struct SecuritySettings: Sendable, Equatable {
|
||||
var redactSecrets: Bool
|
||||
var redactPII: Bool // from privacy.redact_pii
|
||||
var tirithEnabled: Bool
|
||||
var tirithPath: String
|
||||
var tirithTimeout: Int
|
||||
var tirithFailOpen: Bool
|
||||
var blocklistEnabled: Bool
|
||||
var blocklistDomains: [String]
|
||||
|
||||
static let empty = SecuritySettings(
|
||||
redactSecrets: true,
|
||||
redactPII: false,
|
||||
tirithEnabled: true,
|
||||
tirithPath: "tirith",
|
||||
tirithTimeout: 5,
|
||||
tirithFailOpen: true,
|
||||
blocklistEnabled: false,
|
||||
blocklistDomains: []
|
||||
)
|
||||
}
|
||||
|
||||
/// Human-delay simulates realistic typing pace (`human_delay.*`).
|
||||
struct HumanDelaySettings: Sendable, Equatable {
|
||||
var mode: String // "off" | "natural" | "custom"
|
||||
var minMS: Int
|
||||
var maxMS: Int
|
||||
|
||||
static let empty = HumanDelaySettings(mode: "off", minMS: 800, maxMS: 2500)
|
||||
}
|
||||
|
||||
/// Compression / context routing.
|
||||
struct CompressionSettings: Sendable, Equatable {
|
||||
var enabled: Bool
|
||||
var threshold: Double
|
||||
var targetRatio: Double
|
||||
var protectLastN: Int
|
||||
|
||||
static let empty = CompressionSettings(enabled: true, threshold: 0.5, targetRatio: 0.2, protectLastN: 20)
|
||||
}
|
||||
|
||||
struct CheckpointSettings: Sendable, Equatable {
|
||||
var enabled: Bool
|
||||
var maxSnapshots: Int
|
||||
|
||||
static let empty = CheckpointSettings(enabled: true, maxSnapshots: 50)
|
||||
}
|
||||
|
||||
struct LoggingSettings: Sendable, Equatable {
|
||||
var level: String // DEBUG | INFO | WARNING | ERROR
|
||||
var maxSizeMB: Int
|
||||
var backupCount: Int
|
||||
|
||||
static let empty = LoggingSettings(level: "INFO", maxSizeMB: 5, backupCount: 3)
|
||||
}
|
||||
|
||||
struct DelegationSettings: Sendable, Equatable {
|
||||
var model: String
|
||||
var provider: String
|
||||
var baseURL: String
|
||||
var apiKey: String
|
||||
var maxIterations: Int
|
||||
|
||||
static let empty = DelegationSettings(model: "", provider: "", baseURL: "", apiKey: "", maxIterations: 50)
|
||||
}
|
||||
|
||||
/// Discord-specific platform settings (`discord.*`). Other platforms currently have thinner schemas.
|
||||
struct DiscordSettings: Sendable, Equatable {
|
||||
var requireMention: Bool
|
||||
var freeResponseChannels: String
|
||||
var autoThread: Bool
|
||||
var reactions: Bool
|
||||
|
||||
static let empty = DiscordSettings(requireMention: true, freeResponseChannels: "", autoThread: true, reactions: true)
|
||||
}
|
||||
|
||||
/// Telegram settings under `telegram.*` in config.yaml. Most Telegram tuning is
|
||||
/// done via environment variables (`TELEGRAM_*`) — this is the subset that lives
|
||||
/// in the YAML.
|
||||
struct TelegramSettings: Sendable, Equatable {
|
||||
var requireMention: Bool
|
||||
var reactions: Bool
|
||||
|
||||
static let empty = TelegramSettings(requireMention: true, reactions: false)
|
||||
}
|
||||
|
||||
/// Slack settings under `platforms.slack.*` (and a couple of top-level keys).
|
||||
struct SlackSettings: Sendable, Equatable {
|
||||
var replyToMode: String // "off" | "first" | "all"
|
||||
var requireMention: Bool
|
||||
var replyInThread: Bool
|
||||
var replyBroadcast: Bool
|
||||
|
||||
static let empty = SlackSettings(replyToMode: "first", requireMention: true, replyInThread: true, replyBroadcast: false)
|
||||
}
|
||||
|
||||
/// Matrix settings under `matrix.*`.
|
||||
struct MatrixSettings: Sendable, Equatable {
|
||||
var requireMention: Bool
|
||||
var autoThread: Bool
|
||||
var dmMentionThreads: Bool
|
||||
|
||||
static let empty = MatrixSettings(requireMention: true, autoThread: true, dmMentionThreads: false)
|
||||
}
|
||||
|
||||
/// Mattermost settings. Mattermost is mostly driven by env vars; config.yaml
|
||||
/// currently just exposes `group_sessions_per_user` at the top level, but we
|
||||
/// reserve this struct for future expansion so the form has a stable type.
|
||||
struct MattermostSettings: Sendable, Equatable {
|
||||
var requireMention: Bool
|
||||
var replyMode: String // "thread" | "off"
|
||||
|
||||
static let empty = MattermostSettings(requireMention: true, replyMode: "off")
|
||||
}
|
||||
|
||||
/// WhatsApp settings under `whatsapp.*`.
|
||||
struct WhatsAppSettings: Sendable, Equatable {
|
||||
var unauthorizedDMBehavior: String // "pair" | "ignore"
|
||||
var replyPrefix: String
|
||||
|
||||
static let empty = WhatsAppSettings(unauthorizedDMBehavior: "pair", replyPrefix: "")
|
||||
}
|
||||
|
||||
/// Home Assistant filters under `platforms.homeassistant.extra`. Hermes ignores
|
||||
/// every state change by default; users must opt-in via at least one filter.
|
||||
struct HomeAssistantSettings: Sendable, Equatable {
|
||||
var watchDomains: [String]
|
||||
var watchEntities: [String]
|
||||
var watchAll: Bool
|
||||
var ignoreEntities: [String]
|
||||
var cooldownSeconds: Int
|
||||
|
||||
static let empty = HomeAssistantSettings(watchDomains: [], watchEntities: [], watchAll: false, ignoreEntities: [], cooldownSeconds: 30)
|
||||
}
|
||||
|
||||
// MARK: - Root Config
|
||||
|
||||
struct HermesConfig: Sendable {
|
||||
// Original fields — preserved for zero breakage with existing call sites.
|
||||
var model: String
|
||||
var provider: String
|
||||
var maxTurns: Int
|
||||
@@ -30,6 +328,37 @@ struct HermesConfig: Sendable {
|
||||
var interimAssistantMessages: Bool
|
||||
var honchoInitOnSessionStart: Bool
|
||||
|
||||
// Phase 1 additions
|
||||
var timezone: String
|
||||
var userProfileEnabled: Bool
|
||||
var toolUseEnforcement: String // "auto" | "true" | "false" | comma list
|
||||
var gatewayTimeout: Int
|
||||
var approvalTimeout: Int
|
||||
var fileReadMaxChars: Int
|
||||
var cronWrapResponse: Bool
|
||||
var prefillMessagesFile: String
|
||||
var skillsExternalDirs: [String]
|
||||
|
||||
// Grouped blocks
|
||||
var display: DisplaySettings
|
||||
var terminal: TerminalSettings
|
||||
var browser: BrowserSettings
|
||||
var voice: VoiceSettings
|
||||
var auxiliary: AuxiliarySettings
|
||||
var security: SecuritySettings
|
||||
var humanDelay: HumanDelaySettings
|
||||
var compression: CompressionSettings
|
||||
var checkpoints: CheckpointSettings
|
||||
var logging: LoggingSettings
|
||||
var delegation: DelegationSettings
|
||||
var discord: DiscordSettings
|
||||
var telegram: TelegramSettings
|
||||
var slack: SlackSettings
|
||||
var matrix: MatrixSettings
|
||||
var mattermost: MattermostSettings
|
||||
var whatsapp: WhatsAppSettings
|
||||
var homeAssistant: HomeAssistantSettings
|
||||
|
||||
static let empty = HermesConfig(
|
||||
model: "unknown",
|
||||
provider: "unknown",
|
||||
@@ -58,7 +387,34 @@ struct HermesConfig: Sendable {
|
||||
forceIPv4: false,
|
||||
contextEngine: "compressor",
|
||||
interimAssistantMessages: true,
|
||||
honchoInitOnSessionStart: false
|
||||
honchoInitOnSessionStart: false,
|
||||
timezone: "",
|
||||
userProfileEnabled: true,
|
||||
toolUseEnforcement: "auto",
|
||||
gatewayTimeout: 1800,
|
||||
approvalTimeout: 60,
|
||||
fileReadMaxChars: 100_000,
|
||||
cronWrapResponse: true,
|
||||
prefillMessagesFile: "",
|
||||
skillsExternalDirs: [],
|
||||
display: .empty,
|
||||
terminal: .empty,
|
||||
browser: .empty,
|
||||
voice: .empty,
|
||||
auxiliary: .empty,
|
||||
security: .empty,
|
||||
humanDelay: .empty,
|
||||
compression: .empty,
|
||||
checkpoints: .empty,
|
||||
logging: .empty,
|
||||
delegation: .empty,
|
||||
discord: .empty,
|
||||
telegram: .empty,
|
||||
slack: .empty,
|
||||
matrix: .empty,
|
||||
mattermost: .empty,
|
||||
whatsapp: .empty,
|
||||
homeAssistant: .empty
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -19,10 +19,30 @@ enum HermesPaths: Sendable {
|
||||
nonisolated static let errorsLog: String = home + "/logs/errors.log"
|
||||
nonisolated static let agentLog: String = home + "/logs/agent.log"
|
||||
nonisolated static let gatewayLog: String = home + "/logs/gateway.log"
|
||||
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"
|
||||
|
||||
/// Install locations we look for the `hermes` binary in, in priority order.
|
||||
/// Checked every access so a user installing via a different method doesn't
|
||||
/// need to relaunch Scarf.
|
||||
nonisolated static let hermesBinaryCandidates: [String] = [
|
||||
userHome + "/.local/bin/hermes", // pipx / pip --user (default)
|
||||
"/opt/homebrew/bin/hermes", // Homebrew on Apple Silicon
|
||||
"/usr/local/bin/hermes", // Homebrew on Intel / manual install
|
||||
userHome + "/.hermes/bin/hermes" // Some self-install layouts
|
||||
]
|
||||
|
||||
/// Resolved path to the `hermes` executable. Returns the first candidate
|
||||
/// that exists and is executable; falls back to the pipx default so error
|
||||
/// messages ("Expected at …") still make sense on a fresh machine.
|
||||
nonisolated static var hermesBinary: String {
|
||||
for path in hermesBinaryCandidates
|
||||
where FileManager.default.isExecutableFile(atPath: path) {
|
||||
return path
|
||||
}
|
||||
return hermesBinaryCandidates[0]
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - SQLite Constants
|
||||
|
||||
@@ -24,6 +24,27 @@ actor ACPClient {
|
||||
private(set) var currentSessionId: String?
|
||||
private(set) var statusMessage = ""
|
||||
|
||||
/// Ring buffer of recent stderr lines from `hermes acp` — used to attach
|
||||
/// a diagnostic tail to user-visible errors. Capped to avoid unbounded
|
||||
/// growth when the subprocess logs heavily.
|
||||
private var stderrBuffer: [String] = []
|
||||
private static let stderrBufferMaxLines = 50
|
||||
|
||||
/// Returns the last ~`stderrBufferMaxLines` stderr lines captured from the
|
||||
/// `hermes acp` subprocess, joined by newlines.
|
||||
var recentStderr: String {
|
||||
stderrBuffer.joined(separator: "\n")
|
||||
}
|
||||
|
||||
fileprivate func appendStderr(_ text: String) {
|
||||
for line in text.split(separator: "\n", omittingEmptySubsequences: true) {
|
||||
stderrBuffer.append(String(line))
|
||||
}
|
||||
if stderrBuffer.count > Self.stderrBufferMaxLines {
|
||||
stderrBuffer.removeFirst(stderrBuffer.count - Self.stderrBufferMaxLines)
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if the underlying process is still alive and connected.
|
||||
var isHealthy: Bool {
|
||||
guard isConnected, let process else { return false }
|
||||
@@ -66,8 +87,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
|
||||
|
||||
@@ -396,7 +419,8 @@ actor ACPClient {
|
||||
await self?.handleReadLoopEnded()
|
||||
}
|
||||
|
||||
// Read stderr in background for diagnostic logging
|
||||
// Read stderr in background for diagnostic logging AND ring-buffer
|
||||
// capture so we can attach a tail to user-visible errors.
|
||||
stderrTask = Task.detached { [weak self] in
|
||||
let handle = stderr.fileHandleForReading
|
||||
while !Task.isCancelled {
|
||||
@@ -405,6 +429,7 @@ actor ACPClient {
|
||||
if let text = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!text.isEmpty {
|
||||
await self?.logger.info("ACP stderr: \(text.prefix(500))")
|
||||
await self?.appendStderr(text)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -514,3 +539,35 @@ enum ACPClientError: Error, LocalizedError {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Maps a raw error message (RPC message or captured stderr) to a short
|
||||
/// human-readable hint for the chat UI. Pattern-matches the most common
|
||||
/// fresh-install failure modes. Returns nil when no known pattern matches.
|
||||
enum ACPErrorHint {
|
||||
static func classify(errorMessage: String, stderrTail: String) -> String? {
|
||||
let haystack = errorMessage + "\n" + stderrTail
|
||||
if haystack.range(of: #"No\s+(Anthropic|OpenAI|OpenRouter|Gemini|Google|Groq|Mistral|XAI)?\s*credentials\s+found"#,
|
||||
options: .regularExpression) != nil
|
||||
|| haystack.contains("ANTHROPIC_API_KEY")
|
||||
|| haystack.contains("ANTHROPIC_TOKEN")
|
||||
|| haystack.contains("claude setup-token")
|
||||
|| haystack.contains("claude /login") {
|
||||
return "Hermes can't find your AI provider credentials. Set `ANTHROPIC_API_KEY` (or similar) in `~/.hermes/.env` or your shell profile, then restart Scarf."
|
||||
}
|
||||
if let match = haystack.range(of: #"No such file or directory:\s*'([^']+)'"#,
|
||||
options: .regularExpression) {
|
||||
let matched = String(haystack[match])
|
||||
if let nameStart = matched.range(of: "'"),
|
||||
let nameEnd = matched.range(of: "'", range: nameStart.upperBound..<matched.endIndex) {
|
||||
let name = String(matched[nameStart.upperBound..<nameEnd.lowerBound])
|
||||
return "Hermes couldn't find `\(name)` on PATH. If you use nvm/asdf/mise, make sure it's exported in `~/.zprofile` (not only `~/.zshrc`), then restart Scarf."
|
||||
}
|
||||
return "Hermes couldn't find a required binary on PATH. Check that your shell's PATH is exported in `~/.zprofile`, then restart Scarf."
|
||||
}
|
||||
if haystack.localizedCaseInsensitiveContains("rate limit")
|
||||
|| haystack.localizedCaseInsensitiveContains("429") {
|
||||
return "Your AI provider returned a rate-limit error. Try again in a moment."
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
/// Read/write `~/.hermes/.env` while preserving comments, blank lines, and the
|
||||
/// ordering of keys we don't touch.
|
||||
///
|
||||
/// Hermes treats `.env` as a traditional dotenv file: `KEY=value`, `#` comments,
|
||||
/// and optional double-quoted values for strings with spaces or special chars.
|
||||
/// We do NOT attempt to implement full shell-style escaping; the fields we write
|
||||
/// from the GUI are bot tokens, user IDs, URLs, and on/off flags — none of which
|
||||
/// contain characters needing escaping beyond double-quoting.
|
||||
///
|
||||
/// Design choices:
|
||||
/// - **Non-destructive "unset"**: clearing a field comments the line out rather
|
||||
/// than deleting it, so users can restore a key by uncommenting without losing
|
||||
/// their value.
|
||||
/// - **Atomic write**: write to `.env.tmp`, then rename. Avoids a partially
|
||||
/// written file if Scarf crashes mid-write.
|
||||
/// - **Never logs values**: secrets flow through this service.
|
||||
struct HermesEnvService: Sendable {
|
||||
private let logger = Logger(subsystem: "com.scarf", category: "HermesEnvService")
|
||||
|
||||
/// Path to `~/.hermes/.env`. Kept configurable for tests.
|
||||
let path: String
|
||||
|
||||
init(path: String = HermesPaths.home + "/.env") {
|
||||
self.path = path
|
||||
}
|
||||
|
||||
/// Read the .env file into a `[key: value]` dict. Comments and commented-out
|
||||
/// assignments are ignored. Missing file returns an empty dict.
|
||||
func load() -> [String: String] {
|
||||
guard let content = try? String(contentsOfFile: path, encoding: .utf8) else {
|
||||
return [:]
|
||||
}
|
||||
var result: [String: String] = [:]
|
||||
for line in content.components(separatedBy: "\n") {
|
||||
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||
// Skip blanks and comments. A line beginning with `#` is either a pure
|
||||
// comment or a disabled assignment — both should be treated as "unset".
|
||||
if trimmed.isEmpty || trimmed.hasPrefix("#") { continue }
|
||||
guard let eq = trimmed.firstIndex(of: "=") else { continue }
|
||||
let key = String(trimmed[trimmed.startIndex..<eq]).trimmingCharacters(in: .whitespaces)
|
||||
let raw = String(trimmed[trimmed.index(after: eq)...]).trimmingCharacters(in: .whitespaces)
|
||||
result[key] = Self.stripEnvQuotes(raw)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func get(_ key: String) -> String? {
|
||||
load()[key]
|
||||
}
|
||||
|
||||
/// Write/update a single key. Preserves the position of existing assignments
|
||||
/// (even if they were commented out — the new assignment replaces the comment
|
||||
/// line in place). New keys are appended at the end.
|
||||
@discardableResult
|
||||
func set(_ key: String, value: String) -> Bool {
|
||||
setMany([key: value])
|
||||
}
|
||||
|
||||
/// Update multiple keys in one atomic rewrite. Use this when a form saves
|
||||
/// several fields at once so the file doesn't get repeatedly rewritten.
|
||||
///
|
||||
/// Returns `true` on success, `false` if the atomic rewrite failed.
|
||||
@discardableResult
|
||||
func setMany(_ pairs: [String: String]) -> Bool {
|
||||
var remaining = pairs
|
||||
var lines: [String]
|
||||
|
||||
// Start from existing file contents, or a minimal header if creating new.
|
||||
if let content = try? String(contentsOfFile: path, encoding: .utf8) {
|
||||
lines = content.components(separatedBy: "\n")
|
||||
// Trim a single trailing empty line from splitting the final newline;
|
||||
// we'll re-add it on write.
|
||||
if lines.last == "" { lines.removeLast() }
|
||||
} else {
|
||||
lines = ["# Hermes Agent Environment Configuration"]
|
||||
}
|
||||
|
||||
// First pass: update in-place (handles both live and commented-out lines).
|
||||
for (idx, line) in lines.enumerated() {
|
||||
guard let match = Self.extractKey(fromLine: line) else { continue }
|
||||
if let newValue = remaining.removeValue(forKey: match.key) {
|
||||
// A commented-out `# KEY=...` becomes a live `KEY=...` with the new value.
|
||||
lines[idx] = Self.formatLine(key: match.key, value: newValue)
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: append any keys that didn't match an existing line.
|
||||
if !remaining.isEmpty {
|
||||
// Leave a blank line before appending new keys for visual separation.
|
||||
if let last = lines.last, !last.isEmpty {
|
||||
lines.append("")
|
||||
}
|
||||
for key in remaining.keys.sorted() {
|
||||
lines.append(Self.formatLine(key: key, value: remaining[key]!))
|
||||
}
|
||||
}
|
||||
|
||||
return atomicWrite(lines.joined(separator: "\n") + "\n")
|
||||
}
|
||||
|
||||
/// Comment out a key. The value is preserved so the user can restore by
|
||||
/// uncommenting. If the key doesn't exist, this is a no-op.
|
||||
@discardableResult
|
||||
func unset(_ key: String) -> Bool {
|
||||
guard let content = try? String(contentsOfFile: path, encoding: .utf8) else {
|
||||
return true
|
||||
}
|
||||
var lines = content.components(separatedBy: "\n")
|
||||
if lines.last == "" { lines.removeLast() }
|
||||
|
||||
var changed = false
|
||||
for (idx, line) in lines.enumerated() {
|
||||
guard let match = Self.extractKey(fromLine: line), match.key == key else { continue }
|
||||
// Skip lines that are already commented — nothing to do.
|
||||
if Self.isCommentedOutAssignment(line) { continue }
|
||||
lines[idx] = "# " + line
|
||||
changed = true
|
||||
}
|
||||
guard changed else { return true }
|
||||
return atomicWrite(lines.joined(separator: "\n") + "\n")
|
||||
}
|
||||
|
||||
// MARK: - Internals
|
||||
|
||||
/// Writes the entire file in one shot via a tmp + rename to avoid corrupting
|
||||
/// `.env` if the process is killed mid-write. Preserves `0600` permissions
|
||||
/// since `.env` typically holds secrets.
|
||||
private func atomicWrite(_ content: String) -> Bool {
|
||||
let tmp = path + ".tmp"
|
||||
do {
|
||||
try content.write(toFile: tmp, atomically: false, encoding: .utf8)
|
||||
// Mirror the typical `.env` mode of `0600` (owner read/write only).
|
||||
try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: tmp)
|
||||
// Swap into place. FileManager.replaceItem handles the replacement
|
||||
// atomically on the same volume; fall back to a two-step rename.
|
||||
let destURL = URL(fileURLWithPath: path)
|
||||
let tmpURL = URL(fileURLWithPath: tmp)
|
||||
if FileManager.default.fileExists(atPath: path) {
|
||||
_ = try FileManager.default.replaceItemAt(destURL, withItemAt: tmpURL)
|
||||
} else {
|
||||
try FileManager.default.moveItem(at: tmpURL, to: destURL)
|
||||
}
|
||||
return true
|
||||
} catch {
|
||||
logger.error("Failed to write .env: \(error.localizedDescription)")
|
||||
try? FileManager.default.removeItem(atPath: tmp)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract a key name and whether the line was active or commented-out.
|
||||
/// Accepts both `KEY=value` and `# KEY=value` (any amount of whitespace after `#`).
|
||||
private static func extractKey(fromLine line: String) -> (key: String, active: Bool)? {
|
||||
var work = line.trimmingCharacters(in: .whitespaces)
|
||||
var active = true
|
||||
if work.hasPrefix("#") {
|
||||
active = false
|
||||
work = String(work.dropFirst()).trimmingCharacters(in: .whitespaces)
|
||||
}
|
||||
guard let eq = work.firstIndex(of: "=") else { return nil }
|
||||
let key = String(work[work.startIndex..<eq]).trimmingCharacters(in: .whitespaces)
|
||||
// Reject non-identifier looking keys to avoid matching prose in comments
|
||||
// (e.g. "# This is a note about something = nice").
|
||||
guard key.range(of: "^[A-Za-z_][A-Za-z0-9_]*$", options: .regularExpression) != nil else {
|
||||
return nil
|
||||
}
|
||||
return (key, active)
|
||||
}
|
||||
|
||||
private static func isCommentedOutAssignment(_ line: String) -> Bool {
|
||||
guard let match = extractKey(fromLine: line) else { return false }
|
||||
return !match.active
|
||||
}
|
||||
|
||||
/// Format a single `KEY=value` line. Values containing whitespace or shell
|
||||
/// metacharacters get double-quoted; simple tokens go in unquoted to match
|
||||
/// hermes's own output style.
|
||||
private static func formatLine(key: String, value: String) -> String {
|
||||
if Self.needsQuoting(value) {
|
||||
// Escape embedded backslashes and double quotes, then wrap.
|
||||
let escaped = value
|
||||
.replacingOccurrences(of: "\\", with: "\\\\")
|
||||
.replacingOccurrences(of: "\"", with: "\\\"")
|
||||
return "\(key)=\"\(escaped)\""
|
||||
}
|
||||
return "\(key)=\(value)"
|
||||
}
|
||||
|
||||
private static func needsQuoting(_ value: String) -> Bool {
|
||||
if value.isEmpty { return false }
|
||||
// Whitespace, shell metacharacters, or quotes trigger quoting.
|
||||
let metacharacters: Set<Character> = [" ", "\t", "#", "$", "`", "\"", "'", "\\", "(", ")", "{", "}", "[", "]", "|", "&", ";", "<", ">", "*", "?"]
|
||||
return value.contains(where: { metacharacters.contains($0) })
|
||||
}
|
||||
|
||||
/// Strip one layer of matched double or single quotes from a loaded value.
|
||||
private static func stripEnvQuotes(_ s: String) -> String {
|
||||
guard s.count >= 2 else { return s }
|
||||
let first = s.first!
|
||||
let last = s.last!
|
||||
if (first == "\"" && last == "\"") || (first == "'" && last == "'") {
|
||||
var inner = String(s.dropFirst().dropLast())
|
||||
if first == "\"" {
|
||||
inner = inner
|
||||
.replacingOccurrences(of: "\\\"", with: "\"")
|
||||
.replacingOccurrences(of: "\\\\", with: "\\")
|
||||
}
|
||||
return inner
|
||||
}
|
||||
return s
|
||||
}
|
||||
}
|
||||
@@ -10,91 +10,364 @@ struct HermesFileService: Sendable {
|
||||
}
|
||||
|
||||
private func parseConfig(_ yaml: String) -> HermesConfig {
|
||||
var values: [String: String] = [:]
|
||||
var currentSection = ""
|
||||
var dockerEnv: [String: String] = [:]
|
||||
var commandAllowlist: [String] = []
|
||||
var inDockerEnv = false
|
||||
var inAllowlist = false
|
||||
let parsed = Self.parseNestedYAML(yaml)
|
||||
let values = parsed.values
|
||||
let lists = parsed.lists
|
||||
let maps = parsed.maps
|
||||
|
||||
for line in yaml.components(separatedBy: "\n") {
|
||||
func bool(_ key: String, default def: Bool) -> Bool {
|
||||
guard let v = values[key] else { return def }
|
||||
return v == "true"
|
||||
}
|
||||
func int(_ key: String, default def: Int) -> Int {
|
||||
Int(values[key] ?? "") ?? def
|
||||
}
|
||||
func double(_ key: String, default def: Double) -> Double {
|
||||
Double(values[key] ?? "") ?? def
|
||||
}
|
||||
func str(_ key: String, default def: String = "") -> String {
|
||||
// Strip quotes added by Hermes's YAML dumper around strings with special chars.
|
||||
let raw = values[key] ?? def
|
||||
return Self.stripYAMLQuotes(raw)
|
||||
}
|
||||
|
||||
let dockerEnv = maps["terminal.docker_env"] ?? [:]
|
||||
let commandAllowlist = lists["permanent_allowlist"] ?? lists["command_allowlist"] ?? []
|
||||
|
||||
let display = DisplaySettings(
|
||||
skin: str("display.skin", default: "default"),
|
||||
compact: bool("display.compact", default: false),
|
||||
resumeDisplay: str("display.resume_display", default: "full"),
|
||||
bellOnComplete: bool("display.bell_on_complete", default: false),
|
||||
inlineDiffs: bool("display.inline_diffs", default: true),
|
||||
toolProgressCommand: bool("display.tool_progress_command", default: false),
|
||||
toolPreviewLength: int("display.tool_preview_length", default: 0),
|
||||
busyInputMode: str("display.busy_input_mode", default: "interrupt")
|
||||
)
|
||||
|
||||
let terminal = TerminalSettings(
|
||||
cwd: str("terminal.cwd", default: "."),
|
||||
timeout: int("terminal.timeout", default: 180),
|
||||
envPassthrough: lists["terminal.env_passthrough"] ?? [],
|
||||
persistentShell: bool("terminal.persistent_shell", default: true),
|
||||
dockerImage: str("terminal.docker_image"),
|
||||
dockerMountCwdToWorkspace: bool("terminal.docker_mount_cwd_to_workspace", default: false),
|
||||
dockerForwardEnv: lists["terminal.docker_forward_env"] ?? [],
|
||||
dockerVolumes: lists["terminal.docker_volumes"] ?? [],
|
||||
containerCPU: int("terminal.container_cpu", default: 0),
|
||||
containerMemory: int("terminal.container_memory", default: 0),
|
||||
containerDisk: int("terminal.container_disk", default: 0),
|
||||
containerPersistent: bool("terminal.container_persistent", default: false),
|
||||
modalImage: str("terminal.modal_image"),
|
||||
modalMode: str("terminal.modal_mode", default: "auto"),
|
||||
daytonaImage: str("terminal.daytona_image"),
|
||||
singularityImage: str("terminal.singularity_image")
|
||||
)
|
||||
|
||||
let browser = BrowserSettings(
|
||||
inactivityTimeout: int("browser.inactivity_timeout", default: 120),
|
||||
commandTimeout: int("browser.command_timeout", default: 30),
|
||||
recordSessions: bool("browser.record_sessions", default: false),
|
||||
allowPrivateURLs: bool("browser.allow_private_urls", default: false),
|
||||
camofoxManagedPersistence: bool("browser.camofox.managed_persistence", default: false)
|
||||
)
|
||||
|
||||
let voice = VoiceSettings(
|
||||
recordKey: str("voice.record_key", default: "ctrl+b"),
|
||||
maxRecordingSeconds: int("voice.max_recording_seconds", default: 120),
|
||||
silenceDuration: double("voice.silence_duration", default: 3.0),
|
||||
ttsProvider: str("tts.provider", default: "edge"),
|
||||
ttsEdgeVoice: str("tts.edge.voice", default: "en-US-AriaNeural"),
|
||||
ttsElevenLabsVoiceID: str("tts.elevenlabs.voice_id"),
|
||||
ttsElevenLabsModelID: str("tts.elevenlabs.model_id", default: "eleven_multilingual_v2"),
|
||||
ttsOpenAIModel: str("tts.openai.model", default: "gpt-4o-mini-tts"),
|
||||
ttsOpenAIVoice: str("tts.openai.voice", default: "alloy"),
|
||||
ttsNeuTTSModel: str("tts.neutts.model"),
|
||||
ttsNeuTTSDevice: str("tts.neutts.device", default: "cpu"),
|
||||
sttEnabled: bool("stt.enabled", default: true),
|
||||
sttProvider: str("stt.provider", default: "local"),
|
||||
sttLocalModel: str("stt.local.model", default: "base"),
|
||||
sttLocalLanguage: str("stt.local.language"),
|
||||
sttOpenAIModel: str("stt.openai.model", default: "whisper-1"),
|
||||
sttMistralModel: str("stt.mistral.model", default: "voxtral-mini-latest")
|
||||
)
|
||||
|
||||
func aux(_ name: String) -> AuxiliaryModel {
|
||||
AuxiliaryModel(
|
||||
provider: str("auxiliary.\(name).provider", default: "auto"),
|
||||
model: str("auxiliary.\(name).model"),
|
||||
baseURL: str("auxiliary.\(name).base_url"),
|
||||
apiKey: str("auxiliary.\(name).api_key"),
|
||||
timeout: int("auxiliary.\(name).timeout", default: 30)
|
||||
)
|
||||
}
|
||||
let auxiliary = AuxiliarySettings(
|
||||
vision: aux("vision"),
|
||||
webExtract: aux("web_extract"),
|
||||
compression: aux("compression"),
|
||||
sessionSearch: aux("session_search"),
|
||||
skillsHub: aux("skills_hub"),
|
||||
approval: aux("approval"),
|
||||
mcp: aux("mcp"),
|
||||
flushMemories: aux("flush_memories")
|
||||
)
|
||||
|
||||
let security = SecuritySettings(
|
||||
redactSecrets: bool("security.redact_secrets", default: true),
|
||||
redactPII: bool("privacy.redact_pii", default: false),
|
||||
tirithEnabled: bool("security.tirith_enabled", default: true),
|
||||
tirithPath: str("security.tirith_path", default: "tirith"),
|
||||
tirithTimeout: int("security.tirith_timeout", default: 5),
|
||||
tirithFailOpen: bool("security.tirith_fail_open", default: true),
|
||||
blocklistEnabled: bool("security.website_blocklist.enabled", default: false),
|
||||
blocklistDomains: lists["security.website_blocklist.domains"] ?? []
|
||||
)
|
||||
|
||||
let humanDelay = HumanDelaySettings(
|
||||
mode: str("human_delay.mode", default: "off"),
|
||||
minMS: int("human_delay.min_ms", default: 800),
|
||||
maxMS: int("human_delay.max_ms", default: 2500)
|
||||
)
|
||||
|
||||
let compression = CompressionSettings(
|
||||
enabled: bool("compression.enabled", default: true),
|
||||
threshold: double("compression.threshold", default: 0.5),
|
||||
targetRatio: double("compression.target_ratio", default: 0.2),
|
||||
protectLastN: int("compression.protect_last_n", default: 20)
|
||||
)
|
||||
|
||||
let checkpoints = CheckpointSettings(
|
||||
enabled: bool("checkpoints.enabled", default: true),
|
||||
maxSnapshots: int("checkpoints.max_snapshots", default: 50)
|
||||
)
|
||||
|
||||
let logging = LoggingSettings(
|
||||
level: str("logging.level", default: "INFO"),
|
||||
maxSizeMB: int("logging.max_size_mb", default: 5),
|
||||
backupCount: int("logging.backup_count", default: 3)
|
||||
)
|
||||
|
||||
let delegation = DelegationSettings(
|
||||
model: str("delegation.model"),
|
||||
provider: str("delegation.provider"),
|
||||
baseURL: str("delegation.base_url"),
|
||||
apiKey: str("delegation.api_key"),
|
||||
maxIterations: int("delegation.max_iterations", default: 50)
|
||||
)
|
||||
|
||||
let discord = DiscordSettings(
|
||||
requireMention: bool("discord.require_mention", default: true),
|
||||
freeResponseChannels: str("discord.free_response_channels"),
|
||||
autoThread: bool("discord.auto_thread", default: true),
|
||||
reactions: bool("discord.reactions", default: true)
|
||||
)
|
||||
|
||||
let telegram = TelegramSettings(
|
||||
requireMention: bool("telegram.require_mention", default: true),
|
||||
reactions: bool("telegram.reactions", default: false)
|
||||
)
|
||||
|
||||
// Slack fields live under both `platforms.slack.*` (newer) and `slack.*`
|
||||
// (legacy) in config.yaml. Prefer the newer path but fall back.
|
||||
let slack = SlackSettings(
|
||||
replyToMode: values["platforms.slack.reply_to_mode"] ?? values["slack.reply_to_mode"] ?? "first",
|
||||
requireMention: (values["platforms.slack.require_mention"] ?? values["slack.require_mention"]) != "false",
|
||||
replyInThread: (values["platforms.slack.extra.reply_in_thread"] ?? "true") != "false",
|
||||
replyBroadcast: (values["platforms.slack.extra.reply_broadcast"] ?? "false") == "true"
|
||||
)
|
||||
|
||||
let matrix = MatrixSettings(
|
||||
requireMention: bool("matrix.require_mention", default: true),
|
||||
autoThread: bool("matrix.auto_thread", default: true),
|
||||
dmMentionThreads: bool("matrix.dm_mention_threads", default: false)
|
||||
)
|
||||
|
||||
let mattermost = MattermostSettings(
|
||||
requireMention: bool("mattermost.require_mention", default: true),
|
||||
replyMode: str("mattermost.reply_mode", default: "off")
|
||||
)
|
||||
|
||||
let whatsapp = WhatsAppSettings(
|
||||
unauthorizedDMBehavior: str("whatsapp.unauthorized_dm_behavior", default: "pair"),
|
||||
replyPrefix: str("whatsapp.reply_prefix")
|
||||
)
|
||||
|
||||
// Home Assistant lives under `platforms.homeassistant.extra.*`.
|
||||
let homeAssistant = HomeAssistantSettings(
|
||||
watchDomains: lists["platforms.homeassistant.extra.watch_domains"] ?? [],
|
||||
watchEntities: lists["platforms.homeassistant.extra.watch_entities"] ?? [],
|
||||
watchAll: bool("platforms.homeassistant.extra.watch_all", default: false),
|
||||
ignoreEntities: lists["platforms.homeassistant.extra.ignore_entities"] ?? [],
|
||||
cooldownSeconds: int("platforms.homeassistant.extra.cooldown_seconds", default: 30)
|
||||
)
|
||||
|
||||
return HermesConfig(
|
||||
model: str("model.default", default: "unknown"),
|
||||
provider: str("model.provider", default: "unknown"),
|
||||
maxTurns: int("agent.max_turns", default: 0),
|
||||
personality: str("display.personality", default: "default"),
|
||||
terminalBackend: str("terminal.backend", default: "local"),
|
||||
memoryEnabled: bool("memory.memory_enabled", default: false),
|
||||
memoryCharLimit: int("memory.memory_char_limit", default: 0),
|
||||
userCharLimit: int("memory.user_char_limit", default: 0),
|
||||
nudgeInterval: int("memory.nudge_interval", default: 0),
|
||||
streaming: values["display.streaming"] != "false",
|
||||
showReasoning: bool("display.show_reasoning", default: false),
|
||||
verbose: bool("agent.verbose", default: false),
|
||||
autoTTS: values["voice.auto_tts"] != "false",
|
||||
silenceThreshold: int("voice.silence_threshold", default: QueryDefaults.defaultSilenceThreshold),
|
||||
reasoningEffort: str("agent.reasoning_effort", default: "medium"),
|
||||
showCost: bool("display.show_cost", default: false),
|
||||
approvalMode: str("approvals.mode", default: "manual"),
|
||||
browserBackend: str("browser.backend"),
|
||||
memoryProvider: str("memory.provider"),
|
||||
dockerEnv: dockerEnv,
|
||||
commandAllowlist: commandAllowlist,
|
||||
memoryProfile: str("memory.profile"),
|
||||
serviceTier: str("agent.service_tier", default: "normal"),
|
||||
gatewayNotifyInterval: int("agent.gateway_notify_interval", default: 600),
|
||||
forceIPv4: bool("network.force_ipv4", default: false),
|
||||
contextEngine: str("context.engine", default: "compressor"),
|
||||
interimAssistantMessages: values["display.interim_assistant_messages"] != "false",
|
||||
honchoInitOnSessionStart: bool("honcho.initOnSessionStart", default: false),
|
||||
timezone: str("timezone"),
|
||||
userProfileEnabled: bool("memory.user_profile_enabled", default: true),
|
||||
toolUseEnforcement: str("agent.tool_use_enforcement", default: "auto"),
|
||||
gatewayTimeout: int("agent.gateway_timeout", default: 1800),
|
||||
approvalTimeout: int("approvals.timeout", default: 60),
|
||||
fileReadMaxChars: int("file_read_max_chars", default: 100_000),
|
||||
cronWrapResponse: bool("cron.wrap_response", default: true),
|
||||
prefillMessagesFile: str("prefill_messages_file"),
|
||||
skillsExternalDirs: lists["skills.external_dirs"] ?? [],
|
||||
display: display,
|
||||
terminal: terminal,
|
||||
browser: browser,
|
||||
voice: voice,
|
||||
auxiliary: auxiliary,
|
||||
security: security,
|
||||
humanDelay: humanDelay,
|
||||
compression: compression,
|
||||
checkpoints: checkpoints,
|
||||
logging: logging,
|
||||
delegation: delegation,
|
||||
discord: discord,
|
||||
telegram: telegram,
|
||||
slack: slack,
|
||||
matrix: matrix,
|
||||
mattermost: mattermost,
|
||||
whatsapp: whatsapp,
|
||||
homeAssistant: homeAssistant
|
||||
)
|
||||
}
|
||||
|
||||
/// Parsed YAML result bundle.
|
||||
struct ParsedYAML: Sendable {
|
||||
var values: [String: String] // "section.key" -> scalar string
|
||||
var lists: [String: [String]] // "section.key" -> items from a bullet list
|
||||
var maps: [String: [String: String]] // "section.key" -> nested key-value map
|
||||
}
|
||||
|
||||
/// Parse a subset of YAML into flat dotted paths.
|
||||
///
|
||||
/// Supports:
|
||||
/// - Scalar key-value pairs at any indent level → `values["a.b.c"] = "..."`
|
||||
/// - Empty-valued section headers → acts as a path prefix for nested scalars
|
||||
/// - Bullet lists (`- item`) nested under a `key:` → `lists["a.b"]`
|
||||
/// - Nested maps where a header has no value and children are `k: v` pairs →
|
||||
/// captured as `maps["a.b"]` AND each child as `values["a.b.k"]`.
|
||||
///
|
||||
/// This is sufficient for Hermes config; we do not attempt full YAML compliance.
|
||||
nonisolated static func parseNestedYAML(_ yaml: String) -> ParsedYAML {
|
||||
var values: [String: String] = [:]
|
||||
var lists: [String: [String]] = [:]
|
||||
var maps: [String: [String: String]] = [:]
|
||||
// Path stack: each entry is (indent, name). Pop when indent shrinks.
|
||||
var stack: [(indent: Int, name: String)] = []
|
||||
|
||||
func currentPath(joinedWith child: String? = nil) -> String {
|
||||
var parts = stack.map(\.name)
|
||||
if let child { parts.append(child) }
|
||||
return parts.joined(separator: ".")
|
||||
}
|
||||
|
||||
let rawLines = yaml.components(separatedBy: "\n")
|
||||
for line in rawLines {
|
||||
// Skip comment-only and blank lines but preserve indent semantics.
|
||||
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||
if trimmed.isEmpty || trimmed.hasPrefix("#") { continue }
|
||||
|
||||
let indent = line.prefix(while: { $0 == " " }).count
|
||||
let isListItem = trimmed.hasPrefix("- ")
|
||||
|
||||
// Detect end of nested blocks when indent returns to section level
|
||||
if indent <= 2 && (inDockerEnv || inAllowlist) {
|
||||
inDockerEnv = false
|
||||
inAllowlist = false
|
||||
}
|
||||
|
||||
// Collect docker_env nested key-value pairs
|
||||
if inDockerEnv, indent >= 4, let colonIdx = trimmed.firstIndex(of: ":") {
|
||||
let key = String(trimmed[trimmed.startIndex..<colonIdx]).trimmingCharacters(in: .whitespaces)
|
||||
let val = String(trimmed[trimmed.index(after: colonIdx)...]).trimmingCharacters(in: .whitespaces)
|
||||
dockerEnv[key] = val
|
||||
continue
|
||||
}
|
||||
|
||||
// Collect allowlist items
|
||||
if inAllowlist, indent >= 4, trimmed.hasPrefix("- ") {
|
||||
commandAllowlist.append(String(trimmed.dropFirst(2)))
|
||||
continue
|
||||
}
|
||||
|
||||
if indent == 0 && trimmed.hasSuffix(":") {
|
||||
currentSection = String(trimmed.dropLast())
|
||||
continue
|
||||
}
|
||||
|
||||
if let colonIdx = trimmed.firstIndex(of: ":") {
|
||||
let key = String(trimmed[trimmed.startIndex..<colonIdx]).trimmingCharacters(in: .whitespaces)
|
||||
let val = String(trimmed[trimmed.index(after: colonIdx)...]).trimmingCharacters(in: .whitespaces)
|
||||
|
||||
if key == "docker_env" && val.isEmpty {
|
||||
inDockerEnv = true
|
||||
continue
|
||||
}
|
||||
if key == "permanent_allowlist" && val.isEmpty {
|
||||
inAllowlist = true
|
||||
continue
|
||||
// Pop stack entries with indent >= current indent.
|
||||
// Exception: a list item at the same indent as its parent key is
|
||||
// valid block-style YAML ("toolsets:\n- hermes-cli") — keep the
|
||||
// parent so the item is attributed to it.
|
||||
while let top = stack.last {
|
||||
let shouldPop: Bool
|
||||
if isListItem && top.indent == indent {
|
||||
shouldPop = false
|
||||
} else {
|
||||
shouldPop = top.indent >= indent
|
||||
}
|
||||
if shouldPop { stack.removeLast() } else { break }
|
||||
}
|
||||
|
||||
values[currentSection + "." + key] = val
|
||||
if isListItem {
|
||||
let item = String(trimmed.dropFirst(2)).trimmingCharacters(in: .whitespaces)
|
||||
let stripped = stripYAMLQuotes(item)
|
||||
let path = currentPath()
|
||||
guard !path.isEmpty else { continue }
|
||||
lists[path, default: []].append(stripped)
|
||||
continue
|
||||
}
|
||||
|
||||
// Key-value or section line.
|
||||
guard let colonIdx = trimmed.firstIndex(of: ":") else { continue }
|
||||
let key = String(trimmed[trimmed.startIndex..<colonIdx]).trimmingCharacters(in: .whitespaces)
|
||||
let afterColon = String(trimmed[trimmed.index(after: colonIdx)...]).trimmingCharacters(in: .whitespaces)
|
||||
|
||||
let path = currentPath(joinedWith: key)
|
||||
|
||||
if afterColon.isEmpty || afterColon == "|" || afterColon == ">" {
|
||||
// Section header or empty-valued key — push onto stack so children nest.
|
||||
stack.append((indent: indent, name: key))
|
||||
continue
|
||||
}
|
||||
|
||||
// Inline `{}` / `[]` literals → treat as empty.
|
||||
if afterColon == "{}" {
|
||||
values[path] = ""
|
||||
maps[path] = [:]
|
||||
continue
|
||||
}
|
||||
if afterColon == "[]" {
|
||||
values[path] = ""
|
||||
lists[path] = []
|
||||
continue
|
||||
}
|
||||
|
||||
values[path] = afterColon
|
||||
|
||||
// Also record as a map entry under the parent, so we can treat blocks
|
||||
// like `terminal.docker_env` as `[String: String]` without a separate scan.
|
||||
if !stack.isEmpty {
|
||||
let parentPath = currentPath()
|
||||
maps[parentPath, default: [:]][key] = stripYAMLQuotes(afterColon)
|
||||
}
|
||||
}
|
||||
return ParsedYAML(values: values, lists: lists, maps: maps)
|
||||
}
|
||||
|
||||
return HermesConfig(
|
||||
model: values["model.default"] ?? "unknown",
|
||||
provider: values["model.provider"] ?? "unknown",
|
||||
maxTurns: Int(values["agent.max_turns"] ?? "") ?? 0,
|
||||
personality: values["display.personality"] ?? "default",
|
||||
terminalBackend: values["terminal.backend"] ?? "local",
|
||||
memoryEnabled: values["memory.memory_enabled"] == "true",
|
||||
memoryCharLimit: Int(values["memory.memory_char_limit"] ?? "") ?? 0,
|
||||
userCharLimit: Int(values["memory.user_char_limit"] ?? "") ?? 0,
|
||||
nudgeInterval: Int(values["memory.nudge_interval"] ?? "") ?? 0,
|
||||
streaming: values["display.streaming"] != "false",
|
||||
showReasoning: values["display.show_reasoning"] == "true",
|
||||
verbose: values["agent.verbose"] == "true",
|
||||
autoTTS: values["voice.auto_tts"] != "false",
|
||||
silenceThreshold: Int(values["voice.silence_threshold"] ?? "") ?? QueryDefaults.defaultSilenceThreshold,
|
||||
reasoningEffort: values["agent.reasoning_effort"] ?? "medium",
|
||||
showCost: values["display.show_cost"] == "true",
|
||||
approvalMode: values["approvals.mode"] ?? "manual",
|
||||
browserBackend: values["browser.backend"] ?? "",
|
||||
memoryProvider: values["memory.provider"] ?? "",
|
||||
dockerEnv: dockerEnv,
|
||||
commandAllowlist: commandAllowlist,
|
||||
memoryProfile: values["memory.profile"] ?? "",
|
||||
serviceTier: values["agent.service_tier"] ?? "normal",
|
||||
gatewayNotifyInterval: Int(values["agent.gateway_notify_interval"] ?? "") ?? 600,
|
||||
forceIPv4: values["network.force_ipv4"] == "true",
|
||||
contextEngine: values["context.engine"] ?? "compressor",
|
||||
interimAssistantMessages: values["display.interim_assistant_messages"] != "false",
|
||||
honchoInitOnSessionStart: values["honcho.initOnSessionStart"] == "true"
|
||||
)
|
||||
/// Strip a single layer of surrounding single or double quotes from a YAML scalar.
|
||||
nonisolated static func stripYAMLQuotes(_ s: String) -> String {
|
||||
guard s.count >= 2 else { return s }
|
||||
let first = s.first!
|
||||
let last = s.last!
|
||||
if (first == "'" && last == "'") || (first == "\"" && last == "\"") {
|
||||
return String(s.dropFirst().dropLast())
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// MARK: - Gateway State
|
||||
@@ -323,10 +596,18 @@ struct HermesFileService: Sendable {
|
||||
}.value
|
||||
let elapsed = Date().timeIntervalSince(started)
|
||||
let tools = Self.parseToolListFromTestOutput(result.1)
|
||||
// hermes mcp test exits 0 even when the inner connection fails — it
|
||||
// reports the failure on stdout instead. Look for explicit failure
|
||||
// markers so the UI doesn't show a green check on a broken server.
|
||||
let output = result.1
|
||||
let hasFailureMarker = output.contains("✗")
|
||||
|| output.range(of: "Connection failed", options: .caseInsensitive) != nil
|
||||
|| output.range(of: "No such file or directory", options: .caseInsensitive) != nil
|
||||
|| output.range(of: "Error:", options: .caseInsensitive) != nil
|
||||
return MCPTestResult(
|
||||
serverName: name,
|
||||
succeeded: result.0 == 0,
|
||||
output: result.1,
|
||||
succeeded: result.0 == 0 && !hasFailureMarker,
|
||||
output: output,
|
||||
tools: tools,
|
||||
elapsed: elapsed
|
||||
)
|
||||
@@ -922,12 +1203,190 @@ struct HermesFileService: Sendable {
|
||||
}
|
||||
|
||||
nonisolated func hermesBinaryPath() -> String? {
|
||||
let candidates = [
|
||||
("\(NSHomeDirectory())/.local/bin/hermes"),
|
||||
"/opt/homebrew/bin/hermes",
|
||||
"/usr/local/bin/hermes"
|
||||
]
|
||||
return candidates.first { FileManager.default.isExecutableFile(atPath: $0) }
|
||||
// Single source of truth for install-location candidates lives in
|
||||
// HermesPaths.hermesBinaryCandidates — keeps pipx/brew/manual lookups
|
||||
// consistent across the app.
|
||||
return HermesPaths.hermesBinaryCandidates
|
||||
.first { FileManager.default.isExecutableFile(atPath: $0) }
|
||||
}
|
||||
|
||||
/// Keys queried from the user's login shell. PATH is needed because .app
|
||||
/// bundles launched from Finder/Dock get a minimal PATH (no Homebrew, no
|
||||
/// nvm, no asdf, no mise). The credential keys are needed because Hermes
|
||||
/// resolves AI provider auth by reading env vars — a GUI-launched Scarf
|
||||
/// subprocess sees none of the `export ANTHROPIC_API_KEY=…` lines from
|
||||
/// the user's shell init files.
|
||||
private static let shellEnvKeys: [String] = [
|
||||
"PATH",
|
||||
"ANTHROPIC_API_KEY", "ANTHROPIC_TOKEN", "ANTHROPIC_BASE_URL",
|
||||
"OPENAI_API_KEY", "OPENAI_BASE_URL",
|
||||
"OPENROUTER_API_KEY",
|
||||
"GEMINI_API_KEY", "GOOGLE_API_KEY",
|
||||
"GROQ_API_KEY", "MISTRAL_API_KEY", "XAI_API_KEY",
|
||||
"CLAUDE_CODE_OAUTH_TOKEN"
|
||||
]
|
||||
|
||||
/// Env vars harvested from the user's login shell. Computed once and cached.
|
||||
///
|
||||
/// Probing strategy — two attempts, best result wins:
|
||||
/// 1. `zsh -l -i` (login + interactive) — sources BOTH `.zprofile` and
|
||||
/// `.zshrc`, which is required for nvm/asdf/mise PATH on most setups
|
||||
/// (those tools inject PATH from `.zshrc`, not `.zprofile`).
|
||||
/// Interactive mode can hang on prompt frameworks (oh-my-zsh,
|
||||
/// powerlevel10k, starship) so we suppress prompts via env and bound
|
||||
/// with a 5-second timeout.
|
||||
/// 2. If that yields no PATH (timed out / prompt framework broke it),
|
||||
/// fall back to `zsh -l` (login only) with a 3-second timeout.
|
||||
/// 3. If that also fails, hardcoded sane-default PATH; no credentials.
|
||||
private static let enrichedShellEnv: [String: String] = {
|
||||
// Build a shell script that prints `KEY\0VALUE\0` for each key.
|
||||
// Using printf with \0 as separator lets us unambiguously split the
|
||||
// output even if a value contains newlines.
|
||||
let script = shellEnvKeys.map { key in
|
||||
#"printf '%s\0%s\0' "\#(key)" "$\#(key)""#
|
||||
}.joined(separator: "; ")
|
||||
|
||||
// Attempt 1: login + interactive (covers nvm/asdf/mise in .zshrc).
|
||||
if let result = runShellProbe(script: script, interactive: true, timeout: 5.0),
|
||||
result["PATH"] != nil {
|
||||
return result
|
||||
}
|
||||
// Attempt 2: login only (safe fallback if interactive hangs).
|
||||
if let result = runShellProbe(script: script, interactive: false, timeout: 3.0),
|
||||
result["PATH"] != nil {
|
||||
return result
|
||||
}
|
||||
|
||||
// Fallback when the login shell can't be queried (zsh missing,
|
||||
// sandbox restriction, timeout). Covers Apple Silicon + Intel
|
||||
// Homebrew plus the standard system paths. No credential env is
|
||||
// inferred — the user will see the missing-credentials hint instead.
|
||||
let home = NSHomeDirectory()
|
||||
let fallbackPath = [
|
||||
"\(home)/.local/bin",
|
||||
"/opt/homebrew/bin",
|
||||
"/usr/local/bin",
|
||||
"/usr/bin",
|
||||
"/bin",
|
||||
"/usr/sbin",
|
||||
"/sbin"
|
||||
].joined(separator: ":")
|
||||
return ["PATH": fallbackPath]
|
||||
}()
|
||||
|
||||
/// Runs a zsh probe with the given script and returns the parsed
|
||||
/// `KEY\0VALUE\0`-delimited output. Returns nil on timeout/failure.
|
||||
/// When `interactive` is true, injects env vars that suppress common
|
||||
/// prompt frameworks so the shell doesn't hang waiting for terminal setup.
|
||||
private static func runShellProbe(script: String, interactive: Bool, timeout: TimeInterval) -> [String: String]? {
|
||||
let pipe = Pipe()
|
||||
let errPipe = Pipe()
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: "/bin/zsh")
|
||||
process.arguments = interactive ? ["-l", "-i", "-c", script] : ["-l", "-c", script]
|
||||
process.standardOutput = pipe
|
||||
process.standardError = errPipe
|
||||
|
||||
if interactive {
|
||||
// Defang prompt frameworks so -i doesn't hang on async prompt init.
|
||||
// We still inherit the parent env (HOME, USER etc.) so rc files resolve.
|
||||
var env = ProcessInfo.processInfo.environment
|
||||
env["TERM"] = "dumb" // disables fancy prompt setup
|
||||
env["PS1"] = ""
|
||||
env["PROMPT"] = ""
|
||||
env["RPROMPT"] = ""
|
||||
env["POWERLEVEL9K_INSTANT_PROMPT"] = "off" // p10k
|
||||
env["STARSHIP_DISABLE"] = "1" // starship (some versions)
|
||||
env["ZSH_DISABLE_COMPFIX"] = "true" // oh-my-zsh compaudit hang
|
||||
process.environment = env
|
||||
}
|
||||
|
||||
defer {
|
||||
try? pipe.fileHandleForReading.close()
|
||||
try? pipe.fileHandleForWriting.close()
|
||||
try? errPipe.fileHandleForReading.close()
|
||||
try? errPipe.fileHandleForWriting.close()
|
||||
}
|
||||
do {
|
||||
try process.run()
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while process.isRunning && Date() < deadline {
|
||||
Thread.sleep(forTimeInterval: 0.05)
|
||||
}
|
||||
if process.isRunning {
|
||||
process.terminate()
|
||||
// Brief grace period for SIGTERM to take; then the defer
|
||||
// cleanup closes the pipes regardless.
|
||||
Thread.sleep(forTimeInterval: 0.1)
|
||||
return nil
|
||||
}
|
||||
process.waitUntilExit()
|
||||
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
||||
guard process.terminationStatus == 0, !data.isEmpty else { return nil }
|
||||
var result: [String: String] = [:]
|
||||
let parts = data.split(separator: 0, omittingEmptySubsequences: false)
|
||||
var i = 0
|
||||
while i + 1 < parts.count {
|
||||
if let key = String(data: Data(parts[i]), encoding: .utf8),
|
||||
let value = String(data: Data(parts[i + 1]), encoding: .utf8),
|
||||
!key.isEmpty, !value.isEmpty {
|
||||
result[key] = value
|
||||
}
|
||||
i += 2
|
||||
}
|
||||
return result.isEmpty ? nil : result
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Environment to hand any subprocess that may itself spawn user-installed
|
||||
/// binaries (Hermes spawning MCP servers, ACP tool calls, etc.). Starts
|
||||
/// from ProcessInfo.environment and overlays PATH + allowlisted credential
|
||||
/// env vars harvested from the user's login shell.
|
||||
nonisolated static func enrichedEnvironment() -> [String: String] {
|
||||
var env = ProcessInfo.processInfo.environment
|
||||
for (key, value) in enrichedShellEnv where !value.isEmpty {
|
||||
// Shell wins for PATH (we explicitly want the enriched one). For
|
||||
// credential keys, also let the shell win — GUI env rarely has
|
||||
// them, and if it does, the shell-exported value is usually the
|
||||
// one the user actually maintains.
|
||||
env[key] = value
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
/// True if any known AI-provider credential is reachable — either already
|
||||
/// in the current process env, present in the login-shell env we queried,
|
||||
/// or present in `~/.hermes/.env`. Used by Chat to warn the user before
|
||||
/// `hermes acp` fails on send with "No Anthropic credentials found".
|
||||
nonisolated static func hasAnyAICredential() -> Bool {
|
||||
let credentialKeys = shellEnvKeys.filter { $0 != "PATH" && $0 != "ANTHROPIC_BASE_URL" && $0 != "OPENAI_BASE_URL" }
|
||||
let env = enrichedEnvironment()
|
||||
for key in credentialKeys {
|
||||
if let value = env[key], !value.isEmpty {
|
||||
return true
|
||||
}
|
||||
}
|
||||
// Scan ~/.hermes/.env for KEY= lines. Uses a simple substring check —
|
||||
// good enough for a preflight hint; hermes itself does the real parse.
|
||||
let envPath = HermesPaths.home + "/.env"
|
||||
if let data = try? String(contentsOfFile: envPath, encoding: .utf8) {
|
||||
for line in data.split(separator: "\n") {
|
||||
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||
if trimmed.isEmpty || trimmed.hasPrefix("#") { continue }
|
||||
for key in credentialKeys where trimmed.hasPrefix("\(key)=") || trimmed.hasPrefix("export \(key)=") {
|
||||
// Must have a non-empty value after `=`
|
||||
if let eq = trimmed.firstIndex(of: "="),
|
||||
trimmed.index(after: eq) < trimmed.endIndex {
|
||||
let value = trimmed[trimmed.index(after: eq)...]
|
||||
.trimmingCharacters(in: CharacterSet(charactersIn: "\"' "))
|
||||
if !value.isEmpty { return true }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
@@ -939,6 +1398,7 @@ struct HermesFileService: Sendable {
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: binary)
|
||||
process.arguments = args
|
||||
process.environment = Self.enrichedEnvironment()
|
||||
process.standardOutput = stdoutPipe
|
||||
process.standardError = stderrPipe
|
||||
if let stdinPipe { process.standardInput = stdinPipe }
|
||||
|
||||
@@ -12,6 +12,7 @@ final class HermesFileWatcher {
|
||||
HermesPaths.stateDB,
|
||||
HermesPaths.stateDB + "-wal",
|
||||
HermesPaths.configYAML,
|
||||
HermesPaths.home + "/.env", // Platform setup forms write here.
|
||||
HermesPaths.memoryMD,
|
||||
HermesPaths.userMD,
|
||||
HermesPaths.cronJobsJSON,
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
/// A single model from the models.dev catalog shipped with hermes.
|
||||
struct HermesModelInfo: Sendable, Identifiable, Hashable {
|
||||
var id: String { providerID + ":" + modelID }
|
||||
|
||||
let providerID: String
|
||||
let providerName: String
|
||||
let modelID: String
|
||||
let modelName: String
|
||||
let contextWindow: Int?
|
||||
let maxOutput: Int?
|
||||
let costInput: Double? // USD per 1M input tokens
|
||||
let costOutput: Double? // USD per 1M output tokens
|
||||
let reasoning: Bool
|
||||
let toolCall: Bool
|
||||
let releaseDate: String?
|
||||
|
||||
/// Display-friendly cost string, or nil if cost is unknown.
|
||||
var costDisplay: String? {
|
||||
guard let input = costInput, let output = costOutput else { return nil }
|
||||
return String(format: "$%.2f / $%.2f", input, output)
|
||||
}
|
||||
|
||||
/// Display-friendly context window ("200K", "1M", etc.).
|
||||
var contextDisplay: String? {
|
||||
guard let ctx = contextWindow else { return nil }
|
||||
if ctx >= 1_000_000 { return "\(ctx / 1_000_000)M" }
|
||||
if ctx >= 1_000 { return "\(ctx / 1_000)K" }
|
||||
return "\(ctx)"
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider summary — one row in the left column of the picker.
|
||||
struct HermesProviderInfo: Sendable, Identifiable, Hashable {
|
||||
var id: String { providerID }
|
||||
|
||||
let providerID: String
|
||||
let providerName: String
|
||||
let envVars: [String] // e.g. ["ANTHROPIC_API_KEY"]
|
||||
let docURL: String?
|
||||
let modelCount: Int
|
||||
}
|
||||
|
||||
/// Reads the models.dev catalog that hermes caches at
|
||||
/// `~/.hermes/models_dev_cache.json`. Offline-capable, fast enough to read per
|
||||
/// call (~1500 models across ~110 providers).
|
||||
///
|
||||
/// We decode a trimmed subset so unknown fields don't break loading. Every
|
||||
/// field we care about is optional on disk — providers may omit cost, context
|
||||
/// limits, etc.
|
||||
struct ModelCatalogService: Sendable {
|
||||
private let logger = Logger(subsystem: "com.scarf", category: "ModelCatalogService")
|
||||
let path: String
|
||||
|
||||
init(path: String = HermesPaths.home + "/models_dev_cache.json") {
|
||||
self.path = path
|
||||
}
|
||||
|
||||
/// All providers, sorted by display name.
|
||||
func loadProviders() -> [HermesProviderInfo] {
|
||||
guard let catalog = loadCatalog() else { return [] }
|
||||
return catalog
|
||||
.map { (id, p) in
|
||||
HermesProviderInfo(
|
||||
providerID: id,
|
||||
providerName: p.name ?? id,
|
||||
envVars: p.env ?? [],
|
||||
docURL: p.doc,
|
||||
modelCount: p.models?.count ?? 0
|
||||
)
|
||||
}
|
||||
.sorted { $0.providerName.localizedCaseInsensitiveCompare($1.providerName) == .orderedAscending }
|
||||
}
|
||||
|
||||
/// Models for one provider, sorted by release date (newest first), then name.
|
||||
func loadModels(for providerID: String) -> [HermesModelInfo] {
|
||||
guard let catalog = loadCatalog(), let provider = catalog[providerID] else { return [] }
|
||||
let providerName = provider.name ?? providerID
|
||||
let models = (provider.models ?? [:]).map { (id, m) in
|
||||
HermesModelInfo(
|
||||
providerID: providerID,
|
||||
providerName: providerName,
|
||||
modelID: id,
|
||||
modelName: m.name ?? id,
|
||||
contextWindow: m.limit?.context,
|
||||
maxOutput: m.limit?.output,
|
||||
costInput: m.cost?.input,
|
||||
costOutput: m.cost?.output,
|
||||
reasoning: m.reasoning ?? false,
|
||||
toolCall: m.tool_call ?? false,
|
||||
releaseDate: m.release_date
|
||||
)
|
||||
}
|
||||
return models.sorted { lhs, rhs in
|
||||
// Newest-first by release date if both are known; otherwise fall
|
||||
// back to alphabetical on display name.
|
||||
if let lDate = lhs.releaseDate, let rDate = rhs.releaseDate, lDate != rDate {
|
||||
return lDate > rDate
|
||||
}
|
||||
return lhs.modelName.localizedCaseInsensitiveCompare(rhs.modelName) == .orderedAscending
|
||||
}
|
||||
}
|
||||
|
||||
/// Find the provider that ships a given model ID. Useful for auto-syncing
|
||||
/// provider when the user picks a model from a flat list or types one in.
|
||||
func provider(for modelID: String) -> HermesProviderInfo? {
|
||||
guard let catalog = loadCatalog() else { return nil }
|
||||
for (providerID, p) in catalog {
|
||||
if p.models?[modelID] != nil {
|
||||
return HermesProviderInfo(
|
||||
providerID: providerID,
|
||||
providerName: p.name ?? providerID,
|
||||
envVars: p.env ?? [],
|
||||
docURL: p.doc,
|
||||
modelCount: p.models?.count ?? 0
|
||||
)
|
||||
}
|
||||
}
|
||||
// Handle provider-prefixed IDs like "openai/gpt-4o" — look up the
|
||||
// prefix before the slash.
|
||||
if let slash = modelID.firstIndex(of: "/") {
|
||||
let prefix = String(modelID[modelID.startIndex..<slash])
|
||||
if let p = catalog[prefix] {
|
||||
return HermesProviderInfo(
|
||||
providerID: prefix,
|
||||
providerName: p.name ?? prefix,
|
||||
envVars: p.env ?? [],
|
||||
docURL: p.doc,
|
||||
modelCount: p.models?.count ?? 0
|
||||
)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Look up a specific model by provider + ID. Returns nil if not in the
|
||||
/// catalog (e.g., free-typed custom model).
|
||||
func model(providerID: String, modelID: String) -> HermesModelInfo? {
|
||||
guard let catalog = loadCatalog(),
|
||||
let provider = catalog[providerID],
|
||||
let raw = provider.models?[modelID] else { return nil }
|
||||
return HermesModelInfo(
|
||||
providerID: providerID,
|
||||
providerName: provider.name ?? providerID,
|
||||
modelID: modelID,
|
||||
modelName: raw.name ?? modelID,
|
||||
contextWindow: raw.limit?.context,
|
||||
maxOutput: raw.limit?.output,
|
||||
costInput: raw.cost?.input,
|
||||
costOutput: raw.cost?.output,
|
||||
reasoning: raw.reasoning ?? false,
|
||||
toolCall: raw.tool_call ?? false,
|
||||
releaseDate: raw.release_date
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Decoding
|
||||
|
||||
private func loadCatalog() -> [String: ProviderEntry]? {
|
||||
guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)) else {
|
||||
return nil
|
||||
}
|
||||
do {
|
||||
return try JSONDecoder().decode([String: ProviderEntry].self, from: data)
|
||||
} catch {
|
||||
logger.error("Failed to decode models_dev_cache.json: \(error.localizedDescription)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Trimmed representations — we decode a subset of fields and tolerate
|
||||
// anything new hermes adds later. `snake_case` field names match the file.
|
||||
private struct ProviderEntry: Decodable {
|
||||
let id: String?
|
||||
let name: String?
|
||||
let env: [String]?
|
||||
let doc: String?
|
||||
let models: [String: ModelEntry]?
|
||||
}
|
||||
|
||||
private struct ModelEntry: Decodable {
|
||||
let name: String?
|
||||
let reasoning: Bool?
|
||||
let tool_call: Bool?
|
||||
let release_date: String?
|
||||
let cost: CostEntry?
|
||||
let limit: LimitEntry?
|
||||
}
|
||||
|
||||
private struct CostEntry: Decodable {
|
||||
let input: Double?
|
||||
let output: Double?
|
||||
}
|
||||
|
||||
private struct LimitEntry: Decodable {
|
||||
let context: Int?
|
||||
let output: Int?
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import Foundation
|
||||
import Sparkle
|
||||
|
||||
/// Thin wrapper around Sparkle's `SPUStandardUpdaterController`.
|
||||
///
|
||||
/// Sparkle reads `SUFeedURL`, `SUPublicEDKey`, and check-interval defaults from Info.plist.
|
||||
/// This service exposes the bits the UI needs: a "check now" trigger, a toggle for automatic
|
||||
/// checks, and observable state for the Settings screen.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class UpdaterService: NSObject {
|
||||
private let controller: SPUStandardUpdaterController
|
||||
|
||||
/// User-facing toggle. Mirrors `updater.automaticallyChecksForUpdates`.
|
||||
var automaticallyChecksForUpdates: Bool {
|
||||
get { controller.updater.automaticallyChecksForUpdates }
|
||||
set { controller.updater.automaticallyChecksForUpdates = newValue }
|
||||
}
|
||||
|
||||
/// Last time Sparkle checked the appcast (nil before the first check).
|
||||
var lastUpdateCheckDate: Date? {
|
||||
controller.updater.lastUpdateCheckDate
|
||||
}
|
||||
|
||||
override init() {
|
||||
// startingUpdater: true → Sparkle scans for updates on launch per Info.plist schedule.
|
||||
// Default delegates are sufficient for a non-sandboxed app.
|
||||
self.controller = SPUStandardUpdaterController(
|
||||
startingUpdater: true,
|
||||
updaterDelegate: nil,
|
||||
userDriverDelegate: nil
|
||||
)
|
||||
super.init()
|
||||
}
|
||||
|
||||
/// Triggers a user-initiated update check. Sparkle handles the UI (alert, progress, install).
|
||||
func checkForUpdates() {
|
||||
controller.checkForUpdates(nil)
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,14 @@ final class ChatViewModel {
|
||||
var isACPConnected: Bool { acpClient != nil && hasActiveProcess }
|
||||
var acpStatus: String = ""
|
||||
var acpError: String?
|
||||
/// Human-readable hint derived from error + stderr (e.g. "set ANTHROPIC_API_KEY").
|
||||
/// Shown above the raw error in the UI when present.
|
||||
var acpErrorHint: String?
|
||||
/// Tail of stderr captured from `hermes acp` at the time of the last
|
||||
/// failure — shown in a collapsible details section so users can copy/paste.
|
||||
var acpErrorDetails: String?
|
||||
/// True when `hasAnyAICredential()` returned false at last preflight.
|
||||
var missingCredentials: Bool = false
|
||||
|
||||
private static let maxReconnectAttempts = 5
|
||||
private static let reconnectBaseDelay: UInt64 = 1_000_000_000 // 1 second
|
||||
@@ -39,6 +47,34 @@ final class ChatViewModel {
|
||||
FileManager.default.fileExists(atPath: HermesPaths.hermesBinary)
|
||||
}
|
||||
|
||||
/// Re-checks env + `~/.hermes/.env` for AI-provider credentials and
|
||||
/// updates `missingCredentials`. Cheap — safe to call from view `.task`.
|
||||
func refreshCredentialPreflight() {
|
||||
missingCredentials = !HermesFileService.hasAnyAICredential()
|
||||
}
|
||||
|
||||
/// Clears the error/hint/details triplet so future failures overwrite
|
||||
/// cleanly instead of stacking on top of stale state.
|
||||
private func clearACPErrorState() {
|
||||
acpError = nil
|
||||
acpErrorHint = nil
|
||||
acpErrorDetails = nil
|
||||
}
|
||||
|
||||
/// Populates acpError, acpErrorHint, acpErrorDetails from an error + the
|
||||
/// stderr tail the ACP client captured, and logs the failure with a
|
||||
/// site-specific context label. Call on any failure path.
|
||||
@MainActor
|
||||
private func recordACPFailure(_ error: Error, client: ACPClient?, context: String) async {
|
||||
let msg = error.localizedDescription
|
||||
logger.error("\(context): \(msg)")
|
||||
let stderrTail = await client?.recentStderr ?? ""
|
||||
let hint = ACPErrorHint.classify(errorMessage: msg, stderrTail: stderrTail)
|
||||
acpError = msg
|
||||
acpErrorHint = hint
|
||||
acpErrorDetails = stderrTail.isEmpty ? nil : stderrTail
|
||||
}
|
||||
|
||||
// MARK: - Session Lifecycle
|
||||
|
||||
func startNewSession() {
|
||||
@@ -157,10 +193,8 @@ final class ChatViewModel {
|
||||
// Now send the queued prompt
|
||||
sendViaACP(client: client, text: text)
|
||||
} catch {
|
||||
let msg = error.localizedDescription
|
||||
logger.error("Auto-start ACP failed: \(msg)")
|
||||
acpStatus = "Failed"
|
||||
acpError = msg
|
||||
await recordACPFailure(error, client: client, context: "Auto-start ACP failed")
|
||||
hasActiveProcess = false
|
||||
acpClient = nil
|
||||
}
|
||||
@@ -169,6 +203,7 @@ final class ChatViewModel {
|
||||
|
||||
private func sendViaACP(client: ACPClient, text: String) {
|
||||
guard let sessionId = richChatViewModel.sessionId else {
|
||||
clearACPErrorState()
|
||||
acpError = "No session ID — cannot send"
|
||||
return
|
||||
}
|
||||
@@ -192,10 +227,8 @@ final class ChatViewModel {
|
||||
} catch is CancellationError {
|
||||
acpStatus = "Cancelled"
|
||||
} catch {
|
||||
let msg = error.localizedDescription
|
||||
logger.error("ACP prompt failed: \(msg)")
|
||||
acpStatus = "Error"
|
||||
acpError = msg
|
||||
await recordACPFailure(error, client: client, context: "ACP prompt failed")
|
||||
richChatViewModel.handleACPEvent(
|
||||
.promptComplete(sessionId: sessionId, response: ACPPromptResult(
|
||||
stopReason: "error",
|
||||
@@ -211,7 +244,7 @@ final class ChatViewModel {
|
||||
|
||||
private func startACPSession(resume sessionId: String?) {
|
||||
stopACP()
|
||||
acpError = nil
|
||||
clearACPErrorState()
|
||||
acpStatus = "Starting..."
|
||||
|
||||
let client = ACPClient()
|
||||
@@ -259,10 +292,8 @@ final class ChatViewModel {
|
||||
|
||||
logger.info("ACP session ready: \(resolvedSessionId)")
|
||||
} catch {
|
||||
let msg = error.localizedDescription
|
||||
logger.error("Failed to start ACP session: \(msg)")
|
||||
acpStatus = "Failed"
|
||||
acpError = msg
|
||||
await recordACPFailure(error, client: client, context: "Failed to start ACP session")
|
||||
hasActiveProcess = false
|
||||
acpClient = nil
|
||||
}
|
||||
@@ -333,7 +364,7 @@ final class ChatViewModel {
|
||||
|
||||
private func attemptReconnect(sessionId: String) {
|
||||
reconnectTask?.cancel()
|
||||
acpError = nil
|
||||
clearACPErrorState()
|
||||
|
||||
reconnectTask = Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
@@ -379,7 +410,7 @@ final class ChatViewModel {
|
||||
await richChatViewModel.reconcileWithDB(sessionId: resolvedSessionId)
|
||||
|
||||
acpStatus = "Reconnected (\(resolvedSessionId.prefix(12)))"
|
||||
acpError = nil
|
||||
clearACPErrorState()
|
||||
|
||||
startACPEventLoop(client: client)
|
||||
startHealthMonitor(client: client)
|
||||
@@ -404,6 +435,7 @@ final class ChatViewModel {
|
||||
private func showConnectionFailure() {
|
||||
richChatViewModel.handleACPEvent(.connectionLost(reason: "The ACP process terminated unexpectedly"))
|
||||
acpStatus = "Connection lost"
|
||||
clearACPErrorState()
|
||||
acpError = "Connection lost. Use the Session menu to reconnect."
|
||||
}
|
||||
|
||||
|
||||
@@ -3,18 +3,113 @@ import SwiftUI
|
||||
struct ChatView: View {
|
||||
@Environment(ChatViewModel.self) private var viewModel
|
||||
@Environment(HermesFileWatcher.self) private var fileWatcher
|
||||
@State private var showErrorDetails = false
|
||||
|
||||
var body: some View {
|
||||
@Bindable var vm = viewModel
|
||||
VStack(spacing: 0) {
|
||||
toolbar
|
||||
Divider()
|
||||
errorBanner
|
||||
chatArea
|
||||
}
|
||||
.navigationTitle("Chat")
|
||||
.task { await viewModel.loadRecentSessions() }
|
||||
.task {
|
||||
await viewModel.loadRecentSessions()
|
||||
viewModel.refreshCredentialPreflight()
|
||||
}
|
||||
.onChange(of: fileWatcher.lastChangeDate) {
|
||||
Task { await viewModel.loadRecentSessions() }
|
||||
viewModel.refreshCredentialPreflight()
|
||||
}
|
||||
}
|
||||
|
||||
/// Banner rendered between the toolbar and the chat area when either
|
||||
/// (a) a preflight credential check failed, or (b) the ACP subprocess
|
||||
/// returned an error we captured. Shows a short hint + expandable raw
|
||||
/// details (stderr tail) that the user can copy to the clipboard.
|
||||
@ViewBuilder
|
||||
private var errorBanner: some View {
|
||||
if let err = viewModel.acpError {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(.orange)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
if let hint = viewModel.acpErrorHint {
|
||||
Text(hint)
|
||||
.font(.callout)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
Text(err)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
.lineLimit(showErrorDetails ? nil : 2)
|
||||
}
|
||||
Spacer()
|
||||
if viewModel.acpErrorDetails != nil {
|
||||
Button(showErrorDetails ? "Hide details" : "Show details") {
|
||||
showErrorDetails.toggle()
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.controlSize(.small)
|
||||
}
|
||||
Button {
|
||||
let payload = [viewModel.acpErrorHint, err, viewModel.acpErrorDetails]
|
||||
.compactMap { $0 }
|
||||
.joined(separator: "\n\n")
|
||||
let pb = NSPasteboard.general
|
||||
pb.clearContents()
|
||||
pb.setString(payload, forType: .string)
|
||||
} label: {
|
||||
Image(systemName: "doc.on.doc")
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.help("Copy error details")
|
||||
}
|
||||
if showErrorDetails, let details = viewModel.acpErrorDetails {
|
||||
ScrollView {
|
||||
Text(details)
|
||||
.font(.system(.caption2, design: .monospaced))
|
||||
.textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.frame(maxHeight: 160)
|
||||
.padding(8)
|
||||
.background(Color(nsColor: .textBackgroundColor))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
}
|
||||
.padding(10)
|
||||
.background(Color.orange.opacity(0.08))
|
||||
.overlay(
|
||||
Rectangle()
|
||||
.fill(Color.orange.opacity(0.25))
|
||||
.frame(height: 1),
|
||||
alignment: .bottom
|
||||
)
|
||||
} else if viewModel.missingCredentials && !viewModel.hasActiveProcess {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "key.fill")
|
||||
.foregroundStyle(.orange)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("No AI provider credentials detected")
|
||||
.font(.callout)
|
||||
Text("Add `ANTHROPIC_API_KEY` (or similar) to `~/.hermes/.env` or your shell profile, then restart Scarf.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(10)
|
||||
.background(Color.orange.opacity(0.08))
|
||||
.overlay(
|
||||
Rectangle()
|
||||
.fill(Color.orange.opacity(0.25))
|
||||
.frame(height: 1),
|
||||
alignment: .bottom
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
import Foundation
|
||||
import AppKit
|
||||
import os
|
||||
|
||||
/// A single pooled credential for a provider (rotation entry).
|
||||
struct HermesCredential: Identifiable, Sendable, Equatable {
|
||||
var id: String { "\(provider):\(index):\(internalID)" }
|
||||
let internalID: String // Stable id from auth.json (e.g. "9f8d9b")
|
||||
let provider: String
|
||||
let index: Int // 0-based index in the provider's pool
|
||||
let label: String // Human label ("OPENROUTER_API_KEY")
|
||||
let authType: String // "api_key" | "oauth"
|
||||
let source: String // "env:OPENROUTER_API_KEY" | "gh_cli" | "file:..."
|
||||
let tokenTail: String // Last 4 chars of the token — NEVER store full token in UI state
|
||||
let lastStatus: String // "ok" | "cooldown" | "exhausted" | ""
|
||||
let requestCount: Int
|
||||
}
|
||||
|
||||
/// Summary of one provider's pool with its rotation strategy.
|
||||
struct HermesCredentialPool: Identifiable, Sendable {
|
||||
var id: String { provider }
|
||||
let provider: String
|
||||
let strategy: String // "fill_first" | "round_robin" | "least_used" | "random"
|
||||
let credentials: [HermesCredential]
|
||||
}
|
||||
|
||||
@Observable
|
||||
@MainActor
|
||||
final class CredentialPoolsViewModel {
|
||||
private let logger = Logger(subsystem: "com.scarf", category: "CredentialPoolsViewModel")
|
||||
|
||||
var pools: [HermesCredentialPool] = []
|
||||
var isLoading = false
|
||||
var message: String?
|
||||
|
||||
/// Driver for the OAuth flow. Uses Process + pipes (not SwiftTerm) so we
|
||||
/// can extract the authorization URL, pop it open with an explicit button,
|
||||
/// and feed the code back via stdin. See OAuthFlowController for why we
|
||||
/// moved off the embedded-terminal approach.
|
||||
let oauthFlow = OAuthFlowController()
|
||||
var oauthProvider: String = ""
|
||||
/// Convenience — the sheet keys a lot of UI off "is the flow running?".
|
||||
var oauthInProgress: Bool { oauthFlow.isRunning }
|
||||
|
||||
let strategyOptions = ["fill_first", "round_robin", "least_used", "random"]
|
||||
|
||||
/// Source of truth is `~/.hermes/auth.json`. Parsing box-drawn `hermes auth list`
|
||||
/// output is fragile — the JSON file is structured, stable, and already stores
|
||||
/// exactly the pool data the UI needs. We never display full tokens.
|
||||
func load() {
|
||||
isLoading = true
|
||||
defer { isLoading = false }
|
||||
|
||||
let authPath = HermesPaths.home + "/auth.json"
|
||||
let strategies = parseStrategies()
|
||||
|
||||
guard let data = try? Data(contentsOf: URL(fileURLWithPath: authPath)) else {
|
||||
pools = []
|
||||
return
|
||||
}
|
||||
do {
|
||||
let decoded = try JSONDecoder().decode(AuthFile.self, from: data)
|
||||
pools = Self.buildPools(from: decoded, strategies: strategies)
|
||||
} catch {
|
||||
logger.error("Failed to decode auth.json: \(error.localizedDescription)")
|
||||
pools = []
|
||||
}
|
||||
}
|
||||
|
||||
/// The `credential_pool_strategies:` map lives in config.yaml as `<provider>: <strategy>`.
|
||||
private func parseStrategies() -> [String: String] {
|
||||
guard let yaml = try? String(contentsOfFile: HermesPaths.configYAML, encoding: .utf8) else { return [:] }
|
||||
let parsed = HermesFileService.parseNestedYAML(yaml)
|
||||
return parsed.maps["credential_pool_strategies"] ?? [:]
|
||||
}
|
||||
|
||||
private static func buildPools(from auth: AuthFile, strategies: [String: String]) -> [HermesCredentialPool] {
|
||||
auth.credential_pool.keys.sorted().map { provider in
|
||||
let entries = auth.credential_pool[provider] ?? []
|
||||
let creds = entries.enumerated().map { index, entry in
|
||||
HermesCredential(
|
||||
internalID: entry.id ?? "",
|
||||
provider: provider,
|
||||
index: index,
|
||||
label: entry.label ?? entry.source ?? "",
|
||||
authType: entry.auth_type ?? "",
|
||||
source: entry.source ?? "",
|
||||
tokenTail: Self.tail(of: entry.access_token ?? ""),
|
||||
lastStatus: entry.last_status ?? "",
|
||||
requestCount: entry.request_count ?? 0
|
||||
)
|
||||
}
|
||||
return HermesCredentialPool(
|
||||
provider: provider,
|
||||
strategy: strategies[provider] ?? "fill_first",
|
||||
credentials: creds
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Return last 4 chars prefixed with "…", or "" if the token is too short.
|
||||
/// Callers MUST NOT pass the full token anywhere user-visible beyond this.
|
||||
private static func tail(of token: String) -> String {
|
||||
guard token.count >= 4 else { return "" }
|
||||
return "…" + String(token.suffix(4))
|
||||
}
|
||||
|
||||
// MARK: - Mutations (all routed through the hermes CLI so hermes stays authoritative)
|
||||
|
||||
func setStrategy(_ strategy: String, for provider: String) {
|
||||
let result = runHermes(["config", "set", "credential_pool_strategies.\(provider)", strategy])
|
||||
if result.exitCode == 0 {
|
||||
message = "Strategy updated for \(provider)"
|
||||
load()
|
||||
} else {
|
||||
message = "Failed to update strategy"
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
|
||||
self?.message = nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Add an API-key credential to a provider's pool. Runs non-interactively.
|
||||
///
|
||||
/// **Critical:** we must pass `--type api-key` in addition to `--api-key`.
|
||||
/// Without `--type`, hermes falls back to the provider's default (OAuth for
|
||||
/// Anthropic, etc.) and launches the browser flow even though the user
|
||||
/// just gave us a key.
|
||||
func addAPIKey(provider: String, apiKey: String, label: String) {
|
||||
var args = ["auth", "add", provider, "--type", "api-key", "--api-key", apiKey]
|
||||
let trimmedLabel = label.trimmingCharacters(in: .whitespaces)
|
||||
if !trimmedLabel.isEmpty {
|
||||
args += ["--label", trimmedLabel]
|
||||
}
|
||||
let result = runHermes(args)
|
||||
if result.exitCode == 0 {
|
||||
message = "Credential added"
|
||||
load()
|
||||
} else {
|
||||
logger.warning("Add credential failed: \(result.output)")
|
||||
message = "Add failed: \(result.output.prefix(160))"
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
||||
self?.message = nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Kick off the OAuth flow. Uses OAuthFlowController (Process + pipes) so
|
||||
/// we can detect the authorization URL from hermes's output, open the
|
||||
/// browser ourselves, and feed the code back via stdin — avoiding the
|
||||
/// subprocess-can't-open-browser problem SwiftTerm had.
|
||||
func startOAuth(provider: String, label: String) {
|
||||
guard !provider.isEmpty else { return }
|
||||
oauthProvider = provider
|
||||
|
||||
oauthFlow.onExit = { [weak self] _ in
|
||||
guard let self else { return }
|
||||
self.message = self.oauthFlow.succeeded
|
||||
? "OAuth login succeeded"
|
||||
: (self.oauthFlow.errorMessage ?? "OAuth login failed or cancelled")
|
||||
// Reload regardless — hermes may have written a partial credential
|
||||
// even on a soft failure, and we want the list to reflect truth.
|
||||
self.load()
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 4) { [weak self] in
|
||||
self?.message = nil
|
||||
}
|
||||
}
|
||||
|
||||
oauthFlow.start(provider: provider, label: label)
|
||||
}
|
||||
|
||||
/// Submit the authorization code the user pasted into the form's text
|
||||
/// field. Writes it to hermes's stdin.
|
||||
func submitOAuthCode(_ code: String) {
|
||||
oauthFlow.submitCode(code)
|
||||
}
|
||||
|
||||
/// Cancel an in-progress OAuth attempt (e.g., user closed the sheet).
|
||||
func cancelOAuth() {
|
||||
oauthFlow.stop()
|
||||
}
|
||||
|
||||
func removeCredential(provider: String, index: Int) {
|
||||
// The CLI uses 1-based indexing ("#1", "#2" in `hermes auth list`); our
|
||||
// stored `index` is 0-based, so add 1 when handing to the CLI.
|
||||
let result = runHermes(["auth", "remove", provider, String(index + 1)])
|
||||
if result.exitCode == 0 {
|
||||
message = "Credential removed"
|
||||
load()
|
||||
} else {
|
||||
message = "Remove failed"
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
|
||||
self?.message = nil
|
||||
}
|
||||
}
|
||||
|
||||
func resetProvider(_ provider: String) {
|
||||
let result = runHermes(["auth", "reset", provider])
|
||||
message = result.exitCode == 0 ? "Cooldowns cleared for \(provider)" : "Reset failed"
|
||||
load()
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
|
||||
self?.message = nil
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func runHermes(_ arguments: [String]) -> (output: String, exitCode: Int32) {
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: HermesPaths.hermesBinary)
|
||||
process.arguments = arguments
|
||||
process.environment = HermesFileService.enrichedEnvironment()
|
||||
let pipe = Pipe()
|
||||
process.standardOutput = pipe
|
||||
process.standardError = Pipe()
|
||||
do {
|
||||
try process.run()
|
||||
process.waitUntilExit()
|
||||
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
||||
return (String(data: data, encoding: .utf8) ?? "", process.terminationStatus)
|
||||
} catch {
|
||||
return ("", -1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - auth.json decoding
|
||||
// Shape verified against a real `~/.hermes/auth.json` — see sample in plan notes.
|
||||
// All fields are optional because the format evolves and we want decoding to
|
||||
// succeed even if hermes adds new keys or omits some for certain auth types.
|
||||
|
||||
private struct AuthFile: Decodable {
|
||||
let credential_pool: [String: [AuthEntry]]
|
||||
}
|
||||
|
||||
private struct AuthEntry: Decodable {
|
||||
let id: String?
|
||||
let label: String?
|
||||
let auth_type: String?
|
||||
let source: String?
|
||||
let access_token: String?
|
||||
let last_status: String?
|
||||
let request_count: Int?
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
import Foundation
|
||||
import AppKit
|
||||
import os
|
||||
|
||||
/// Drives the `hermes auth add <provider> --type oauth` flow via `Process` +
|
||||
/// pipes instead of SwiftTerm. The embedded terminal approach turned out to
|
||||
/// have two problems:
|
||||
///
|
||||
/// 1. Python's `webbrowser.open` called from a subprocess doesn't reliably
|
||||
/// open the user's browser — the macOS `open` command can fail silently
|
||||
/// depending on how the parent app was launched.
|
||||
/// 2. Even when it works, users can't easily copy the URL from a terminal
|
||||
/// emulator to click or share.
|
||||
///
|
||||
/// This controller runs hermes with `--no-browser`, captures stdout/stderr,
|
||||
/// regex-extracts the authorization URL, and exposes it to the UI as a plain
|
||||
/// string. The UI shows a real "Open in Browser" button (via NSWorkspace) and
|
||||
/// a code input text field. Submitting writes the code + newline to hermes's
|
||||
/// stdin pipe, which Python's `input()` reads normally — verified in shell
|
||||
/// testing that hermes accepts piped stdin when a TTY isn't available.
|
||||
///
|
||||
/// Hermes exits 0 even on "login did not return credentials" failures, so we
|
||||
/// detect success by scanning output for failure markers AND by letting the
|
||||
/// calling VM reload `auth.json` to see whether a new credential actually
|
||||
/// landed.
|
||||
@Observable
|
||||
@MainActor
|
||||
final class OAuthFlowController {
|
||||
private let logger = Logger(subsystem: "com.scarf", category: "OAuthFlowController")
|
||||
|
||||
// MARK: - Observable state
|
||||
|
||||
/// Accumulated terminal output for display. Grows monotonically during
|
||||
/// the flow; cleared on `start(...)`.
|
||||
var output: String = ""
|
||||
|
||||
/// Authorization URL extracted from hermes's output. Shown as a prominent
|
||||
/// "Open in Browser" button once detected.
|
||||
var authorizationURL: String?
|
||||
|
||||
/// True once hermes has printed the "Authorization code:" prompt. Gates
|
||||
/// the code submit button so users can't submit too early.
|
||||
var awaitingCode: Bool = false
|
||||
|
||||
/// True between `start(...)` and process termination.
|
||||
var isRunning: Bool = false
|
||||
|
||||
/// Set when the process exits with a success signal (both zero exit AND
|
||||
/// no failure marker in output). The VM checks this + reloads auth.json.
|
||||
var succeeded: Bool = false
|
||||
|
||||
/// Human-readable error message if start/submit failed mid-flow.
|
||||
var errorMessage: String?
|
||||
|
||||
/// Fired when the process exits, with the raw exit code. Use this to
|
||||
/// trigger a UI reload or close the sheet.
|
||||
var onExit: ((Int32) -> Void)?
|
||||
|
||||
// MARK: - Private state
|
||||
|
||||
private var process: Process?
|
||||
private var stdinPipe: Pipe?
|
||||
private var stdoutPipe: Pipe?
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
/// Start the OAuth flow. Any prior in-flight flow is terminated first.
|
||||
func start(provider: String, label: String) {
|
||||
stop()
|
||||
|
||||
output = ""
|
||||
authorizationURL = nil
|
||||
awaitingCode = false
|
||||
succeeded = false
|
||||
errorMessage = nil
|
||||
|
||||
// Pass --no-browser so hermes doesn't try (and potentially fail) to
|
||||
// launch the browser itself — we do it explicitly with the button.
|
||||
var args = ["auth", "add", provider, "--type", "oauth", "--no-browser"]
|
||||
let trimmedLabel = label.trimmingCharacters(in: .whitespaces)
|
||||
if !trimmedLabel.isEmpty {
|
||||
args += ["--label", trimmedLabel]
|
||||
}
|
||||
|
||||
let proc = Process()
|
||||
proc.executableURL = URL(fileURLWithPath: HermesPaths.hermesBinary)
|
||||
proc.arguments = args
|
||||
proc.environment = HermesFileService.enrichedEnvironment()
|
||||
|
||||
let outPipe = Pipe()
|
||||
let inPipe = Pipe()
|
||||
// Merge stderr into stdout: hermes prints the URL + prompt to stdout,
|
||||
// but diagnostic messages can land on stderr; we want both interleaved
|
||||
// in display order.
|
||||
proc.standardOutput = outPipe
|
||||
proc.standardError = outPipe
|
||||
proc.standardInput = inPipe
|
||||
|
||||
outPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in
|
||||
let data = handle.availableData
|
||||
if data.isEmpty {
|
||||
// EOF — the peer closed its write end. Drop the handler so
|
||||
// Foundation doesn't keep calling us with empty reads.
|
||||
handle.readabilityHandler = nil
|
||||
return
|
||||
}
|
||||
let chunk = String(data: data, encoding: .utf8) ?? ""
|
||||
// Hop onto the main actor to mutate observable state.
|
||||
Task { @MainActor [weak self] in
|
||||
self?.handleOutputChunk(chunk)
|
||||
}
|
||||
}
|
||||
|
||||
proc.terminationHandler = { [weak self] p in
|
||||
let code = p.terminationStatus
|
||||
Task { @MainActor [weak self] in
|
||||
outPipe.fileHandleForReading.readabilityHandler = nil
|
||||
self?.handleTermination(exitCode: code)
|
||||
}
|
||||
}
|
||||
|
||||
do {
|
||||
try proc.run()
|
||||
process = proc
|
||||
stdinPipe = inPipe
|
||||
stdoutPipe = outPipe
|
||||
isRunning = true
|
||||
} catch {
|
||||
errorMessage = "Failed to start hermes: \(error.localizedDescription)"
|
||||
logger.error("Failed to start hermes: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Terminate the in-flight process (if any). Safe to call when nothing is running.
|
||||
func stop() {
|
||||
stdoutPipe?.fileHandleForReading.readabilityHandler = nil
|
||||
process?.terminate()
|
||||
process = nil
|
||||
stdinPipe = nil
|
||||
stdoutPipe = nil
|
||||
isRunning = false
|
||||
awaitingCode = false
|
||||
}
|
||||
|
||||
/// Send the authorization code to hermes's stdin. Called when the user
|
||||
/// taps "Submit" in the sheet's code input field.
|
||||
func submitCode(_ code: String) {
|
||||
let trimmed = code.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else {
|
||||
errorMessage = "Authorization code is empty"
|
||||
return
|
||||
}
|
||||
guard let stdinPipe else {
|
||||
errorMessage = "Process is no longer accepting input"
|
||||
return
|
||||
}
|
||||
let payload = trimmed + "\n"
|
||||
guard let data = payload.data(using: .utf8) else {
|
||||
errorMessage = "Could not encode code"
|
||||
return
|
||||
}
|
||||
do {
|
||||
try stdinPipe.fileHandleForWriting.write(contentsOf: data)
|
||||
// After writing, we don't close stdin — hermes might prompt again
|
||||
// on failure. Instead we flip `awaitingCode` off so the UI can
|
||||
// dim the submit button until another prompt appears.
|
||||
awaitingCode = false
|
||||
} catch {
|
||||
errorMessage = "Failed to send code: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
|
||||
/// Explicitly open the detected authorization URL in the default browser.
|
||||
/// Does nothing if no URL has been detected yet.
|
||||
func openURLInBrowser() {
|
||||
guard let url = authorizationURL, let parsed = URL(string: url) else { return }
|
||||
NSWorkspace.shared.open(parsed)
|
||||
}
|
||||
|
||||
// MARK: - Output handling
|
||||
|
||||
private func handleOutputChunk(_ chunk: String) {
|
||||
output += chunk
|
||||
|
||||
if authorizationURL == nil, let url = Self.extractAuthURL(from: output) {
|
||||
authorizationURL = url
|
||||
// Auto-open the browser on first detection, since that's what a
|
||||
// well-behaved hermes would have done. We keep the manual button
|
||||
// available for retries / copy-paste.
|
||||
if let parsed = URL(string: url) {
|
||||
NSWorkspace.shared.open(parsed)
|
||||
}
|
||||
}
|
||||
|
||||
// The prompt may arrive in the same chunk as the URL. Checking
|
||||
// cumulative output (rather than just this chunk) is safer.
|
||||
if !awaitingCode, output.contains("Authorization code:") {
|
||||
awaitingCode = true
|
||||
}
|
||||
}
|
||||
|
||||
private func handleTermination(exitCode: Int32) {
|
||||
isRunning = false
|
||||
// Hermes exits 0 even on "login did not return credentials" — detect
|
||||
// that failure marker explicitly so we don't report false success.
|
||||
let failureMarkers = [
|
||||
"did not return credentials",
|
||||
"Token exchange failed",
|
||||
"OAuth login failed",
|
||||
"HTTP Error"
|
||||
]
|
||||
let outputFailed = failureMarkers.contains { output.localizedCaseInsensitiveContains($0) }
|
||||
succeeded = exitCode == 0 && !outputFailed
|
||||
if !succeeded, errorMessage == nil {
|
||||
if outputFailed {
|
||||
errorMessage = "OAuth did not complete — check the output above for details"
|
||||
} else if exitCode != 0 {
|
||||
errorMessage = "hermes exited with code \(exitCode)"
|
||||
}
|
||||
}
|
||||
onExit?(exitCode)
|
||||
}
|
||||
|
||||
// MARK: - URL extraction
|
||||
|
||||
/// Extract the OAuth authorization URL from hermes's output. Hermes prints
|
||||
/// it on its own line in a Rich-rendered box; we want a plain https URL
|
||||
/// that looks like a provider OAuth endpoint.
|
||||
///
|
||||
/// Priority order:
|
||||
/// 1. URLs containing `client_id=` — real OAuth auth URLs always have this.
|
||||
/// 2. URLs containing `/authorize` — fallback for providers that don't
|
||||
/// include client_id in the query (unusual but possible).
|
||||
/// 3. URLs containing `/oauth/` — last resort.
|
||||
///
|
||||
/// Docs URLs and generic callback URLs are filtered out by these checks.
|
||||
nonisolated static func extractAuthURL(from text: String) -> String? {
|
||||
let pattern = #"https://[^\s\)\]\"'`<>]+"#
|
||||
guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil }
|
||||
let range = NSRange(text.startIndex..., in: text)
|
||||
let urls: [String] = regex.matches(in: text, range: range).compactMap { match in
|
||||
Range(match.range, in: text).map { String(text[$0]) }
|
||||
}
|
||||
// Prefer the strongest signal so we don't accidentally surface the
|
||||
// redirect callback URL when both appear unencoded in output.
|
||||
if let url = urls.first(where: { $0.contains("client_id=") }) { return url }
|
||||
if let url = urls.first(where: { $0.contains("/authorize") }) { return url }
|
||||
if let url = urls.first(where: { $0.contains("/oauth/") }) { return url }
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,472 @@
|
||||
import SwiftUI
|
||||
|
||||
struct CredentialPoolsView: View {
|
||||
@State private var viewModel = CredentialPoolsViewModel()
|
||||
@State private var showAddSheet = false
|
||||
@State private var pendingRemove: HermesCredential?
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
header
|
||||
safetyNotice
|
||||
if viewModel.isLoading {
|
||||
ProgressView().padding()
|
||||
} else if viewModel.pools.isEmpty {
|
||||
emptyState
|
||||
} else {
|
||||
ForEach(viewModel.pools) { pool in
|
||||
poolSection(pool)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||
}
|
||||
.navigationTitle("Credential Pools")
|
||||
.onAppear { viewModel.load() }
|
||||
.sheet(isPresented: $showAddSheet) {
|
||||
AddCredentialSheet(viewModel: viewModel) {
|
||||
showAddSheet = false
|
||||
}
|
||||
}
|
||||
.confirmationDialog(
|
||||
pendingRemove.map { "Remove credential for \($0.provider)?" } ?? "",
|
||||
isPresented: Binding(get: { pendingRemove != nil }, set: { if !$0 { pendingRemove = nil } })
|
||||
) {
|
||||
Button("Remove", role: .destructive) {
|
||||
if let target = pendingRemove {
|
||||
viewModel.removeCredential(provider: target.provider, index: target.index)
|
||||
}
|
||||
pendingRemove = nil
|
||||
}
|
||||
Button("Cancel", role: .cancel) { pendingRemove = nil }
|
||||
} message: {
|
||||
Text("This removes the credential from hermes. The upstream provider key is not revoked.")
|
||||
}
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack {
|
||||
if let msg = viewModel.message {
|
||||
Label(msg, systemImage: "info.circle.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Button {
|
||||
showAddSheet = true
|
||||
} label: {
|
||||
Label("Add Credential", systemImage: "plus")
|
||||
}
|
||||
.controlSize(.small)
|
||||
Button("Reload") { viewModel.load() }
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
|
||||
private var safetyNotice: some View {
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
Image(systemName: "lock.shield")
|
||||
.foregroundStyle(.secondary)
|
||||
Text("API keys are never displayed in full. Scarf only shows the last 4 characters for identification. Full key values are stored by hermes in ~/.hermes/auth.json.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(8)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
|
||||
private var emptyState: some View {
|
||||
VStack(spacing: 8) {
|
||||
Image(systemName: "key.horizontal")
|
||||
.font(.largeTitle)
|
||||
.foregroundStyle(.secondary)
|
||||
Text("No credential pools configured")
|
||||
.foregroundStyle(.secondary)
|
||||
Text("Add rotation credentials so hermes can failover between keys when one hits rate limits.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 40)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func poolSection(_ pool: HermesCredentialPool) -> some View {
|
||||
SettingsSection(title: pool.provider, icon: "key.horizontal") {
|
||||
PickerRow(label: "Rotation", selection: pool.strategy, options: viewModel.strategyOptions) { strategy in
|
||||
viewModel.setStrategy(strategy, for: pool.provider)
|
||||
}
|
||||
ForEach(pool.credentials) { cred in
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: cred.authType == "oauth" ? "person.badge.key" : "key.fill")
|
||||
.foregroundStyle(.secondary)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(spacing: 6) {
|
||||
Text("#\(cred.index + 1)")
|
||||
.font(.system(.caption, design: .monospaced, weight: .bold))
|
||||
if !cred.label.isEmpty {
|
||||
Text(cred.label).font(.caption)
|
||||
}
|
||||
if !cred.authType.isEmpty {
|
||||
Text(cred.authType)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal, 5)
|
||||
.padding(.vertical, 1)
|
||||
.background(.quaternary)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
if !cred.lastStatus.isEmpty {
|
||||
Text(cred.lastStatus)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(statusColor(cred.lastStatus))
|
||||
}
|
||||
}
|
||||
HStack(spacing: 8) {
|
||||
Text(cred.tokenTail.isEmpty ? "—" : cred.tokenTail)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
if !cred.source.isEmpty {
|
||||
Text(cred.source)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
if cred.requestCount > 0 {
|
||||
Text("\(cred.requestCount) req")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
Button("Remove", role: .destructive) { pendingRemove = cred }
|
||||
.controlSize(.small)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
}
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("Reset Cooldowns") { viewModel.resetProvider(pool.provider) }
|
||||
.controlSize(.small)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
}
|
||||
}
|
||||
|
||||
private func statusColor(_ status: String) -> Color {
|
||||
switch status {
|
||||
case "ok", "active": return .green
|
||||
case "cooldown": return .orange
|
||||
case "exhausted": return .red
|
||||
default: return .secondary
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Two-step sheet for adding a credential:
|
||||
/// 1. Provider picker (populated from the models catalog, falls back to free text)
|
||||
/// + type selector (API Key vs OAuth) + optional label
|
||||
/// 2. Either an immediate save (API key) or an embedded terminal running the
|
||||
/// OAuth flow so the user can paste the authorization code back.
|
||||
private struct AddCredentialSheet: View {
|
||||
@Bindable var viewModel: CredentialPoolsViewModel
|
||||
let onDismiss: () -> Void
|
||||
|
||||
enum AuthType: String, CaseIterable, Identifiable {
|
||||
case apiKey = "API Key"
|
||||
case oauth = "OAuth"
|
||||
var id: String { rawValue }
|
||||
}
|
||||
|
||||
@State private var providerID: String = ""
|
||||
@State private var authType: AuthType = .apiKey
|
||||
@State private var apiKey: String = ""
|
||||
@State private var label: String = ""
|
||||
@State private var providers: [HermesProviderInfo] = []
|
||||
@State private var oauthStarted: Bool = false
|
||||
@State private var authCode: String = ""
|
||||
|
||||
private let catalog = ModelCatalogService()
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("Add Credential")
|
||||
.font(.headline)
|
||||
if !oauthStarted {
|
||||
configSection
|
||||
} else {
|
||||
oauthSection
|
||||
}
|
||||
Divider()
|
||||
footer
|
||||
}
|
||||
.padding()
|
||||
.frame(minWidth: 600, minHeight: 460)
|
||||
.onAppear {
|
||||
providers = catalog.loadProviders()
|
||||
}
|
||||
// Auto-close the sheet once a credential is actually saved. We key
|
||||
// off `succeeded` which the controller sets only when hermes exited
|
||||
// zero AND the output has no failure markers. The 0.8s delay lets the
|
||||
// user see the success banner before the sheet disappears.
|
||||
.onChange(of: viewModel.oauthFlow.succeeded) { _, newValue in
|
||||
guard newValue else { return }
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) {
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Step 1: provider + type + label + optional API key
|
||||
|
||||
private var configSection: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Provider").font(.caption).foregroundStyle(.secondary)
|
||||
HStack {
|
||||
// Free-text first so providers missing from the catalog
|
||||
// (e.g. "nous") are still addable.
|
||||
TextField("e.g. anthropic", text: $providerID)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
Menu("Browse") {
|
||||
ForEach(providers) { provider in
|
||||
Button(provider.providerName + " (\(provider.providerID))") {
|
||||
providerID = provider.providerID
|
||||
}
|
||||
}
|
||||
}
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Credential Type").font(.caption).foregroundStyle(.secondary)
|
||||
Picker("", selection: $authType) {
|
||||
ForEach(AuthType.allCases) { type in
|
||||
Text(type.rawValue).tag(type)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.labelsHidden()
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Label (optional)").font(.caption).foregroundStyle(.secondary)
|
||||
TextField("e.g. team-prod", text: $label)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
|
||||
if authType == .apiKey {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("API Key").font(.caption).foregroundStyle(.secondary)
|
||||
SecureField("sk-…", text: $apiKey)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
}
|
||||
} else {
|
||||
oauthPreamble
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Brief explanation shown before the user clicks "Start OAuth". Sets
|
||||
/// expectations about the embedded-terminal flow so the browser window
|
||||
/// and code-paste step aren't surprises.
|
||||
private var oauthPreamble: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Clicking Start OAuth opens the provider's authorization page in your browser. After you approve, copy the code the provider displays and paste it back into the terminal that appears next.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Text("The terminal is a real TTY — paste with ⌘V, press Return, and wait for the process to exit with \"login succeeded\".")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(8)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
|
||||
// MARK: - Step 2: OAuth — URL button, code field, live output log
|
||||
|
||||
private var oauthSection: some View {
|
||||
// Pull the observable controller into a local so the view redraws
|
||||
// when its @Observable properties change.
|
||||
let flow = viewModel.oauthFlow
|
||||
return VStack(alignment: .leading, spacing: 10) {
|
||||
oauthHeader(flow: flow)
|
||||
urlBlock(flow: flow)
|
||||
codeEntryBlock(flow: flow)
|
||||
outputLogBlock(flow: flow)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func oauthHeader(flow: OAuthFlowController) -> some View {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "person.badge.key")
|
||||
Text("OAuth login for \(viewModel.oauthProvider)")
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
if flow.isRunning {
|
||||
ProgressView().controlSize(.small)
|
||||
} else if flow.succeeded {
|
||||
Label("Succeeded", systemImage: "checkmark.circle.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.green)
|
||||
} else if let err = flow.errorMessage {
|
||||
Label(err, systemImage: "exclamationmark.triangle.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.orange)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Authorization URL block. Hermes prints the URL on startup; we detect
|
||||
/// it via regex and expose a prominent Open + Copy pair. The URL keeps
|
||||
/// showing even after the browser is opened so users can paste it into
|
||||
/// a different browser profile if needed.
|
||||
@ViewBuilder
|
||||
private func urlBlock(flow: OAuthFlowController) -> some View {
|
||||
if let url = flow.authorizationURL {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Label("Authorization URL", systemImage: "link")
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(.secondary)
|
||||
HStack(spacing: 6) {
|
||||
Text(url)
|
||||
.font(.caption.monospaced())
|
||||
.textSelection(.enabled)
|
||||
.lineLimit(2)
|
||||
.truncationMode(.middle)
|
||||
Spacer()
|
||||
Button {
|
||||
flow.openURLInBrowser()
|
||||
} label: {
|
||||
Label("Open in Browser", systemImage: "safari")
|
||||
}
|
||||
.controlSize(.small)
|
||||
.buttonStyle(.borderedProminent)
|
||||
Button {
|
||||
NSPasteboard.general.clearContents()
|
||||
NSPasteboard.general.setString(url, forType: .string)
|
||||
} label: {
|
||||
Label("Copy", systemImage: "doc.on.doc")
|
||||
}
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
.padding(8)
|
||||
.background(.blue.opacity(0.08))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
} else if flow.isRunning {
|
||||
// Still waiting for hermes to print the URL — usually <1s.
|
||||
HStack(spacing: 6) {
|
||||
ProgressView().controlSize(.small)
|
||||
Text("Waiting for authorization URL…")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Authorization code input. Only active once hermes has printed its
|
||||
/// "Authorization code:" prompt so users can't submit before hermes is
|
||||
/// ready to receive input.
|
||||
@ViewBuilder
|
||||
private func codeEntryBlock(flow: OAuthFlowController) -> some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Label("Authorization Code", systemImage: "keyboard")
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(.secondary)
|
||||
Text("After approving in your browser, the provider shows a code. Paste it below and submit.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
HStack(spacing: 6) {
|
||||
TextField("Paste code here…", text: $authCode)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.disabled(!flow.awaitingCode)
|
||||
.onSubmit { submitCode(flow: flow) }
|
||||
Button("Submit") { submitCode(flow: flow) }
|
||||
.controlSize(.small)
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(!flow.awaitingCode || authCode.trimmingCharacters(in: .whitespaces).isEmpty)
|
||||
}
|
||||
if !flow.awaitingCode && flow.isRunning {
|
||||
Text("Waiting for hermes to prompt for the code…")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Live output log — useful for diagnostics if the flow stalls or errors.
|
||||
@ViewBuilder
|
||||
private func outputLogBlock(flow: OAuthFlowController) -> some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Label("Output", systemImage: "text.alignleft")
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(.secondary)
|
||||
ScrollView {
|
||||
Text(flow.output.isEmpty ? "(no output yet)" : flow.output)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(8)
|
||||
}
|
||||
.frame(minHeight: 120, maxHeight: 200)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
}
|
||||
|
||||
private func submitCode(flow: OAuthFlowController) {
|
||||
let trimmed = authCode.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
viewModel.submitOAuthCode(trimmed)
|
||||
authCode = ""
|
||||
}
|
||||
|
||||
// MARK: - Footer (buttons)
|
||||
|
||||
private var footer: some View {
|
||||
HStack {
|
||||
Spacer()
|
||||
if oauthStarted {
|
||||
Button("Close") {
|
||||
// Closing mid-flow terminates hermes so we don't leave a
|
||||
// zombie process waiting for stdin forever.
|
||||
viewModel.cancelOAuth()
|
||||
onDismiss()
|
||||
}
|
||||
} else {
|
||||
Button("Cancel") { onDismiss() }
|
||||
if authType == .apiKey {
|
||||
Button("Add") {
|
||||
viewModel.addAPIKey(provider: providerID, apiKey: apiKey, label: label)
|
||||
onDismiss()
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(providerID.trimmingCharacters(in: .whitespaces).isEmpty || apiKey.trimmingCharacters(in: .whitespaces).isEmpty)
|
||||
} else {
|
||||
Button("Start OAuth") {
|
||||
viewModel.startOAuth(provider: providerID, label: label)
|
||||
oauthStarted = true
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(providerID.trimmingCharacters(in: .whitespaces).isEmpty)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,101 @@
|
||||
import Foundation
|
||||
import AppKit
|
||||
import os
|
||||
|
||||
@Observable
|
||||
final class CronViewModel {
|
||||
private let logger = Logger(subsystem: "com.scarf", category: "CronViewModel")
|
||||
private let fileService = HermesFileService()
|
||||
|
||||
var jobs: [HermesCronJob] = []
|
||||
var selectedJob: HermesCronJob?
|
||||
var jobOutput: String?
|
||||
var availableSkills: [String] = []
|
||||
var message: String?
|
||||
var showCreateSheet = false
|
||||
var editingJob: HermesCronJob?
|
||||
|
||||
func load() {
|
||||
jobs = fileService.loadCronJobs()
|
||||
availableSkills = fileService.loadSkills().flatMap { $0.skills.map(\.id) }.sorted()
|
||||
if let selected = selectedJob, let refreshed = jobs.first(where: { $0.id == selected.id }) {
|
||||
selectedJob = refreshed
|
||||
jobOutput = fileService.loadCronOutput(jobId: refreshed.id)
|
||||
}
|
||||
}
|
||||
|
||||
func selectJob(_ job: HermesCronJob) {
|
||||
selectedJob = job
|
||||
jobOutput = fileService.loadCronOutput(jobId: job.id)
|
||||
}
|
||||
|
||||
// MARK: - CLI wrappers
|
||||
|
||||
func pauseJob(_ job: HermesCronJob) {
|
||||
runAndReload(["cron", "pause", job.id], success: "Paused")
|
||||
}
|
||||
|
||||
func resumeJob(_ job: HermesCronJob) {
|
||||
runAndReload(["cron", "resume", job.id], success: "Resumed")
|
||||
}
|
||||
|
||||
func runNow(_ job: HermesCronJob) {
|
||||
runAndReload(["cron", "run", job.id], success: "Scheduled for next tick")
|
||||
}
|
||||
|
||||
func deleteJob(_ job: HermesCronJob) {
|
||||
runAndReload(["cron", "remove", job.id], success: "Removed")
|
||||
if selectedJob?.id == job.id {
|
||||
selectedJob = nil
|
||||
jobOutput = nil
|
||||
}
|
||||
}
|
||||
|
||||
func createJob(schedule: String, prompt: String, name: String, deliver: String, skills: [String], script: String, repeatCount: String) {
|
||||
var args = ["cron", "create"]
|
||||
if !name.isEmpty { args += ["--name", name] }
|
||||
if !deliver.isEmpty { args += ["--deliver", deliver] }
|
||||
if !repeatCount.isEmpty { args += ["--repeat", repeatCount] }
|
||||
for skill in skills where !skill.isEmpty { args += ["--skill", skill] }
|
||||
if !script.isEmpty { args += ["--script", script] }
|
||||
args.append(schedule)
|
||||
if !prompt.isEmpty { args.append(prompt) }
|
||||
runAndReload(args, success: "Job created")
|
||||
}
|
||||
|
||||
func updateJob(id: String, schedule: String?, prompt: String?, name: String?, deliver: String?, repeatCount: String?, newSkills: [String]?, clearSkills: Bool, script: String?) {
|
||||
var args = ["cron", "edit", id]
|
||||
if let schedule, !schedule.isEmpty { args += ["--schedule", schedule] }
|
||||
if let prompt, !prompt.isEmpty { args += ["--prompt", prompt] }
|
||||
if let name, !name.isEmpty { args += ["--name", name] }
|
||||
if let deliver { args += ["--deliver", deliver] }
|
||||
if let repeatCount, !repeatCount.isEmpty { args += ["--repeat", repeatCount] }
|
||||
if clearSkills {
|
||||
args.append("--clear-skills")
|
||||
} else if let newSkills {
|
||||
for skill in newSkills where !skill.isEmpty { args += ["--skill", skill] }
|
||||
}
|
||||
if let script { args += ["--script", script] }
|
||||
runAndReload(args, success: "Updated")
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func runAndReload(_ arguments: [String], success: String) {
|
||||
Task.detached { [fileService] in
|
||||
let result = fileService.runHermesCLI(args: arguments, timeout: 60)
|
||||
await MainActor.run {
|
||||
if result.exitCode == 0 {
|
||||
self.message = success
|
||||
} else {
|
||||
self.message = "Failed: \(result.output.prefix(200))"
|
||||
self.logger.warning("cron command failed: args=\(arguments) output=\(result.output)")
|
||||
}
|
||||
self.load()
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
||||
self?.message = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,60 +2,141 @@ import SwiftUI
|
||||
|
||||
struct CronView: View {
|
||||
@State private var viewModel = CronViewModel()
|
||||
@State private var pendingDelete: HermesCronJob?
|
||||
|
||||
var body: some View {
|
||||
HSplitView {
|
||||
jobsList
|
||||
.frame(minWidth: 300, idealWidth: 350)
|
||||
.frame(minWidth: 320, idealWidth: 360)
|
||||
jobDetail
|
||||
.frame(minWidth: 400)
|
||||
}
|
||||
.navigationTitle("Cron Jobs")
|
||||
.onAppear { viewModel.load() }
|
||||
.sheet(isPresented: $viewModel.showCreateSheet) {
|
||||
CronJobEditor(mode: .create, availableSkills: viewModel.availableSkills) { form in
|
||||
viewModel.createJob(
|
||||
schedule: form.schedule,
|
||||
prompt: form.prompt,
|
||||
name: form.name,
|
||||
deliver: form.deliver,
|
||||
skills: form.skills,
|
||||
script: form.script,
|
||||
repeatCount: form.repeatCount
|
||||
)
|
||||
viewModel.showCreateSheet = false
|
||||
} onCancel: {
|
||||
viewModel.showCreateSheet = false
|
||||
}
|
||||
}
|
||||
.sheet(item: $viewModel.editingJob) { job in
|
||||
CronJobEditor(mode: .edit(job), availableSkills: viewModel.availableSkills) { form in
|
||||
viewModel.updateJob(
|
||||
id: job.id,
|
||||
schedule: form.schedule,
|
||||
prompt: form.prompt,
|
||||
name: form.name,
|
||||
deliver: form.deliver,
|
||||
repeatCount: form.repeatCount,
|
||||
newSkills: form.skills,
|
||||
clearSkills: form.clearSkills,
|
||||
script: form.script
|
||||
)
|
||||
viewModel.editingJob = nil
|
||||
} onCancel: {
|
||||
viewModel.editingJob = nil
|
||||
}
|
||||
}
|
||||
.confirmationDialog(
|
||||
pendingDelete.map { "Delete \($0.name)?" } ?? "",
|
||||
isPresented: Binding(get: { pendingDelete != nil }, set: { if !$0 { pendingDelete = nil } })
|
||||
) {
|
||||
Button("Delete", role: .destructive) {
|
||||
if let job = pendingDelete { viewModel.deleteJob(job) }
|
||||
pendingDelete = nil
|
||||
}
|
||||
Button("Cancel", role: .cancel) { pendingDelete = nil }
|
||||
} message: {
|
||||
Text("This removes the scheduled job permanently.")
|
||||
}
|
||||
}
|
||||
|
||||
private var jobsList: some View {
|
||||
List(selection: Binding(
|
||||
get: { viewModel.selectedJob?.id },
|
||||
set: { id in
|
||||
if let id, let job = viewModel.jobs.first(where: { $0.id == id }) {
|
||||
viewModel.selectJob(job)
|
||||
} else {
|
||||
viewModel.selectedJob = nil
|
||||
viewModel.jobOutput = nil
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
if let msg = viewModel.message {
|
||||
Label(msg, systemImage: "info.circle.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Button {
|
||||
viewModel.showCreateSheet = true
|
||||
} label: {
|
||||
Label("Add", systemImage: "plus")
|
||||
}
|
||||
.controlSize(.small)
|
||||
Button("Reload") { viewModel.load() }
|
||||
.controlSize(.small)
|
||||
}
|
||||
)) {
|
||||
ForEach(viewModel.jobs) { job in
|
||||
HStack {
|
||||
Image(systemName: job.stateIcon)
|
||||
.foregroundStyle(job.enabled ? .primary : .secondary)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(job.name)
|
||||
.lineLimit(1)
|
||||
Text(job.schedule.display ?? job.schedule.kind)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
if job.silent == true {
|
||||
Text("SILENT")
|
||||
.font(.caption2.bold())
|
||||
.foregroundStyle(.purple)
|
||||
}
|
||||
if !job.enabled {
|
||||
Text("Disabled")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 6)
|
||||
Divider()
|
||||
List(selection: Binding(
|
||||
get: { viewModel.selectedJob?.id },
|
||||
set: { id in
|
||||
if let id, let job = viewModel.jobs.first(where: { $0.id == id }) {
|
||||
viewModel.selectJob(job)
|
||||
} else {
|
||||
viewModel.selectedJob = nil
|
||||
viewModel.jobOutput = nil
|
||||
}
|
||||
}
|
||||
)) {
|
||||
ForEach(viewModel.jobs) { job in
|
||||
HStack {
|
||||
Image(systemName: job.stateIcon)
|
||||
.foregroundStyle(job.enabled ? .primary : .secondary)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(job.name)
|
||||
.lineLimit(1)
|
||||
Text(job.schedule.display ?? job.schedule.kind)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
if job.silent == true {
|
||||
Text("SILENT")
|
||||
.font(.caption2.bold())
|
||||
.foregroundStyle(.purple)
|
||||
}
|
||||
if !job.enabled {
|
||||
Text("Disabled")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.tag(job.id)
|
||||
.contextMenu {
|
||||
Button(job.enabled ? "Pause" : "Resume") {
|
||||
if job.enabled {
|
||||
viewModel.pauseJob(job)
|
||||
} else {
|
||||
viewModel.resumeJob(job)
|
||||
}
|
||||
}
|
||||
Button("Run Now") { viewModel.runNow(job) }
|
||||
Button("Edit") { viewModel.editingJob = job }
|
||||
Divider()
|
||||
Button("Delete", role: .destructive) { pendingDelete = job }
|
||||
}
|
||||
}
|
||||
.tag(job.id)
|
||||
}
|
||||
}
|
||||
.listStyle(.inset)
|
||||
.overlay {
|
||||
if viewModel.jobs.isEmpty {
|
||||
ContentUnavailableView("No Cron Jobs", systemImage: "clock.arrow.2.circlepath", description: Text("No scheduled jobs configured"))
|
||||
.listStyle(.inset)
|
||||
.overlay {
|
||||
if viewModel.jobs.isEmpty {
|
||||
ContentUnavailableView("No Cron Jobs", systemImage: "clock.arrow.2.circlepath", description: Text("No scheduled jobs configured"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -65,108 +146,10 @@ struct CronView: View {
|
||||
if let job = viewModel.selectedJob {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(job.name)
|
||||
.font(.title2.bold())
|
||||
HStack(spacing: 16) {
|
||||
Label(job.state, systemImage: job.stateIcon)
|
||||
Label(job.schedule.display ?? job.schedule.kind, systemImage: "clock")
|
||||
Label(job.enabled ? "Enabled" : "Disabled", systemImage: job.enabled ? "checkmark.circle" : "xmark.circle")
|
||||
if let deliver = job.deliveryDisplay {
|
||||
Label("Deliver: \(deliver)", systemImage: "paperplane")
|
||||
}
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
detailHeader(job)
|
||||
actionBar(job)
|
||||
Divider()
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Prompt")
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(.secondary)
|
||||
Text(job.prompt)
|
||||
.textSelection(.enabled)
|
||||
.padding(8)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(.quaternary.opacity(0.5))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
if let script = job.preRunScript, !script.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Pre-Run Script")
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(.secondary)
|
||||
Text(script)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.textSelection(.enabled)
|
||||
.padding(8)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(.quaternary.opacity(0.5))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
}
|
||||
if let skills = job.skills, !skills.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Skills")
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(.secondary)
|
||||
HStack {
|
||||
ForEach(skills, id: \.self) { skill in
|
||||
Text(skill)
|
||||
.font(.caption.monospaced())
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 2)
|
||||
.background(.quaternary)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let nextRun = job.nextRunAt {
|
||||
Label("Next run: \(nextRun)", systemImage: "arrow.forward.circle")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if let lastRun = job.lastRunAt {
|
||||
Label("Last run: \(lastRun)", systemImage: "arrow.backward.circle")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if let error = job.lastError {
|
||||
Label(error, systemImage: "exclamationmark.triangle")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
if let timeout = job.timeoutSeconds {
|
||||
Label("Timeout: \(timeout)s (\(job.timeoutType ?? "wall_clock"))", systemImage: "timer")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if let failures = job.deliveryFailures, failures > 0 {
|
||||
Label("\(failures) delivery failure\(failures == 1 ? "" : "s")", systemImage: "exclamationmark.triangle")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
if let deliveryError = job.lastDeliveryError {
|
||||
Label(deliveryError, systemImage: "paperplane.circle")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
if let output = viewModel.jobOutput {
|
||||
Divider()
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Last Output")
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(.secondary)
|
||||
Text(output)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.textSelection(.enabled)
|
||||
.padding(8)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(.quaternary.opacity(0.5))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
}
|
||||
detailBody(job)
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||
@@ -176,4 +159,257 @@ struct CronView: View {
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
private func detailHeader(_ job: HermesCronJob) -> some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(job.name)
|
||||
.font(.title2.bold())
|
||||
HStack(spacing: 16) {
|
||||
Label(job.state, systemImage: job.stateIcon)
|
||||
Label(job.schedule.display ?? job.schedule.kind, systemImage: "clock")
|
||||
Label(job.enabled ? "Enabled" : "Disabled", systemImage: job.enabled ? "checkmark.circle" : "xmark.circle")
|
||||
if let deliver = job.deliveryDisplay {
|
||||
Label("Deliver: \(deliver)", systemImage: "paperplane")
|
||||
}
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
private func actionBar(_ job: HermesCronJob) -> some View {
|
||||
HStack(spacing: 8) {
|
||||
Button {
|
||||
if job.enabled { viewModel.pauseJob(job) } else { viewModel.resumeJob(job) }
|
||||
} label: {
|
||||
Label(job.enabled ? "Pause" : "Resume", systemImage: job.enabled ? "pause" : "play")
|
||||
}
|
||||
Button {
|
||||
viewModel.runNow(job)
|
||||
} label: {
|
||||
Label("Run Now", systemImage: "bolt")
|
||||
}
|
||||
Button {
|
||||
viewModel.editingJob = job
|
||||
} label: {
|
||||
Label("Edit", systemImage: "pencil")
|
||||
}
|
||||
Spacer()
|
||||
Button(role: .destructive) {
|
||||
pendingDelete = job
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
.controlSize(.small)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func detailBody(_ job: HermesCronJob) -> some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Prompt")
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(.secondary)
|
||||
Text(job.prompt)
|
||||
.textSelection(.enabled)
|
||||
.padding(8)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(.quaternary.opacity(0.5))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
if let script = job.preRunScript, !script.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Pre-Run Script")
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(.secondary)
|
||||
Text(script)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.textSelection(.enabled)
|
||||
.padding(8)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(.quaternary.opacity(0.5))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
}
|
||||
if let skills = job.skills, !skills.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Skills")
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(.secondary)
|
||||
HStack {
|
||||
ForEach(skills, id: \.self) { skill in
|
||||
Text(skill)
|
||||
.font(.caption.monospaced())
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 2)
|
||||
.background(.quaternary)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let nextRun = job.nextRunAt {
|
||||
Label("Next run: \(nextRun)", systemImage: "arrow.forward.circle")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if let lastRun = job.lastRunAt {
|
||||
Label("Last run: \(lastRun)", systemImage: "arrow.backward.circle")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if let error = job.lastError {
|
||||
Label(error, systemImage: "exclamationmark.triangle")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
if let timeout = job.timeoutSeconds {
|
||||
Label("Timeout: \(timeout)s (\(job.timeoutType ?? "wall_clock"))", systemImage: "timer")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if let failures = job.deliveryFailures, failures > 0 {
|
||||
Label("\(failures) delivery failure\(failures == 1 ? "" : "s")", systemImage: "exclamationmark.triangle")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
if let deliveryError = job.lastDeliveryError {
|
||||
Label(deliveryError, systemImage: "paperplane.circle")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
if let output = viewModel.jobOutput {
|
||||
Divider()
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Last Output")
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(.secondary)
|
||||
Text(output)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.textSelection(.enabled)
|
||||
.padding(8)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(.quaternary.opacity(0.5))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Create/edit sheet. Form fields mirror `hermes cron create|edit` flags.
|
||||
struct CronJobEditor: View {
|
||||
enum Mode {
|
||||
case create
|
||||
case edit(HermesCronJob)
|
||||
}
|
||||
|
||||
struct FormState {
|
||||
var name: String = ""
|
||||
var schedule: String = ""
|
||||
var prompt: String = ""
|
||||
var deliver: String = ""
|
||||
var repeatCount: String = ""
|
||||
var skills: [String] = []
|
||||
var clearSkills: Bool = false
|
||||
var script: String = ""
|
||||
}
|
||||
|
||||
let mode: Mode
|
||||
let availableSkills: [String]
|
||||
let onSave: (FormState) -> Void
|
||||
let onCancel: () -> Void
|
||||
|
||||
@State private var form = FormState()
|
||||
@State private var isEditMode = false
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text(headerText)
|
||||
.font(.headline)
|
||||
formField("Name", text: $form.name, placeholder: "Friendly label")
|
||||
formField("Schedule", text: $form.schedule, placeholder: "0 9 * * * or 30m or every 2h", mono: true)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Prompt")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
TextEditor(text: $form.prompt)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.frame(minHeight: 100)
|
||||
.padding(4)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
formField("Deliver", text: $form.deliver, placeholder: "origin | local | discord:CHANNEL | telegram:CHAT", mono: true)
|
||||
formField("Repeat", text: $form.repeatCount, placeholder: "Optional count")
|
||||
formField("Script path", text: $form.script, placeholder: "Python script whose stdout is injected", mono: true)
|
||||
if !availableSkills.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Skills")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
ForEach(availableSkills, id: \.self) { skill in
|
||||
Toggle(skill, isOn: Binding(
|
||||
get: { form.skills.contains(skill) },
|
||||
set: { on in
|
||||
if on {
|
||||
form.skills.append(skill)
|
||||
} else {
|
||||
form.skills.removeAll { $0 == skill }
|
||||
}
|
||||
}
|
||||
))
|
||||
.font(.caption.monospaced())
|
||||
.toggleStyle(.checkbox)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxHeight: 120)
|
||||
.padding(6)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
if isEditMode {
|
||||
Toggle("Clear all skills on save", isOn: $form.clearSkills)
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
}
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("Cancel") { onCancel() }
|
||||
Button("Save") { onSave(form) }
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(form.schedule.isEmpty)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.frame(minWidth: 560, minHeight: 560)
|
||||
.onAppear {
|
||||
if case .edit(let job) = mode {
|
||||
isEditMode = true
|
||||
form.name = job.name
|
||||
form.schedule = job.schedule.expression ?? job.schedule.display ?? ""
|
||||
form.prompt = job.prompt
|
||||
form.deliver = job.deliver ?? ""
|
||||
form.skills = job.skills ?? []
|
||||
form.script = job.preRunScript ?? ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var headerText: String {
|
||||
switch mode {
|
||||
case .create: return "Create Cron Job"
|
||||
case .edit(let job): return "Edit \(job.name)"
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func formField(_ label: String, text: Binding<String>, placeholder: String, mono: Bool = false) -> some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(label).font(.caption).foregroundStyle(.secondary)
|
||||
TextField(placeholder, text: text)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(mono ? .system(.caption, design: .monospaced) : .caption)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,10 @@ final class HealthViewModel {
|
||||
var hermesPID: pid_t?
|
||||
var actionMessage: String?
|
||||
|
||||
/// Text output from `hermes dump` / `hermes debug share`. Shown in an expandable panel.
|
||||
var diagnosticsOutput: String = ""
|
||||
var isSharingDebug = false
|
||||
|
||||
func load() {
|
||||
isLoading = true
|
||||
refreshProcessStatus()
|
||||
@@ -201,6 +205,37 @@ final class HealthViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
/// Capture `hermes dump` output — a setup summary used for debugging / support.
|
||||
/// Does NOT upload anything.
|
||||
func runDump() {
|
||||
actionMessage = "Running dump…"
|
||||
let result = runHermes(["dump"])
|
||||
diagnosticsOutput = result.output
|
||||
actionMessage = result.exitCode == 0 ? "Dump captured" : "Dump failed"
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
||||
self?.actionMessage = nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Upload a debug report via `hermes debug share`. THIS UPLOADS DATA to Nous
|
||||
/// Research support infrastructure — caller must confirm with the user first.
|
||||
func runDebugShare() {
|
||||
isSharingDebug = true
|
||||
actionMessage = "Uploading debug report…"
|
||||
Task.detached { [fileService] in
|
||||
let result = fileService.runHermesCLI(args: ["debug", "share"], timeout: 120)
|
||||
await MainActor.run {
|
||||
self.isSharingDebug = false
|
||||
self.diagnosticsOutput = result.output
|
||||
self.actionMessage = result.exitCode == 0 ? "Upload complete" : "Upload failed"
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 4) { [weak self] in
|
||||
self?.actionMessage = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func runHermes(_ arguments: [String]) -> (output: String, exitCode: Int32) {
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: HermesPaths.hermesBinary)
|
||||
|
||||
@@ -4,18 +4,38 @@ struct HealthView: View {
|
||||
@State private var viewModel = HealthViewModel()
|
||||
@State private var expandedSection: UUID?
|
||||
@State private var selectedTab = 0
|
||||
@State private var showShareConfirm = false
|
||||
@State private var showDiagnostics = false
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
headerBar
|
||||
Divider()
|
||||
Picker("", selection: $selectedTab) {
|
||||
Text("Status").tag(0)
|
||||
Text("Diagnostics").tag(1)
|
||||
HStack {
|
||||
Picker("", selection: $selectedTab) {
|
||||
Text("Status").tag(0)
|
||||
Text("Diagnostics").tag(1)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.frame(maxWidth: 300)
|
||||
Spacer()
|
||||
Button("Run Dump") {
|
||||
viewModel.runDump()
|
||||
showDiagnostics = true
|
||||
}
|
||||
.controlSize(.small)
|
||||
Button("Share Debug Report…") {
|
||||
showShareConfirm = true
|
||||
}
|
||||
.controlSize(.small)
|
||||
.disabled(viewModel.isSharingDebug)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.frame(maxWidth: 300)
|
||||
.padding(.vertical, 8)
|
||||
.padding(.horizontal)
|
||||
if showDiagnostics && !viewModel.diagnosticsOutput.isEmpty {
|
||||
Divider()
|
||||
diagnosticsPanel
|
||||
}
|
||||
Divider()
|
||||
ScrollView {
|
||||
sectionGrid(selectedTab == 0 ? viewModel.statusSections : viewModel.doctorSections)
|
||||
@@ -24,6 +44,40 @@ struct HealthView: View {
|
||||
}
|
||||
.navigationTitle("Health")
|
||||
.onAppear { viewModel.load() }
|
||||
.confirmationDialog("Upload debug report?", isPresented: $showShareConfirm) {
|
||||
Button("Upload", role: .destructive) {
|
||||
viewModel.runDebugShare()
|
||||
showDiagnostics = true
|
||||
}
|
||||
Button("Cancel", role: .cancel) {}
|
||||
} message: {
|
||||
Text("This uploads logs, config (with secrets redacted), and system info to Nous Research support infrastructure. Review the output below before sharing the returned URL.")
|
||||
}
|
||||
}
|
||||
|
||||
private var diagnosticsPanel: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack {
|
||||
Text("Diagnostic Output")
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Button("Hide") { showDiagnostics = false }
|
||||
.controlSize(.mini)
|
||||
}
|
||||
ScrollView {
|
||||
Text(viewModel.diagnosticsOutput)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.textSelection(.enabled)
|
||||
.padding(8)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.frame(maxHeight: 240)
|
||||
.background(.quaternary.opacity(0.5))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
|
||||
// MARK: - Header
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import Foundation
|
||||
import AppKit
|
||||
import os
|
||||
|
||||
/// A personality defined under the `personalities:` block in config.yaml.
|
||||
/// Each entry may have a free-form `prompt` string plus arbitrary extra fields.
|
||||
struct HermesPersonality: Identifiable, Sendable, Equatable {
|
||||
var id: String { name }
|
||||
let name: String
|
||||
let prompt: String
|
||||
}
|
||||
|
||||
@Observable
|
||||
final class PersonalitiesViewModel {
|
||||
private let logger = Logger(subsystem: "com.scarf", category: "PersonalitiesViewModel")
|
||||
private let fileService = HermesFileService()
|
||||
|
||||
var personalities: [HermesPersonality] = []
|
||||
var activeName: String = ""
|
||||
var soulMarkdown: String = ""
|
||||
var soulPath: String { HermesPaths.home + "/SOUL.md" }
|
||||
var message: String?
|
||||
|
||||
func load() {
|
||||
let config = fileService.loadConfig()
|
||||
activeName = config.personality
|
||||
personalities = parsePersonalitiesBlock()
|
||||
soulMarkdown = (try? String(contentsOfFile: soulPath, encoding: .utf8)) ?? ""
|
||||
}
|
||||
|
||||
/// Parse the `personalities:` section of config.yaml using the nested parser.
|
||||
/// Each personality is a top-level key under `personalities`, optionally with
|
||||
/// a `prompt:` child.
|
||||
private func parsePersonalitiesBlock() -> [HermesPersonality] {
|
||||
guard let yaml = try? String(contentsOfFile: HermesPaths.configYAML, encoding: .utf8) else { return [] }
|
||||
let parsed = HermesFileService.parseNestedYAML(yaml)
|
||||
// Find all keys "personalities.<name>[.subkey]"
|
||||
var nameSet: Set<String> = []
|
||||
for key in parsed.values.keys where key.hasPrefix("personalities.") {
|
||||
let parts = key.split(separator: ".", maxSplits: 2, omittingEmptySubsequences: false)
|
||||
if parts.count >= 2 { nameSet.insert(String(parts[1])) }
|
||||
}
|
||||
for key in parsed.lists.keys where key.hasPrefix("personalities.") {
|
||||
let parts = key.split(separator: ".", maxSplits: 2, omittingEmptySubsequences: false)
|
||||
if parts.count >= 2 { nameSet.insert(String(parts[1])) }
|
||||
}
|
||||
return nameSet.sorted().map { name in
|
||||
let prompt = parsed.values["personalities.\(name).prompt"] ?? ""
|
||||
return HermesPersonality(name: name, prompt: HermesFileService.stripYAMLQuotes(prompt))
|
||||
}
|
||||
}
|
||||
|
||||
func setActive(_ name: String) {
|
||||
let result = runHermes(["config", "set", "display.personality", name])
|
||||
if result.exitCode == 0 {
|
||||
activeName = name
|
||||
message = "Active personality set to \(name)"
|
||||
} else {
|
||||
logger.warning("Failed to set personality: \(result.output)")
|
||||
message = "Failed to set personality"
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
|
||||
self?.message = nil
|
||||
}
|
||||
}
|
||||
|
||||
func saveSOUL(_ content: String) {
|
||||
do {
|
||||
try content.write(toFile: soulPath, atomically: true, encoding: .utf8)
|
||||
soulMarkdown = content
|
||||
message = "SOUL.md saved"
|
||||
} catch {
|
||||
logger.error("Failed to write SOUL.md: \(error.localizedDescription)")
|
||||
message = "Save failed"
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
|
||||
self?.message = nil
|
||||
}
|
||||
}
|
||||
|
||||
func openConfigInEditor() {
|
||||
NSWorkspace.shared.open(URL(fileURLWithPath: HermesPaths.configYAML))
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func runHermes(_ arguments: [String]) -> (output: String, exitCode: Int32) {
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: HermesPaths.hermesBinary)
|
||||
process.arguments = arguments
|
||||
process.environment = HermesFileService.enrichedEnvironment()
|
||||
let pipe = Pipe()
|
||||
process.standardOutput = pipe
|
||||
process.standardError = Pipe()
|
||||
do {
|
||||
try process.run()
|
||||
process.waitUntilExit()
|
||||
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
||||
return (String(data: data, encoding: .utf8) ?? "", process.terminationStatus)
|
||||
} catch {
|
||||
return ("", -1)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import SwiftUI
|
||||
|
||||
struct PersonalitiesView: View {
|
||||
@State private var viewModel = PersonalitiesViewModel()
|
||||
@State private var soulDraft = ""
|
||||
@State private var editingSOUL = false
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
header
|
||||
activeSection
|
||||
listSection
|
||||
soulSection
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||
}
|
||||
.navigationTitle("Personalities")
|
||||
.onAppear {
|
||||
viewModel.load()
|
||||
soulDraft = viewModel.soulMarkdown
|
||||
}
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack {
|
||||
if let msg = viewModel.message {
|
||||
Label(msg, systemImage: "checkmark.circle.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.green)
|
||||
}
|
||||
Spacer()
|
||||
Button("Edit config.yaml") { viewModel.openConfigInEditor() }
|
||||
.controlSize(.small)
|
||||
Button("Reload") { viewModel.load(); soulDraft = viewModel.soulMarkdown }
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
|
||||
private var activeSection: some View {
|
||||
SettingsSection(title: "Active Personality", icon: "theatermasks.fill") {
|
||||
if viewModel.personalities.isEmpty {
|
||||
ReadOnlyRow(label: "Current", value: viewModel.activeName.isEmpty ? "default" : viewModel.activeName)
|
||||
ReadOnlyRow(label: "Defined", value: "None in config.yaml — add under `personalities:` to customize.")
|
||||
} else {
|
||||
PickerRow(label: "Active", selection: viewModel.activeName, options: viewModel.personalities.map(\.name)) { viewModel.setActive($0) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var listSection: some View {
|
||||
if !viewModel.personalities.isEmpty {
|
||||
SettingsSection(title: "Defined Personalities", icon: "list.bullet") {
|
||||
ForEach(viewModel.personalities) { personality in
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
Text(personality.name)
|
||||
.font(.system(.body, design: .monospaced, weight: .medium))
|
||||
if personality.name == viewModel.activeName {
|
||||
Text("active")
|
||||
.font(.caption2.bold())
|
||||
.foregroundStyle(.green)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 1)
|
||||
.background(.green.opacity(0.15))
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
if !personality.prompt.isEmpty {
|
||||
Text(personality.prompt)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(6)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var soulSection: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack {
|
||||
Label("SOUL.md", systemImage: "sparkles")
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
if editingSOUL {
|
||||
Button("Cancel") {
|
||||
editingSOUL = false
|
||||
soulDraft = viewModel.soulMarkdown
|
||||
}
|
||||
.controlSize(.small)
|
||||
Button("Save") {
|
||||
viewModel.saveSOUL(soulDraft)
|
||||
editingSOUL = false
|
||||
}
|
||||
.controlSize(.small)
|
||||
.keyboardShortcut("s", modifiers: .command)
|
||||
} else {
|
||||
Button("Edit") { editingSOUL = true }
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
Text("SOUL.md describes the agent's voice, values, and personality at ~/.hermes/SOUL.md. It is injected into every session's context.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
if editingSOUL {
|
||||
TextEditor(text: $soulDraft)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.frame(minHeight: 220)
|
||||
.padding(6)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
} else {
|
||||
Text(viewModel.soulMarkdown.isEmpty ? "(empty)" : viewModel.soulMarkdown)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.foregroundStyle(viewModel.soulMarkdown.isEmpty ? .secondary : .primary)
|
||||
.textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(8)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
/// Discord setup. Bot token + user IDs in `.env`, behavior knobs in `discord.*`.
|
||||
/// Field reference: https://hermes-agent.nousresearch.com/docs/user-guide/messaging/discord
|
||||
@Observable
|
||||
@MainActor
|
||||
final class DiscordSetupViewModel {
|
||||
var botToken: String = ""
|
||||
var allowedUsers: String = ""
|
||||
var homeChannel: String = ""
|
||||
var homeChannelName: String = ""
|
||||
var allowBots: String = "none" // "none" | "mentions" | "all"
|
||||
var replyToMode: String = "first" // "off" | "first" | "all"
|
||||
|
||||
// config.yaml — these mirror the existing `HermesConfig.discord` block so we
|
||||
// stay consistent with whatever the Settings UI shows.
|
||||
var requireMention: Bool = true
|
||||
var freeResponseChannels: String = ""
|
||||
var autoThread: Bool = true
|
||||
var reactions: Bool = true
|
||||
|
||||
var message: String?
|
||||
|
||||
let allowBotsOptions = ["none", "mentions", "all"]
|
||||
let replyToModeOptions = ["off", "first", "all"]
|
||||
|
||||
func load() {
|
||||
let env = HermesEnvService().load()
|
||||
botToken = env["DISCORD_BOT_TOKEN"] ?? ""
|
||||
allowedUsers = env["DISCORD_ALLOWED_USERS"] ?? ""
|
||||
homeChannel = env["DISCORD_HOME_CHANNEL"] ?? ""
|
||||
homeChannelName = env["DISCORD_HOME_CHANNEL_NAME"] ?? ""
|
||||
allowBots = env["DISCORD_ALLOW_BOTS"] ?? "none"
|
||||
replyToMode = env["DISCORD_REPLY_TO_MODE"] ?? "first"
|
||||
|
||||
let cfg = HermesFileService().loadConfig().discord
|
||||
requireMention = cfg.requireMention
|
||||
freeResponseChannels = cfg.freeResponseChannels
|
||||
autoThread = cfg.autoThread
|
||||
reactions = cfg.reactions
|
||||
}
|
||||
|
||||
func save() {
|
||||
let envPairs: [String: String] = [
|
||||
"DISCORD_BOT_TOKEN": botToken,
|
||||
"DISCORD_ALLOWED_USERS": allowedUsers,
|
||||
"DISCORD_HOME_CHANNEL": homeChannel,
|
||||
"DISCORD_HOME_CHANNEL_NAME": homeChannelName,
|
||||
"DISCORD_ALLOW_BOTS": allowBots == "none" ? "" : allowBots, // default is "none", don't persist
|
||||
"DISCORD_REPLY_TO_MODE": replyToMode == "first" ? "" : replyToMode
|
||||
]
|
||||
let configKV: [String: String] = [
|
||||
"discord.require_mention": PlatformSetupHelpers.envBool(requireMention),
|
||||
"discord.free_response_channels": freeResponseChannels,
|
||||
"discord.auto_thread": PlatformSetupHelpers.envBool(autoThread),
|
||||
"discord.reactions": PlatformSetupHelpers.envBool(reactions)
|
||||
]
|
||||
message = PlatformSetupHelpers.saveForm(envPairs: envPairs, configKV: configKV)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
||||
self?.message = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import Foundation
|
||||
|
||||
/// Email setup. IMAP/SMTP with app passwords — no OAuth.
|
||||
/// Field reference: https://hermes-agent.nousresearch.com/docs/user-guide/messaging/email
|
||||
@Observable
|
||||
@MainActor
|
||||
final class EmailSetupViewModel {
|
||||
var address: String = ""
|
||||
var password: String = ""
|
||||
var imapHost: String = ""
|
||||
var smtpHost: String = ""
|
||||
var imapPort: String = "993"
|
||||
var smtpPort: String = "587"
|
||||
var pollInterval: String = "15"
|
||||
var allowedUsers: String = ""
|
||||
var homeAddress: String = ""
|
||||
var allowAllUsers: Bool = false
|
||||
var skipAttachments: Bool = false
|
||||
|
||||
var message: String?
|
||||
|
||||
/// Common provider presets so users don't have to look up IMAP/SMTP servers.
|
||||
struct Preset {
|
||||
let name: String
|
||||
let imap: String
|
||||
let smtp: String
|
||||
}
|
||||
let presets: [Preset] = [
|
||||
Preset(name: "Gmail", imap: "imap.gmail.com", smtp: "smtp.gmail.com"),
|
||||
Preset(name: "Outlook", imap: "outlook.office365.com", smtp: "smtp.office365.com"),
|
||||
Preset(name: "iCloud", imap: "imap.mail.me.com", smtp: "smtp.mail.me.com"),
|
||||
Preset(name: "Fastmail", imap: "imap.fastmail.com", smtp: "smtp.fastmail.com"),
|
||||
Preset(name: "Yahoo", imap: "imap.mail.yahoo.com", smtp: "smtp.mail.yahoo.com")
|
||||
]
|
||||
|
||||
func load() {
|
||||
let env = HermesEnvService().load()
|
||||
address = env["EMAIL_ADDRESS"] ?? ""
|
||||
password = env["EMAIL_PASSWORD"] ?? ""
|
||||
imapHost = env["EMAIL_IMAP_HOST"] ?? ""
|
||||
smtpHost = env["EMAIL_SMTP_HOST"] ?? ""
|
||||
imapPort = env["EMAIL_IMAP_PORT"] ?? "993"
|
||||
smtpPort = env["EMAIL_SMTP_PORT"] ?? "587"
|
||||
pollInterval = env["EMAIL_POLL_INTERVAL"] ?? "15"
|
||||
allowedUsers = env["EMAIL_ALLOWED_USERS"] ?? ""
|
||||
homeAddress = env["EMAIL_HOME_ADDRESS"] ?? ""
|
||||
allowAllUsers = PlatformSetupHelpers.parseEnvBool(env["EMAIL_ALLOW_ALL_USERS"])
|
||||
// skip_attachments lives in config.yaml.
|
||||
let yaml = (try? String(contentsOfFile: HermesPaths.configYAML, encoding: .utf8)) ?? ""
|
||||
let parsed = HermesFileService.parseNestedYAML(yaml)
|
||||
skipAttachments = (parsed.values["platforms.email.skip_attachments"] ?? "false") == "true"
|
||||
}
|
||||
|
||||
func applyPreset(_ preset: Preset) {
|
||||
imapHost = preset.imap
|
||||
smtpHost = preset.smtp
|
||||
}
|
||||
|
||||
func save() {
|
||||
let envPairs: [String: String] = [
|
||||
"EMAIL_ADDRESS": address,
|
||||
"EMAIL_PASSWORD": password,
|
||||
"EMAIL_IMAP_HOST": imapHost,
|
||||
"EMAIL_SMTP_HOST": smtpHost,
|
||||
"EMAIL_IMAP_PORT": imapPort,
|
||||
"EMAIL_SMTP_PORT": smtpPort,
|
||||
"EMAIL_POLL_INTERVAL": pollInterval,
|
||||
"EMAIL_ALLOWED_USERS": allowAllUsers ? "" : allowedUsers,
|
||||
"EMAIL_HOME_ADDRESS": homeAddress,
|
||||
"EMAIL_ALLOW_ALL_USERS": allowAllUsers ? "true" : ""
|
||||
]
|
||||
let configKV: [String: String] = [
|
||||
"platforms.email.skip_attachments": PlatformSetupHelpers.envBool(skipAttachments)
|
||||
]
|
||||
message = PlatformSetupHelpers.saveForm(envPairs: envPairs, configKV: configKV)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
||||
self?.message = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import Foundation
|
||||
|
||||
/// Feishu/Lark setup. Choose domain (feishu = China, lark = international).
|
||||
/// Field reference: https://hermes-agent.nousresearch.com/docs/user-guide/messaging/feishu
|
||||
@Observable
|
||||
@MainActor
|
||||
final class FeishuSetupViewModel {
|
||||
var appID: String = ""
|
||||
var appSecret: String = ""
|
||||
var domain: String = "lark"
|
||||
var encryptKey: String = ""
|
||||
var verificationToken: String = ""
|
||||
var allowedUsers: String = ""
|
||||
var connectionMode: String = "websocket" // "websocket" | "webhook"
|
||||
|
||||
var message: String?
|
||||
|
||||
let domainOptions = ["feishu", "lark"]
|
||||
let connectionOptions = ["websocket", "webhook"]
|
||||
|
||||
func load() {
|
||||
let env = HermesEnvService().load()
|
||||
appID = env["FEISHU_APP_ID"] ?? ""
|
||||
appSecret = env["FEISHU_APP_SECRET"] ?? ""
|
||||
domain = env["FEISHU_DOMAIN"] ?? "lark"
|
||||
encryptKey = env["FEISHU_ENCRYPT_KEY"] ?? ""
|
||||
verificationToken = env["FEISHU_VERIFICATION_TOKEN"] ?? ""
|
||||
allowedUsers = env["FEISHU_ALLOWED_USERS"] ?? ""
|
||||
connectionMode = env["FEISHU_CONNECTION_MODE"] ?? "websocket"
|
||||
}
|
||||
|
||||
func save() {
|
||||
let envPairs: [String: String] = [
|
||||
"FEISHU_APP_ID": appID,
|
||||
"FEISHU_APP_SECRET": appSecret,
|
||||
"FEISHU_DOMAIN": domain,
|
||||
"FEISHU_ENCRYPT_KEY": encryptKey,
|
||||
"FEISHU_VERIFICATION_TOKEN": verificationToken,
|
||||
"FEISHU_ALLOWED_USERS": allowedUsers,
|
||||
"FEISHU_CONNECTION_MODE": connectionMode == "websocket" ? "" : connectionMode
|
||||
]
|
||||
message = PlatformSetupHelpers.saveForm(envPairs: envPairs, configKV: [:])
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
||||
self?.message = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
import Foundation
|
||||
import AppKit
|
||||
|
||||
/// Home Assistant setup. Long-lived access token in `.env`, scalar filters via
|
||||
/// `hermes config set` under `platforms.homeassistant.extra.*`.
|
||||
///
|
||||
/// **List fields** (`watch_domains`, `watch_entities`, `ignore_entities`) are
|
||||
/// NOT editable in the form. `hermes config set` stores array arguments as
|
||||
/// quoted strings instead of YAML lists, which hermes would then reject as
|
||||
/// invalid. Users edit these directly in config.yaml — the view shows the
|
||||
/// current values (read-only) and an "Edit in config.yaml" button that opens
|
||||
/// the file.
|
||||
///
|
||||
/// Field reference: https://hermes-agent.nousresearch.com/docs/user-guide/messaging/homeassistant
|
||||
@Observable
|
||||
@MainActor
|
||||
final class HomeAssistantSetupViewModel {
|
||||
var url: String = "http://homeassistant.local:8123"
|
||||
var token: String = ""
|
||||
|
||||
// Scalar filters — writable via hermes config set.
|
||||
var watchAll: Bool = false
|
||||
var cooldownSeconds: Int = 30
|
||||
|
||||
// List filters — read-only; user must edit config.yaml manually.
|
||||
var watchDomains: [String] = []
|
||||
var watchEntities: [String] = []
|
||||
var ignoreEntities: [String] = []
|
||||
|
||||
var message: String?
|
||||
|
||||
func load() {
|
||||
let env = HermesEnvService().load()
|
||||
url = env["HASS_URL"] ?? "http://homeassistant.local:8123"
|
||||
token = env["HASS_TOKEN"] ?? ""
|
||||
|
||||
let cfg = HermesFileService().loadConfig().homeAssistant
|
||||
watchAll = cfg.watchAll
|
||||
cooldownSeconds = cfg.cooldownSeconds
|
||||
watchDomains = cfg.watchDomains
|
||||
watchEntities = cfg.watchEntities
|
||||
ignoreEntities = cfg.ignoreEntities
|
||||
}
|
||||
|
||||
func save() {
|
||||
let envPairs: [String: String] = [
|
||||
"HASS_URL": url,
|
||||
"HASS_TOKEN": token
|
||||
]
|
||||
// Only scalar config values — lists are skipped intentionally; see
|
||||
// file header comment for rationale.
|
||||
let configKV: [String: String] = [
|
||||
"platforms.homeassistant.extra.watch_all": PlatformSetupHelpers.envBool(watchAll),
|
||||
"platforms.homeassistant.extra.cooldown_seconds": String(cooldownSeconds)
|
||||
]
|
||||
message = PlatformSetupHelpers.saveForm(envPairs: envPairs, configKV: configKV)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
||||
self?.message = nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Open config.yaml in the user's default editor so they can manually edit
|
||||
/// the list-valued filter fields.
|
||||
func openConfigForLists() {
|
||||
NSWorkspace.shared.open(URL(fileURLWithPath: HermesPaths.configYAML))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import Foundation
|
||||
|
||||
/// iMessage via BlueBubbles. Requires a BlueBubbles Server running on a Mac
|
||||
/// that's always on, with an Apple ID signed into Messages.app.
|
||||
/// Field reference: https://hermes-agent.nousresearch.com/docs/user-guide/messaging/bluebubbles
|
||||
@Observable
|
||||
@MainActor
|
||||
final class IMessageSetupViewModel {
|
||||
var serverURL: String = ""
|
||||
var password: String = ""
|
||||
var webhookHost: String = "127.0.0.1"
|
||||
var webhookPort: String = "8645"
|
||||
var webhookPath: String = ""
|
||||
var allowedUsers: String = ""
|
||||
var homeChannel: String = ""
|
||||
var allowAllUsers: Bool = false
|
||||
var sendReadReceipts: Bool = false
|
||||
|
||||
var message: String?
|
||||
|
||||
func load() {
|
||||
let env = HermesEnvService().load()
|
||||
serverURL = env["BLUEBUBBLES_SERVER_URL"] ?? ""
|
||||
password = env["BLUEBUBBLES_PASSWORD"] ?? ""
|
||||
webhookHost = env["BLUEBUBBLES_WEBHOOK_HOST"] ?? "127.0.0.1"
|
||||
webhookPort = env["BLUEBUBBLES_WEBHOOK_PORT"] ?? "8645"
|
||||
webhookPath = env["BLUEBUBBLES_WEBHOOK_PATH"] ?? ""
|
||||
allowedUsers = env["BLUEBUBBLES_ALLOWED_USERS"] ?? ""
|
||||
homeChannel = env["BLUEBUBBLES_HOME_CHANNEL"] ?? ""
|
||||
allowAllUsers = PlatformSetupHelpers.parseEnvBool(env["BLUEBUBBLES_ALLOW_ALL_USERS"])
|
||||
sendReadReceipts = PlatformSetupHelpers.parseEnvBool(env["BLUEBUBBLES_SEND_READ_RECEIPTS"])
|
||||
}
|
||||
|
||||
func save() {
|
||||
let envPairs: [String: String] = [
|
||||
"BLUEBUBBLES_SERVER_URL": serverURL,
|
||||
"BLUEBUBBLES_PASSWORD": password,
|
||||
"BLUEBUBBLES_WEBHOOK_HOST": webhookHost,
|
||||
"BLUEBUBBLES_WEBHOOK_PORT": webhookPort,
|
||||
"BLUEBUBBLES_WEBHOOK_PATH": webhookPath,
|
||||
"BLUEBUBBLES_ALLOWED_USERS": allowAllUsers ? "" : allowedUsers,
|
||||
"BLUEBUBBLES_HOME_CHANNEL": homeChannel,
|
||||
"BLUEBUBBLES_ALLOW_ALL_USERS": allowAllUsers ? "true" : "",
|
||||
"BLUEBUBBLES_SEND_READ_RECEIPTS": sendReadReceipts ? "true" : ""
|
||||
]
|
||||
message = PlatformSetupHelpers.saveForm(envPairs: envPairs, configKV: [:])
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
||||
self?.message = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import Foundation
|
||||
|
||||
/// Matrix setup. Supports both access-token and password auth. No SSO.
|
||||
/// Field reference: https://hermes-agent.nousresearch.com/docs/user-guide/messaging/matrix
|
||||
@Observable
|
||||
@MainActor
|
||||
final class MatrixSetupViewModel {
|
||||
var homeserver: String = ""
|
||||
var accessToken: String = "" // preferred
|
||||
var userID: String = ""
|
||||
var password: String = "" // alternative to accessToken
|
||||
var allowedUsers: String = ""
|
||||
var homeRoom: String = ""
|
||||
var recoveryKey: String = ""
|
||||
var encryption: Bool = false
|
||||
|
||||
// config.yaml
|
||||
var requireMention: Bool = true
|
||||
var autoThread: Bool = true
|
||||
var dmMentionThreads: Bool = false
|
||||
|
||||
var message: String?
|
||||
|
||||
func load() {
|
||||
let env = HermesEnvService().load()
|
||||
homeserver = env["MATRIX_HOMESERVER"] ?? ""
|
||||
accessToken = env["MATRIX_ACCESS_TOKEN"] ?? ""
|
||||
userID = env["MATRIX_USER_ID"] ?? ""
|
||||
password = env["MATRIX_PASSWORD"] ?? ""
|
||||
allowedUsers = env["MATRIX_ALLOWED_USERS"] ?? ""
|
||||
homeRoom = env["MATRIX_HOME_ROOM"] ?? ""
|
||||
recoveryKey = env["MATRIX_RECOVERY_KEY"] ?? ""
|
||||
encryption = PlatformSetupHelpers.parseEnvBool(env["MATRIX_ENCRYPTION"])
|
||||
|
||||
let cfg = HermesFileService().loadConfig().matrix
|
||||
requireMention = cfg.requireMention
|
||||
autoThread = cfg.autoThread
|
||||
dmMentionThreads = cfg.dmMentionThreads
|
||||
}
|
||||
|
||||
func save() {
|
||||
let envPairs: [String: String] = [
|
||||
"MATRIX_HOMESERVER": homeserver,
|
||||
"MATRIX_ACCESS_TOKEN": accessToken,
|
||||
"MATRIX_USER_ID": userID,
|
||||
"MATRIX_PASSWORD": password,
|
||||
"MATRIX_ALLOWED_USERS": allowedUsers,
|
||||
"MATRIX_HOME_ROOM": homeRoom,
|
||||
"MATRIX_RECOVERY_KEY": recoveryKey,
|
||||
"MATRIX_ENCRYPTION": encryption ? "true" : ""
|
||||
]
|
||||
let configKV: [String: String] = [
|
||||
"matrix.require_mention": PlatformSetupHelpers.envBool(requireMention),
|
||||
"matrix.auto_thread": PlatformSetupHelpers.envBool(autoThread),
|
||||
"matrix.dm_mention_threads": PlatformSetupHelpers.envBool(dmMentionThreads)
|
||||
]
|
||||
message = PlatformSetupHelpers.saveForm(envPairs: envPairs, configKV: configKV)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
||||
self?.message = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
import Foundation
|
||||
|
||||
/// Mattermost setup. Server URL + personal access token (or bot token).
|
||||
/// Field reference: https://hermes-agent.nousresearch.com/docs/user-guide/messaging/mattermost
|
||||
@Observable
|
||||
@MainActor
|
||||
final class MattermostSetupViewModel {
|
||||
var serverURL: String = ""
|
||||
var token: String = ""
|
||||
var allowedUsers: String = ""
|
||||
var homeChannel: String = ""
|
||||
var freeResponseChannels: String = ""
|
||||
|
||||
var replyMode: String = "off"
|
||||
var requireMention: Bool = true
|
||||
|
||||
var message: String?
|
||||
let replyModeOptions = ["off", "thread"]
|
||||
|
||||
func load() {
|
||||
let env = HermesEnvService().load()
|
||||
serverURL = env["MATTERMOST_URL"] ?? ""
|
||||
token = env["MATTERMOST_TOKEN"] ?? ""
|
||||
allowedUsers = env["MATTERMOST_ALLOWED_USERS"] ?? ""
|
||||
homeChannel = env["MATTERMOST_HOME_CHANNEL"] ?? ""
|
||||
freeResponseChannels = env["MATTERMOST_FREE_RESPONSE_CHANNELS"] ?? ""
|
||||
replyMode = env["MATTERMOST_REPLY_MODE"] ?? "off"
|
||||
|
||||
let cfg = HermesFileService().loadConfig().mattermost
|
||||
requireMention = cfg.requireMention
|
||||
}
|
||||
|
||||
func save() {
|
||||
let envPairs: [String: String] = [
|
||||
"MATTERMOST_URL": serverURL,
|
||||
"MATTERMOST_TOKEN": token,
|
||||
"MATTERMOST_ALLOWED_USERS": allowedUsers,
|
||||
"MATTERMOST_HOME_CHANNEL": homeChannel,
|
||||
"MATTERMOST_FREE_RESPONSE_CHANNELS": freeResponseChannels,
|
||||
"MATTERMOST_REPLY_MODE": replyMode == "off" ? "" : replyMode,
|
||||
"MATTERMOST_REQUIRE_MENTION": PlatformSetupHelpers.envBool(requireMention)
|
||||
]
|
||||
message = PlatformSetupHelpers.saveForm(envPairs: envPairs, configKV: [:])
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
||||
self?.message = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import Foundation
|
||||
import AppKit
|
||||
import os
|
||||
|
||||
/// Shared helpers used by every per-platform setup view model.
|
||||
///
|
||||
/// Each platform form follows the same pattern:
|
||||
/// 1. Load current values from `.env` + config.yaml into local `@Observable` state.
|
||||
/// 2. Present them in a form where changes happen in-memory.
|
||||
/// 3. On save, write env vars via `HermesEnvService.setMany` and config.yaml keys
|
||||
/// via `hermes config set`, then surface a success/error toast.
|
||||
///
|
||||
/// Putting the save logic here keeps each per-platform VM focused on its own
|
||||
/// field set without re-implementing the write plumbing 12 times.
|
||||
@MainActor
|
||||
enum PlatformSetupHelpers {
|
||||
static let logger = Logger(subsystem: "com.scarf", category: "PlatformSetup")
|
||||
static let envService = HermesEnvService()
|
||||
|
||||
/// Apply a form save in one atomic batch.
|
||||
///
|
||||
/// - `envPairs`: values to write into `.env`. Empty strings trigger `unset()`
|
||||
/// (commenting the line out) rather than storing a literal empty value.
|
||||
/// - `configKV`: scalar config.yaml paths to set via `hermes config set`.
|
||||
/// Empty strings still produce a `config set <key> ""` call because
|
||||
/// some fields accept an explicit empty string (e.g., `display.skin: ""`).
|
||||
///
|
||||
/// Returns a user-facing summary message.
|
||||
@discardableResult
|
||||
static func saveForm(envPairs: [String: String], configKV: [String: String]) -> String {
|
||||
// Split env pairs into set vs. unset.
|
||||
var toSet: [String: String] = [:]
|
||||
var toUnset: [String] = []
|
||||
for (k, v) in envPairs {
|
||||
if v.isEmpty {
|
||||
toUnset.append(k)
|
||||
} else {
|
||||
toSet[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
var envOK = true
|
||||
if !toSet.isEmpty {
|
||||
envOK = envService.setMany(toSet)
|
||||
}
|
||||
for key in toUnset {
|
||||
_ = envService.unset(key)
|
||||
}
|
||||
|
||||
var configFailures: [String] = []
|
||||
for (key, value) in configKV {
|
||||
let result = runHermesCLI(args: ["config", "set", key, value])
|
||||
if result.exitCode != 0 {
|
||||
configFailures.append(key)
|
||||
logger.warning("hermes config set \(key) failed: \(result.output)")
|
||||
}
|
||||
}
|
||||
|
||||
if !envOK { return "Failed to write .env" }
|
||||
if !configFailures.isEmpty { return "Saved, but failed to update: \(configFailures.joined(separator: ", "))" }
|
||||
return "Saved — restart gateway to apply"
|
||||
}
|
||||
|
||||
/// Synchronous hermes CLI invocation. Use only for fast commands like
|
||||
/// `config set`; longer commands should use `HermesFileService.runHermesCLI`
|
||||
/// from a `Task.detached`.
|
||||
static func runHermesCLI(args: [String], timeout: TimeInterval = 15) -> (exitCode: Int32, output: String) {
|
||||
HermesFileService().runHermesCLI(args: args, timeout: timeout)
|
||||
}
|
||||
|
||||
/// Ask the user's default browser to open a URL (typically a hermes doc page
|
||||
/// or a platform developer portal).
|
||||
static func openURL(_ string: String) {
|
||||
guard let url = URL(string: string) else { return }
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
|
||||
/// Bool <-> "true"/"false" round-trip for env vars. Hermes accepts both
|
||||
/// "true"/"false" and "1"/"0"; we emit the string form for readability.
|
||||
static func envBool(_ on: Bool) -> String { on ? "true" : "false" }
|
||||
|
||||
/// Parse an env string as a bool. Treats missing/empty as `false`.
|
||||
/// "true", "1", "yes", "on" (case-insensitive) are true.
|
||||
static func parseEnvBool(_ s: String?) -> Bool {
|
||||
guard let s else { return false }
|
||||
switch s.lowercased() {
|
||||
case "true", "1", "yes", "on": return true
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import Foundation
|
||||
|
||||
/// Signal setup. Users must install `signal-cli` externally (needs Java), link
|
||||
/// their account via `signal-cli link -n ...`, and run a daemon on an HTTP port
|
||||
/// that hermes talks to. We expose an embedded terminal for both the link and
|
||||
/// daemon commands.
|
||||
///
|
||||
/// Field reference: https://hermes-agent.nousresearch.com/docs/user-guide/messaging/signal
|
||||
@Observable
|
||||
@MainActor
|
||||
final class SignalSetupViewModel {
|
||||
var httpURL: String = "http://127.0.0.1:8080"
|
||||
var account: String = "" // E.164 phone, e.g. +15551234567
|
||||
var allowedUsers: String = ""
|
||||
var groupAllowedUsers: String = ""
|
||||
var homeChannel: String = ""
|
||||
var allowAllUsers: Bool = false
|
||||
|
||||
var message: String?
|
||||
|
||||
let terminalController = EmbeddedSetupTerminalController()
|
||||
var signalCLIInstalled: Bool = false
|
||||
var activeTask: SignalTerminalTask = .none
|
||||
|
||||
enum SignalTerminalTask: Equatable {
|
||||
case none
|
||||
case link
|
||||
case daemon
|
||||
}
|
||||
|
||||
func load() {
|
||||
let env = HermesEnvService().load()
|
||||
httpURL = env["SIGNAL_HTTP_URL"] ?? "http://127.0.0.1:8080"
|
||||
account = env["SIGNAL_ACCOUNT"] ?? ""
|
||||
allowedUsers = env["SIGNAL_ALLOWED_USERS"] ?? ""
|
||||
groupAllowedUsers = env["SIGNAL_GROUP_ALLOWED_USERS"] ?? ""
|
||||
homeChannel = env["SIGNAL_HOME_CHANNEL"] ?? ""
|
||||
allowAllUsers = PlatformSetupHelpers.parseEnvBool(env["SIGNAL_ALLOW_ALL_USERS"])
|
||||
signalCLIInstalled = Self.detectSignalCLI()
|
||||
}
|
||||
|
||||
/// Best-effort `signal-cli` binary lookup on the login-shell PATH.
|
||||
private static func detectSignalCLI() -> Bool {
|
||||
let env = HermesFileService.enrichedEnvironment()
|
||||
let paths = env["PATH"]?.split(separator: ":").map(String.init) ?? []
|
||||
for dir in paths {
|
||||
if FileManager.default.isExecutableFile(atPath: dir + "/signal-cli") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func save() {
|
||||
let envPairs: [String: String] = [
|
||||
"SIGNAL_HTTP_URL": httpURL,
|
||||
"SIGNAL_ACCOUNT": account,
|
||||
"SIGNAL_ALLOWED_USERS": allowAllUsers ? "" : allowedUsers,
|
||||
"SIGNAL_GROUP_ALLOWED_USERS": groupAllowedUsers,
|
||||
"SIGNAL_HOME_CHANNEL": homeChannel,
|
||||
"SIGNAL_ALLOW_ALL_USERS": allowAllUsers ? "true" : ""
|
||||
]
|
||||
message = PlatformSetupHelpers.saveForm(envPairs: envPairs, configKV: [:])
|
||||
clearMessageAfterDelay()
|
||||
}
|
||||
|
||||
/// Run `signal-cli link -n HermesAgent` to generate a QR code.
|
||||
func startLink() {
|
||||
guard signalCLIInstalled else {
|
||||
message = "signal-cli not found on PATH — install it first"
|
||||
clearMessageAfterDelay()
|
||||
return
|
||||
}
|
||||
activeTask = .link
|
||||
terminalController.onExit = { [weak self] _ in
|
||||
self?.activeTask = .none
|
||||
self?.message = "Link step exited — save credentials and start the daemon next"
|
||||
self?.clearMessageAfterDelay()
|
||||
}
|
||||
terminalController.start(executable: "/usr/bin/env", arguments: ["signal-cli", "link", "-n", "HermesAgent"])
|
||||
}
|
||||
|
||||
/// Run the signal-cli daemon. Users can stop it by closing the panel.
|
||||
func startDaemon() {
|
||||
guard !account.isEmpty else {
|
||||
message = "Enter your Signal account (E.164 format) first"
|
||||
clearMessageAfterDelay()
|
||||
return
|
||||
}
|
||||
guard signalCLIInstalled else {
|
||||
message = "signal-cli not found on PATH"
|
||||
clearMessageAfterDelay()
|
||||
return
|
||||
}
|
||||
activeTask = .daemon
|
||||
let bind = httpURL.replacingOccurrences(of: "http://", with: "").replacingOccurrences(of: "https://", with: "")
|
||||
terminalController.onExit = { [weak self] _ in
|
||||
self?.activeTask = .none
|
||||
}
|
||||
terminalController.start(
|
||||
executable: "/usr/bin/env",
|
||||
arguments: ["signal-cli", "--account", account, "daemon", "--http", bind]
|
||||
)
|
||||
}
|
||||
|
||||
func stopTerminal() {
|
||||
terminalController.stop()
|
||||
activeTask = .none
|
||||
}
|
||||
|
||||
private func clearMessageAfterDelay() {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
||||
self?.message = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import Foundation
|
||||
|
||||
/// Slack setup. Requires two tokens (bot + app-level for Socket Mode).
|
||||
/// Field reference: https://hermes-agent.nousresearch.com/docs/user-guide/messaging/slack
|
||||
@Observable
|
||||
@MainActor
|
||||
final class SlackSetupViewModel {
|
||||
var botToken: String = "" // xoxb-...
|
||||
var appToken: String = "" // xapp-...
|
||||
var allowedUsers: String = ""
|
||||
var homeChannel: String = ""
|
||||
var homeChannelName: String = ""
|
||||
|
||||
var replyToMode: String = "first"
|
||||
var requireMention: Bool = true
|
||||
var replyInThread: Bool = true
|
||||
var replyBroadcast: Bool = false
|
||||
|
||||
var message: String?
|
||||
|
||||
let replyToModeOptions = ["off", "first", "all"]
|
||||
|
||||
func load() {
|
||||
let env = HermesEnvService().load()
|
||||
botToken = env["SLACK_BOT_TOKEN"] ?? ""
|
||||
appToken = env["SLACK_APP_TOKEN"] ?? ""
|
||||
allowedUsers = env["SLACK_ALLOWED_USERS"] ?? ""
|
||||
homeChannel = env["SLACK_HOME_CHANNEL"] ?? ""
|
||||
homeChannelName = env["SLACK_HOME_CHANNEL_NAME"] ?? ""
|
||||
|
||||
let cfg = HermesFileService().loadConfig().slack
|
||||
replyToMode = cfg.replyToMode
|
||||
requireMention = cfg.requireMention
|
||||
replyInThread = cfg.replyInThread
|
||||
replyBroadcast = cfg.replyBroadcast
|
||||
}
|
||||
|
||||
func save() {
|
||||
let envPairs: [String: String] = [
|
||||
"SLACK_BOT_TOKEN": botToken,
|
||||
"SLACK_APP_TOKEN": appToken,
|
||||
"SLACK_ALLOWED_USERS": allowedUsers,
|
||||
"SLACK_HOME_CHANNEL": homeChannel,
|
||||
"SLACK_HOME_CHANNEL_NAME": homeChannelName
|
||||
]
|
||||
// Slack uses the modern `platforms.slack.*` schema.
|
||||
let configKV: [String: String] = [
|
||||
"platforms.slack.reply_to_mode": replyToMode,
|
||||
"platforms.slack.require_mention": PlatformSetupHelpers.envBool(requireMention),
|
||||
"platforms.slack.extra.reply_in_thread": PlatformSetupHelpers.envBool(replyInThread),
|
||||
"platforms.slack.extra.reply_broadcast": PlatformSetupHelpers.envBool(replyBroadcast)
|
||||
]
|
||||
message = PlatformSetupHelpers.saveForm(envPairs: envPairs, configKV: configKV)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
||||
self?.message = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
/// Telegram platform setup. Credentials live in `.env` (`TELEGRAM_*`); mention /
|
||||
/// reactions toggles live in `config.yaml` under `telegram.*`.
|
||||
///
|
||||
/// Field reference: https://hermes-agent.nousresearch.com/docs/user-guide/messaging/telegram
|
||||
@Observable
|
||||
@MainActor
|
||||
final class TelegramSetupViewModel {
|
||||
// Required
|
||||
var botToken: String = ""
|
||||
var allowedUsers: String = ""
|
||||
// Optional
|
||||
var homeChannel: String = ""
|
||||
var webhookURL: String = ""
|
||||
var webhookPort: String = ""
|
||||
var webhookSecret: String = ""
|
||||
// Config.yaml toggles
|
||||
var requireMention: Bool = true
|
||||
var reactions: Bool = false
|
||||
|
||||
var message: String?
|
||||
|
||||
func load() {
|
||||
let env = HermesEnvService().load()
|
||||
botToken = env["TELEGRAM_BOT_TOKEN"] ?? ""
|
||||
allowedUsers = env["TELEGRAM_ALLOWED_USERS"] ?? ""
|
||||
homeChannel = env["TELEGRAM_HOME_CHANNEL"] ?? ""
|
||||
webhookURL = env["TELEGRAM_WEBHOOK_URL"] ?? ""
|
||||
webhookPort = env["TELEGRAM_WEBHOOK_PORT"] ?? ""
|
||||
webhookSecret = env["TELEGRAM_WEBHOOK_SECRET"] ?? ""
|
||||
|
||||
let cfg = HermesFileService().loadConfig()
|
||||
requireMention = cfg.telegram.requireMention
|
||||
reactions = cfg.telegram.reactions
|
||||
}
|
||||
|
||||
func save() {
|
||||
let envPairs: [String: String] = [
|
||||
"TELEGRAM_BOT_TOKEN": botToken,
|
||||
"TELEGRAM_ALLOWED_USERS": allowedUsers,
|
||||
"TELEGRAM_HOME_CHANNEL": homeChannel,
|
||||
"TELEGRAM_WEBHOOK_URL": webhookURL,
|
||||
"TELEGRAM_WEBHOOK_PORT": webhookPort,
|
||||
"TELEGRAM_WEBHOOK_SECRET": webhookSecret
|
||||
]
|
||||
let configKV: [String: String] = [
|
||||
"telegram.require_mention": PlatformSetupHelpers.envBool(requireMention),
|
||||
"telegram.reactions": PlatformSetupHelpers.envBool(reactions)
|
||||
]
|
||||
message = PlatformSetupHelpers.saveForm(envPairs: envPairs, configKV: configKV)
|
||||
clearMessageAfterDelay()
|
||||
}
|
||||
|
||||
private func clearMessageAfterDelay() {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
||||
self?.message = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import Foundation
|
||||
|
||||
/// Webhook platform setup. Just the global enable/port/secret — per-subscription
|
||||
/// routes live in the Webhooks sidebar feature.
|
||||
///
|
||||
/// Field reference: https://hermes-agent.nousresearch.com/docs/user-guide/messaging/webhooks
|
||||
@Observable
|
||||
@MainActor
|
||||
final class WebhookSetupViewModel {
|
||||
var enabled: Bool = false
|
||||
var port: String = "8644"
|
||||
var secret: String = ""
|
||||
|
||||
var message: String?
|
||||
|
||||
func load() {
|
||||
let env = HermesEnvService().load()
|
||||
enabled = PlatformSetupHelpers.parseEnvBool(env["WEBHOOK_ENABLED"])
|
||||
port = env["WEBHOOK_PORT"] ?? "8644"
|
||||
secret = env["WEBHOOK_SECRET"] ?? ""
|
||||
}
|
||||
|
||||
func save() {
|
||||
let envPairs: [String: String] = [
|
||||
"WEBHOOK_ENABLED": enabled ? "true" : "",
|
||||
"WEBHOOK_PORT": port,
|
||||
"WEBHOOK_SECRET": secret
|
||||
]
|
||||
message = PlatformSetupHelpers.saveForm(envPairs: envPairs, configKV: [:])
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
||||
self?.message = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import Foundation
|
||||
|
||||
/// WhatsApp setup. Unlike other platforms, pairing requires scanning a QR code
|
||||
/// via the `hermes whatsapp` CLI wizard — we expose that as an embedded
|
||||
/// terminal below the config form.
|
||||
///
|
||||
/// Field reference: https://hermes-agent.nousresearch.com/docs/user-guide/messaging/whatsapp
|
||||
@Observable
|
||||
@MainActor
|
||||
final class WhatsAppSetupViewModel {
|
||||
var enabled: Bool = false
|
||||
var mode: String = "bot" // "bot" | "self-chat"
|
||||
var allowedUsers: String = "" // Comma-separated phone numbers (no +)
|
||||
var allowAllUsers: Bool = false
|
||||
|
||||
// config.yaml knobs
|
||||
var unauthorizedDMBehavior: String = "pair" // "pair" | "ignore"
|
||||
var replyPrefix: String = ""
|
||||
|
||||
var message: String?
|
||||
let modeOptions = ["bot", "self-chat"]
|
||||
let unauthorizedOptions = ["pair", "ignore"]
|
||||
|
||||
/// The embedded terminal for the pairing step. Owned here so we can
|
||||
/// `stop()` it cleanly when the user navigates away.
|
||||
let terminalController = EmbeddedSetupTerminalController()
|
||||
var pairingInProgress: Bool = false
|
||||
|
||||
func load() {
|
||||
let env = HermesEnvService().load()
|
||||
enabled = PlatformSetupHelpers.parseEnvBool(env["WHATSAPP_ENABLED"])
|
||||
mode = env["WHATSAPP_MODE"] ?? "bot"
|
||||
allowedUsers = env["WHATSAPP_ALLOWED_USERS"] ?? ""
|
||||
allowAllUsers = PlatformSetupHelpers.parseEnvBool(env["WHATSAPP_ALLOW_ALL_USERS"])
|
||||
// Hermes accepts two equivalent ways to mean "allow everyone":
|
||||
// WHATSAPP_ALLOW_ALL_USERS=true OR WHATSAPP_ALLOWED_USERS=*
|
||||
// Normalize so the checkbox reflects either form.
|
||||
if allowedUsers == "*" {
|
||||
allowAllUsers = true
|
||||
allowedUsers = ""
|
||||
}
|
||||
|
||||
let cfg = HermesFileService().loadConfig().whatsapp
|
||||
unauthorizedDMBehavior = cfg.unauthorizedDMBehavior
|
||||
replyPrefix = cfg.replyPrefix
|
||||
}
|
||||
|
||||
func save() {
|
||||
let envPairs: [String: String] = [
|
||||
"WHATSAPP_ENABLED": PlatformSetupHelpers.envBool(enabled),
|
||||
"WHATSAPP_MODE": mode,
|
||||
// If "allow all" is set, the allowlist becomes "*" per hermes docs.
|
||||
"WHATSAPP_ALLOWED_USERS": allowAllUsers ? "*" : allowedUsers,
|
||||
"WHATSAPP_ALLOW_ALL_USERS": allowAllUsers ? "true" : ""
|
||||
]
|
||||
let configKV: [String: String] = [
|
||||
"whatsapp.unauthorized_dm_behavior": unauthorizedDMBehavior,
|
||||
"whatsapp.reply_prefix": replyPrefix
|
||||
]
|
||||
message = PlatformSetupHelpers.saveForm(envPairs: envPairs, configKV: configKV)
|
||||
clearMessageAfterDelay()
|
||||
}
|
||||
|
||||
/// Launch `hermes whatsapp` in the embedded terminal. The user scans the QR
|
||||
/// code; hermes writes the session to `~/.hermes/platforms/whatsapp/session`
|
||||
/// and exits when pairing is complete.
|
||||
func startPairing() {
|
||||
pairingInProgress = true
|
||||
terminalController.onExit = { [weak self] _ in
|
||||
self?.pairingInProgress = false
|
||||
self?.message = "Pairing terminal exited — check output for status"
|
||||
self?.clearMessageAfterDelay()
|
||||
}
|
||||
terminalController.start(
|
||||
executable: HermesPaths.hermesBinary,
|
||||
arguments: ["whatsapp"]
|
||||
)
|
||||
}
|
||||
|
||||
func stopPairing() {
|
||||
terminalController.stop()
|
||||
pairingInProgress = false
|
||||
}
|
||||
|
||||
private func clearMessageAfterDelay() {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
||||
self?.message = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
/// Platform list/selection coordinator. Per-platform configuration now lives in
|
||||
/// dedicated `<Platform>SetupViewModel` classes under `ViewModels/PlatformSetup/`.
|
||||
/// This VM only manages the sidebar list, connectivity detection, and the
|
||||
/// "Restart Gateway" action.
|
||||
@Observable
|
||||
@MainActor
|
||||
final class PlatformsViewModel {
|
||||
private let logger = Logger(subsystem: "com.scarf", category: "PlatformsViewModel")
|
||||
private let fileService = HermesFileService()
|
||||
|
||||
var gatewayState: GatewayState?
|
||||
var selected: HermesToolPlatform = KnownPlatforms.cli
|
||||
var message: String?
|
||||
var restartInProgress: Bool = false
|
||||
|
||||
var platforms: [HermesToolPlatform] { KnownPlatforms.all }
|
||||
|
||||
func load() {
|
||||
gatewayState = fileService.loadGatewayState()
|
||||
}
|
||||
|
||||
func connectivity(for platform: HermesToolPlatform) -> PlatformConnectivity {
|
||||
if let pState = gatewayState?.platforms?[platform.name] {
|
||||
if let err = pState.error, !err.isEmpty { return .error(err) }
|
||||
if pState.connected == true { return .connected }
|
||||
}
|
||||
return hasConfigBlock(for: platform) ? .configured : .notConfigured
|
||||
}
|
||||
|
||||
/// Does the platform have any configuration on disk — either a top-level
|
||||
/// `<platform>:` block in config.yaml, or an "identifying" env var in
|
||||
/// `.env` (e.g. `TELEGRAM_BOT_TOKEN`, `DISCORD_BOT_TOKEN`)?
|
||||
///
|
||||
/// We need the env-var check because the new per-platform setup forms
|
||||
/// write credentials to `.env` primarily; most platforms don't create a
|
||||
/// YAML block until the user saves a behavior toggle. Without this,
|
||||
/// platforms configured via the new flow would display as "Not configured"
|
||||
/// until the first YAML edit.
|
||||
func hasConfigBlock(for platform: HermesToolPlatform) -> Bool {
|
||||
if platform.name == "cli" { return true }
|
||||
let yaml = (try? String(contentsOfFile: HermesPaths.configYAML, encoding: .utf8)) ?? ""
|
||||
for line in yaml.components(separatedBy: "\n") where !line.hasPrefix(" ") && !line.hasPrefix("\t") {
|
||||
if line.trimmingCharacters(in: .whitespaces) == "\(platform.name):" { return true }
|
||||
}
|
||||
// Env-var fallback: any identifying env var for this platform counts
|
||||
// as "configured". Uses the shared `identifyingEnvVar(for:)` mapping.
|
||||
if let key = Self.identifyingEnvVar(for: platform.name) {
|
||||
let env = HermesEnvService().load()
|
||||
if let value = env[key], !value.isEmpty { return true }
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/// Primary credential env var for a platform — the one whose presence
|
||||
/// signals that the user has started setup. Centralized here so both the
|
||||
/// connectivity detector and future diagnostics agree on the check.
|
||||
private static func identifyingEnvVar(for platformName: String) -> String? {
|
||||
switch platformName {
|
||||
case "telegram": return "TELEGRAM_BOT_TOKEN"
|
||||
case "discord": return "DISCORD_BOT_TOKEN"
|
||||
case "slack": return "SLACK_BOT_TOKEN"
|
||||
case "whatsapp": return "WHATSAPP_ENABLED"
|
||||
case "signal": return "SIGNAL_ACCOUNT"
|
||||
case "email": return "EMAIL_ADDRESS"
|
||||
case "matrix": return "MATRIX_HOMESERVER"
|
||||
case "mattermost": return "MATTERMOST_URL"
|
||||
case "feishu": return "FEISHU_APP_ID"
|
||||
case "imessage": return "BLUEBUBBLES_SERVER_URL"
|
||||
case "homeassistant": return "HASS_TOKEN"
|
||||
case "webhook": return "WEBHOOK_ENABLED"
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Restart the hermes gateway so newly-saved config takes effect. Runs on a
|
||||
/// background task so the UI stays responsive during the ~second or two
|
||||
/// `hermes gateway restart` takes.
|
||||
func restartGateway() {
|
||||
restartInProgress = true
|
||||
message = "Restarting gateway…"
|
||||
Task.detached { [fileService] in
|
||||
let result = fileService.runHermesCLI(args: ["gateway", "restart"], timeout: 30)
|
||||
await MainActor.run {
|
||||
self.restartInProgress = false
|
||||
self.message = result.exitCode == 0 ? "Gateway restarted" : "Restart failed"
|
||||
self.load()
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
||||
self?.message = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import SwiftUI
|
||||
|
||||
struct DiscordSetupView: View {
|
||||
@State private var viewModel = DiscordSetupViewModel()
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
instructions
|
||||
|
||||
SettingsSection(title: "Required", icon: "key") {
|
||||
SecretTextField(label: "Bot Token", value: viewModel.botToken) { viewModel.botToken = $0 }
|
||||
EditableTextField(label: "Allowed User IDs", value: viewModel.allowedUsers) { viewModel.allowedUsers = $0 }
|
||||
}
|
||||
|
||||
SettingsSection(title: "Home Channel", icon: "house") {
|
||||
EditableTextField(label: "Home Channel ID", value: viewModel.homeChannel) { viewModel.homeChannel = $0 }
|
||||
EditableTextField(label: "Display Name", value: viewModel.homeChannelName) { viewModel.homeChannelName = $0 }
|
||||
}
|
||||
|
||||
SettingsSection(title: "Behavior", icon: "slider.horizontal.3") {
|
||||
ToggleRow(label: "Require @mention", isOn: viewModel.requireMention) { viewModel.requireMention = $0 }
|
||||
EditableTextField(label: "Free-Response Channels", value: viewModel.freeResponseChannels) { viewModel.freeResponseChannels = $0 }
|
||||
ToggleRow(label: "Auto-thread on mention", isOn: viewModel.autoThread) { viewModel.autoThread = $0 }
|
||||
ToggleRow(label: "Reactions", isOn: viewModel.reactions) { viewModel.reactions = $0 }
|
||||
PickerRow(label: "Allow Other Bots", selection: viewModel.allowBots, options: viewModel.allowBotsOptions) { viewModel.allowBots = $0 }
|
||||
PickerRow(label: "Reply Mode", selection: viewModel.replyToMode, options: viewModel.replyToModeOptions) { viewModel.replyToMode = $0 }
|
||||
}
|
||||
|
||||
saveBar
|
||||
}
|
||||
.onAppear { viewModel.load() }
|
||||
}
|
||||
|
||||
private var instructions: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Create an app in Discord's Developer Portal, enable Message Content and Server Members intents, and copy the bot token. Invite the bot to your server via the OAuth2 URL generator.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
HStack(spacing: 12) {
|
||||
Button("Open Developer Portal") { PlatformSetupHelpers.openURL("https://discord.com/developers/applications") }
|
||||
.controlSize(.small)
|
||||
Button("Discord Setup Docs") { PlatformSetupHelpers.openURL("https://hermes-agent.nousresearch.com/docs/user-guide/messaging/discord") }
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var saveBar: some View {
|
||||
HStack {
|
||||
if let msg = viewModel.message {
|
||||
Label(msg, systemImage: "checkmark.circle.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.green)
|
||||
}
|
||||
Spacer()
|
||||
Button("Reload") { viewModel.load() }.controlSize(.small)
|
||||
Button("Save") { viewModel.save() }.buttonStyle(.borderedProminent).controlSize(.small)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import SwiftUI
|
||||
|
||||
struct EmailSetupView: View {
|
||||
@State private var viewModel = EmailSetupViewModel()
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
instructions
|
||||
presetBar
|
||||
|
||||
SettingsSection(title: "Credentials", icon: "envelope") {
|
||||
EditableTextField(label: "Email Address", value: viewModel.address) { viewModel.address = $0 }
|
||||
SecretTextField(label: "App Password", value: viewModel.password) { viewModel.password = $0 }
|
||||
}
|
||||
|
||||
SettingsSection(title: "Servers", icon: "server.rack") {
|
||||
EditableTextField(label: "IMAP Host", value: viewModel.imapHost) { viewModel.imapHost = $0 }
|
||||
EditableTextField(label: "SMTP Host", value: viewModel.smtpHost) { viewModel.smtpHost = $0 }
|
||||
EditableTextField(label: "IMAP Port", value: viewModel.imapPort) { viewModel.imapPort = $0 }
|
||||
EditableTextField(label: "SMTP Port", value: viewModel.smtpPort) { viewModel.smtpPort = $0 }
|
||||
EditableTextField(label: "Poll Interval (s)", value: viewModel.pollInterval) { viewModel.pollInterval = $0 }
|
||||
}
|
||||
|
||||
SettingsSection(title: "Access Control", icon: "person.badge.shield.checkmark") {
|
||||
ToggleRow(label: "Allow All Senders", isOn: viewModel.allowAllUsers) { viewModel.allowAllUsers = $0 }
|
||||
if !viewModel.allowAllUsers {
|
||||
EditableTextField(label: "Allowed Senders", value: viewModel.allowedUsers) { viewModel.allowedUsers = $0 }
|
||||
}
|
||||
EditableTextField(label: "Home Address", value: viewModel.homeAddress) { viewModel.homeAddress = $0 }
|
||||
}
|
||||
|
||||
SettingsSection(title: "Behavior", icon: "slider.horizontal.3") {
|
||||
ToggleRow(label: "Skip Attachments", isOn: viewModel.skipAttachments) { viewModel.skipAttachments = $0 }
|
||||
}
|
||||
|
||||
saveBar
|
||||
}
|
||||
.onAppear { viewModel.load() }
|
||||
}
|
||||
|
||||
private var instructions: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Enable 2FA on your email account and generate an app password. Regular account passwords will fail. Always set allowed senders — otherwise anyone knowing the address can message the agent.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
HStack {
|
||||
Button("Email Setup Docs") { PlatformSetupHelpers.openURL("https://hermes-agent.nousresearch.com/docs/user-guide/messaging/email") }
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var presetBar: some View {
|
||||
HStack(spacing: 8) {
|
||||
Text("Preset:")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
ForEach(viewModel.presets, id: \.name) { preset in
|
||||
Button(preset.name) { viewModel.applyPreset(preset) }
|
||||
.controlSize(.small)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
|
||||
private var saveBar: some View {
|
||||
HStack {
|
||||
if let msg = viewModel.message {
|
||||
Label(msg, systemImage: "checkmark.circle.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.green)
|
||||
}
|
||||
Spacer()
|
||||
Button("Reload") { viewModel.load() }.controlSize(.small)
|
||||
Button("Save") { viewModel.save() }.buttonStyle(.borderedProminent).controlSize(.small)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import SwiftUI
|
||||
import AppKit
|
||||
import SwiftTerm
|
||||
import os
|
||||
|
||||
/// Inline SwiftTerm terminal for platform pairing wizards that genuinely require
|
||||
/// a TTY (WhatsApp QR, Signal `signal-cli link`). This is a lightweight sibling
|
||||
/// to `PersistentTerminalView` in the Chat feature — scoped to run a single
|
||||
/// command, show its output, and notify when the process exits.
|
||||
///
|
||||
/// Usage:
|
||||
/// EmbeddedSetupTerminal(controller: viewModel.terminalController)
|
||||
/// // Controller exposes start()/terminate() that the view model owns.
|
||||
struct EmbeddedSetupTerminal: NSViewRepresentable {
|
||||
let controller: EmbeddedSetupTerminalController
|
||||
|
||||
func makeNSView(context: Context) -> NSView {
|
||||
let container = NSView()
|
||||
controller.attach(to: container)
|
||||
return container
|
||||
}
|
||||
|
||||
func updateNSView(_ nsView: NSView, context: Context) {
|
||||
// If the view model recreated its terminal view (e.g., after re-launching
|
||||
// the pairing command), re-attach it to the container.
|
||||
controller.reattachIfNeeded(to: nsView)
|
||||
}
|
||||
}
|
||||
|
||||
/// Owns the `LocalProcessTerminalView` so it survives SwiftUI body redraws.
|
||||
/// Lives on the view model (one per platform that uses it).
|
||||
@MainActor
|
||||
final class EmbeddedSetupTerminalController {
|
||||
private let logger = Logger(subsystem: "com.scarf", category: "EmbeddedSetupTerminal")
|
||||
|
||||
/// The hosting NSView from the `NSViewRepresentable`. Weak because SwiftUI
|
||||
/// owns the container's lifetime; we just attach our terminal view inside it.
|
||||
private weak var container: NSView?
|
||||
|
||||
/// The actual terminal emulator. Recreated per launch so a terminated
|
||||
/// process doesn't leave stale buffer state mixed with new output.
|
||||
private var terminalView: LocalProcessTerminalView?
|
||||
private var coordinator: Coordinator?
|
||||
|
||||
/// Invoked when the spawned process exits. The `Int32` is the exit code
|
||||
/// (`0` success, non-zero failure). Runs on the main actor.
|
||||
var onExit: ((Int32) -> Void)?
|
||||
|
||||
var isRunning: Bool { terminalView != nil }
|
||||
|
||||
/// Start a process in the embedded terminal. If a process is already running,
|
||||
/// it is terminated first to avoid orphans.
|
||||
func start(executable: String, arguments: [String], environment: [String: String] = [:]) {
|
||||
stop()
|
||||
guard let container else {
|
||||
logger.warning("start() called before terminal was attached to a container")
|
||||
return
|
||||
}
|
||||
|
||||
let terminal = LocalProcessTerminalView(frame: .zero)
|
||||
terminal.font = NSFont.monospacedSystemFont(ofSize: 12, weight: .regular)
|
||||
terminal.nativeBackgroundColor = NSColor(red: 0.11, green: 0.12, blue: 0.14, alpha: 1.0)
|
||||
terminal.nativeForegroundColor = NSColor(red: 0.85, green: 0.87, blue: 0.91, alpha: 1.0)
|
||||
|
||||
let coord = Coordinator { [weak self] exitCode in
|
||||
self?.onExit?(exitCode ?? -1)
|
||||
}
|
||||
terminal.processDelegate = coord
|
||||
coordinator = coord
|
||||
|
||||
// Merge caller-provided env over the enriched shell env so `npx`, `node`,
|
||||
// `signal-cli`, etc. resolve from PATH.
|
||||
var env = HermesFileService.enrichedEnvironment()
|
||||
env["TERM"] = "xterm-256color"
|
||||
env["COLORTERM"] = "truecolor"
|
||||
for (k, v) in environment { env[k] = v }
|
||||
let envArray = env.map { "\($0.key)=\($0.value)" }
|
||||
|
||||
terminal.startProcess(
|
||||
executable: executable,
|
||||
args: arguments,
|
||||
environment: envArray,
|
||||
execName: nil
|
||||
)
|
||||
|
||||
// Attach with AutoLayout constraints — matches the pattern used by
|
||||
// Features/Chat/Views/TerminalRepresentable.swift. Relying on
|
||||
// autoresizingMask is unreliable when SwiftUI hosts the NSView,
|
||||
// because SwiftUI drives layout via AutoLayout.
|
||||
container.subviews.forEach { $0.removeFromSuperview() }
|
||||
terminal.translatesAutoresizingMaskIntoConstraints = false
|
||||
container.addSubview(terminal)
|
||||
NSLayoutConstraint.activate([
|
||||
terminal.leadingAnchor.constraint(equalTo: container.leadingAnchor),
|
||||
terminal.trailingAnchor.constraint(equalTo: container.trailingAnchor),
|
||||
terminal.topAnchor.constraint(equalTo: container.topAnchor),
|
||||
terminal.bottomAnchor.constraint(equalTo: container.bottomAnchor),
|
||||
])
|
||||
terminalView = terminal
|
||||
}
|
||||
|
||||
/// Kill the running process (if any). Safe to call when nothing is running.
|
||||
func stop() {
|
||||
terminalView?.terminate()
|
||||
terminalView?.removeFromSuperview()
|
||||
terminalView = nil
|
||||
}
|
||||
|
||||
// MARK: - NSViewRepresentable plumbing
|
||||
|
||||
func attach(to container: NSView) {
|
||||
self.container = container
|
||||
if let tv = terminalView, tv.superview !== container {
|
||||
container.subviews.forEach { $0.removeFromSuperview() }
|
||||
tv.translatesAutoresizingMaskIntoConstraints = false
|
||||
container.addSubview(tv)
|
||||
NSLayoutConstraint.activate([
|
||||
tv.leadingAnchor.constraint(equalTo: container.leadingAnchor),
|
||||
tv.trailingAnchor.constraint(equalTo: container.trailingAnchor),
|
||||
tv.topAnchor.constraint(equalTo: container.topAnchor),
|
||||
tv.bottomAnchor.constraint(equalTo: container.bottomAnchor),
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
func reattachIfNeeded(to container: NSView) {
|
||||
self.container = container
|
||||
guard let tv = terminalView, tv.superview !== container else { return }
|
||||
container.subviews.forEach { $0.removeFromSuperview() }
|
||||
tv.translatesAutoresizingMaskIntoConstraints = false
|
||||
container.addSubview(tv)
|
||||
NSLayoutConstraint.activate([
|
||||
tv.leadingAnchor.constraint(equalTo: container.leadingAnchor),
|
||||
tv.trailingAnchor.constraint(equalTo: container.trailingAnchor),
|
||||
tv.topAnchor.constraint(equalTo: container.topAnchor),
|
||||
tv.bottomAnchor.constraint(equalTo: container.bottomAnchor),
|
||||
])
|
||||
}
|
||||
|
||||
final class Coordinator: NSObject, LocalProcessTerminalViewDelegate {
|
||||
let onTerminated: (Int32?) -> Void
|
||||
|
||||
init(onTerminated: @escaping (Int32?) -> Void) {
|
||||
self.onTerminated = onTerminated
|
||||
}
|
||||
|
||||
func sizeChanged(source: LocalProcessTerminalView, newCols: Int, newRows: Int) {}
|
||||
func setTerminalTitle(source: LocalProcessTerminalView, title: String) {}
|
||||
func hostCurrentDirectoryUpdate(source: TerminalView, directory: String?) {}
|
||||
|
||||
func processTerminated(source: TerminalView, exitCode: Int32?) {
|
||||
let terminal = source.getTerminal()
|
||||
terminal.feed(text: "\r\n[Process exited with code \(exitCode ?? -1)]\r\n")
|
||||
let code = exitCode
|
||||
DispatchQueue.main.async { self.onTerminated(code) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import SwiftUI
|
||||
|
||||
struct FeishuSetupView: View {
|
||||
@State private var viewModel = FeishuSetupViewModel()
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
instructions
|
||||
|
||||
SettingsSection(title: "App Credentials", icon: "key") {
|
||||
EditableTextField(label: "App ID", value: viewModel.appID) { viewModel.appID = $0 }
|
||||
SecretTextField(label: "App Secret", value: viewModel.appSecret) { viewModel.appSecret = $0 }
|
||||
PickerRow(label: "Domain", selection: viewModel.domain, options: viewModel.domainOptions) { viewModel.domain = $0 }
|
||||
}
|
||||
|
||||
SettingsSection(title: "Webhook Security", icon: "lock.shield") {
|
||||
SecretTextField(label: "Encrypt Key", value: viewModel.encryptKey) { viewModel.encryptKey = $0 }
|
||||
SecretTextField(label: "Verification Token", value: viewModel.verificationToken) { viewModel.verificationToken = $0 }
|
||||
}
|
||||
|
||||
SettingsSection(title: "Access Control", icon: "person.badge.shield.checkmark") {
|
||||
EditableTextField(label: "Allowed Users", value: viewModel.allowedUsers) { viewModel.allowedUsers = $0 }
|
||||
PickerRow(label: "Connection Mode", selection: viewModel.connectionMode, options: viewModel.connectionOptions) { viewModel.connectionMode = $0 }
|
||||
}
|
||||
|
||||
saveBar
|
||||
}
|
||||
.onAppear { viewModel.load() }
|
||||
}
|
||||
|
||||
private var instructions: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Create an app in the Feishu/Lark Developer Console, enable Interactive Card if you need button responses, and copy the App ID and App Secret. WebSocket mode (recommended) doesn't need a public endpoint.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
HStack {
|
||||
Button("Feishu Setup Docs") { PlatformSetupHelpers.openURL("https://hermes-agent.nousresearch.com/docs/user-guide/messaging/feishu") }
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var saveBar: some View {
|
||||
HStack {
|
||||
if let msg = viewModel.message {
|
||||
Label(msg, systemImage: "checkmark.circle.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.green)
|
||||
}
|
||||
Spacer()
|
||||
Button("Reload") { viewModel.load() }.controlSize(.small)
|
||||
Button("Save") { viewModel.save() }.buttonStyle(.borderedProminent).controlSize(.small)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import SwiftUI
|
||||
|
||||
struct HomeAssistantSetupView: View {
|
||||
@State private var viewModel = HomeAssistantSetupViewModel()
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
instructions
|
||||
|
||||
SettingsSection(title: "Connection", icon: "network") {
|
||||
EditableTextField(label: "URL", value: viewModel.url) { viewModel.url = $0 }
|
||||
SecretTextField(label: "Long-Lived Token", value: viewModel.token) { viewModel.token = $0 }
|
||||
}
|
||||
|
||||
SettingsSection(title: "Event Filters", icon: "line.3.horizontal.decrease.circle") {
|
||||
ToggleRow(label: "Watch All Changes", isOn: viewModel.watchAll) { viewModel.watchAll = $0 }
|
||||
StepperRow(label: "Cooldown (s)", value: viewModel.cooldownSeconds, range: 0...3600, step: 5) { viewModel.cooldownSeconds = $0 }
|
||||
}
|
||||
|
||||
listFiltersSection
|
||||
saveBar
|
||||
}
|
||||
.onAppear { viewModel.load() }
|
||||
}
|
||||
|
||||
private var instructions: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Create a long-lived access token in Home Assistant (Profile → Security → Long-Lived Access Tokens). By default, no events are forwarded — enable Watch All Changes, or add entity filters below.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
HStack {
|
||||
Button("Home Assistant Docs") { PlatformSetupHelpers.openURL("https://hermes-agent.nousresearch.com/docs/user-guide/messaging/homeassistant") }
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Read-only display of list-valued filters (watch_domains, watch_entities,
|
||||
/// ignore_entities). Editing requires hand-modifying config.yaml because
|
||||
/// the `hermes config set` CLI can't produce YAML lists — it stores
|
||||
/// arrays as quoted strings, which hermes rejects.
|
||||
private var listFiltersSection: some View {
|
||||
SettingsSection(title: "Entity Filters (config.yaml only)", icon: "list.bullet") {
|
||||
ReadOnlyRow(label: "Watch Domains", value: viewModel.watchDomains.joined(separator: ", "))
|
||||
ReadOnlyRow(label: "Watch Entities", value: viewModel.watchEntities.joined(separator: ", "))
|
||||
ReadOnlyRow(label: "Ignore Entities", value: viewModel.ignoreEntities.joined(separator: ", "))
|
||||
HStack {
|
||||
Text("")
|
||||
.frame(width: 160, alignment: .trailing)
|
||||
Text("These list fields must be edited directly in config.yaml.")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
Button("Edit config.yaml") { viewModel.openConfigForLists() }
|
||||
.controlSize(.mini)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
}
|
||||
}
|
||||
|
||||
private var saveBar: some View {
|
||||
HStack {
|
||||
if let msg = viewModel.message {
|
||||
Label(msg, systemImage: "checkmark.circle.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.green)
|
||||
}
|
||||
Spacer()
|
||||
Button("Reload") { viewModel.load() }.controlSize(.small)
|
||||
Button("Save") { viewModel.save() }.buttonStyle(.borderedProminent).controlSize(.small)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import SwiftUI
|
||||
|
||||
struct IMessageSetupView: View {
|
||||
@State private var viewModel = IMessageSetupViewModel()
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
instructions
|
||||
|
||||
SettingsSection(title: "BlueBubbles Server", icon: "server.rack") {
|
||||
EditableTextField(label: "Server URL", value: viewModel.serverURL) { viewModel.serverURL = $0 }
|
||||
SecretTextField(label: "Server Password", value: viewModel.password) { viewModel.password = $0 }
|
||||
}
|
||||
|
||||
SettingsSection(title: "Webhook (hermes side)", icon: "arrow.up.right.square") {
|
||||
EditableTextField(label: "Host", value: viewModel.webhookHost) { viewModel.webhookHost = $0 }
|
||||
EditableTextField(label: "Port", value: viewModel.webhookPort) { viewModel.webhookPort = $0 }
|
||||
EditableTextField(label: "Path", value: viewModel.webhookPath) { viewModel.webhookPath = $0 }
|
||||
}
|
||||
|
||||
SettingsSection(title: "Access Control", icon: "person.badge.shield.checkmark") {
|
||||
ToggleRow(label: "Allow All Users", isOn: viewModel.allowAllUsers) { viewModel.allowAllUsers = $0 }
|
||||
if !viewModel.allowAllUsers {
|
||||
EditableTextField(label: "Allowed Users", value: viewModel.allowedUsers) { viewModel.allowedUsers = $0 }
|
||||
}
|
||||
EditableTextField(label: "Home Channel", value: viewModel.homeChannel) { viewModel.homeChannel = $0 }
|
||||
}
|
||||
|
||||
SettingsSection(title: "Behavior", icon: "slider.horizontal.3") {
|
||||
ToggleRow(label: "Send Read Receipts", isOn: viewModel.sendReadReceipts) { viewModel.sendReadReceipts = $0 }
|
||||
}
|
||||
|
||||
saveBar
|
||||
}
|
||||
.onAppear { viewModel.load() }
|
||||
}
|
||||
|
||||
private var instructions: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("iMessage integration runs through BlueBubbles Server. You need a Mac that stays on with Messages.app signed in — install BlueBubbles Server on it, then point hermes at that server's URL.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
HStack(spacing: 12) {
|
||||
Button("Install BlueBubbles Server") { PlatformSetupHelpers.openURL("https://bluebubbles.app/") }
|
||||
.controlSize(.small)
|
||||
Button("BlueBubbles Docs") { PlatformSetupHelpers.openURL("https://hermes-agent.nousresearch.com/docs/user-guide/messaging/bluebubbles") }
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var saveBar: some View {
|
||||
HStack {
|
||||
if let msg = viewModel.message {
|
||||
Label(msg, systemImage: "checkmark.circle.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.green)
|
||||
}
|
||||
Spacer()
|
||||
Button("Reload") { viewModel.load() }.controlSize(.small)
|
||||
Button("Save") { viewModel.save() }.buttonStyle(.borderedProminent).controlSize(.small)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import SwiftUI
|
||||
|
||||
struct MatrixSetupView: View {
|
||||
@State private var viewModel = MatrixSetupViewModel()
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
instructions
|
||||
|
||||
SettingsSection(title: "Homeserver", icon: "network") {
|
||||
EditableTextField(label: "Homeserver URL", value: viewModel.homeserver) { viewModel.homeserver = $0 }
|
||||
}
|
||||
|
||||
SettingsSection(title: "Authentication", icon: "person.badge.key") {
|
||||
SecretTextField(label: "Access Token", value: viewModel.accessToken) { viewModel.accessToken = $0 }
|
||||
Text("— or use user/password login —")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 2)
|
||||
EditableTextField(label: "User ID", value: viewModel.userID) { viewModel.userID = $0 }
|
||||
SecretTextField(label: "Password", value: viewModel.password) { viewModel.password = $0 }
|
||||
}
|
||||
|
||||
SettingsSection(title: "Access Control", icon: "person.badge.shield.checkmark") {
|
||||
EditableTextField(label: "Allowed Users", value: viewModel.allowedUsers) { viewModel.allowedUsers = $0 }
|
||||
EditableTextField(label: "Home Room", value: viewModel.homeRoom) { viewModel.homeRoom = $0 }
|
||||
}
|
||||
|
||||
SettingsSection(title: "Behavior", icon: "slider.horizontal.3") {
|
||||
ToggleRow(label: "Require @mention", isOn: viewModel.requireMention) { viewModel.requireMention = $0 }
|
||||
ToggleRow(label: "Auto-thread on mention", isOn: viewModel.autoThread) { viewModel.autoThread = $0 }
|
||||
ToggleRow(label: "DM mention threads", isOn: viewModel.dmMentionThreads) { viewModel.dmMentionThreads = $0 }
|
||||
}
|
||||
|
||||
SettingsSection(title: "End-to-End Encryption (experimental)", icon: "lock.shield") {
|
||||
ToggleRow(label: "Enable E2EE", isOn: viewModel.encryption) { viewModel.encryption = $0 }
|
||||
if viewModel.encryption {
|
||||
SecretTextField(label: "Recovery Key", value: viewModel.recoveryKey) { viewModel.recoveryKey = $0 }
|
||||
}
|
||||
}
|
||||
|
||||
saveBar
|
||||
}
|
||||
.onAppear { viewModel.load() }
|
||||
}
|
||||
|
||||
private var instructions: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Matrix uses either an access token (preferred) or username/password. Get an access token from Element: Settings → Help & About → Access Token.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
HStack {
|
||||
Button("Matrix Setup Docs") { PlatformSetupHelpers.openURL("https://hermes-agent.nousresearch.com/docs/user-guide/messaging/matrix") }
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var saveBar: some View {
|
||||
HStack {
|
||||
if let msg = viewModel.message {
|
||||
Label(msg, systemImage: "checkmark.circle.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.green)
|
||||
}
|
||||
Spacer()
|
||||
Button("Reload") { viewModel.load() }.controlSize(.small)
|
||||
Button("Save") { viewModel.save() }.buttonStyle(.borderedProminent).controlSize(.small)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import SwiftUI
|
||||
|
||||
struct MattermostSetupView: View {
|
||||
@State private var viewModel = MattermostSetupViewModel()
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
instructions
|
||||
|
||||
SettingsSection(title: "Server", icon: "network") {
|
||||
EditableTextField(label: "Server URL", value: viewModel.serverURL) { viewModel.serverURL = $0 }
|
||||
SecretTextField(label: "Token", value: viewModel.token) { viewModel.token = $0 }
|
||||
}
|
||||
|
||||
SettingsSection(title: "Access Control", icon: "person.badge.shield.checkmark") {
|
||||
EditableTextField(label: "Allowed Users", value: viewModel.allowedUsers) { viewModel.allowedUsers = $0 }
|
||||
EditableTextField(label: "Home Channel", value: viewModel.homeChannel) { viewModel.homeChannel = $0 }
|
||||
EditableTextField(label: "Free-Response Channels", value: viewModel.freeResponseChannels) { viewModel.freeResponseChannels = $0 }
|
||||
}
|
||||
|
||||
SettingsSection(title: "Behavior", icon: "slider.horizontal.3") {
|
||||
ToggleRow(label: "Require @mention", isOn: viewModel.requireMention) { viewModel.requireMention = $0 }
|
||||
PickerRow(label: "Reply Mode", selection: viewModel.replyMode, options: viewModel.replyModeOptions) { viewModel.replyMode = $0 }
|
||||
}
|
||||
|
||||
saveBar
|
||||
}
|
||||
.onAppear { viewModel.load() }
|
||||
}
|
||||
|
||||
private var instructions: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Create a personal access token under Profile → Security → Personal Access Tokens, or create a bot account. Use the token as the MATTERMOST_TOKEN value.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
HStack {
|
||||
Button("Mattermost Setup Docs") { PlatformSetupHelpers.openURL("https://hermes-agent.nousresearch.com/docs/user-guide/messaging/mattermost") }
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var saveBar: some View {
|
||||
HStack {
|
||||
if let msg = viewModel.message {
|
||||
Label(msg, systemImage: "checkmark.circle.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.green)
|
||||
}
|
||||
Spacer()
|
||||
Button("Reload") { viewModel.load() }.controlSize(.small)
|
||||
Button("Save") { viewModel.save() }.buttonStyle(.borderedProminent).controlSize(.small)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import SwiftUI
|
||||
|
||||
struct SignalSetupView: View {
|
||||
@State private var viewModel = SignalSetupViewModel()
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
instructions
|
||||
prerequisiteStatus
|
||||
|
||||
SettingsSection(title: "Daemon Endpoint", icon: "network") {
|
||||
EditableTextField(label: "HTTP URL", value: viewModel.httpURL) { viewModel.httpURL = $0 }
|
||||
EditableTextField(label: "Account (E.164)", value: viewModel.account) { viewModel.account = $0 }
|
||||
}
|
||||
|
||||
SettingsSection(title: "Access Control", icon: "person.badge.shield.checkmark") {
|
||||
ToggleRow(label: "Allow All Users", isOn: viewModel.allowAllUsers) { viewModel.allowAllUsers = $0 }
|
||||
if !viewModel.allowAllUsers {
|
||||
EditableTextField(label: "Allowed Users", value: viewModel.allowedUsers) { viewModel.allowedUsers = $0 }
|
||||
}
|
||||
EditableTextField(label: "Group Allowed Users", value: viewModel.groupAllowedUsers) { viewModel.groupAllowedUsers = $0 }
|
||||
EditableTextField(label: "Home Channel", value: viewModel.homeChannel) { viewModel.homeChannel = $0 }
|
||||
}
|
||||
|
||||
saveBar
|
||||
Divider()
|
||||
terminalSection
|
||||
}
|
||||
.onAppear { viewModel.load() }
|
||||
.onDisappear { viewModel.stopTerminal() }
|
||||
}
|
||||
|
||||
private var instructions: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Signal integration requires signal-cli (Java-based) installed locally. Link this Mac as a Signal device, then keep the daemon running so hermes can send/receive messages.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
HStack(spacing: 12) {
|
||||
Button("Install signal-cli") { PlatformSetupHelpers.openURL("https://github.com/AsamK/signal-cli/wiki/Quickstart") }
|
||||
.controlSize(.small)
|
||||
Button("Signal Setup Docs") { PlatformSetupHelpers.openURL("https://hermes-agent.nousresearch.com/docs/user-guide/messaging/signal") }
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var prerequisiteStatus: some View {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: viewModel.signalCLIInstalled ? "checkmark.circle.fill" : "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(viewModel.signalCLIInstalled ? .green : .orange)
|
||||
Text(viewModel.signalCLIInstalled ? "signal-cli is available on PATH" : "signal-cli not found on PATH — install it first")
|
||||
.font(.caption)
|
||||
.foregroundStyle(viewModel.signalCLIInstalled ? Color.primary : Color.orange)
|
||||
Spacer()
|
||||
}
|
||||
.padding(8)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
|
||||
private var saveBar: some View {
|
||||
HStack {
|
||||
if let msg = viewModel.message {
|
||||
Label(msg, systemImage: "info.circle")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Button("Reload") { viewModel.load() }.controlSize(.small)
|
||||
Button("Save") { viewModel.save() }.buttonStyle(.borderedProminent).controlSize(.small)
|
||||
}
|
||||
}
|
||||
|
||||
private var terminalSection: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
Label("signal-cli Terminal", systemImage: "terminal")
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
switch viewModel.activeTask {
|
||||
case .none:
|
||||
Button("Link Device") { viewModel.startLink() }.controlSize(.small)
|
||||
.disabled(!viewModel.signalCLIInstalled)
|
||||
Button("Start Daemon") { viewModel.startDaemon() }.buttonStyle(.borderedProminent).controlSize(.small)
|
||||
.disabled(!viewModel.signalCLIInstalled || viewModel.account.isEmpty)
|
||||
case .link:
|
||||
Text("Linking…").font(.caption).foregroundStyle(.secondary)
|
||||
Button("Stop") { viewModel.stopTerminal() }.controlSize(.small)
|
||||
case .daemon:
|
||||
Text("Daemon running").font(.caption).foregroundStyle(.green)
|
||||
Button("Stop") { viewModel.stopTerminal() }.controlSize(.small)
|
||||
}
|
||||
}
|
||||
Text("Link the device first to generate and scan a QR code. Once linked, start the daemon — it must keep running for hermes to send/receive messages.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
EmbeddedSetupTerminal(controller: viewModel.terminalController)
|
||||
.frame(minHeight: 260, maxHeight: 360)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import SwiftUI
|
||||
|
||||
struct SlackSetupView: View {
|
||||
@State private var viewModel = SlackSetupViewModel()
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
instructions
|
||||
|
||||
SettingsSection(title: "Required Tokens", icon: "key") {
|
||||
SecretTextField(label: "Bot Token (xoxb-)", value: viewModel.botToken) { viewModel.botToken = $0 }
|
||||
SecretTextField(label: "App Token (xapp-)", value: viewModel.appToken) { viewModel.appToken = $0 }
|
||||
EditableTextField(label: "Allowed User IDs", value: viewModel.allowedUsers) { viewModel.allowedUsers = $0 }
|
||||
}
|
||||
|
||||
SettingsSection(title: "Home Channel", icon: "house") {
|
||||
EditableTextField(label: "Channel ID", value: viewModel.homeChannel) { viewModel.homeChannel = $0 }
|
||||
EditableTextField(label: "Display Name", value: viewModel.homeChannelName) { viewModel.homeChannelName = $0 }
|
||||
}
|
||||
|
||||
SettingsSection(title: "Behavior", icon: "slider.horizontal.3") {
|
||||
ToggleRow(label: "Require @mention", isOn: viewModel.requireMention) { viewModel.requireMention = $0 }
|
||||
PickerRow(label: "Reply Mode", selection: viewModel.replyToMode, options: viewModel.replyToModeOptions) { viewModel.replyToMode = $0 }
|
||||
ToggleRow(label: "Reply in thread", isOn: viewModel.replyInThread) { viewModel.replyInThread = $0 }
|
||||
ToggleRow(label: "Reply broadcast", isOn: viewModel.replyBroadcast) { viewModel.replyBroadcast = $0 }
|
||||
}
|
||||
|
||||
saveBar
|
||||
}
|
||||
.onAppear { viewModel.load() }
|
||||
}
|
||||
|
||||
private var instructions: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Create a Slack app at api.slack.com/apps, enable Socket Mode, grant bot scopes (chat:write, app_mentions:read, channels:history, etc.), then copy the Bot User OAuth Token (xoxb-) and the App-Level Token (xapp-).")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
HStack(spacing: 12) {
|
||||
Button("Open Slack API") { PlatformSetupHelpers.openURL("https://api.slack.com/apps") }
|
||||
.controlSize(.small)
|
||||
Button("Slack Setup Docs") { PlatformSetupHelpers.openURL("https://hermes-agent.nousresearch.com/docs/user-guide/messaging/slack") }
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var saveBar: some View {
|
||||
HStack {
|
||||
if let msg = viewModel.message {
|
||||
Label(msg, systemImage: "checkmark.circle.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.green)
|
||||
}
|
||||
Spacer()
|
||||
Button("Reload") { viewModel.load() }.controlSize(.small)
|
||||
Button("Save") { viewModel.save() }.buttonStyle(.borderedProminent).controlSize(.small)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import SwiftUI
|
||||
|
||||
struct TelegramSetupView: View {
|
||||
@State private var viewModel = TelegramSetupViewModel()
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
instructions
|
||||
|
||||
SettingsSection(title: "Required", icon: "key") {
|
||||
SecretTextField(label: "Bot Token", value: viewModel.botToken) { viewModel.botToken = $0 }
|
||||
EditableTextField(label: "Allowed Users", value: viewModel.allowedUsers) { viewModel.allowedUsers = $0 }
|
||||
}
|
||||
|
||||
SettingsSection(title: "Optional", icon: "slider.horizontal.3") {
|
||||
EditableTextField(label: "Home Channel", value: viewModel.homeChannel) { viewModel.homeChannel = $0 }
|
||||
ToggleRow(label: "Require @mention", isOn: viewModel.requireMention) { viewModel.requireMention = $0 }
|
||||
ToggleRow(label: "Reactions", isOn: viewModel.reactions) { viewModel.reactions = $0 }
|
||||
}
|
||||
|
||||
SettingsSection(title: "Webhook (advanced)", icon: "arrow.up.right.square") {
|
||||
EditableTextField(label: "Webhook URL", value: viewModel.webhookURL) { viewModel.webhookURL = $0 }
|
||||
EditableTextField(label: "Webhook Port", value: viewModel.webhookPort) { viewModel.webhookPort = $0 }
|
||||
SecretTextField(label: "Webhook Secret", value: viewModel.webhookSecret) { viewModel.webhookSecret = $0 }
|
||||
}
|
||||
|
||||
saveBar
|
||||
}
|
||||
.onAppear { viewModel.load() }
|
||||
}
|
||||
|
||||
private var instructions: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Create a bot via @BotFather and get your numeric user ID from @userinfobot. Paste the token and your user ID below — the bot will only respond to allowed users.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
HStack(spacing: 12) {
|
||||
Button("Open BotFather") { PlatformSetupHelpers.openURL("https://t.me/BotFather") }
|
||||
.controlSize(.small)
|
||||
Button("Telegram Setup Docs") { PlatformSetupHelpers.openURL("https://hermes-agent.nousresearch.com/docs/user-guide/messaging/telegram") }
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var saveBar: some View {
|
||||
HStack {
|
||||
if let msg = viewModel.message {
|
||||
Label(msg, systemImage: "checkmark.circle.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.green)
|
||||
}
|
||||
Spacer()
|
||||
Button("Reload") { viewModel.load() }
|
||||
.controlSize(.small)
|
||||
Button("Save") { viewModel.save() }
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import SwiftUI
|
||||
|
||||
struct WebhookSetupView: View {
|
||||
@State private var viewModel = WebhookSetupViewModel()
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
instructions
|
||||
|
||||
SettingsSection(title: "Global Settings", icon: "arrow.up.right.square") {
|
||||
ToggleRow(label: "Webhook Enabled", isOn: viewModel.enabled) { viewModel.enabled = $0 }
|
||||
EditableTextField(label: "Port", value: viewModel.port) { viewModel.port = $0 }
|
||||
SecretTextField(label: "HMAC Secret", value: viewModel.secret) { viewModel.secret = $0 }
|
||||
}
|
||||
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
Image(systemName: "info.circle")
|
||||
.foregroundStyle(.secondary)
|
||||
Text("Per-route subscriptions (events, prompt template, delivery target) are managed in the Webhooks sidebar — not here. This panel only controls whether the webhook platform is listening at all.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(10)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
|
||||
saveBar
|
||||
}
|
||||
.onAppear { viewModel.load() }
|
||||
}
|
||||
|
||||
private var instructions: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Enable the webhook platform to accept event-driven agent triggers. The HMAC secret is used as a fallback when individual routes don't provide their own.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
HStack {
|
||||
Button("Webhook Setup Docs") { PlatformSetupHelpers.openURL("https://hermes-agent.nousresearch.com/docs/user-guide/messaging/webhooks") }
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var saveBar: some View {
|
||||
HStack {
|
||||
if let msg = viewModel.message {
|
||||
Label(msg, systemImage: "checkmark.circle.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.green)
|
||||
}
|
||||
Spacer()
|
||||
Button("Reload") { viewModel.load() }.controlSize(.small)
|
||||
Button("Save") { viewModel.save() }.buttonStyle(.borderedProminent).controlSize(.small)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import SwiftUI
|
||||
|
||||
struct WhatsAppSetupView: View {
|
||||
@State private var viewModel = WhatsAppSetupViewModel()
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
instructions
|
||||
|
||||
SettingsSection(title: "Status", icon: "power") {
|
||||
ToggleRow(label: "WhatsApp Enabled", isOn: viewModel.enabled) { viewModel.enabled = $0 }
|
||||
PickerRow(label: "Mode", selection: viewModel.mode, options: viewModel.modeOptions) { viewModel.mode = $0 }
|
||||
}
|
||||
|
||||
SettingsSection(title: "Access Control", icon: "person.badge.shield.checkmark") {
|
||||
ToggleRow(label: "Allow All Users", isOn: viewModel.allowAllUsers) { viewModel.allowAllUsers = $0 }
|
||||
if !viewModel.allowAllUsers {
|
||||
EditableTextField(label: "Allowed Numbers", value: viewModel.allowedUsers) { viewModel.allowedUsers = $0 }
|
||||
}
|
||||
}
|
||||
|
||||
SettingsSection(title: "Behavior", icon: "slider.horizontal.3") {
|
||||
PickerRow(label: "Unauthorized DM", selection: viewModel.unauthorizedDMBehavior, options: viewModel.unauthorizedOptions) { viewModel.unauthorizedDMBehavior = $0 }
|
||||
EditableTextField(label: "Reply Prefix", value: viewModel.replyPrefix) { viewModel.replyPrefix = $0 }
|
||||
}
|
||||
|
||||
saveBar
|
||||
Divider()
|
||||
pairingSection
|
||||
}
|
||||
.onAppear { viewModel.load() }
|
||||
.onDisappear { viewModel.stopPairing() }
|
||||
}
|
||||
|
||||
private var instructions: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("WhatsApp uses the Baileys library to emulate a WhatsApp Web session. Pair this Mac as a linked device by running the pairing wizard and scanning the QR code with your phone (Settings → Linked Devices → Link a Device).")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
HStack(spacing: 12) {
|
||||
Button("WhatsApp Setup Docs") { PlatformSetupHelpers.openURL("https://hermes-agent.nousresearch.com/docs/user-guide/messaging/whatsapp") }
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var saveBar: some View {
|
||||
HStack {
|
||||
if let msg = viewModel.message {
|
||||
Label(msg, systemImage: "checkmark.circle.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.green)
|
||||
}
|
||||
Spacer()
|
||||
Button("Reload") { viewModel.load() }.controlSize(.small)
|
||||
Button("Save") { viewModel.save() }.buttonStyle(.borderedProminent).controlSize(.small)
|
||||
}
|
||||
}
|
||||
|
||||
private var pairingSection: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
Label("Pair Device", systemImage: "qrcode")
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
if viewModel.pairingInProgress {
|
||||
Button("Stop") { viewModel.stopPairing() }
|
||||
.controlSize(.small)
|
||||
} else {
|
||||
Button("Start Pairing") { viewModel.startPairing() }
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
Text("A QR code will appear below. Scan it with WhatsApp on your phone. The session is saved to ~/.hermes/platforms/whatsapp/ so you won't need to scan again after restarts.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
EmbeddedSetupTerminal(controller: viewModel.terminalController)
|
||||
.frame(minHeight: 260, maxHeight: 360)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import SwiftUI
|
||||
|
||||
struct PlatformsView: View {
|
||||
@State private var viewModel = PlatformsViewModel()
|
||||
@Environment(HermesFileWatcher.self) private var fileWatcher
|
||||
|
||||
// HSplitView (not nested NavigationSplitView) because ContentView already
|
||||
// hosts the outer NavigationSplitView — nesting them breaks layout on macOS.
|
||||
var body: some View {
|
||||
HSplitView {
|
||||
platformList
|
||||
.frame(minWidth: 220, idealWidth: 240, maxWidth: 300)
|
||||
detail
|
||||
.frame(minWidth: 480)
|
||||
}
|
||||
.navigationTitle("Platforms")
|
||||
.onAppear { viewModel.load() }
|
||||
// Re-read config.yaml / .env / gateway_state.json when any of them
|
||||
// changes on disk. This is how the left-side connectivity dots refresh
|
||||
// after the user saves in a per-platform setup form.
|
||||
.onChange(of: fileWatcher.lastChangeDate) { viewModel.load() }
|
||||
}
|
||||
|
||||
private var platformList: some View {
|
||||
VStack(spacing: 0) {
|
||||
List(selection: Binding(
|
||||
get: { viewModel.selected.name },
|
||||
set: { name in
|
||||
if let p = viewModel.platforms.first(where: { $0.name == name }) {
|
||||
viewModel.selected = p
|
||||
}
|
||||
}
|
||||
)) {
|
||||
ForEach(viewModel.platforms) { platform in
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: KnownPlatforms.icon(for: platform.name))
|
||||
.frame(width: 20)
|
||||
Text(platform.displayName)
|
||||
Spacer()
|
||||
Circle()
|
||||
.fill(statusColor(viewModel.connectivity(for: platform)))
|
||||
.frame(width: 8, height: 8)
|
||||
}
|
||||
.tag(platform.name)
|
||||
}
|
||||
}
|
||||
.listStyle(.inset)
|
||||
|
||||
Divider()
|
||||
|
||||
VStack(spacing: 4) {
|
||||
Button {
|
||||
viewModel.restartGateway()
|
||||
} label: {
|
||||
Label("Restart Gateway", systemImage: "arrow.triangle.2.circlepath")
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.disabled(viewModel.restartInProgress)
|
||||
}
|
||||
.font(.caption)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var detail: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
header
|
||||
connectivitySection
|
||||
platformForm
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||
}
|
||||
.id(viewModel.selected.name) // Force view rebuild when platform changes so per-platform state resets.
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: KnownPlatforms.icon(for: viewModel.selected.name))
|
||||
.font(.title)
|
||||
VStack(alignment: .leading) {
|
||||
Text(viewModel.selected.displayName)
|
||||
.font(.title2.bold())
|
||||
Text(statusDescription(viewModel.connectivity(for: viewModel.selected)))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
if let msg = viewModel.message {
|
||||
Label(msg, systemImage: "checkmark.circle.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.green)
|
||||
}
|
||||
if viewModel.restartInProgress {
|
||||
ProgressView().controlSize(.small)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var connectivitySection: some View {
|
||||
SettingsSection(title: "Connection", icon: "dot.radiowaves.left.and.right") {
|
||||
let status = viewModel.connectivity(for: viewModel.selected)
|
||||
ReadOnlyRow(label: "Status", value: statusDescription(status))
|
||||
if case .error(let msg) = status {
|
||||
ReadOnlyRow(label: "Error", value: msg)
|
||||
}
|
||||
ReadOnlyRow(label: "Configured", value: viewModel.hasConfigBlock(for: viewModel.selected) ? "Yes" : "No")
|
||||
}
|
||||
}
|
||||
|
||||
/// Dispatch to the right per-platform setup view based on the selection.
|
||||
/// Each setup view owns its own `@State` view model and handles load/save
|
||||
/// independently; we don't push state down from this container.
|
||||
@ViewBuilder
|
||||
private var platformForm: some View {
|
||||
switch viewModel.selected.name {
|
||||
case "cli": cliPanel
|
||||
case "telegram": TelegramSetupView()
|
||||
case "discord": DiscordSetupView()
|
||||
case "slack": SlackSetupView()
|
||||
case "whatsapp": WhatsAppSetupView()
|
||||
case "signal": SignalSetupView()
|
||||
case "email": EmailSetupView()
|
||||
case "matrix": MatrixSetupView()
|
||||
case "mattermost": MattermostSetupView()
|
||||
case "feishu": FeishuSetupView()
|
||||
case "imessage": IMessageSetupView()
|
||||
case "homeassistant": HomeAssistantSetupView()
|
||||
case "webhook": WebhookSetupView()
|
||||
default:
|
||||
SettingsSection(title: viewModel.selected.displayName, icon: KnownPlatforms.icon(for: viewModel.selected.name)) {
|
||||
ReadOnlyRow(label: "Setup", value: "No setup form for this platform yet.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var cliPanel: some View {
|
||||
SettingsSection(title: "CLI", icon: "terminal") {
|
||||
ReadOnlyRow(label: "Scope", value: "Local terminal sessions")
|
||||
ReadOnlyRow(label: "Note", value: "CLI uses the main app — no platform-specific config.")
|
||||
}
|
||||
}
|
||||
|
||||
private func statusColor(_ status: PlatformConnectivity) -> Color {
|
||||
switch status {
|
||||
case .connected: return .green
|
||||
case .configured: return .orange
|
||||
case .notConfigured: return .secondary.opacity(0.4)
|
||||
case .error: return .red
|
||||
}
|
||||
}
|
||||
|
||||
private func statusDescription(_ status: PlatformConnectivity) -> String {
|
||||
switch status {
|
||||
case .connected: return "Connected"
|
||||
case .configured: return "Configured · not running"
|
||||
case .notConfigured: return "Not configured"
|
||||
case .error(let msg): return "Error: \(msg)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
struct HermesPlugin: Identifiable, Sendable, Equatable {
|
||||
var id: String { name }
|
||||
let name: String
|
||||
let source: String // Git URL or `owner/repo` (read from plugin manifest if present)
|
||||
let enabled: Bool // True unless a `.disabled` marker exists
|
||||
let version: String // From plugin.json / manifest if present
|
||||
let path: String // Absolute directory path
|
||||
}
|
||||
|
||||
@Observable
|
||||
final class PluginsViewModel {
|
||||
private let logger = Logger(subsystem: "com.scarf", category: "PluginsViewModel")
|
||||
private let fileService = HermesFileService()
|
||||
|
||||
var plugins: [HermesPlugin] = []
|
||||
var isLoading = false
|
||||
var message: String?
|
||||
|
||||
private var pluginsDir: String { HermesPaths.home + "/plugins" }
|
||||
|
||||
/// Source of truth is the `~/.hermes/plugins/` directory. Each plugin is a
|
||||
/// subdirectory — we read its `plugin.json` (if present) for source/version
|
||||
/// metadata. Parsing `hermes plugins list` box-drawn output is fragile.
|
||||
func load() {
|
||||
isLoading = true
|
||||
defer { isLoading = false }
|
||||
|
||||
let fm = FileManager.default
|
||||
guard let entries = try? fm.contentsOfDirectory(atPath: pluginsDir) else {
|
||||
plugins = []
|
||||
return
|
||||
}
|
||||
var result: [HermesPlugin] = []
|
||||
for entry in entries.sorted() where !entry.hasPrefix(".") {
|
||||
let path = pluginsDir + "/" + entry
|
||||
var isDir: ObjCBool = false
|
||||
guard fm.fileExists(atPath: path, isDirectory: &isDir), isDir.boolValue else { continue }
|
||||
|
||||
let manifest = Self.readManifest(path: path)
|
||||
let disabled = fm.fileExists(atPath: path + "/.disabled")
|
||||
result.append(HermesPlugin(
|
||||
name: entry,
|
||||
source: manifest.source,
|
||||
enabled: !disabled,
|
||||
version: manifest.version,
|
||||
path: path
|
||||
))
|
||||
}
|
||||
plugins = result
|
||||
}
|
||||
|
||||
/// Best-effort manifest read. Supports both plugin.json and plugin.yaml shapes.
|
||||
private static func readManifest(path: String) -> (source: String, version: String) {
|
||||
let fm = FileManager.default
|
||||
let jsonPath = path + "/plugin.json"
|
||||
if fm.fileExists(atPath: jsonPath),
|
||||
let data = try? Data(contentsOf: URL(fileURLWithPath: jsonPath)),
|
||||
let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
|
||||
let source = (obj["source"] as? String) ?? (obj["repository"] as? String) ?? (obj["url"] as? String) ?? ""
|
||||
let version = (obj["version"] as? String) ?? ""
|
||||
return (source, version)
|
||||
}
|
||||
let yamlPath = path + "/plugin.yaml"
|
||||
if fm.fileExists(atPath: yamlPath),
|
||||
let yaml = try? String(contentsOfFile: yamlPath, encoding: .utf8) {
|
||||
let parsed = HermesFileService.parseNestedYAML(yaml)
|
||||
let source = HermesFileService.stripYAMLQuotes(parsed.values["source"] ?? parsed.values["repository"] ?? parsed.values["url"] ?? "")
|
||||
let version = HermesFileService.stripYAMLQuotes(parsed.values["version"] ?? "")
|
||||
return (source, version)
|
||||
}
|
||||
return ("", "")
|
||||
}
|
||||
|
||||
func install(_ identifier: String) {
|
||||
isLoading = true
|
||||
message = "Installing \(identifier)…"
|
||||
Task.detached { [fileService] in
|
||||
let result = fileService.runHermesCLI(args: ["plugins", "install", identifier], timeout: 180)
|
||||
await MainActor.run {
|
||||
self.isLoading = false
|
||||
self.message = result.exitCode == 0 ? "Installed" : "Install failed"
|
||||
self.load()
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
||||
self?.message = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func update(_ plugin: HermesPlugin) {
|
||||
runAndReload(["plugins", "update", plugin.name], success: "Updated")
|
||||
}
|
||||
|
||||
func remove(_ plugin: HermesPlugin) {
|
||||
runAndReload(["plugins", "remove", plugin.name], success: "Removed")
|
||||
}
|
||||
|
||||
func enable(_ plugin: HermesPlugin) {
|
||||
runAndReload(["plugins", "enable", plugin.name], success: "Enabled")
|
||||
}
|
||||
|
||||
func disable(_ plugin: HermesPlugin) {
|
||||
runAndReload(["plugins", "disable", plugin.name], success: "Disabled")
|
||||
}
|
||||
|
||||
private func runAndReload(_ args: [String], success: String) {
|
||||
Task.detached { [fileService] in
|
||||
let result = fileService.runHermesCLI(args: args, timeout: 60)
|
||||
await MainActor.run {
|
||||
self.message = result.exitCode == 0 ? success : "Failed"
|
||||
self.load()
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
|
||||
self?.message = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import SwiftUI
|
||||
|
||||
struct PluginsView: View {
|
||||
@State private var viewModel = PluginsViewModel()
|
||||
@State private var installIdentifier = ""
|
||||
@State private var showInstall = false
|
||||
@State private var pendingRemove: HermesPlugin?
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
header
|
||||
Divider()
|
||||
if viewModel.isLoading && viewModel.plugins.isEmpty {
|
||||
ProgressView().padding()
|
||||
} else if viewModel.plugins.isEmpty {
|
||||
emptyState
|
||||
} else {
|
||||
list
|
||||
}
|
||||
}
|
||||
.navigationTitle("Plugins")
|
||||
.onAppear { viewModel.load() }
|
||||
.sheet(isPresented: $showInstall) { installSheet }
|
||||
.confirmationDialog(
|
||||
pendingRemove.map { "Remove \($0.name)?" } ?? "",
|
||||
isPresented: Binding(get: { pendingRemove != nil }, set: { if !$0 { pendingRemove = nil } })
|
||||
) {
|
||||
Button("Remove", role: .destructive) {
|
||||
if let plugin = pendingRemove { viewModel.remove(plugin) }
|
||||
pendingRemove = nil
|
||||
}
|
||||
Button("Cancel", role: .cancel) { pendingRemove = nil }
|
||||
}
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack {
|
||||
if let msg = viewModel.message {
|
||||
Label(msg, systemImage: "info.circle.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.green)
|
||||
}
|
||||
Spacer()
|
||||
Button {
|
||||
installIdentifier = ""
|
||||
showInstall = true
|
||||
} label: {
|
||||
Label("Install", systemImage: "plus")
|
||||
}
|
||||
.controlSize(.small)
|
||||
Button("Reload") { viewModel.load() }
|
||||
.controlSize(.small)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
|
||||
private var emptyState: some View {
|
||||
VStack(spacing: 8) {
|
||||
Image(systemName: "app.badge.checkmark")
|
||||
.font(.largeTitle)
|
||||
.foregroundStyle(.secondary)
|
||||
Text("No plugins installed")
|
||||
.foregroundStyle(.secondary)
|
||||
Text("Plugins extend hermes with custom tools, providers, or memory backends.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: 400)
|
||||
Button("Install a Plugin") {
|
||||
installIdentifier = ""
|
||||
showInstall = true
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.small)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.padding()
|
||||
}
|
||||
|
||||
private var list: some View {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 1) {
|
||||
ForEach(viewModel.plugins) { plugin in
|
||||
row(plugin)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
private func row(_ plugin: HermesPlugin) -> some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: plugin.enabled ? "app.badge.checkmark.fill" : "app.badge")
|
||||
.foregroundStyle(plugin.enabled ? .green : .secondary)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(spacing: 6) {
|
||||
Text(plugin.name)
|
||||
.font(.system(.body, design: .monospaced, weight: .medium))
|
||||
if !plugin.version.isEmpty {
|
||||
Text(plugin.version)
|
||||
.font(.caption2.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
if !plugin.source.isEmpty {
|
||||
Text(plugin.source)
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
Button(plugin.enabled ? "Disable" : "Enable") {
|
||||
if plugin.enabled { viewModel.disable(plugin) } else { viewModel.enable(plugin) }
|
||||
}
|
||||
.controlSize(.small)
|
||||
Button("Update") { viewModel.update(plugin) }
|
||||
.controlSize(.small)
|
||||
Button("Remove", role: .destructive) { pendingRemove = plugin }
|
||||
.controlSize(.small)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
}
|
||||
|
||||
private var installSheet: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Install Plugin")
|
||||
.font(.headline)
|
||||
Text("Provide a Git URL (https://github.com/...) or a shorthand like `owner/repo`.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
TextField("github.com/owner/plugin-repo or owner/repo", text: $installIdentifier)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("Cancel") { showInstall = false }
|
||||
Button("Install") {
|
||||
viewModel.install(installIdentifier)
|
||||
showInstall = false
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(installIdentifier.trimmingCharacters(in: .whitespaces).isEmpty)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.frame(minWidth: 500, minHeight: 200)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
struct HermesProfile: Identifiable, Sendable, Equatable {
|
||||
var id: String { name }
|
||||
let name: String
|
||||
let isActive: Bool
|
||||
let path: String
|
||||
}
|
||||
|
||||
@Observable
|
||||
final class ProfilesViewModel {
|
||||
private let logger = Logger(subsystem: "com.scarf", category: "ProfilesViewModel")
|
||||
private let fileService = HermesFileService()
|
||||
|
||||
var profiles: [HermesProfile] = []
|
||||
var activeName: String = "default"
|
||||
var isLoading = false
|
||||
var message: String?
|
||||
var detailOutput: String = ""
|
||||
|
||||
func load() {
|
||||
isLoading = true
|
||||
Task.detached { [fileService] in
|
||||
let result = fileService.runHermesCLI(args: ["profile", "list"], timeout: 20)
|
||||
let (parsed, active) = Self.parseProfileList(result.output)
|
||||
await MainActor.run {
|
||||
self.isLoading = false
|
||||
self.profiles = parsed
|
||||
self.activeName = active
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func showDetail(_ profile: HermesProfile) {
|
||||
detailOutput = "Loading…"
|
||||
Task.detached { [fileService] in
|
||||
let result = fileService.runHermesCLI(args: ["profile", "show", profile.name], timeout: 15)
|
||||
await MainActor.run {
|
||||
self.detailOutput = result.output
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func switchTo(_ profile: HermesProfile) {
|
||||
runAndReload(["profile", "use", profile.name], success: "Active profile set to \(profile.name)")
|
||||
}
|
||||
|
||||
func create(name: String, cloneConfig: Bool, cloneAll: Bool) {
|
||||
var args = ["profile", "create", name]
|
||||
if cloneAll { args.append("--clone-all") }
|
||||
else if cloneConfig { args.append("--clone") }
|
||||
runAndReload(args, success: "Profile '\(name)' created")
|
||||
}
|
||||
|
||||
func rename(_ profile: HermesProfile, to newName: String) {
|
||||
runAndReload(["profile", "rename", profile.name, newName], success: "Renamed")
|
||||
}
|
||||
|
||||
func delete(_ profile: HermesProfile) {
|
||||
runAndReload(["profile", "delete", profile.name], success: "Deleted \(profile.name)")
|
||||
}
|
||||
|
||||
func export(_ profile: HermesProfile, to path: String) {
|
||||
runAndReload(["profile", "export", profile.name, "--output", path], success: "Exported")
|
||||
}
|
||||
|
||||
func `import`(from path: String) {
|
||||
runAndReload(["profile", "import", path], success: "Imported")
|
||||
}
|
||||
|
||||
private func runAndReload(_ args: [String], success: String) {
|
||||
Task.detached { [fileService] in
|
||||
let result = fileService.runHermesCLI(args: args, timeout: 60)
|
||||
await MainActor.run {
|
||||
self.message = result.exitCode == 0 ? success : "Failed: \(result.output.prefix(120))"
|
||||
self.load()
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
||||
self?.message = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse `hermes profile list` output. Hermes emits a box-drawn Rich table:
|
||||
///
|
||||
/// Profile Model Gateway Alias
|
||||
/// ─────────────── ──────── ────────── ─────
|
||||
/// ◆default — running —
|
||||
/// experimental gpt-4 stopped hermes-exp
|
||||
///
|
||||
/// Active profiles are prefixed with `◆` (U+25C6). Columns are separated by
|
||||
/// whitespace; there are no vertical bars. We ignore box-drawing lines and
|
||||
/// the header row, then extract the name from column 0 of each data row.
|
||||
nonisolated private static func parseProfileList(_ output: String) -> (profiles: [HermesProfile], active: String) {
|
||||
var results: [HermesProfile] = []
|
||||
var active = "default"
|
||||
var sawHeader = false
|
||||
|
||||
for raw in output.components(separatedBy: "\n") {
|
||||
let line = raw.trimmingCharacters(in: .whitespaces)
|
||||
if line.isEmpty { continue }
|
||||
// Box-drawing separator rows: contain only ─ (U+2500) and whitespace.
|
||||
if line.unicodeScalars.allSatisfy({ $0.value == 0x2500 || $0.properties.isWhitespace }) { continue }
|
||||
// Header row (first non-empty, non-separator line in the table).
|
||||
if !sawHeader && line.lowercased().contains("profile") && line.lowercased().contains("gateway") {
|
||||
sawHeader = true
|
||||
continue
|
||||
}
|
||||
// Data row. Strip active marker first.
|
||||
var working = line
|
||||
var isActive = false
|
||||
if working.hasPrefix("◆") {
|
||||
isActive = true
|
||||
working = String(working.dropFirst()).trimmingCharacters(in: .whitespaces)
|
||||
} else if working.hasPrefix("*") {
|
||||
isActive = true
|
||||
working = String(working.dropFirst()).trimmingCharacters(in: .whitespaces)
|
||||
}
|
||||
let tokens = working.split(whereSeparator: { $0.isWhitespace }).map(String.init)
|
||||
guard let nameStr = tokens.first else { continue }
|
||||
// Reject rows whose first token is something like "Tip:" or a localized
|
||||
// label — real profile names are lowercase alphanumeric with - or _.
|
||||
guard nameStr.range(of: "^[a-zA-Z0-9_-]+$", options: .regularExpression) != nil else { continue }
|
||||
if isActive { active = nameStr }
|
||||
results.append(HermesProfile(name: nameStr, isActive: isActive, path: ""))
|
||||
}
|
||||
return (results, active)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
import SwiftUI
|
||||
import AppKit
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
struct ProfilesView: View {
|
||||
@State private var viewModel = ProfilesViewModel()
|
||||
@State private var selected: HermesProfile?
|
||||
@State private var showCreate = false
|
||||
@State private var createName = ""
|
||||
@State private var createCloneConfig = true
|
||||
@State private var createCloneAll = false
|
||||
@State private var showRename = false
|
||||
@State private var renameTarget: HermesProfile?
|
||||
@State private var renameNewName = ""
|
||||
@State private var pendingDelete: HermesProfile?
|
||||
|
||||
var body: some View {
|
||||
HSplitView {
|
||||
listSection
|
||||
.frame(minWidth: 260, idealWidth: 300)
|
||||
detailSection
|
||||
.frame(minWidth: 400)
|
||||
}
|
||||
.navigationTitle("Profiles")
|
||||
.onAppear { viewModel.load() }
|
||||
.sheet(isPresented: $showCreate) { createSheet }
|
||||
.sheet(isPresented: Binding(get: { renameTarget != nil }, set: { if !$0 { renameTarget = nil } })) {
|
||||
renameSheet
|
||||
}
|
||||
.confirmationDialog(
|
||||
pendingDelete.map { "Delete profile '\($0.name)'?" } ?? "",
|
||||
isPresented: Binding(get: { pendingDelete != nil }, set: { if !$0 { pendingDelete = nil } })
|
||||
) {
|
||||
Button("Delete", role: .destructive) {
|
||||
if let profile = pendingDelete { viewModel.delete(profile) }
|
||||
pendingDelete = nil
|
||||
}
|
||||
Button("Cancel", role: .cancel) { pendingDelete = nil }
|
||||
} message: {
|
||||
Text("This removes the profile directory and all data within it. This cannot be undone.")
|
||||
}
|
||||
}
|
||||
|
||||
private var listSection: some View {
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
if let msg = viewModel.message {
|
||||
Label(msg, systemImage: "info.circle")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Button {
|
||||
createName = ""; createCloneConfig = true; createCloneAll = false
|
||||
showCreate = true
|
||||
} label: {
|
||||
Label("Create", systemImage: "plus")
|
||||
}
|
||||
.controlSize(.small)
|
||||
Button {
|
||||
let panel = NSOpenPanel()
|
||||
panel.allowedContentTypes = [.zip]
|
||||
panel.canChooseFiles = true
|
||||
panel.canChooseDirectories = false
|
||||
panel.allowsMultipleSelection = false
|
||||
if panel.runModal() == .OK, let url = panel.url {
|
||||
viewModel.import(from: url.path)
|
||||
}
|
||||
} label: {
|
||||
Label("Import", systemImage: "square.and.arrow.down")
|
||||
}
|
||||
.controlSize(.small)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 6)
|
||||
Divider()
|
||||
List(selection: Binding(
|
||||
get: { selected?.id },
|
||||
set: { id in
|
||||
if let id, let profile = viewModel.profiles.first(where: { $0.id == id }) {
|
||||
selected = profile
|
||||
viewModel.showDetail(profile)
|
||||
}
|
||||
}
|
||||
)) {
|
||||
ForEach(viewModel.profiles) { profile in
|
||||
HStack {
|
||||
Image(systemName: profile.isActive ? "checkmark.circle.fill" : "person.crop.square")
|
||||
.foregroundStyle(profile.isActive ? .green : .secondary)
|
||||
Text(profile.name)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
Spacer()
|
||||
if profile.isActive {
|
||||
Text("active")
|
||||
.font(.caption2.bold())
|
||||
.foregroundStyle(.green)
|
||||
}
|
||||
}
|
||||
.tag(profile.id)
|
||||
.contextMenu {
|
||||
Button("Use") { viewModel.switchTo(profile) }
|
||||
.disabled(profile.isActive)
|
||||
Button("Rename") {
|
||||
renameTarget = profile
|
||||
renameNewName = profile.name
|
||||
}
|
||||
Button("Export…") {
|
||||
let panel = NSSavePanel()
|
||||
panel.allowedContentTypes = [.zip]
|
||||
panel.nameFieldStringValue = "\(profile.name)-profile.zip"
|
||||
if panel.runModal() == .OK, let url = panel.url {
|
||||
viewModel.export(profile, to: url.path)
|
||||
}
|
||||
}
|
||||
Divider()
|
||||
Button("Delete", role: .destructive) { pendingDelete = profile }
|
||||
.disabled(profile.isActive)
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.inset)
|
||||
.overlay {
|
||||
if viewModel.profiles.isEmpty && !viewModel.isLoading {
|
||||
ContentUnavailableView("No Profiles", systemImage: "person.2.crop.square.stack", description: Text("Create a profile to isolate config and skills."))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var detailSection: some View {
|
||||
if let profile = selected {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "person.crop.square.filled.and.at.rectangle")
|
||||
.font(.title)
|
||||
VStack(alignment: .leading) {
|
||||
Text(profile.name).font(.title2.bold())
|
||||
Text(profile.isActive ? "Active profile" : "Inactive")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
if !profile.isActive {
|
||||
Button {
|
||||
viewModel.switchTo(profile)
|
||||
} label: {
|
||||
Label("Switch to This Profile", systemImage: "arrow.triangle.swap")
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
if !profile.isActive {
|
||||
profileSwitchWarning
|
||||
}
|
||||
SettingsSection(title: "Details", icon: "info.circle") {
|
||||
if !profile.path.isEmpty {
|
||||
ReadOnlyRow(label: "Path", value: profile.path)
|
||||
}
|
||||
}
|
||||
if !viewModel.detailOutput.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("hermes profile show")
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(.secondary)
|
||||
Text(viewModel.detailOutput)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.textSelection(.enabled)
|
||||
.padding(8)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(.quaternary.opacity(0.5))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||
}
|
||||
} else {
|
||||
ContentUnavailableView("Select a Profile", systemImage: "person.2.crop.square.stack", description: Text("Choose a profile to inspect."))
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
private var profileSwitchWarning: some View {
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
Image(systemName: "exclamationmark.triangle")
|
||||
.foregroundStyle(.orange)
|
||||
Text("Switching the active profile changes the `~/.hermes` directory hermes uses. Restart Scarf after switching so it re-reads from the new profile's files.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(10)
|
||||
.background(.orange.opacity(0.1))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
|
||||
private var createSheet: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Create Profile").font(.headline)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Name").font(.caption).foregroundStyle(.secondary)
|
||||
TextField("e.g. experimental", text: $createName)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
Toggle("Clone config, .env, SOUL.md from active profile", isOn: $createCloneConfig)
|
||||
.disabled(createCloneAll)
|
||||
Toggle("Full copy of active profile (all state)", isOn: $createCloneAll)
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("Cancel") { showCreate = false }
|
||||
Button("Create") {
|
||||
viewModel.create(name: createName, cloneConfig: createCloneConfig, cloneAll: createCloneAll)
|
||||
showCreate = false
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(createName.trimmingCharacters(in: .whitespaces).isEmpty)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.frame(minWidth: 460, minHeight: 240)
|
||||
}
|
||||
|
||||
private var renameSheet: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Rename Profile").font(.headline)
|
||||
if let target = renameTarget {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("New name for '\(target.name)'").font(.caption).foregroundStyle(.secondary)
|
||||
TextField("new-name", text: $renameNewName)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
}
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("Cancel") { renameTarget = nil }
|
||||
Button("Rename") {
|
||||
if let target = renameTarget {
|
||||
viewModel.rename(target, to: renameNewName)
|
||||
}
|
||||
renameTarget = nil
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(renameNewName.trimmingCharacters(in: .whitespaces).isEmpty)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.frame(minWidth: 440, minHeight: 180)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import Foundation
|
||||
import AppKit
|
||||
import os
|
||||
|
||||
/// A user-defined shell shortcut that hermes exposes in chat (e.g. `/my_cmd`).
|
||||
struct HermesQuickCommand: Identifiable, Sendable, Equatable {
|
||||
var id: String { name }
|
||||
let name: String
|
||||
let type: String // "exec" is the only supported type today
|
||||
let command: String
|
||||
}
|
||||
|
||||
@Observable
|
||||
final class QuickCommandsViewModel {
|
||||
private let logger = Logger(subsystem: "com.scarf", category: "QuickCommandsViewModel")
|
||||
|
||||
var commands: [HermesQuickCommand] = []
|
||||
var message: String?
|
||||
|
||||
func load() {
|
||||
guard let yaml = try? String(contentsOfFile: HermesPaths.configYAML, encoding: .utf8) else {
|
||||
commands = []
|
||||
return
|
||||
}
|
||||
let parsed = HermesFileService.parseNestedYAML(yaml)
|
||||
// Each quick command is `quick_commands.<name>.type` + `quick_commands.<name>.command`.
|
||||
var byName: [String: (type: String, command: String)] = [:]
|
||||
for (key, value) in parsed.values where key.hasPrefix("quick_commands.") {
|
||||
let parts = key.split(separator: ".", maxSplits: 2, omittingEmptySubsequences: false)
|
||||
guard parts.count == 3 else { continue }
|
||||
let name = String(parts[1])
|
||||
let field = String(parts[2])
|
||||
var existing = byName[name] ?? (type: "exec", command: "")
|
||||
let stripped = HermesFileService.stripYAMLQuotes(value)
|
||||
if field == "type" { existing.type = stripped }
|
||||
if field == "command" { existing.command = stripped }
|
||||
byName[name] = existing
|
||||
}
|
||||
commands = byName.map { HermesQuickCommand(name: $0.key, type: $0.value.type, command: $0.value.command) }
|
||||
.sorted { $0.name < $1.name }
|
||||
}
|
||||
|
||||
/// Check for obviously destructive shell strings. Display-only; we do not block.
|
||||
static func isDangerous(_ command: String) -> Bool {
|
||||
let lowered = command.lowercased()
|
||||
let patterns = ["rm -rf /", "rm -rf ~", ":(){", "mkfs", "dd if=", "> /dev/sd", "shutdown", "reboot"]
|
||||
return patterns.contains { lowered.contains($0) }
|
||||
}
|
||||
|
||||
func addOrUpdate(name: String, command: String) {
|
||||
guard !name.isEmpty, !command.isEmpty else {
|
||||
message = "Name and command are required"
|
||||
return
|
||||
}
|
||||
let sanitizedName = name.replacingOccurrences(of: " ", with: "_")
|
||||
let typeResult = runHermes(["config", "set", "quick_commands.\(sanitizedName).type", "exec"])
|
||||
let cmdResult = runHermes(["config", "set", "quick_commands.\(sanitizedName).command", command])
|
||||
if typeResult.exitCode == 0 && cmdResult.exitCode == 0 {
|
||||
message = "Saved /\(sanitizedName)"
|
||||
load()
|
||||
} else {
|
||||
logger.warning("Failed to save quick command: type=\(typeResult.output) cmd=\(cmdResult.output)")
|
||||
message = "Save failed"
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
|
||||
self?.message = nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Removal requires editing config.yaml directly — `hermes config set` has no
|
||||
/// unset for nested keys. Open the file in the editor for manual removal.
|
||||
func openConfigForRemoval() {
|
||||
NSWorkspace.shared.open(URL(fileURLWithPath: HermesPaths.configYAML))
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func runHermes(_ arguments: [String]) -> (output: String, exitCode: Int32) {
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: HermesPaths.hermesBinary)
|
||||
process.arguments = arguments
|
||||
process.environment = HermesFileService.enrichedEnvironment()
|
||||
let pipe = Pipe()
|
||||
process.standardOutput = pipe
|
||||
process.standardError = Pipe()
|
||||
do {
|
||||
try process.run()
|
||||
process.waitUntilExit()
|
||||
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
||||
return (String(data: data, encoding: .utf8) ?? "", process.terminationStatus)
|
||||
} catch {
|
||||
return ("", -1)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import SwiftUI
|
||||
|
||||
struct QuickCommandsView: View {
|
||||
@State private var viewModel = QuickCommandsViewModel()
|
||||
@State private var showAddSheet = false
|
||||
@State private var editTarget: HermesQuickCommand?
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
header
|
||||
intro
|
||||
if viewModel.commands.isEmpty {
|
||||
emptyState
|
||||
} else {
|
||||
list
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||
}
|
||||
.navigationTitle("Quick Commands")
|
||||
.onAppear { viewModel.load() }
|
||||
.sheet(isPresented: $showAddSheet) {
|
||||
QuickCommandEditor(initial: nil) { name, cmd in
|
||||
viewModel.addOrUpdate(name: name, command: cmd)
|
||||
showAddSheet = false
|
||||
} onCancel: {
|
||||
showAddSheet = false
|
||||
}
|
||||
}
|
||||
.sheet(item: $editTarget) { target in
|
||||
QuickCommandEditor(initial: target) { name, cmd in
|
||||
viewModel.addOrUpdate(name: name, command: cmd)
|
||||
editTarget = nil
|
||||
} onCancel: {
|
||||
editTarget = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack {
|
||||
if let msg = viewModel.message {
|
||||
Label(msg, systemImage: "checkmark.circle.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.green)
|
||||
}
|
||||
Spacer()
|
||||
Button {
|
||||
showAddSheet = true
|
||||
} label: {
|
||||
Label("Add Command", systemImage: "plus")
|
||||
}
|
||||
.controlSize(.small)
|
||||
Button("Reload") { viewModel.load() }
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
|
||||
private var intro: some View {
|
||||
Text("Quick commands are shell shortcuts hermes exposes in chat as `/command_name`. They live under `quick_commands:` in config.yaml.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
private var emptyState: some View {
|
||||
VStack(spacing: 8) {
|
||||
Image(systemName: "command.square")
|
||||
.font(.largeTitle)
|
||||
.foregroundStyle(.secondary)
|
||||
Text("No quick commands configured")
|
||||
.foregroundStyle(.secondary)
|
||||
Button("Add your first command") { showAddSheet = true }
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.small)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 40)
|
||||
}
|
||||
|
||||
private var list: some View {
|
||||
VStack(spacing: 1) {
|
||||
ForEach(viewModel.commands) { cmd in
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "command.square")
|
||||
.foregroundStyle(.blue)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(spacing: 6) {
|
||||
Text("/\(cmd.name)")
|
||||
.font(.system(.body, design: .monospaced, weight: .medium))
|
||||
if QuickCommandsViewModel.isDangerous(cmd.command) {
|
||||
Label("dangerous", systemImage: "exclamationmark.triangle.fill")
|
||||
.font(.caption2.bold())
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
Text(cmd.command)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
.lineLimit(3)
|
||||
}
|
||||
Spacer()
|
||||
Button("Edit") { editTarget = cmd }
|
||||
.controlSize(.small)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
}
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("Remove via config.yaml…") { viewModel.openConfigForRemoval() }
|
||||
.controlSize(.small)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.top, 8)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Inline editor for add/update. Removal requires hand-editing config.yaml because
|
||||
/// `hermes config set` has no unset primitive for nested keys.
|
||||
private struct QuickCommandEditor: View {
|
||||
let initial: HermesQuickCommand?
|
||||
let onSave: (String, String) -> Void
|
||||
let onCancel: () -> Void
|
||||
|
||||
@State private var name: String
|
||||
@State private var command: String
|
||||
|
||||
init(initial: HermesQuickCommand?, onSave: @escaping (String, String) -> Void, onCancel: @escaping () -> Void) {
|
||||
self.initial = initial
|
||||
self.onSave = onSave
|
||||
self.onCancel = onCancel
|
||||
_name = State(initialValue: initial?.name ?? "")
|
||||
_command = State(initialValue: initial?.command ?? "")
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text(initial == nil ? "Add Quick Command" : "Edit /\(initial!.name)")
|
||||
.font(.headline)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Name (no leading slash)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
TextField("e.g. deploy", text: $name)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.disabled(initial != nil)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Shell Command")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
TextEditor(text: $command)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.frame(minHeight: 100)
|
||||
.padding(4)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
if QuickCommandsViewModel.isDangerous(command) {
|
||||
Label("Command looks destructive. Double-check before saving.", systemImage: "exclamationmark.triangle.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("Cancel") { onCancel() }
|
||||
Button("Save") { onSave(name, command) }
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(name.trimmingCharacters(in: .whitespaces).isEmpty || command.trimmingCharacters(in: .whitespaces).isEmpty)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.frame(minWidth: 500, minHeight: 320)
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
import Foundation
|
||||
import AppKit
|
||||
import UniformTypeIdentifiers
|
||||
import os
|
||||
|
||||
@Observable
|
||||
final class SettingsViewModel {
|
||||
private let logger = Logger(subsystem: "com.scarf", category: "SettingsViewModel")
|
||||
private let fileService = HermesFileService()
|
||||
|
||||
var config = HermesConfig.empty
|
||||
@@ -14,8 +16,10 @@ final class SettingsViewModel {
|
||||
var providers = ["anthropic", "openrouter", "nous", "openai-codex", "google-ai-studio", "xai", "ollama-cloud", "zai", "kimi-coding", "minimax"]
|
||||
var terminalBackends = ["local", "docker", "singularity", "modal", "daytona", "ssh"]
|
||||
var browserBackends = ["browseruse", "firecrawl", "local"]
|
||||
var ttsProviders = ["edge", "elevenlabs", "openai", "minimax", "mistral", "neutts"]
|
||||
var sttProviders = ["local", "groq", "openai", "mistral"]
|
||||
var memoryProviders = ["", "honcho", "openviking", "mem0", "hindsight", "holographic", "retaindb", "byterover", "supermemory"]
|
||||
var saveMessage: String?
|
||||
var showAuthRemoveConfirmation = false
|
||||
|
||||
func load() {
|
||||
config = fileService.loadConfig()
|
||||
@@ -24,12 +28,14 @@ final class SettingsViewModel {
|
||||
do {
|
||||
rawConfigYAML = try String(contentsOfFile: HermesPaths.configYAML, encoding: .utf8)
|
||||
} catch {
|
||||
print("[Scarf] Failed to read config.yaml: \(error.localizedDescription)")
|
||||
logger.error("Failed to read config.yaml: \(error.localizedDescription)")
|
||||
rawConfigYAML = ""
|
||||
}
|
||||
personalities = parsePersonalities()
|
||||
}
|
||||
|
||||
/// Set a scalar config value via `hermes config set <key> <value>` and reload
|
||||
/// the config on success so the UI reflects the new state.
|
||||
func setSetting(_ key: String, value: String) {
|
||||
let result = runHermes(["config", "set", key, value])
|
||||
if result.exitCode == 0 {
|
||||
@@ -38,34 +44,172 @@ final class SettingsViewModel {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
|
||||
self?.saveMessage = nil
|
||||
}
|
||||
} else {
|
||||
logger.warning("hermes config set \(key) failed (exit \(result.exitCode)): \(result.output)")
|
||||
saveMessage = "Failed to save \(key)"
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
||||
self?.saveMessage = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Model
|
||||
|
||||
func setModel(_ value: String) { setSetting("model.default", value: value) }
|
||||
func setProvider(_ value: String) { setSetting("model.provider", value: value) }
|
||||
func setTimezone(_ value: String) { setSetting("timezone", value: value) }
|
||||
|
||||
// MARK: - Display
|
||||
|
||||
func setPersonality(_ value: String) { setSetting("display.personality", value: value) }
|
||||
func setTerminalBackend(_ value: String) { setSetting("terminal.backend", value: value) }
|
||||
func setStreaming(_ value: Bool) { setSetting("display.streaming", value: value ? "true" : "false") }
|
||||
func setShowReasoning(_ value: Bool) { setSetting("display.show_reasoning", value: value ? "true" : "false") }
|
||||
func setShowCost(_ value: Bool) { setSetting("display.show_cost", value: value ? "true" : "false") }
|
||||
func setInterimAssistantMessages(_ value: Bool) { setSetting("display.interim_assistant_messages", value: value ? "true" : "false") }
|
||||
func setSkin(_ value: String) { setSetting("display.skin", value: value) }
|
||||
func setDisplayCompact(_ value: Bool) { setSetting("display.compact", value: value ? "true" : "false") }
|
||||
func setResumeDisplay(_ value: String) { setSetting("display.resume_display", value: value) }
|
||||
func setBellOnComplete(_ value: Bool) { setSetting("display.bell_on_complete", value: value ? "true" : "false") }
|
||||
func setInlineDiffs(_ value: Bool) { setSetting("display.inline_diffs", value: value ? "true" : "false") }
|
||||
func setToolProgressCommand(_ value: Bool) { setSetting("display.tool_progress_command", value: value ? "true" : "false") }
|
||||
func setToolPreviewLength(_ value: Int) { setSetting("display.tool_preview_length", value: String(value)) }
|
||||
func setBusyInputMode(_ value: String) { setSetting("display.busy_input_mode", value: value) }
|
||||
|
||||
// MARK: - Agent
|
||||
|
||||
func setMaxTurns(_ value: Int) { setSetting("agent.max_turns", value: String(value)) }
|
||||
func setReasoningEffort(_ value: String) { setSetting("agent.reasoning_effort", value: value) }
|
||||
func setVerbose(_ value: Bool) { setSetting("agent.verbose", value: value ? "true" : "false") }
|
||||
func setServiceTier(_ value: String) { setSetting("agent.service_tier", value: value) }
|
||||
func setGatewayNotifyInterval(_ value: Int) { setSetting("agent.gateway_notify_interval", value: String(value)) }
|
||||
func setGatewayTimeout(_ value: Int) { setSetting("agent.gateway_timeout", value: String(value)) }
|
||||
func setToolUseEnforcement(_ value: String) { setSetting("agent.tool_use_enforcement", value: value) }
|
||||
func setApprovalMode(_ value: String) { setSetting("approvals.mode", value: value) }
|
||||
func setApprovalTimeout(_ value: Int) { setSetting("approvals.timeout", value: String(value)) }
|
||||
|
||||
// MARK: - Terminal
|
||||
|
||||
func setTerminalBackend(_ value: String) { setSetting("terminal.backend", value: value) }
|
||||
func setTerminalCwd(_ value: String) { setSetting("terminal.cwd", value: value) }
|
||||
func setTerminalTimeout(_ value: Int) { setSetting("terminal.timeout", value: String(value)) }
|
||||
func setPersistentShell(_ value: Bool) { setSetting("terminal.persistent_shell", value: value ? "true" : "false") }
|
||||
func setDockerImage(_ value: String) { setSetting("terminal.docker_image", value: value) }
|
||||
func setDockerMountCwd(_ value: Bool) { setSetting("terminal.docker_mount_cwd_to_workspace", value: value ? "true" : "false") }
|
||||
func setContainerCPU(_ value: Int) { setSetting("terminal.container_cpu", value: String(value)) }
|
||||
func setContainerMemory(_ value: Int) { setSetting("terminal.container_memory", value: String(value)) }
|
||||
func setContainerDisk(_ value: Int) { setSetting("terminal.container_disk", value: String(value)) }
|
||||
func setContainerPersistent(_ value: Bool) { setSetting("terminal.container_persistent", value: value ? "true" : "false") }
|
||||
func setModalImage(_ value: String) { setSetting("terminal.modal_image", value: value) }
|
||||
func setModalMode(_ value: String) { setSetting("terminal.modal_mode", value: value) }
|
||||
func setDaytonaImage(_ value: String) { setSetting("terminal.daytona_image", value: value) }
|
||||
func setSingularityImage(_ value: String) { setSetting("terminal.singularity_image", value: value) }
|
||||
|
||||
// MARK: - Browser
|
||||
|
||||
func setBrowserBackend(_ value: String) { setSetting("browser.backend", value: value) }
|
||||
func setBrowserInactivityTimeout(_ value: Int) { setSetting("browser.inactivity_timeout", value: String(value)) }
|
||||
func setBrowserCommandTimeout(_ value: Int) { setSetting("browser.command_timeout", value: String(value)) }
|
||||
func setBrowserRecordSessions(_ value: Bool) { setSetting("browser.record_sessions", value: value ? "true" : "false") }
|
||||
func setBrowserAllowPrivateURLs(_ value: Bool) { setSetting("browser.allow_private_urls", value: value ? "true" : "false") }
|
||||
func setCamofoxManagedPersistence(_ value: Bool) { setSetting("browser.camofox.managed_persistence", value: value ? "true" : "false") }
|
||||
|
||||
// MARK: - Voice / TTS / STT
|
||||
|
||||
func setAutoTTS(_ value: Bool) { setSetting("voice.auto_tts", value: value ? "true" : "false") }
|
||||
func setSilenceThreshold(_ value: Int) { setSetting("voice.silence_threshold", value: String(value)) }
|
||||
func setRecordKey(_ value: String) { setSetting("voice.record_key", value: value) }
|
||||
func setMaxRecordingSeconds(_ value: Int) { setSetting("voice.max_recording_seconds", value: String(value)) }
|
||||
func setSilenceDuration(_ value: Double) { setSetting("voice.silence_duration", value: String(value)) }
|
||||
func setTTSProvider(_ value: String) { setSetting("tts.provider", value: value) }
|
||||
func setTTSEdgeVoice(_ value: String) { setSetting("tts.edge.voice", value: value) }
|
||||
func setTTSElevenLabsVoiceID(_ value: String) { setSetting("tts.elevenlabs.voice_id", value: value) }
|
||||
func setTTSElevenLabsModelID(_ value: String) { setSetting("tts.elevenlabs.model_id", value: value) }
|
||||
func setTTSOpenAIModel(_ value: String) { setSetting("tts.openai.model", value: value) }
|
||||
func setTTSOpenAIVoice(_ value: String) { setSetting("tts.openai.voice", value: value) }
|
||||
func setTTSNeuTTSModel(_ value: String) { setSetting("tts.neutts.model", value: value) }
|
||||
func setTTSNeuTTSDevice(_ value: String) { setSetting("tts.neutts.device", value: value) }
|
||||
func setSTTEnabled(_ value: Bool) { setSetting("stt.enabled", value: value ? "true" : "false") }
|
||||
func setSTTProvider(_ value: String) { setSetting("stt.provider", value: value) }
|
||||
func setSTTLocalModel(_ value: String) { setSetting("stt.local.model", value: value) }
|
||||
func setSTTLocalLanguage(_ value: String) { setSetting("stt.local.language", value: value) }
|
||||
func setSTTOpenAIModel(_ value: String) { setSetting("stt.openai.model", value: value) }
|
||||
func setSTTMistralModel(_ value: String) { setSetting("stt.mistral.model", value: value) }
|
||||
|
||||
// MARK: - Memory
|
||||
|
||||
func setMemoryEnabled(_ value: Bool) { setSetting("memory.memory_enabled", value: value ? "true" : "false") }
|
||||
func setUserProfileEnabled(_ value: Bool) { setSetting("memory.user_profile_enabled", value: value ? "true" : "false") }
|
||||
func setMemoryCharLimit(_ value: Int) { setSetting("memory.memory_char_limit", value: String(value)) }
|
||||
func setUserCharLimit(_ value: Int) { setSetting("memory.user_char_limit", value: String(value)) }
|
||||
func setNudgeInterval(_ value: Int) { setSetting("memory.nudge_interval", value: String(value)) }
|
||||
func setStreaming(_ value: Bool) { setSetting("display.streaming", value: value ? "true" : "false") }
|
||||
func setShowReasoning(_ value: Bool) { setSetting("display.show_reasoning", value: value ? "true" : "false") }
|
||||
func setVerbose(_ value: Bool) { setSetting("agent.verbose", value: value ? "true" : "false") }
|
||||
func setAutoTTS(_ value: Bool) { setSetting("voice.auto_tts", value: value ? "true" : "false") }
|
||||
func setSilenceThreshold(_ value: Int) { setSetting("voice.silence_threshold", value: String(value)) }
|
||||
func setReasoningEffort(_ value: String) { setSetting("agent.reasoning_effort", value: value) }
|
||||
func setShowCost(_ value: Bool) { setSetting("display.show_cost", value: value ? "true" : "false") }
|
||||
func setApprovalMode(_ value: String) { setSetting("approvals.mode", value: value) }
|
||||
func setBrowserBackend(_ value: String) { setSetting("browser.backend", value: value) }
|
||||
func setServiceTier(_ value: String) { setSetting("agent.service_tier", value: value) }
|
||||
func setGatewayNotifyInterval(_ value: Int) { setSetting("agent.gateway_notify_interval", value: String(value)) }
|
||||
func setForceIPv4(_ value: Bool) { setSetting("network.force_ipv4", value: value ? "true" : "false") }
|
||||
func setInterimAssistantMessages(_ value: Bool) { setSetting("display.interim_assistant_messages", value: value ? "true" : "false") }
|
||||
/// Provider switching for external memory plugins. Uses `hermes memory setup/off`
|
||||
/// because the CLI wizard runs provider-specific init steps beyond a simple
|
||||
/// config.yaml write.
|
||||
func setMemoryProvider(_ value: String) {
|
||||
if value.isEmpty {
|
||||
_ = runHermes(["memory", "off"])
|
||||
} else {
|
||||
setSetting("memory.provider", value: value)
|
||||
}
|
||||
config = fileService.loadConfig()
|
||||
}
|
||||
// Hermes v0.9.0 PR #6995: the key is camelCase in config.yaml (not snake_case like the rest of Hermes).
|
||||
func setHonchoInitOnSessionStart(_ value: Bool) { setSetting("honcho.initOnSessionStart", value: value ? "true" : "false") }
|
||||
|
||||
// MARK: - Auxiliary model sub-tasks
|
||||
|
||||
func setAuxiliary(_ task: String, field: String, value: String) {
|
||||
setSetting("auxiliary.\(task).\(field)", value: value)
|
||||
}
|
||||
func setAuxiliaryTimeout(_ task: String, value: Int) {
|
||||
setSetting("auxiliary.\(task).timeout", value: String(value))
|
||||
}
|
||||
|
||||
// MARK: - Security / Privacy
|
||||
|
||||
func setRedactSecrets(_ value: Bool) { setSetting("security.redact_secrets", value: value ? "true" : "false") }
|
||||
func setRedactPII(_ value: Bool) { setSetting("privacy.redact_pii", value: value ? "true" : "false") }
|
||||
func setTirithEnabled(_ value: Bool) { setSetting("security.tirith_enabled", value: value ? "true" : "false") }
|
||||
func setTirithPath(_ value: String) { setSetting("security.tirith_path", value: value) }
|
||||
func setTirithTimeout(_ value: Int) { setSetting("security.tirith_timeout", value: String(value)) }
|
||||
func setTirithFailOpen(_ value: Bool) { setSetting("security.tirith_fail_open", value: value ? "true" : "false") }
|
||||
func setBlocklistEnabled(_ value: Bool) { setSetting("security.website_blocklist.enabled", value: value ? "true" : "false") }
|
||||
func setHumanDelayMode(_ value: String) { setSetting("human_delay.mode", value: value) }
|
||||
func setHumanDelayMinMS(_ value: Int) { setSetting("human_delay.min_ms", value: String(value)) }
|
||||
func setHumanDelayMaxMS(_ value: Int) { setSetting("human_delay.max_ms", value: String(value)) }
|
||||
|
||||
// MARK: - Performance / Advanced
|
||||
|
||||
func setForceIPv4(_ value: Bool) { setSetting("network.force_ipv4", value: value ? "true" : "false") }
|
||||
func setFileReadMaxChars(_ value: Int) { setSetting("file_read_max_chars", value: String(value)) }
|
||||
func setCompressionEnabled(_ value: Bool) { setSetting("compression.enabled", value: value ? "true" : "false") }
|
||||
func setCompressionThreshold(_ value: Double) { setSetting("compression.threshold", value: String(value)) }
|
||||
func setCompressionTargetRatio(_ value: Double) { setSetting("compression.target_ratio", value: String(value)) }
|
||||
func setCompressionProtectLastN(_ value: Int) { setSetting("compression.protect_last_n", value: String(value)) }
|
||||
func setCheckpointsEnabled(_ value: Bool) { setSetting("checkpoints.enabled", value: value ? "true" : "false") }
|
||||
func setCheckpointsMaxSnapshots(_ value: Int) { setSetting("checkpoints.max_snapshots", value: String(value)) }
|
||||
func setLoggingLevel(_ value: String) { setSetting("logging.level", value: value) }
|
||||
func setLoggingMaxSizeMB(_ value: Int) { setSetting("logging.max_size_mb", value: String(value)) }
|
||||
func setLoggingBackupCount(_ value: Int) { setSetting("logging.backup_count", value: String(value)) }
|
||||
func setDelegationModel(_ value: String) { setSetting("delegation.model", value: value) }
|
||||
func setDelegationProvider(_ value: String) { setSetting("delegation.provider", value: value) }
|
||||
func setDelegationBaseURL(_ value: String) { setSetting("delegation.base_url", value: value) }
|
||||
func setDelegationMaxIterations(_ value: Int) { setSetting("delegation.max_iterations", value: String(value)) }
|
||||
func setCronWrapResponse(_ value: Bool) { setSetting("cron.wrap_response", value: value ? "true" : "false") }
|
||||
|
||||
// MARK: - Config diagnostics
|
||||
|
||||
func runConfigCheck() -> String {
|
||||
let result = runHermes(["config", "check"])
|
||||
return result.output
|
||||
}
|
||||
|
||||
func runConfigMigrate() -> String {
|
||||
let result = runHermes(["config", "migrate"])
|
||||
config = fileService.loadConfig()
|
||||
return result.output
|
||||
}
|
||||
|
||||
// MARK: - Backup & Restore (v0.9.0)
|
||||
|
||||
var backupInProgress = false
|
||||
@@ -133,18 +277,6 @@ final class SettingsViewModel {
|
||||
return url
|
||||
}
|
||||
|
||||
func removeAuth() {
|
||||
let result = runHermes(["auth", "remove"])
|
||||
if result.exitCode == 0 {
|
||||
saveMessage = "Credentials removed"
|
||||
} else {
|
||||
saveMessage = "Failed to remove credentials"
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
|
||||
self?.saveMessage = nil
|
||||
}
|
||||
}
|
||||
|
||||
func openConfigInEditor() {
|
||||
NSWorkspace.shared.open(URL(fileURLWithPath: HermesPaths.configYAML))
|
||||
}
|
||||
@@ -179,6 +311,7 @@ final class SettingsViewModel {
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: HermesPaths.hermesBinary)
|
||||
process.arguments = arguments
|
||||
process.environment = HermesFileService.enrichedEnvironment()
|
||||
let pipe = Pipe()
|
||||
process.standardOutput = pipe
|
||||
process.standardError = Pipe()
|
||||
@@ -188,6 +321,7 @@ final class SettingsViewModel {
|
||||
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
||||
return (String(data: data, encoding: .utf8) ?? "", process.terminationStatus)
|
||||
} catch {
|
||||
logger.error("Failed to run hermes \(arguments.joined(separator: " ")): \(error.localizedDescription)")
|
||||
return ("", -1)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Row-style model picker that mirrors the visual style of `PickerRow`/`EditableTextField`
|
||||
/// but opens a dedicated sheet browsing providers + models from the catalog.
|
||||
///
|
||||
/// The caller receives (modelID, providerID) and decides how to persist them —
|
||||
/// Settings → General saves both; Delegation saves both to its own keys; aux
|
||||
/// fields that only take a model can ignore the provider parameter.
|
||||
struct ModelPickerRow: View {
|
||||
let label: String
|
||||
let currentModel: String
|
||||
let currentProvider: String
|
||||
let onChange: (_ modelID: String, _ providerID: String) -> Void
|
||||
|
||||
@State private var showSheet = false
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Text(label)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 160, alignment: .trailing)
|
||||
|
||||
Button {
|
||||
showSheet = true
|
||||
} label: {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "cpu")
|
||||
Text(displayValue)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
Image(systemName: "chevron.down")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(.quaternary.opacity(0.4))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 5))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
.sheet(isPresented: $showSheet) {
|
||||
ModelPickerSheet(
|
||||
initialProvider: currentProvider,
|
||||
initialModel: currentModel,
|
||||
onSelect: { modelID, providerID in
|
||||
onChange(modelID, providerID)
|
||||
showSheet = false
|
||||
},
|
||||
onCancel: { showSheet = false }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Format as "<provider> / <model>" when both are known; fall back to
|
||||
/// whichever side exists; fall back to a dim "Select model…" placeholder
|
||||
/// when nothing has been set yet.
|
||||
private var displayValue: String {
|
||||
let hasProvider = !currentProvider.isEmpty && currentProvider != "unknown"
|
||||
let hasModel = !currentModel.isEmpty && currentModel != "unknown"
|
||||
switch (hasProvider, hasModel) {
|
||||
case (true, true): return "\(currentProvider) / \(currentModel)"
|
||||
case (false, true): return currentModel
|
||||
case (true, false): return "\(currentProvider) / (none)"
|
||||
case (false, false): return "Select model…"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Two-column model browser sheet. Left column lists providers, right column
|
||||
/// lists models for the selected provider. Supports filtering and a "Custom…"
|
||||
/// option for free-form model IDs not in the catalog.
|
||||
struct ModelPickerSheet: View {
|
||||
let initialProvider: String
|
||||
let initialModel: String
|
||||
let onSelect: (_ modelID: String, _ providerID: String) -> Void
|
||||
let onCancel: () -> Void
|
||||
|
||||
@State private var providers: [HermesProviderInfo] = []
|
||||
@State private var selectedProviderID: String = ""
|
||||
@State private var models: [HermesModelInfo] = []
|
||||
@State private var selectedModelID: String = ""
|
||||
@State private var searchText: String = ""
|
||||
|
||||
// Custom model entry — used when the catalog doesn't have the exact model
|
||||
// the user needs (e.g., provider-prefixed IDs like "openrouter/some/model").
|
||||
@State private var customMode: Bool = false
|
||||
@State private var customModelID: String = ""
|
||||
@State private var customProviderID: String = ""
|
||||
|
||||
private let catalog = ModelCatalogService()
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
header
|
||||
Divider()
|
||||
if customMode {
|
||||
customEntry
|
||||
} else {
|
||||
HSplitView {
|
||||
providerColumn.frame(minWidth: 220, idealWidth: 240)
|
||||
modelColumn.frame(minWidth: 340)
|
||||
}
|
||||
}
|
||||
Divider()
|
||||
footer
|
||||
}
|
||||
.frame(minWidth: 720, minHeight: 520)
|
||||
.onAppear {
|
||||
providers = catalog.loadProviders()
|
||||
selectedProviderID = initialProvider.isEmpty ? (providers.first?.providerID ?? "") : initialProvider
|
||||
selectedModelID = initialModel
|
||||
loadModelsForSelection()
|
||||
}
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "cpu")
|
||||
Text("Select Model")
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
if !customMode {
|
||||
TextField("Search…", text: $searchText)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 240)
|
||||
}
|
||||
Button(customMode ? "Back to Catalog" : "Custom…") {
|
||||
customMode.toggle()
|
||||
if customMode {
|
||||
customModelID = initialModel
|
||||
customProviderID = initialProvider
|
||||
}
|
||||
}
|
||||
.controlSize(.small)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
|
||||
private var providerColumn: some View {
|
||||
List(selection: Binding(
|
||||
get: { selectedProviderID },
|
||||
set: { newValue in
|
||||
selectedProviderID = newValue
|
||||
loadModelsForSelection()
|
||||
}
|
||||
)) {
|
||||
ForEach(filteredProviders) { provider in
|
||||
HStack {
|
||||
Text(provider.providerName)
|
||||
Spacer()
|
||||
Text("\(provider.modelCount)")
|
||||
.font(.caption2.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.tag(provider.providerID)
|
||||
}
|
||||
}
|
||||
.listStyle(.inset)
|
||||
}
|
||||
|
||||
private var modelColumn: some View {
|
||||
List(selection: $selectedModelID) {
|
||||
ForEach(filteredModels) { model in
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack {
|
||||
Text(model.modelName)
|
||||
.font(.system(.body, design: .default, weight: .medium))
|
||||
Spacer()
|
||||
if let ctx = model.contextDisplay {
|
||||
Text(ctx + " ctx")
|
||||
.font(.caption2.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
HStack(spacing: 6) {
|
||||
Text(model.modelID)
|
||||
.font(.caption2.monospaced())
|
||||
.foregroundStyle(.tertiary)
|
||||
if let cost = model.costDisplay {
|
||||
Text("·")
|
||||
.foregroundStyle(.tertiary)
|
||||
Text(cost)
|
||||
.font(.caption2.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if model.toolCall {
|
||||
capsuleTag("tools")
|
||||
}
|
||||
if model.reasoning {
|
||||
capsuleTag("reasoning")
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
.tag(model.modelID)
|
||||
}
|
||||
}
|
||||
.listStyle(.inset)
|
||||
.overlay {
|
||||
if filteredModels.isEmpty {
|
||||
ContentUnavailableView("No Models", systemImage: "cpu", description: Text("This provider has no catalogued models."))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var customEntry: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Use a model not in the catalog. Hermes accepts any string the provider recognizes, including provider-prefixed forms like \"openrouter/anthropic/claude-opus-4.6\".")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Model ID").font(.caption).foregroundStyle(.secondary)
|
||||
TextField("e.g. openai/gpt-4o", text: $customModelID)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Provider").font(.caption).foregroundStyle(.secondary)
|
||||
TextField("e.g. openai", text: $customProviderID)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
Text("Leave blank to infer from the model ID's prefix (\"openai/...\" → openai).")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
|
||||
private var footer: some View {
|
||||
HStack {
|
||||
if customMode {
|
||||
Text(customProviderPreview)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
} else if let preview = selectedPreview {
|
||||
Text(preview)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Button("Cancel") { onCancel() }
|
||||
Button("Select") { submitSelection() }
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(!canSubmit)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private var filteredProviders: [HermesProviderInfo] {
|
||||
guard !searchText.isEmpty else { return providers }
|
||||
let q = searchText.lowercased()
|
||||
return providers.filter {
|
||||
$0.providerName.lowercased().contains(q) || $0.providerID.lowercased().contains(q)
|
||||
}
|
||||
}
|
||||
|
||||
private var filteredModels: [HermesModelInfo] {
|
||||
guard !searchText.isEmpty else { return models }
|
||||
let q = searchText.lowercased()
|
||||
return models.filter {
|
||||
$0.modelName.lowercased().contains(q) || $0.modelID.lowercased().contains(q)
|
||||
}
|
||||
}
|
||||
|
||||
private var canSubmit: Bool {
|
||||
if customMode {
|
||||
return !customModelID.trimmingCharacters(in: .whitespaces).isEmpty
|
||||
}
|
||||
return !selectedModelID.isEmpty
|
||||
}
|
||||
|
||||
private var selectedPreview: String? {
|
||||
guard !selectedModelID.isEmpty, !selectedProviderID.isEmpty else { return nil }
|
||||
return "\(selectedProviderID) / \(selectedModelID)"
|
||||
}
|
||||
|
||||
private var customProviderPreview: String {
|
||||
let resolved = resolvedCustomProvider()
|
||||
return resolved.isEmpty ? "Provider will not be changed" : "Provider → \(resolved)"
|
||||
}
|
||||
|
||||
private func loadModelsForSelection() {
|
||||
guard !selectedProviderID.isEmpty else {
|
||||
models = []
|
||||
return
|
||||
}
|
||||
models = catalog.loadModels(for: selectedProviderID)
|
||||
// If the current selection is not in the new list, don't try to keep
|
||||
// stale highlight state — clear unless the user originally had this model.
|
||||
if !models.contains(where: { $0.modelID == selectedModelID }) {
|
||||
selectedModelID = models.first?.modelID ?? ""
|
||||
}
|
||||
}
|
||||
|
||||
/// When the user enters a custom model ID without explicitly naming a
|
||||
/// provider, infer from a `provider/model` prefix if present. Otherwise
|
||||
/// fall back to whatever is currently selected (we never blank out the
|
||||
/// existing provider silently).
|
||||
private func resolvedCustomProvider() -> String {
|
||||
let explicit = customProviderID.trimmingCharacters(in: .whitespaces)
|
||||
if !explicit.isEmpty { return explicit }
|
||||
if let slash = customModelID.firstIndex(of: "/") {
|
||||
return String(customModelID[customModelID.startIndex..<slash])
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
private func submitSelection() {
|
||||
if customMode {
|
||||
let model = customModelID.trimmingCharacters(in: .whitespaces)
|
||||
let provider = resolvedCustomProvider()
|
||||
onSelect(model, provider)
|
||||
} else {
|
||||
onSelect(selectedModelID, selectedProviderID)
|
||||
}
|
||||
}
|
||||
|
||||
private func capsuleTag(_ text: String) -> some View {
|
||||
Text(text)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal, 5)
|
||||
.padding(.vertical, 1)
|
||||
.background(.quaternary)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
import SwiftUI
|
||||
import AppKit
|
||||
|
||||
/// Shared form-row components used across the Settings tabs. Extracting these keeps
|
||||
/// individual tab views small and avoids triggering SwiftUI's type-checker timeout
|
||||
/// on large view bodies (per project guidance in CLAUDE.md).
|
||||
|
||||
struct SettingsSection<Content: View>: View {
|
||||
let title: String
|
||||
let icon: String
|
||||
@ViewBuilder let content: Content
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Label(title, systemImage: icon)
|
||||
.font(.headline)
|
||||
VStack(spacing: 1) {
|
||||
content
|
||||
}
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct EditableTextField: View {
|
||||
let label: String
|
||||
let value: String
|
||||
let onCommit: (String) -> Void
|
||||
@State private var text: String = ""
|
||||
@State private var isEditing = false
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Text(label)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 160, alignment: .trailing)
|
||||
if isEditing {
|
||||
TextField(label, text: $text, onCommit: {
|
||||
if text != value { onCommit(text) }
|
||||
isEditing = false
|
||||
})
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
Button("Cancel") { isEditing = false }
|
||||
.controlSize(.mini)
|
||||
} else {
|
||||
Text(value.isEmpty ? "—" : value)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.foregroundStyle(value.isEmpty ? .secondary : .primary)
|
||||
Spacer()
|
||||
Button("Edit") {
|
||||
text = value
|
||||
isEditing = true
|
||||
}
|
||||
.controlSize(.mini)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
}
|
||||
}
|
||||
|
||||
/// Masked text field for API keys, tokens, etc. Shows ••• until the user taps reveal.
|
||||
struct SecretTextField: View {
|
||||
let label: String
|
||||
let value: String
|
||||
let onCommit: (String) -> Void
|
||||
@State private var text: String = ""
|
||||
@State private var isEditing = false
|
||||
@State private var isRevealed = false
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Text(label)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 160, alignment: .trailing)
|
||||
if isEditing {
|
||||
TextField(label, text: $text, onCommit: {
|
||||
if text != value { onCommit(text) }
|
||||
isEditing = false
|
||||
isRevealed = false
|
||||
})
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
Button("Cancel") {
|
||||
isEditing = false
|
||||
isRevealed = false
|
||||
}
|
||||
.controlSize(.mini)
|
||||
} else {
|
||||
Text(displayValue)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.foregroundStyle(value.isEmpty ? .secondary : .primary)
|
||||
Spacer()
|
||||
if !value.isEmpty {
|
||||
Button(isRevealed ? "Hide" : "Reveal") { isRevealed.toggle() }
|
||||
.controlSize(.mini)
|
||||
}
|
||||
Button("Edit") {
|
||||
text = value
|
||||
isEditing = true
|
||||
}
|
||||
.controlSize(.mini)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
}
|
||||
|
||||
private var displayValue: String {
|
||||
if value.isEmpty { return "—" }
|
||||
if isRevealed { return value }
|
||||
let tail = value.suffix(4)
|
||||
return String(repeating: "•", count: max(0, min(12, value.count - 4))) + tail
|
||||
}
|
||||
}
|
||||
|
||||
struct PickerRow: View {
|
||||
let label: String
|
||||
let selection: String
|
||||
let options: [String]
|
||||
let onChange: (String) -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Text(label)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 160, alignment: .trailing)
|
||||
Picker("", selection: Binding(
|
||||
get: { selection },
|
||||
set: { onChange($0) }
|
||||
)) {
|
||||
ForEach(options, id: \.self) { option in
|
||||
Text(option.isEmpty ? "(none)" : option).tag(option)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: 250)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
}
|
||||
}
|
||||
|
||||
struct ToggleRow: View {
|
||||
let label: String
|
||||
let isOn: Bool
|
||||
let onChange: (Bool) -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Text(label)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 160, alignment: .trailing)
|
||||
Toggle("", isOn: Binding(
|
||||
get: { isOn },
|
||||
set: { onChange($0) }
|
||||
))
|
||||
.toggleStyle(.switch)
|
||||
.labelsHidden()
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
}
|
||||
}
|
||||
|
||||
struct StepperRow: View {
|
||||
let label: String
|
||||
let value: Int
|
||||
let range: ClosedRange<Int>
|
||||
let step: Int
|
||||
let onChange: (Int) -> Void
|
||||
|
||||
init(label: String, value: Int, range: ClosedRange<Int>, step: Int = 1, onChange: @escaping (Int) -> Void) {
|
||||
self.label = label
|
||||
self.value = value
|
||||
self.range = range
|
||||
self.step = step
|
||||
self.onChange = onChange
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Text(label)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 160, alignment: .trailing)
|
||||
Text("\(value)")
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.frame(width: 70, alignment: .leading)
|
||||
Stepper("", value: Binding(
|
||||
get: { value },
|
||||
set: { onChange($0) }
|
||||
), in: range, step: step)
|
||||
.labelsHidden()
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
}
|
||||
}
|
||||
|
||||
/// Double stepper that increments by a fractional step (e.g. 0.05 for thresholds).
|
||||
struct DoubleStepperRow: View {
|
||||
let label: String
|
||||
let value: Double
|
||||
let range: ClosedRange<Double>
|
||||
let step: Double
|
||||
let onChange: (Double) -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Text(label)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 160, alignment: .trailing)
|
||||
Text(String(format: "%.2f", value))
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.frame(width: 70, alignment: .leading)
|
||||
Stepper("", value: Binding(
|
||||
get: { value },
|
||||
set: { onChange($0) }
|
||||
), in: range, step: step)
|
||||
.labelsHidden()
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
}
|
||||
}
|
||||
|
||||
struct ReadOnlyRow: View {
|
||||
let label: String
|
||||
let value: String
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Text(label)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 160, alignment: .trailing)
|
||||
Text(value.isEmpty ? "—" : value)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.foregroundStyle(value.isEmpty ? .secondary : .primary)
|
||||
.textSelection(.enabled)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
}
|
||||
}
|
||||
|
||||
struct PathRow: View {
|
||||
let label: String
|
||||
let path: String
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Text(label)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 160, alignment: .trailing)
|
||||
Text(path)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.textSelection(.enabled)
|
||||
Spacer()
|
||||
Button {
|
||||
NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: path)
|
||||
} label: {
|
||||
Image(systemName: "folder")
|
||||
.font(.caption)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Updates section for the General tab. Wraps the Sparkle-backed `UpdaterService`
|
||||
/// in the same row idioms used elsewhere in Settings (per CLAUDE.md guidance —
|
||||
/// extract sections so individual tab bodies stay small).
|
||||
struct UpdatesSection: View {
|
||||
@Environment(UpdaterService.self) private var updater
|
||||
|
||||
var body: some View {
|
||||
SettingsSection(title: "Updates", icon: "arrow.down.circle") {
|
||||
ReadOnlyRow(label: "Current Version", value: versionString)
|
||||
ToggleRow(
|
||||
label: "Check Automatically",
|
||||
isOn: updater.automaticallyChecksForUpdates
|
||||
) { newValue in
|
||||
updater.automaticallyChecksForUpdates = newValue
|
||||
}
|
||||
ReadOnlyRow(label: "Last Checked", value: lastCheckedString)
|
||||
checkNowRow
|
||||
}
|
||||
}
|
||||
|
||||
private var versionString: String {
|
||||
let info = Bundle.main.infoDictionary
|
||||
let short = info?["CFBundleShortVersionString"] as? String ?? "?"
|
||||
let build = info?["CFBundleVersion"] as? String ?? "?"
|
||||
return "\(short) (\(build))"
|
||||
}
|
||||
|
||||
private var lastCheckedString: String {
|
||||
guard let date = updater.lastUpdateCheckDate else { return "Never" }
|
||||
let formatter = RelativeDateTimeFormatter()
|
||||
formatter.unitsStyle = .full
|
||||
return formatter.localizedString(for: date, relativeTo: Date())
|
||||
}
|
||||
|
||||
private var checkNowRow: some View {
|
||||
HStack {
|
||||
Text("Check Now")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 160, alignment: .trailing)
|
||||
Button("Check for Updates…") { updater.checkForUpdates() }
|
||||
.controlSize(.small)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
}
|
||||
}
|
||||
@@ -1,42 +1,64 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Settings is now organized into tabs because the full Hermes config surface is far
|
||||
/// too large for a single scrolling form (~70 config fields). Each tab has its own
|
||||
/// extracted view file under `Tabs/` — per CLAUDE.md guidance, splitting avoids
|
||||
/// SwiftUI type-checker timeouts and keeps each section testable in isolation.
|
||||
struct SettingsView: View {
|
||||
@State private var viewModel = SettingsViewModel()
|
||||
@State private var showRawConfig = false
|
||||
@State private var selectedTab: SettingsTab = .general
|
||||
|
||||
enum SettingsTab: String, CaseIterable, Identifiable {
|
||||
case general = "General"
|
||||
case display = "Display"
|
||||
case agent = "Agent"
|
||||
case terminal = "Terminal"
|
||||
case browser = "Browser"
|
||||
case voice = "Voice"
|
||||
case memory = "Memory"
|
||||
case auxiliary = "Aux Models"
|
||||
case security = "Security"
|
||||
case advanced = "Advanced"
|
||||
|
||||
var id: String { rawValue }
|
||||
var icon: String {
|
||||
switch self {
|
||||
case .general: return "gear"
|
||||
case .display: return "paintbrush"
|
||||
case .agent: return "brain.head.profile"
|
||||
case .terminal: return "terminal"
|
||||
case .browser: return "globe"
|
||||
case .voice: return "mic"
|
||||
case .memory: return "memorychip"
|
||||
case .auxiliary: return "sparkles.rectangle.stack"
|
||||
case .security: return "lock.shield"
|
||||
case .advanced: return "slider.horizontal.3"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 24) {
|
||||
headerBar
|
||||
modelSection
|
||||
displaySection
|
||||
terminalSection
|
||||
if !viewModel.config.dockerEnv.isEmpty {
|
||||
dockerEnvSection
|
||||
VStack(spacing: 0) {
|
||||
headerBar
|
||||
Divider()
|
||||
TabView(selection: $selectedTab) {
|
||||
ForEach(SettingsTab.allCases) { tab in
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
tabContent(tab)
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||
}
|
||||
.tabItem {
|
||||
Label(tab.rawValue, systemImage: tab.icon)
|
||||
}
|
||||
.tag(tab)
|
||||
}
|
||||
if !viewModel.config.commandAllowlist.isEmpty {
|
||||
allowlistSection
|
||||
}
|
||||
voiceSection
|
||||
memorySection
|
||||
performanceSection
|
||||
networkSection
|
||||
advancedSection
|
||||
backupSection
|
||||
pathsSection
|
||||
rawConfigSection
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||
}
|
||||
.navigationTitle("Settings")
|
||||
.onAppear { viewModel.load() }
|
||||
.confirmationDialog("Remove Credentials?", isPresented: $viewModel.showAuthRemoveConfirmation) {
|
||||
Button("Remove", role: .destructive) { viewModel.removeAuth() }
|
||||
Button("Cancel", role: .cancel) {}
|
||||
} message: {
|
||||
Text("This will permanently clear all stored provider credentials.")
|
||||
}
|
||||
}
|
||||
|
||||
private var headerBar: some View {
|
||||
@@ -52,407 +74,23 @@ struct SettingsView: View {
|
||||
Button("Reload") { viewModel.load() }
|
||||
.controlSize(.small)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
|
||||
// MARK: - Model & Provider
|
||||
|
||||
private var modelSection: some View {
|
||||
SettingsSection(title: "Model", icon: "cpu") {
|
||||
EditableTextField(label: "Model", value: viewModel.config.model) { viewModel.setModel($0) }
|
||||
PickerRow(label: "Provider", selection: viewModel.config.provider, options: viewModel.providers) { viewModel.setProvider($0) }
|
||||
HStack {
|
||||
Text("Credentials")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 130, alignment: .trailing)
|
||||
Button("Remove Credentials", role: .destructive) {
|
||||
viewModel.showAuthRemoveConfirmation = true
|
||||
}
|
||||
.controlSize(.small)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Display
|
||||
|
||||
private var displaySection: some View {
|
||||
SettingsSection(title: "Display", icon: "paintbrush") {
|
||||
if !viewModel.personalities.isEmpty {
|
||||
PickerRow(label: "Personality", selection: viewModel.config.personality, options: viewModel.personalities) { viewModel.setPersonality($0) }
|
||||
} else {
|
||||
EditableTextField(label: "Personality", value: viewModel.config.personality) { viewModel.setPersonality($0) }
|
||||
}
|
||||
ToggleRow(label: "Streaming", isOn: viewModel.config.streaming) { viewModel.setStreaming($0) }
|
||||
ToggleRow(label: "Show Reasoning", isOn: viewModel.config.showReasoning) { viewModel.setShowReasoning($0) }
|
||||
ToggleRow(label: "Show Cost", isOn: viewModel.config.showCost) { viewModel.setShowCost($0) }
|
||||
ToggleRow(label: "Interim Messages", isOn: viewModel.config.interimAssistantMessages) { viewModel.setInterimAssistantMessages($0) }
|
||||
ToggleRow(label: "Verbose", isOn: viewModel.config.verbose) { viewModel.setVerbose($0) }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Terminal
|
||||
|
||||
private var terminalSection: some View {
|
||||
SettingsSection(title: "Terminal", icon: "terminal") {
|
||||
PickerRow(label: "Backend", selection: viewModel.config.terminalBackend, options: viewModel.terminalBackends) { viewModel.setTerminalBackend($0) }
|
||||
StepperRow(label: "Max Turns", value: viewModel.config.maxTurns, range: 1...200) { viewModel.setMaxTurns($0) }
|
||||
PickerRow(label: "Reasoning Effort", selection: viewModel.config.reasoningEffort, options: ["low", "medium", "high"]) { viewModel.setReasoningEffort($0) }
|
||||
PickerRow(label: "Approval Mode", selection: viewModel.config.approvalMode, options: ["auto", "manual", "smart"]) { viewModel.setApprovalMode($0) }
|
||||
PickerRow(label: "Browser Backend", selection: viewModel.config.browserBackend, options: viewModel.browserBackends) { viewModel.setBrowserBackend($0) }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Docker Environment
|
||||
|
||||
private var dockerEnvSection: some View {
|
||||
SettingsSection(title: "Docker Environment", icon: "shippingbox") {
|
||||
ForEach(viewModel.config.dockerEnv.sorted(by: { $0.key < $1.key }), id: \.key) { key, value in
|
||||
ReadOnlyRow(label: key, value: value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Command Allowlist
|
||||
|
||||
private var allowlistSection: some View {
|
||||
SettingsSection(title: "Command Allowlist", icon: "checkmark.shield") {
|
||||
ReadOnlyRow(label: "Commands", value: viewModel.config.commandAllowlist.joined(separator: ", "))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Voice
|
||||
|
||||
private var voiceSection: some View {
|
||||
SettingsSection(title: "Voice", icon: "mic") {
|
||||
ToggleRow(label: "Auto TTS", isOn: viewModel.config.autoTTS) { viewModel.setAutoTTS($0) }
|
||||
StepperRow(label: "Silence Threshold", value: viewModel.config.silenceThreshold, range: 50...500) { viewModel.setSilenceThreshold($0) }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Memory
|
||||
|
||||
private var memorySection: some View {
|
||||
SettingsSection(title: "Memory", icon: "brain") {
|
||||
ToggleRow(label: "Memory Enabled", isOn: viewModel.config.memoryEnabled) { viewModel.setMemoryEnabled($0) }
|
||||
if !viewModel.config.memoryProfile.isEmpty {
|
||||
ReadOnlyRow(label: "Profile", value: viewModel.config.memoryProfile)
|
||||
}
|
||||
StepperRow(label: "Memory Char Limit", value: viewModel.config.memoryCharLimit, range: 500...10000) { viewModel.setMemoryCharLimit($0) }
|
||||
StepperRow(label: "User Char Limit", value: viewModel.config.userCharLimit, range: 500...10000) { viewModel.setUserCharLimit($0) }
|
||||
StepperRow(label: "Nudge Interval", value: viewModel.config.nudgeInterval, range: 1...50) { viewModel.setNudgeInterval($0) }
|
||||
if viewModel.config.memoryProvider == "honcho" {
|
||||
ToggleRow(label: "Honcho Eager Init", isOn: viewModel.config.honchoInitOnSessionStart) { viewModel.setHonchoInitOnSessionStart($0) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Performance (v0.9.0)
|
||||
|
||||
private var performanceSection: some View {
|
||||
SettingsSection(title: "Performance", icon: "bolt") {
|
||||
ToggleRow(label: "Fast Mode", isOn: viewModel.config.serviceTier == "fast") { on in
|
||||
viewModel.setServiceTier(on ? "fast" : "normal")
|
||||
}
|
||||
StepperRow(label: "Notify Interval (s)", value: viewModel.config.gatewayNotifyInterval, range: 0...3600) { viewModel.setGatewayNotifyInterval($0) }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Network (v0.9.0)
|
||||
|
||||
private var networkSection: some View {
|
||||
SettingsSection(title: "Network", icon: "network") {
|
||||
ToggleRow(label: "Force IPv4", isOn: viewModel.config.forceIPv4) { viewModel.setForceIPv4($0) }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Advanced (v0.9.0)
|
||||
|
||||
private var advancedSection: some View {
|
||||
SettingsSection(title: "Advanced", icon: "slider.horizontal.3") {
|
||||
ReadOnlyRow(label: "Context Engine", value: viewModel.config.contextEngine)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Backup & Restore (v0.9.0)
|
||||
|
||||
@State private var showRestoreConfirm = false
|
||||
@State private var pendingRestoreURL: URL?
|
||||
|
||||
private var backupSection: some View {
|
||||
SettingsSection(title: "Backup & Restore", icon: "externaldrive") {
|
||||
HStack {
|
||||
Text("Archive")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 130, alignment: .trailing)
|
||||
Button {
|
||||
viewModel.runBackup()
|
||||
} label: {
|
||||
Label("Backup Now", systemImage: "arrow.down.doc")
|
||||
}
|
||||
.controlSize(.small)
|
||||
.disabled(viewModel.backupInProgress)
|
||||
Button {
|
||||
if let url = viewModel.presentRestorePicker() {
|
||||
pendingRestoreURL = url
|
||||
showRestoreConfirm = true
|
||||
}
|
||||
} label: {
|
||||
Label("Restore…", systemImage: "arrow.up.doc")
|
||||
}
|
||||
.controlSize(.small)
|
||||
.disabled(viewModel.backupInProgress)
|
||||
if viewModel.backupInProgress {
|
||||
ProgressView().controlSize(.small)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
}
|
||||
.confirmationDialog("Restore from backup?", isPresented: $showRestoreConfirm) {
|
||||
Button("Restore", role: .destructive) {
|
||||
if let url = pendingRestoreURL {
|
||||
viewModel.runRestore(from: url)
|
||||
}
|
||||
pendingRestoreURL = nil
|
||||
}
|
||||
Button("Cancel", role: .cancel) { pendingRestoreURL = nil }
|
||||
} message: {
|
||||
Text("This will overwrite files under ~/.hermes/ with the archive contents.")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Paths
|
||||
|
||||
private var pathsSection: some View {
|
||||
SettingsSection(title: "Paths", icon: "folder") {
|
||||
PathRow(label: "Hermes Home", path: HermesPaths.home)
|
||||
PathRow(label: "State DB", path: HermesPaths.stateDB)
|
||||
PathRow(label: "Config", path: HermesPaths.configYAML)
|
||||
PathRow(label: "Memory", path: HermesPaths.memoriesDir)
|
||||
PathRow(label: "Sessions", path: HermesPaths.sessionsDir)
|
||||
PathRow(label: "Skills", path: HermesPaths.skillsDir)
|
||||
PathRow(label: "Agent Log", path: HermesPaths.agentLog)
|
||||
PathRow(label: "Error Log", path: HermesPaths.errorsLog)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Raw Config
|
||||
|
||||
private var rawConfigSection: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
Text("Raw Config")
|
||||
.font(.headline)
|
||||
Button(showRawConfig ? "Hide" : "Show") {
|
||||
showRawConfig.toggle()
|
||||
}
|
||||
.controlSize(.small)
|
||||
}
|
||||
if showRawConfig {
|
||||
Text(viewModel.rawConfigYAML)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.textSelection(.enabled)
|
||||
.padding(8)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(.quaternary.opacity(0.5))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
@ViewBuilder
|
||||
private func tabContent(_ tab: SettingsTab) -> some View {
|
||||
switch tab {
|
||||
case .general: GeneralTab(viewModel: viewModel)
|
||||
case .display: DisplayTab(viewModel: viewModel)
|
||||
case .agent: AgentTab(viewModel: viewModel)
|
||||
case .terminal: TerminalTab(viewModel: viewModel)
|
||||
case .browser: BrowserTab(viewModel: viewModel)
|
||||
case .voice: VoiceTab(viewModel: viewModel)
|
||||
case .memory: MemoryTab(viewModel: viewModel)
|
||||
case .auxiliary: AuxiliaryTab(viewModel: viewModel)
|
||||
case .security: SecurityTab(viewModel: viewModel)
|
||||
case .advanced: AdvancedTab(viewModel: viewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Reusable Components
|
||||
|
||||
struct SettingsSection<Content: View>: View {
|
||||
let title: String
|
||||
let icon: String
|
||||
@ViewBuilder let content: Content
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Label(title, systemImage: icon)
|
||||
.font(.headline)
|
||||
VStack(spacing: 1) {
|
||||
content
|
||||
}
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct EditableTextField: View {
|
||||
let label: String
|
||||
let value: String
|
||||
let onCommit: (String) -> Void
|
||||
@State private var text: String = ""
|
||||
@State private var isEditing = false
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Text(label)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 130, alignment: .trailing)
|
||||
if isEditing {
|
||||
TextField(label, text: $text, onCommit: {
|
||||
if text != value { onCommit(text) }
|
||||
isEditing = false
|
||||
})
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
Button("Cancel") { isEditing = false }
|
||||
.controlSize(.mini)
|
||||
} else {
|
||||
Text(value)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
Spacer()
|
||||
Button("Edit") {
|
||||
text = value
|
||||
isEditing = true
|
||||
}
|
||||
.controlSize(.mini)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
}
|
||||
}
|
||||
|
||||
struct PickerRow: View {
|
||||
let label: String
|
||||
let selection: String
|
||||
let options: [String]
|
||||
let onChange: (String) -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Text(label)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 130, alignment: .trailing)
|
||||
Picker("", selection: Binding(
|
||||
get: { selection },
|
||||
set: { onChange($0) }
|
||||
)) {
|
||||
ForEach(options, id: \.self) { option in
|
||||
Text(option).tag(option)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: 250)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
}
|
||||
}
|
||||
|
||||
struct ToggleRow: View {
|
||||
let label: String
|
||||
let isOn: Bool
|
||||
let onChange: (Bool) -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Text(label)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 130, alignment: .trailing)
|
||||
Toggle("", isOn: Binding(
|
||||
get: { isOn },
|
||||
set: { onChange($0) }
|
||||
))
|
||||
.toggleStyle(.switch)
|
||||
.labelsHidden()
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
}
|
||||
}
|
||||
|
||||
struct StepperRow: View {
|
||||
let label: String
|
||||
let value: Int
|
||||
let range: ClosedRange<Int>
|
||||
let onChange: (Int) -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Text(label)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 130, alignment: .trailing)
|
||||
Text("\(value)")
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.frame(width: 50)
|
||||
Stepper("", value: Binding(
|
||||
get: { value },
|
||||
set: { onChange($0) }
|
||||
), in: range)
|
||||
.labelsHidden()
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
}
|
||||
}
|
||||
|
||||
struct ReadOnlyRow: View {
|
||||
let label: String
|
||||
let value: String
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Text(label)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 130, alignment: .trailing)
|
||||
Text(value)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.textSelection(.enabled)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
}
|
||||
}
|
||||
|
||||
struct PathRow: View {
|
||||
let label: String
|
||||
let path: String
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Text(label)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 130, alignment: .trailing)
|
||||
Text(path)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.textSelection(.enabled)
|
||||
Spacer()
|
||||
Button {
|
||||
NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: path)
|
||||
} label: {
|
||||
Image(systemName: "folder")
|
||||
.font(.caption)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Advanced tab — network, compression, checkpoints, logging, delegation, file read cap,
|
||||
/// cron wrap, config diagnostics, backup/restore, paths, raw config.
|
||||
struct AdvancedTab: View {
|
||||
@Bindable var viewModel: SettingsViewModel
|
||||
@State private var showRawConfig = false
|
||||
@State private var showRestoreConfirm = false
|
||||
@State private var pendingRestoreURL: URL?
|
||||
@State private var diagnosticsOutput: String = ""
|
||||
@State private var showDiagnostics = false
|
||||
|
||||
var body: some View {
|
||||
SettingsSection(title: "Network", icon: "network") {
|
||||
ToggleRow(label: "Force IPv4", isOn: viewModel.config.forceIPv4) { viewModel.setForceIPv4($0) }
|
||||
}
|
||||
|
||||
SettingsSection(title: "Context & Compression", icon: "arrow.down.right.and.arrow.up.left") {
|
||||
ReadOnlyRow(label: "Context Engine", value: viewModel.config.contextEngine)
|
||||
StepperRow(label: "File Read Max", value: viewModel.config.fileReadMaxChars, range: 1000...1_000_000, step: 1000) { viewModel.setFileReadMaxChars($0) }
|
||||
ToggleRow(label: "Compression Enabled", isOn: viewModel.config.compression.enabled) { viewModel.setCompressionEnabled($0) }
|
||||
DoubleStepperRow(label: "Threshold", value: viewModel.config.compression.threshold, range: 0.1...1.0, step: 0.05) { viewModel.setCompressionThreshold($0) }
|
||||
DoubleStepperRow(label: "Target Ratio", value: viewModel.config.compression.targetRatio, range: 0.05...0.9, step: 0.05) { viewModel.setCompressionTargetRatio($0) }
|
||||
StepperRow(label: "Protect Last N", value: viewModel.config.compression.protectLastN, range: 0...100) { viewModel.setCompressionProtectLastN($0) }
|
||||
}
|
||||
|
||||
SettingsSection(title: "Checkpoints", icon: "clock.arrow.circlepath") {
|
||||
ToggleRow(label: "Enabled", isOn: viewModel.config.checkpoints.enabled) { viewModel.setCheckpointsEnabled($0) }
|
||||
StepperRow(label: "Max Snapshots", value: viewModel.config.checkpoints.maxSnapshots, range: 1...500, step: 5) { viewModel.setCheckpointsMaxSnapshots($0) }
|
||||
}
|
||||
|
||||
SettingsSection(title: "Logging", icon: "doc.text") {
|
||||
PickerRow(label: "Level", selection: viewModel.config.logging.level, options: ["DEBUG", "INFO", "WARNING", "ERROR"]) { viewModel.setLoggingLevel($0) }
|
||||
StepperRow(label: "Max Size (MB)", value: viewModel.config.logging.maxSizeMB, range: 1...100) { viewModel.setLoggingMaxSizeMB($0) }
|
||||
StepperRow(label: "Backup Count", value: viewModel.config.logging.backupCount, range: 0...20) { viewModel.setLoggingBackupCount($0) }
|
||||
}
|
||||
|
||||
SettingsSection(title: "Delegation", icon: "arrow.triangle.branch") {
|
||||
// Delegation has its own model/provider pair (tasks spawned by the
|
||||
// agent use this instead of the main model). The picker keeps the
|
||||
// two in sync just like Settings → General.
|
||||
ModelPickerRow(
|
||||
label: "Model",
|
||||
currentModel: viewModel.config.delegation.model,
|
||||
currentProvider: viewModel.config.delegation.provider
|
||||
) { modelID, providerID in
|
||||
viewModel.setDelegationModel(modelID)
|
||||
if !providerID.isEmpty {
|
||||
viewModel.setDelegationProvider(providerID)
|
||||
}
|
||||
}
|
||||
ReadOnlyRow(label: "Provider", value: viewModel.config.delegation.provider)
|
||||
EditableTextField(label: "Base URL", value: viewModel.config.delegation.baseURL) { viewModel.setDelegationBaseURL($0) }
|
||||
StepperRow(label: "Max Iterations", value: viewModel.config.delegation.maxIterations, range: 1...500, step: 5) { viewModel.setDelegationMaxIterations($0) }
|
||||
}
|
||||
|
||||
SettingsSection(title: "Cron", icon: "clock") {
|
||||
ToggleRow(label: "Wrap Response", isOn: viewModel.config.cronWrapResponse) { viewModel.setCronWrapResponse($0) }
|
||||
}
|
||||
|
||||
SettingsSection(title: "Config Diagnostics", icon: "stethoscope") {
|
||||
HStack {
|
||||
Text("Actions")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 160, alignment: .trailing)
|
||||
Button("Check") {
|
||||
diagnosticsOutput = viewModel.runConfigCheck()
|
||||
showDiagnostics = true
|
||||
}
|
||||
.controlSize(.small)
|
||||
Button("Migrate") {
|
||||
diagnosticsOutput = viewModel.runConfigMigrate()
|
||||
showDiagnostics = true
|
||||
}
|
||||
.controlSize(.small)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
|
||||
if showDiagnostics {
|
||||
Text(diagnosticsOutput.isEmpty ? "(no output)" : diagnosticsOutput)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.textSelection(.enabled)
|
||||
.padding(8)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(.quaternary.opacity(0.5))
|
||||
}
|
||||
}
|
||||
|
||||
backupSection
|
||||
pathsSection
|
||||
rawConfigSection
|
||||
}
|
||||
|
||||
private var backupSection: some View {
|
||||
SettingsSection(title: "Backup & Restore", icon: "externaldrive") {
|
||||
HStack {
|
||||
Text("Archive")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 160, alignment: .trailing)
|
||||
Button {
|
||||
viewModel.runBackup()
|
||||
} label: {
|
||||
Label("Backup Now", systemImage: "arrow.down.doc")
|
||||
}
|
||||
.controlSize(.small)
|
||||
.disabled(viewModel.backupInProgress)
|
||||
Button {
|
||||
if let url = viewModel.presentRestorePicker() {
|
||||
pendingRestoreURL = url
|
||||
showRestoreConfirm = true
|
||||
}
|
||||
} label: {
|
||||
Label("Restore…", systemImage: "arrow.up.doc")
|
||||
}
|
||||
.controlSize(.small)
|
||||
.disabled(viewModel.backupInProgress)
|
||||
if viewModel.backupInProgress {
|
||||
ProgressView().controlSize(.small)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
}
|
||||
.confirmationDialog("Restore from backup?", isPresented: $showRestoreConfirm) {
|
||||
Button("Restore", role: .destructive) {
|
||||
if let url = pendingRestoreURL {
|
||||
viewModel.runRestore(from: url)
|
||||
}
|
||||
pendingRestoreURL = nil
|
||||
}
|
||||
Button("Cancel", role: .cancel) { pendingRestoreURL = nil }
|
||||
} message: {
|
||||
Text("This will overwrite files under ~/.hermes/ with the archive contents.")
|
||||
}
|
||||
}
|
||||
|
||||
private var pathsSection: some View {
|
||||
SettingsSection(title: "Paths", icon: "folder") {
|
||||
PathRow(label: "Hermes Home", path: HermesPaths.home)
|
||||
PathRow(label: "State DB", path: HermesPaths.stateDB)
|
||||
PathRow(label: "Config", path: HermesPaths.configYAML)
|
||||
PathRow(label: "Memory", path: HermesPaths.memoriesDir)
|
||||
PathRow(label: "Sessions", path: HermesPaths.sessionsDir)
|
||||
PathRow(label: "Skills", path: HermesPaths.skillsDir)
|
||||
PathRow(label: "Agent Log", path: HermesPaths.agentLog)
|
||||
PathRow(label: "Error Log", path: HermesPaths.errorsLog)
|
||||
}
|
||||
}
|
||||
|
||||
private var rawConfigSection: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
Text("Raw Config")
|
||||
.font(.headline)
|
||||
Button(showRawConfig ? "Hide" : "Show") {
|
||||
showRawConfig.toggle()
|
||||
}
|
||||
.controlSize(.small)
|
||||
}
|
||||
if showRawConfig {
|
||||
Text(viewModel.rawConfigYAML)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.textSelection(.enabled)
|
||||
.padding(8)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(.quaternary.opacity(0.5))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Agent tab — turns, reasoning effort, tool use enforcement, approvals, gateway timing, service tier.
|
||||
struct AgentTab: View {
|
||||
@Bindable var viewModel: SettingsViewModel
|
||||
|
||||
var body: some View {
|
||||
SettingsSection(title: "Turns & Reasoning", icon: "arrow.2.circlepath") {
|
||||
StepperRow(label: "Max Turns", value: viewModel.config.maxTurns, range: 1...200) { viewModel.setMaxTurns($0) }
|
||||
PickerRow(label: "Reasoning Effort", selection: viewModel.config.reasoningEffort, options: ["none", "minimal", "low", "medium", "high", "xhigh"]) { viewModel.setReasoningEffort($0) }
|
||||
PickerRow(label: "Tool Use Enforcement", selection: viewModel.config.toolUseEnforcement, options: ["auto", "true", "false"]) { viewModel.setToolUseEnforcement($0) }
|
||||
}
|
||||
|
||||
SettingsSection(title: "Approvals", icon: "checkmark.shield") {
|
||||
PickerRow(label: "Approval Mode", selection: viewModel.config.approvalMode, options: ["auto", "manual", "smart", "off"]) { viewModel.setApprovalMode($0) }
|
||||
StepperRow(label: "Approval Timeout (s)", value: viewModel.config.approvalTimeout, range: 5...600, step: 5) { viewModel.setApprovalTimeout($0) }
|
||||
}
|
||||
|
||||
SettingsSection(title: "Gateway", icon: "antenna.radiowaves.left.and.right") {
|
||||
ToggleRow(label: "Fast Mode", isOn: viewModel.config.serviceTier == "fast") { on in
|
||||
viewModel.setServiceTier(on ? "fast" : "normal")
|
||||
}
|
||||
StepperRow(label: "Gateway Timeout (s)", value: viewModel.config.gatewayTimeout, range: 60...7200, step: 60) { viewModel.setGatewayTimeout($0) }
|
||||
StepperRow(label: "Notify Interval (s)", value: viewModel.config.gatewayNotifyInterval, range: 0...3600, step: 30) { viewModel.setGatewayNotifyInterval($0) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Auxiliary tab — the 8 sub-model tasks hermes delegates to cheaper models.
|
||||
/// Each follows the same provider/model/base_url/api_key/timeout pattern.
|
||||
struct AuxiliaryTab: View {
|
||||
@Bindable var viewModel: SettingsViewModel
|
||||
|
||||
// Keyed by the config path name — matches `auxiliary.<task>.*` in config.yaml.
|
||||
private let tasks: [(key: String, title: String, icon: String)] = [
|
||||
("vision", "Vision", "eye"),
|
||||
("web_extract", "Web Extract", "doc.richtext"),
|
||||
("compression", "Compression", "arrow.down.right.and.arrow.up.left.circle"),
|
||||
("session_search", "Session Search", "magnifyingglass"),
|
||||
("skills_hub", "Skills Hub", "books.vertical"),
|
||||
("approval", "Approval", "checkmark.seal"),
|
||||
("mcp", "MCP", "puzzlepiece"),
|
||||
("flush_memories", "Flush Memories", "trash.slash")
|
||||
]
|
||||
|
||||
var body: some View {
|
||||
Text("Auxiliary tasks use separate, typically cheaper models. Leave Provider as `auto` to inherit the main provider.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.bottom, 4)
|
||||
|
||||
ForEach(tasks, id: \.key) { task in
|
||||
SettingsSection(title: task.title, icon: task.icon) {
|
||||
auxRows(for: task.key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func auxRows(for key: String) -> some View {
|
||||
let model = auxModel(for: key)
|
||||
EditableTextField(label: "Provider", value: model.provider) { viewModel.setAuxiliary(key, field: "provider", value: $0) }
|
||||
EditableTextField(label: "Model", value: model.model) { viewModel.setAuxiliary(key, field: "model", value: $0) }
|
||||
EditableTextField(label: "Base URL", value: model.baseURL) { viewModel.setAuxiliary(key, field: "base_url", value: $0) }
|
||||
SecretTextField(label: "API Key", value: model.apiKey) { viewModel.setAuxiliary(key, field: "api_key", value: $0) }
|
||||
StepperRow(label: "Timeout (s)", value: model.timeout, range: 5...3600, step: 5) { viewModel.setAuxiliaryTimeout(key, value: $0) }
|
||||
}
|
||||
|
||||
private func auxModel(for key: String) -> AuxiliaryModel {
|
||||
switch key {
|
||||
case "vision": return viewModel.config.auxiliary.vision
|
||||
case "web_extract": return viewModel.config.auxiliary.webExtract
|
||||
case "compression": return viewModel.config.auxiliary.compression
|
||||
case "session_search": return viewModel.config.auxiliary.sessionSearch
|
||||
case "skills_hub": return viewModel.config.auxiliary.skillsHub
|
||||
case "approval": return viewModel.config.auxiliary.approval
|
||||
case "mcp": return viewModel.config.auxiliary.mcp
|
||||
case "flush_memories": return viewModel.config.auxiliary.flushMemories
|
||||
default: return .empty
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Browser tab — browser backend + automation timeouts + camofox.
|
||||
struct BrowserTab: View {
|
||||
@Bindable var viewModel: SettingsViewModel
|
||||
|
||||
var body: some View {
|
||||
SettingsSection(title: "Backend", icon: "globe") {
|
||||
PickerRow(label: "Backend", selection: viewModel.config.browserBackend, options: viewModel.browserBackends) { viewModel.setBrowserBackend($0) }
|
||||
}
|
||||
|
||||
SettingsSection(title: "Timeouts", icon: "hourglass") {
|
||||
StepperRow(label: "Inactivity (s)", value: viewModel.config.browser.inactivityTimeout, range: 10...3600, step: 10) { viewModel.setBrowserInactivityTimeout($0) }
|
||||
StepperRow(label: "Command (s)", value: viewModel.config.browser.commandTimeout, range: 5...600, step: 5) { viewModel.setBrowserCommandTimeout($0) }
|
||||
}
|
||||
|
||||
SettingsSection(title: "Behavior", icon: "slider.horizontal.below.rectangle") {
|
||||
ToggleRow(label: "Record Sessions", isOn: viewModel.config.browser.recordSessions) { viewModel.setBrowserRecordSessions($0) }
|
||||
ToggleRow(label: "Allow Private URLs", isOn: viewModel.config.browser.allowPrivateURLs) { viewModel.setBrowserAllowPrivateURLs($0) }
|
||||
}
|
||||
|
||||
SettingsSection(title: "Camofox", icon: "eye.slash") {
|
||||
ToggleRow(label: "Managed Persistence", isOn: viewModel.config.browser.camofoxManagedPersistence) { viewModel.setCamofoxManagedPersistence($0) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Display tab — streaming, reasoning, cost, skin, compact mode, inline diffs, bell, etc.
|
||||
struct DisplayTab: View {
|
||||
@Bindable var viewModel: SettingsViewModel
|
||||
|
||||
var body: some View {
|
||||
SettingsSection(title: "Output", icon: "doc.plaintext") {
|
||||
ToggleRow(label: "Streaming", isOn: viewModel.config.streaming) { viewModel.setStreaming($0) }
|
||||
ToggleRow(label: "Show Reasoning", isOn: viewModel.config.showReasoning) { viewModel.setShowReasoning($0) }
|
||||
ToggleRow(label: "Show Cost", isOn: viewModel.config.showCost) { viewModel.setShowCost($0) }
|
||||
ToggleRow(label: "Interim Messages", isOn: viewModel.config.interimAssistantMessages) { viewModel.setInterimAssistantMessages($0) }
|
||||
ToggleRow(label: "Verbose", isOn: viewModel.config.verbose) { viewModel.setVerbose($0) }
|
||||
ToggleRow(label: "Inline Diffs", isOn: viewModel.config.display.inlineDiffs) { viewModel.setInlineDiffs($0) }
|
||||
}
|
||||
|
||||
SettingsSection(title: "Layout", icon: "rectangle.3.group") {
|
||||
EditableTextField(label: "Skin", value: viewModel.config.display.skin) { viewModel.setSkin($0) }
|
||||
ToggleRow(label: "Compact", isOn: viewModel.config.display.compact) { viewModel.setDisplayCompact($0) }
|
||||
PickerRow(label: "Resume Display", selection: viewModel.config.display.resumeDisplay, options: ["full", "minimal"]) { viewModel.setResumeDisplay($0) }
|
||||
PickerRow(label: "Busy Input Mode", selection: viewModel.config.display.busyInputMode, options: ["interrupt", "queue"]) { viewModel.setBusyInputMode($0) }
|
||||
}
|
||||
|
||||
SettingsSection(title: "Tool Progress", icon: "gauge") {
|
||||
ToggleRow(label: "Tool Progress Command", isOn: viewModel.config.display.toolProgressCommand) { viewModel.setToolProgressCommand($0) }
|
||||
StepperRow(label: "Preview Length", value: viewModel.config.display.toolPreviewLength, range: 0...500, step: 10) { viewModel.setToolPreviewLength($0) }
|
||||
}
|
||||
|
||||
SettingsSection(title: "Feedback", icon: "bell") {
|
||||
ToggleRow(label: "Bell on Complete", isOn: viewModel.config.display.bellOnComplete) { viewModel.setBellOnComplete($0) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
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) }
|
||||
}
|
||||
|
||||
UpdatesSection()
|
||||
}
|
||||
|
||||
/// Breadcrumb-style row that points users to the Credential Pools sidebar
|
||||
/// item. Replaces the old "Remove Credentials" button — that action lived
|
||||
/// here historically but duplicated Credential Pools' per-credential UI.
|
||||
private var credentialsHint: some View {
|
||||
HStack {
|
||||
Text("Credentials")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 160, alignment: .trailing)
|
||||
Button {
|
||||
coordinator.selectedSection = .credentialPools
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
Text("Manage in Credential Pools")
|
||||
.font(.caption)
|
||||
Image(systemName: "arrow.right")
|
||||
.font(.caption2)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.link)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Memory tab — built-in memory settings + external provider picker.
|
||||
struct MemoryTab: View {
|
||||
@Bindable var viewModel: SettingsViewModel
|
||||
|
||||
var body: some View {
|
||||
SettingsSection(title: "Built-in Memory", icon: "brain") {
|
||||
ToggleRow(label: "Memory Enabled", isOn: viewModel.config.memoryEnabled) { viewModel.setMemoryEnabled($0) }
|
||||
ToggleRow(label: "User Profile Enabled", isOn: viewModel.config.userProfileEnabled) { viewModel.setUserProfileEnabled($0) }
|
||||
if !viewModel.config.memoryProfile.isEmpty {
|
||||
ReadOnlyRow(label: "Profile", value: viewModel.config.memoryProfile)
|
||||
}
|
||||
StepperRow(label: "Memory Char Limit", value: viewModel.config.memoryCharLimit, range: 500...10_000, step: 100) { viewModel.setMemoryCharLimit($0) }
|
||||
StepperRow(label: "User Char Limit", value: viewModel.config.userCharLimit, range: 500...10_000, step: 100) { viewModel.setUserCharLimit($0) }
|
||||
StepperRow(label: "Nudge Interval", value: viewModel.config.nudgeInterval, range: 1...50) { viewModel.setNudgeInterval($0) }
|
||||
}
|
||||
|
||||
SettingsSection(title: "External Provider", icon: "externaldrive.connected.to.line.below") {
|
||||
PickerRow(label: "Provider", selection: viewModel.config.memoryProvider, options: viewModel.memoryProviders) { viewModel.setMemoryProvider($0) }
|
||||
if viewModel.config.memoryProvider == "honcho" {
|
||||
ToggleRow(label: "Honcho Eager Init", isOn: viewModel.config.honchoInitOnSessionStart) { viewModel.setHonchoInitOnSessionStart($0) }
|
||||
}
|
||||
HStack {
|
||||
Text("Setup")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 160, alignment: .trailing)
|
||||
Text("Run `hermes memory setup` in Terminal for full provider configuration.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Security tab — redaction, command allowlist (read-only), Tirith sandbox, website blocklist, human delay.
|
||||
struct SecurityTab: View {
|
||||
@Bindable var viewModel: SettingsViewModel
|
||||
|
||||
var body: some View {
|
||||
SettingsSection(title: "Redaction", icon: "eye.slash") {
|
||||
ToggleRow(label: "Redact Secrets", isOn: viewModel.config.security.redactSecrets) { viewModel.setRedactSecrets($0) }
|
||||
ToggleRow(label: "Redact PII", isOn: viewModel.config.security.redactPII) { viewModel.setRedactPII($0) }
|
||||
}
|
||||
|
||||
SettingsSection(title: "Tirith Sandbox", icon: "shield.checkerboard") {
|
||||
ToggleRow(label: "Enabled", isOn: viewModel.config.security.tirithEnabled) { viewModel.setTirithEnabled($0) }
|
||||
EditableTextField(label: "Binary Path", value: viewModel.config.security.tirithPath) { viewModel.setTirithPath($0) }
|
||||
StepperRow(label: "Timeout (s)", value: viewModel.config.security.tirithTimeout, range: 1...60) { viewModel.setTirithTimeout($0) }
|
||||
ToggleRow(label: "Fail Open", isOn: viewModel.config.security.tirithFailOpen) { viewModel.setTirithFailOpen($0) }
|
||||
}
|
||||
|
||||
SettingsSection(title: "Website Blocklist", icon: "xmark.shield") {
|
||||
ToggleRow(label: "Enabled", isOn: viewModel.config.security.blocklistEnabled) { viewModel.setBlocklistEnabled($0) }
|
||||
if !viewModel.config.security.blocklistDomains.isEmpty {
|
||||
ReadOnlyRow(label: "Domains", value: viewModel.config.security.blocklistDomains.joined(separator: ", "))
|
||||
}
|
||||
}
|
||||
|
||||
if !viewModel.config.commandAllowlist.isEmpty {
|
||||
SettingsSection(title: "Command Allowlist", icon: "checkmark.shield") {
|
||||
ReadOnlyRow(label: "Commands", value: viewModel.config.commandAllowlist.joined(separator: ", "))
|
||||
}
|
||||
}
|
||||
|
||||
SettingsSection(title: "Human Delay", icon: "hourglass.tophalf.filled") {
|
||||
PickerRow(label: "Mode", selection: viewModel.config.humanDelay.mode, options: ["off", "natural", "custom"]) { viewModel.setHumanDelayMode($0) }
|
||||
StepperRow(label: "Min (ms)", value: viewModel.config.humanDelay.minMS, range: 0...10_000, step: 50) { viewModel.setHumanDelayMinMS($0) }
|
||||
StepperRow(label: "Max (ms)", value: viewModel.config.humanDelay.maxMS, range: 0...10_000, step: 50) { viewModel.setHumanDelayMaxMS($0) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Terminal tab — backend plus docker/container options.
|
||||
/// Heavy docker/container settings are hidden unless a container backend is selected.
|
||||
struct TerminalTab: View {
|
||||
@Bindable var viewModel: SettingsViewModel
|
||||
|
||||
var body: some View {
|
||||
SettingsSection(title: "Backend", icon: "terminal") {
|
||||
PickerRow(label: "Backend", selection: viewModel.config.terminalBackend, options: viewModel.terminalBackends) { viewModel.setTerminalBackend($0) }
|
||||
EditableTextField(label: "Working Dir", value: viewModel.config.terminal.cwd) { viewModel.setTerminalCwd($0) }
|
||||
StepperRow(label: "Command Timeout (s)", value: viewModel.config.terminal.timeout, range: 10...3600, step: 10) { viewModel.setTerminalTimeout($0) }
|
||||
ToggleRow(label: "Persistent Shell", isOn: viewModel.config.terminal.persistentShell) { viewModel.setPersistentShell($0) }
|
||||
}
|
||||
|
||||
if isContainerBackend {
|
||||
SettingsSection(title: "Container Limits", icon: "cpu.fill") {
|
||||
StepperRow(label: "CPU Count", value: viewModel.config.terminal.containerCPU, range: 0...64) { viewModel.setContainerCPU($0) }
|
||||
StepperRow(label: "Memory (MB)", value: viewModel.config.terminal.containerMemory, range: 0...65_536, step: 256) { viewModel.setContainerMemory($0) }
|
||||
StepperRow(label: "Disk (MB)", value: viewModel.config.terminal.containerDisk, range: 0...1_048_576, step: 1024) { viewModel.setContainerDisk($0) }
|
||||
ToggleRow(label: "Persistent", isOn: viewModel.config.terminal.containerPersistent) { viewModel.setContainerPersistent($0) }
|
||||
}
|
||||
}
|
||||
|
||||
if viewModel.config.terminalBackend == "docker" {
|
||||
SettingsSection(title: "Docker", icon: "shippingbox") {
|
||||
EditableTextField(label: "Image", value: viewModel.config.terminal.dockerImage) { viewModel.setDockerImage($0) }
|
||||
ToggleRow(label: "Mount CWD", isOn: viewModel.config.terminal.dockerMountCwdToWorkspace) { viewModel.setDockerMountCwd($0) }
|
||||
if !viewModel.config.dockerEnv.isEmpty {
|
||||
ForEach(viewModel.config.dockerEnv.sorted(by: { $0.key < $1.key }), id: \.key) { key, value in
|
||||
ReadOnlyRow(label: key, value: value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if viewModel.config.terminalBackend == "modal" {
|
||||
SettingsSection(title: "Modal", icon: "cloud") {
|
||||
EditableTextField(label: "Image", value: viewModel.config.terminal.modalImage) { viewModel.setModalImage($0) }
|
||||
PickerRow(label: "Mode", selection: viewModel.config.terminal.modalMode, options: ["auto", "always", "never"]) { viewModel.setModalMode($0) }
|
||||
}
|
||||
}
|
||||
|
||||
if viewModel.config.terminalBackend == "daytona" {
|
||||
SettingsSection(title: "Daytona", icon: "externaldrive.connected.to.line.below") {
|
||||
EditableTextField(label: "Image", value: viewModel.config.terminal.daytonaImage) { viewModel.setDaytonaImage($0) }
|
||||
}
|
||||
}
|
||||
|
||||
if viewModel.config.terminalBackend == "singularity" {
|
||||
SettingsSection(title: "Singularity", icon: "aqi.medium") {
|
||||
EditableTextField(label: "Image", value: viewModel.config.terminal.singularityImage) { viewModel.setSingularityImage($0) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var isContainerBackend: Bool {
|
||||
["docker", "modal", "daytona", "singularity"].contains(viewModel.config.terminalBackend)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Voice tab — push-to-talk + TTS + STT provider settings.
|
||||
struct VoiceTab: View {
|
||||
@Bindable var viewModel: SettingsViewModel
|
||||
|
||||
var body: some View {
|
||||
SettingsSection(title: "Push-to-Talk", icon: "mic") {
|
||||
ToggleRow(label: "Auto TTS", isOn: viewModel.config.autoTTS) { viewModel.setAutoTTS($0) }
|
||||
EditableTextField(label: "Record Key", value: viewModel.config.voice.recordKey) { viewModel.setRecordKey($0) }
|
||||
StepperRow(label: "Max Recording (s)", value: viewModel.config.voice.maxRecordingSeconds, range: 10...600, step: 10) { viewModel.setMaxRecordingSeconds($0) }
|
||||
StepperRow(label: "Silence Threshold", value: viewModel.config.silenceThreshold, range: 50...500, step: 10) { viewModel.setSilenceThreshold($0) }
|
||||
DoubleStepperRow(label: "Silence Duration (s)", value: viewModel.config.voice.silenceDuration, range: 0.5...10.0, step: 0.5) { viewModel.setSilenceDuration($0) }
|
||||
}
|
||||
|
||||
SettingsSection(title: "Text-to-Speech", icon: "speaker.wave.3") {
|
||||
PickerRow(label: "Provider", selection: viewModel.config.voice.ttsProvider, options: viewModel.ttsProviders) { viewModel.setTTSProvider($0) }
|
||||
switch viewModel.config.voice.ttsProvider {
|
||||
case "edge":
|
||||
EditableTextField(label: "Voice", value: viewModel.config.voice.ttsEdgeVoice) { viewModel.setTTSEdgeVoice($0) }
|
||||
case "elevenlabs":
|
||||
EditableTextField(label: "Voice ID", value: viewModel.config.voice.ttsElevenLabsVoiceID) { viewModel.setTTSElevenLabsVoiceID($0) }
|
||||
EditableTextField(label: "Model ID", value: viewModel.config.voice.ttsElevenLabsModelID) { viewModel.setTTSElevenLabsModelID($0) }
|
||||
case "openai":
|
||||
EditableTextField(label: "Model", value: viewModel.config.voice.ttsOpenAIModel) { viewModel.setTTSOpenAIModel($0) }
|
||||
PickerRow(label: "Voice", selection: viewModel.config.voice.ttsOpenAIVoice, options: ["alloy", "echo", "fable", "onyx", "nova", "shimmer"]) { viewModel.setTTSOpenAIVoice($0) }
|
||||
case "neutts":
|
||||
EditableTextField(label: "Model", value: viewModel.config.voice.ttsNeuTTSModel) { viewModel.setTTSNeuTTSModel($0) }
|
||||
PickerRow(label: "Device", selection: viewModel.config.voice.ttsNeuTTSDevice, options: ["cpu", "cuda"]) { viewModel.setTTSNeuTTSDevice($0) }
|
||||
default:
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
|
||||
SettingsSection(title: "Speech-to-Text", icon: "waveform") {
|
||||
ToggleRow(label: "Enabled", isOn: viewModel.config.voice.sttEnabled) { viewModel.setSTTEnabled($0) }
|
||||
PickerRow(label: "Provider", selection: viewModel.config.voice.sttProvider, options: viewModel.sttProviders) { viewModel.setSTTProvider($0) }
|
||||
switch viewModel.config.voice.sttProvider {
|
||||
case "local":
|
||||
PickerRow(label: "Model", selection: viewModel.config.voice.sttLocalModel, options: ["tiny", "base", "small", "medium", "large-v3"]) { viewModel.setSTTLocalModel($0) }
|
||||
EditableTextField(label: "Language", value: viewModel.config.voice.sttLocalLanguage) { viewModel.setSTTLocalLanguage($0) }
|
||||
case "openai":
|
||||
EditableTextField(label: "Model", value: viewModel.config.voice.sttOpenAIModel) { viewModel.setSTTOpenAIModel($0) }
|
||||
case "mistral":
|
||||
EditableTextField(label: "Model", value: viewModel.config.voice.sttMistralModel) { viewModel.setSTTMistralModel($0) }
|
||||
default:
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,29 @@
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
/// A single search/browse result from a skill registry.
|
||||
struct HermesHubSkill: Identifiable, Sendable, Equatable {
|
||||
var id: String { identifier }
|
||||
let identifier: String // e.g. "openai/skills/skill-creator"
|
||||
let name: String
|
||||
let description: String
|
||||
let source: String // "official" | "skills-sh" | etc.
|
||||
}
|
||||
|
||||
/// A local skill that has an upstream version available.
|
||||
struct HermesSkillUpdate: Identifiable, Sendable, Equatable {
|
||||
var id: String { identifier }
|
||||
let identifier: String
|
||||
let currentVersion: String
|
||||
let availableVersion: String
|
||||
}
|
||||
|
||||
@Observable
|
||||
final class SkillsViewModel {
|
||||
private let logger = Logger(subsystem: "com.scarf", category: "SkillsViewModel")
|
||||
private let fileService = HermesFileService()
|
||||
|
||||
// MARK: - Installed skills (existing behavior)
|
||||
var categories: [HermesSkillCategory] = []
|
||||
var selectedSkill: HermesSkill?
|
||||
var skillContent = ""
|
||||
@@ -14,6 +34,16 @@ final class SkillsViewModel {
|
||||
var editText = ""
|
||||
private var currentConfig = HermesConfig.empty
|
||||
|
||||
// MARK: - Hub integration (new)
|
||||
var hubQuery = ""
|
||||
var hubResults: [HermesHubSkill] = []
|
||||
var updates: [HermesSkillUpdate] = []
|
||||
var isHubLoading = false
|
||||
var hubMessage: String?
|
||||
var hubSource: String = "all"
|
||||
|
||||
let hubSources = ["all", "official", "skills-sh", "well-known", "github", "clawhub", "lobehub"]
|
||||
|
||||
var filteredCategories: [HermesSkillCategory] {
|
||||
guard !searchText.isEmpty else { return categories }
|
||||
return categories.compactMap { category in
|
||||
@@ -88,4 +118,198 @@ final class SkillsViewModel {
|
||||
func cancelEditing() {
|
||||
isEditing = false
|
||||
}
|
||||
|
||||
// MARK: - Hub browse/search/install/update
|
||||
|
||||
func browseHub() {
|
||||
isHubLoading = true
|
||||
Task.detached { [fileService, hubSource] in
|
||||
var args = ["skills", "browse", "--size", "40"]
|
||||
if hubSource != "all" { args += ["--source", hubSource] }
|
||||
let result = fileService.runHermesCLI(args: args, timeout: 30)
|
||||
let parsed = Self.parseHubList(result.output)
|
||||
await MainActor.run {
|
||||
self.isHubLoading = false
|
||||
self.hubResults = parsed
|
||||
if parsed.isEmpty {
|
||||
self.hubMessage = result.exitCode == 0 ? "No results" : "Browse failed"
|
||||
} else {
|
||||
self.hubMessage = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func searchHub() {
|
||||
guard !hubQuery.isEmpty else {
|
||||
browseHub()
|
||||
return
|
||||
}
|
||||
isHubLoading = true
|
||||
Task.detached { [fileService, hubSource, hubQuery] in
|
||||
var args = ["skills", "search", hubQuery, "--limit", "40"]
|
||||
if hubSource != "all" { args += ["--source", hubSource] }
|
||||
let result = fileService.runHermesCLI(args: args, timeout: 30)
|
||||
let parsed = Self.parseHubList(result.output)
|
||||
await MainActor.run {
|
||||
self.isHubLoading = false
|
||||
self.hubResults = parsed
|
||||
if parsed.isEmpty {
|
||||
self.hubMessage = "No matches"
|
||||
} else {
|
||||
self.hubMessage = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func installHubSkill(_ skill: HermesHubSkill) {
|
||||
isHubLoading = true
|
||||
hubMessage = "Installing \(skill.identifier)…"
|
||||
Task.detached { [fileService] in
|
||||
// --yes skips confirmation since we're running non-interactively.
|
||||
let result = fileService.runHermesCLI(args: ["skills", "install", skill.identifier, "--yes"], timeout: 120)
|
||||
await MainActor.run {
|
||||
self.isHubLoading = false
|
||||
self.hubMessage = result.exitCode == 0 ? "Installed \(skill.identifier)" : "Install failed"
|
||||
self.load()
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
||||
self?.hubMessage = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func uninstallHubSkill(_ identifier: String) {
|
||||
Task.detached { [fileService] in
|
||||
let result = fileService.runHermesCLI(args: ["skills", "uninstall", identifier, "--yes"], timeout: 60)
|
||||
await MainActor.run {
|
||||
self.hubMessage = result.exitCode == 0 ? "Uninstalled" : "Uninstall failed"
|
||||
self.load()
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
|
||||
self?.hubMessage = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func checkForUpdates() {
|
||||
isHubLoading = true
|
||||
Task.detached { [fileService] in
|
||||
let result = fileService.runHermesCLI(args: ["skills", "check"], timeout: 60)
|
||||
let parsed = Self.parseUpdateList(result.output)
|
||||
await MainActor.run {
|
||||
self.isHubLoading = false
|
||||
self.updates = parsed
|
||||
self.hubMessage = parsed.isEmpty ? "No updates available" : "\(parsed.count) update(s)"
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
||||
self?.hubMessage = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func updateAll() {
|
||||
Task.detached { [fileService] in
|
||||
let result = fileService.runHermesCLI(args: ["skills", "update", "--yes"], timeout: 300)
|
||||
await MainActor.run {
|
||||
self.hubMessage = result.exitCode == 0 ? "Updated" : "Update failed"
|
||||
self.load()
|
||||
self.checkForUpdates()
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
||||
self?.hubMessage = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Parsers (best-effort, tolerant of format changes)
|
||||
// `nonisolated` so callers in `Task.detached` can run them off the main actor.
|
||||
|
||||
/// Parse `hermes skills browse|search` output.
|
||||
///
|
||||
/// Hermes emits a Rich box-drawn table with vertical bars as column separators:
|
||||
///
|
||||
/// │ # │ Name │ Description │ Source │ Trust │
|
||||
/// ├──────┼────────────────┼────────────────────────┼──────────────┼────────────┤
|
||||
/// │ 1 │ 1password │ Set up and use 1Pass… │ official │ ★ official │
|
||||
///
|
||||
/// Description cells can wrap across multiple rows — the continuation rows have
|
||||
/// an empty `#` column. We join consecutive rows with the same skill by checking
|
||||
/// if the first column (after `│`) is whitespace-only.
|
||||
nonisolated private static func parseHubList(_ output: String) -> [HermesHubSkill] {
|
||||
var results: [HermesHubSkill] = []
|
||||
for raw in output.components(separatedBy: "\n") {
|
||||
let line = raw
|
||||
// Skip everything that isn't a data row. Data rows start with `│` and
|
||||
// contain multiple `│` separators. Border rows (`┏`, `┡`, `├`, `└`, etc.)
|
||||
// are drawn with `━` or `─` and should be skipped.
|
||||
guard line.contains("│") else { continue }
|
||||
let cells = line.split(separator: "│", omittingEmptySubsequences: false).map { $0.trimmingCharacters(in: .whitespaces) }
|
||||
// Expect at least: leading empty, #, Name, Description, Source, Trust, trailing empty
|
||||
guard cells.count >= 6 else { continue }
|
||||
|
||||
let numCell = cells[1]
|
||||
let nameCell = cells[2]
|
||||
let descCell = cells[3]
|
||||
let sourceCell = cells[4]
|
||||
// Trust column (index 5) is informational only — we ignore it in the UI.
|
||||
|
||||
// Continuation row: `#` column is empty. Merge its description into the
|
||||
// last-added entry if present.
|
||||
if numCell.isEmpty {
|
||||
guard !results.isEmpty else { continue }
|
||||
let last = results.removeLast()
|
||||
let merged = [last.description, descCell].filter { !$0.isEmpty }.joined(separator: " ")
|
||||
results.append(HermesHubSkill(
|
||||
identifier: last.identifier,
|
||||
name: last.name,
|
||||
description: merged,
|
||||
source: last.source
|
||||
))
|
||||
continue
|
||||
}
|
||||
// Header row — first data-looking row whose number cell isn't a digit.
|
||||
if Int(numCell) == nil { continue }
|
||||
// Empty name cell shouldn't happen but guard anyway.
|
||||
guard !nameCell.isEmpty else { continue }
|
||||
|
||||
// Identifier: `hermes skills browse` shows the short name in the Name
|
||||
// column. For install we need the full identifier like
|
||||
// `<source>/<name>`. The CLI accepts just the name for official hub,
|
||||
// so we use that as the install target.
|
||||
let source = sourceCell
|
||||
.replacingOccurrences(of: "★", with: "")
|
||||
.trimmingCharacters(in: .whitespaces)
|
||||
results.append(HermesHubSkill(
|
||||
identifier: nameCell, // hermes skills install accepts the name for official/hub-indexed skills
|
||||
name: nameCell,
|
||||
description: descCell,
|
||||
source: source
|
||||
))
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
/// Parse `hermes skills check` output for available updates. Format is
|
||||
/// undocumented; we look for `→` (U+2192) or `->` arrow markers between
|
||||
/// version strings.
|
||||
nonisolated private static func parseUpdateList(_ output: String) -> [HermesSkillUpdate] {
|
||||
var results: [HermesSkillUpdate] = []
|
||||
for raw in output.components(separatedBy: "\n") {
|
||||
let line = raw.trimmingCharacters(in: .whitespaces)
|
||||
guard line.contains("→") || line.contains("->") else { continue }
|
||||
let marker = line.contains("→") ? "→" : "->"
|
||||
let parts = line.components(separatedBy: marker)
|
||||
guard parts.count == 2 else { continue }
|
||||
let left = parts[0].trimmingCharacters(in: .whitespaces)
|
||||
let available = parts[1].trimmingCharacters(in: .whitespaces)
|
||||
let leftTokens = left.split(separator: " ", omittingEmptySubsequences: true).map(String.init)
|
||||
guard leftTokens.count >= 2 else { continue }
|
||||
let identifier = leftTokens[0]
|
||||
let current = leftTokens[leftTokens.count - 1]
|
||||
results.append(HermesSkillUpdate(identifier: identifier, currentVersion: current, availableVersion: available))
|
||||
}
|
||||
return results
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,17 +2,62 @@ import SwiftUI
|
||||
|
||||
struct SkillsView: View {
|
||||
@State private var viewModel = SkillsViewModel()
|
||||
@State private var currentTab: Tab = .installed
|
||||
|
||||
enum Tab: String, CaseIterable, Identifiable {
|
||||
case installed = "Installed"
|
||||
case hub = "Browse Hub"
|
||||
case updates = "Updates"
|
||||
var id: String { rawValue }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
modePicker
|
||||
Divider()
|
||||
switch currentTab {
|
||||
case .installed: installedContent
|
||||
case .hub: hubContent
|
||||
case .updates: updatesContent
|
||||
}
|
||||
}
|
||||
.navigationTitle("Skills (\(viewModel.totalSkillCount))")
|
||||
.onAppear { viewModel.load() }
|
||||
}
|
||||
|
||||
private var modePicker: some View {
|
||||
HStack {
|
||||
Picker("", selection: $currentTab) {
|
||||
ForEach(Tab.allCases) { tab in
|
||||
Text(tab.rawValue).tag(tab)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.frame(maxWidth: 360)
|
||||
Spacer()
|
||||
if let msg = viewModel.hubMessage {
|
||||
Label(msg, systemImage: "info.circle")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if viewModel.isHubLoading {
|
||||
ProgressView().controlSize(.small)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
|
||||
// MARK: - Installed
|
||||
|
||||
private var installedContent: some View {
|
||||
HSplitView {
|
||||
skillsList
|
||||
.frame(minWidth: 250, idealWidth: 300)
|
||||
skillDetail
|
||||
.frame(minWidth: 400)
|
||||
}
|
||||
.navigationTitle("Skills (\(viewModel.totalSkillCount))")
|
||||
.searchable(text: $viewModel.searchText, prompt: "Filter skills...")
|
||||
.onAppear { viewModel.load() }
|
||||
}
|
||||
|
||||
private var skillsList: some View {
|
||||
@@ -103,6 +148,10 @@ struct SkillsView: View {
|
||||
Spacer()
|
||||
Button("Edit") { viewModel.startEditing() }
|
||||
.controlSize(.small)
|
||||
Button("Uninstall", role: .destructive) {
|
||||
viewModel.uninstallHubSkill(skill.id)
|
||||
}
|
||||
.controlSize(.small)
|
||||
}
|
||||
if viewModel.isMarkdownFile {
|
||||
MarkdownContentView(content: viewModel.skillContent)
|
||||
@@ -152,4 +201,141 @@ struct SkillsView: View {
|
||||
}
|
||||
.frame(minWidth: 800, minHeight: 500)
|
||||
}
|
||||
|
||||
// MARK: - Hub
|
||||
|
||||
private var hubContent: some View {
|
||||
VStack(spacing: 0) {
|
||||
hubToolbar
|
||||
Divider()
|
||||
if viewModel.hubResults.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"Browse the Hub",
|
||||
systemImage: "books.vertical",
|
||||
description: Text("Search or browse skills published to registries like skills.sh, GitHub, and the official hub.")
|
||||
)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 1) {
|
||||
ForEach(viewModel.hubResults) { hub in
|
||||
hubRow(hub)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var hubToolbar: some View {
|
||||
HStack(spacing: 8) {
|
||||
TextField("Search registries", text: $viewModel.hubQuery)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.onSubmit { viewModel.searchHub() }
|
||||
Picker("Source", selection: $viewModel.hubSource) {
|
||||
ForEach(viewModel.hubSources, id: \.self) { src in
|
||||
Text(src).tag(src)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: 160)
|
||||
Button("Search") { viewModel.searchHub() }
|
||||
.controlSize(.small)
|
||||
Button("Browse") { viewModel.browseHub() }
|
||||
.controlSize(.small)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
|
||||
private func hubRow(_ hub: HermesHubSkill) -> some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "books.vertical")
|
||||
.foregroundStyle(.blue)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(spacing: 6) {
|
||||
Text(hub.name)
|
||||
.font(.system(.body, design: .monospaced, weight: .medium))
|
||||
if !hub.source.isEmpty {
|
||||
Text(hub.source)
|
||||
.font(.caption2.bold())
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 1)
|
||||
.background(.quaternary)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
}
|
||||
Text(hub.identifier)
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
if !hub.description.isEmpty {
|
||||
Text(hub.description)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(3)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
Button {
|
||||
viewModel.installHubSkill(hub)
|
||||
} label: {
|
||||
Label("Install", systemImage: "arrow.down.to.line")
|
||||
}
|
||||
.controlSize(.small)
|
||||
.disabled(viewModel.isHubLoading)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
}
|
||||
|
||||
// MARK: - Updates
|
||||
|
||||
private var updatesContent: some View {
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
Button("Check for Updates") { viewModel.checkForUpdates() }
|
||||
.controlSize(.small)
|
||||
if !viewModel.updates.isEmpty {
|
||||
Button("Update All") { viewModel.updateAll() }
|
||||
.controlSize(.small)
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding()
|
||||
Divider()
|
||||
if viewModel.updates.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"No Updates",
|
||||
systemImage: "checkmark.circle",
|
||||
description: Text("All installed hub skills are up to date.")
|
||||
)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 1) {
|
||||
ForEach(viewModel.updates) { update in
|
||||
HStack {
|
||||
Image(systemName: "arrow.triangle.2.circlepath")
|
||||
.foregroundStyle(.orange)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(update.identifier)
|
||||
.font(.system(.body, design: .monospaced, weight: .medium))
|
||||
Text("\(update.currentVersion) → \(update.availableVersion)")
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
/// Connection/configuration status for a messaging platform, used for indicator dots in the picker.
|
||||
enum PlatformConnectivity: Sendable, Equatable {
|
||||
case connected // Gateway reports the platform online
|
||||
case configured // Platform has a config block but gateway isn't reporting it as connected
|
||||
case notConfigured // No signal that this platform has been set up
|
||||
case error(String) // Gateway reports an error for this platform
|
||||
}
|
||||
|
||||
@Observable
|
||||
final class ToolsViewModel {
|
||||
private let logger = Logger(subsystem: "com.scarf", category: "ToolsViewModel")
|
||||
@@ -10,6 +18,7 @@ final class ToolsViewModel {
|
||||
var mcpStatus: String = ""
|
||||
var isLoading = false
|
||||
var availablePlatforms: [HermesToolPlatform] = []
|
||||
var connectivity: [String: PlatformConnectivity] = [:]
|
||||
|
||||
@MainActor
|
||||
func load() async {
|
||||
@@ -42,47 +51,68 @@ final class ToolsViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
/// Enumerate all known platforms and compute a connectivity status per platform.
|
||||
///
|
||||
/// Source of truth:
|
||||
/// - `KnownPlatforms.all` defines every platform the app knows about (always show these).
|
||||
/// - `~/.hermes/gateway_state.json` tells us which are currently connected.
|
||||
/// - `~/.hermes/config.yaml` top-level keys (`discord:`, `whatsapp:`, etc.) tell us which have been configured.
|
||||
@MainActor
|
||||
private func loadPlatforms() async {
|
||||
let config: String
|
||||
do {
|
||||
config = try await Task.detached {
|
||||
try String(contentsOfFile: HermesPaths.configYAML, encoding: .utf8)
|
||||
}.value
|
||||
} catch {
|
||||
logger.error("Failed to read config.yaml: \(error.localizedDescription)")
|
||||
config = ""
|
||||
}
|
||||
var platforms: [HermesToolPlatform] = []
|
||||
var inSection = false
|
||||
for line in config.components(separatedBy: "\n") {
|
||||
if line.hasPrefix("platform_toolsets:") {
|
||||
inSection = true
|
||||
continue
|
||||
}
|
||||
if inSection {
|
||||
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||
if trimmed.isEmpty || (!line.hasPrefix(" ") && !line.hasPrefix("\t")) {
|
||||
if !trimmed.isEmpty { break }
|
||||
continue
|
||||
}
|
||||
if trimmed.hasSuffix(":") && !trimmed.hasPrefix("-") {
|
||||
let name = String(trimmed.dropLast()).trimmingCharacters(in: .whitespaces)
|
||||
if let known = KnownPlatforms.all.first(where: { $0.name == name }) {
|
||||
platforms.append(known)
|
||||
} else {
|
||||
platforms.append(HermesToolPlatform(name: name, displayName: name.capitalized, icon: "bubble.left"))
|
||||
}
|
||||
let yaml: String = await Task.detached {
|
||||
(try? String(contentsOfFile: HermesPaths.configYAML, encoding: .utf8)) ?? ""
|
||||
}.value
|
||||
|
||||
let gatewayState: GatewayState? = await Task.detached {
|
||||
HermesFileService().loadGatewayState()
|
||||
}.value
|
||||
|
||||
let configuredNames = Self.parseConfiguredPlatforms(yaml: yaml)
|
||||
var status: [String: PlatformConnectivity] = [:]
|
||||
|
||||
for platform in KnownPlatforms.all {
|
||||
if let pState = gatewayState?.platforms?[platform.name] {
|
||||
if let err = pState.error, !err.isEmpty {
|
||||
status[platform.name] = .error(err)
|
||||
} else if pState.connected == true {
|
||||
status[platform.name] = .connected
|
||||
} else if configuredNames.contains(platform.name) || platform.name == "cli" {
|
||||
status[platform.name] = .configured
|
||||
} else {
|
||||
status[platform.name] = .notConfigured
|
||||
}
|
||||
} else if configuredNames.contains(platform.name) || platform.name == "cli" {
|
||||
status[platform.name] = .configured
|
||||
} else {
|
||||
status[platform.name] = .notConfigured
|
||||
}
|
||||
}
|
||||
availablePlatforms = platforms.isEmpty ? [KnownPlatforms.cli] : platforms
|
||||
|
||||
connectivity = status
|
||||
availablePlatforms = KnownPlatforms.all
|
||||
if !availablePlatforms.contains(where: { $0.name == selectedPlatform.name }),
|
||||
let first = availablePlatforms.first {
|
||||
selectedPlatform = first
|
||||
}
|
||||
}
|
||||
|
||||
/// Find top-level YAML keys that look like messaging platform sections.
|
||||
/// Matches any known platform name followed by `:` at indent 0.
|
||||
private static func parseConfiguredPlatforms(yaml: String) -> Set<String> {
|
||||
var found: Set<String> = []
|
||||
let knownNames = Set(KnownPlatforms.all.map(\.name))
|
||||
for line in yaml.components(separatedBy: "\n") {
|
||||
guard !line.isEmpty, !line.hasPrefix(" "), !line.hasPrefix("\t") else { continue }
|
||||
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||
guard trimmed.hasSuffix(":") else { continue }
|
||||
let name = String(trimmed.dropLast()).trimmingCharacters(in: .whitespaces)
|
||||
if knownNames.contains(name) {
|
||||
found.insert(name)
|
||||
}
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func loadTools(for platform: HermesToolPlatform) async {
|
||||
let result = await runHermes(["tools", "list", "--platform", platform.name])
|
||||
|
||||
@@ -19,19 +19,46 @@ struct ToolsView: View {
|
||||
|
||||
private var platformPicker: some View {
|
||||
HStack(spacing: 12) {
|
||||
Picker("Platform", selection: Binding(
|
||||
get: { viewModel.selectedPlatform.name },
|
||||
set: { name in
|
||||
if let platform = viewModel.availablePlatforms.first(where: { $0.name == name }) {
|
||||
// macOS renders Menu items using NSMenu, which only honors text and
|
||||
// SF Symbol images — custom-drawn Circle() shapes don't appear in the
|
||||
// dropdown. We use a filled SF Symbol "circlebadge.fill" and the status
|
||||
// text suffix so users can tell offline from connected inside the menu.
|
||||
Menu {
|
||||
ForEach(viewModel.availablePlatforms) { platform in
|
||||
Button {
|
||||
Task { await viewModel.switchPlatform(platform) }
|
||||
} label: {
|
||||
let status = viewModel.connectivity[platform.name] ?? .notConfigured
|
||||
Label(
|
||||
menuLabel(platform: platform, status: status),
|
||||
systemImage: statusSymbol(status)
|
||||
)
|
||||
}
|
||||
}
|
||||
)) {
|
||||
ForEach(viewModel.availablePlatforms) { platform in
|
||||
Text(platform.displayName).tag(platform.name)
|
||||
} label: {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: KnownPlatforms.icon(for: viewModel.selectedPlatform.name))
|
||||
Text(viewModel.selectedPlatform.displayName)
|
||||
.fontWeight(.medium)
|
||||
statusDot(for: viewModel.connectivity[viewModel.selectedPlatform.name] ?? .notConfigured)
|
||||
Image(systemName: "chevron.down")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 6)
|
||||
.background(.quaternary.opacity(0.4))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.menuStyle(.borderlessButton)
|
||||
.fixedSize()
|
||||
|
||||
if let tooltip = statusDescription(viewModel.connectivity[viewModel.selectedPlatform.name] ?? .notConfigured) {
|
||||
Text(tooltip)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
Text("\(viewModel.toolsets.filter(\.enabled).count) of \(viewModel.toolsets.count) enabled")
|
||||
.font(.caption)
|
||||
@@ -41,6 +68,52 @@ struct ToolsView: View {
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func statusDot(for status: PlatformConnectivity) -> some View {
|
||||
Circle()
|
||||
.fill(statusColor(status))
|
||||
.frame(width: 8, height: 8)
|
||||
}
|
||||
|
||||
/// SF Symbol name used inside NSMenu (where Circle shapes don't render).
|
||||
private func statusSymbol(_ status: PlatformConnectivity) -> String {
|
||||
switch status {
|
||||
case .connected: return "circle.fill"
|
||||
case .configured: return "circle.dotted"
|
||||
case .notConfigured: return "circle"
|
||||
case .error: return "exclamationmark.circle.fill"
|
||||
}
|
||||
}
|
||||
|
||||
/// Menu-item label with an offline/connected suffix so status is readable even
|
||||
/// if the color of the SF Symbol doesn't come through NSMenu tinting.
|
||||
private func menuLabel(platform: HermesToolPlatform, status: PlatformConnectivity) -> String {
|
||||
switch status {
|
||||
case .connected: return platform.displayName
|
||||
case .configured: return "\(platform.displayName) (offline)"
|
||||
case .notConfigured: return "\(platform.displayName) (not configured)"
|
||||
case .error: return "\(platform.displayName) (error)"
|
||||
}
|
||||
}
|
||||
|
||||
private func statusColor(_ status: PlatformConnectivity) -> Color {
|
||||
switch status {
|
||||
case .connected: return .green
|
||||
case .configured: return .orange
|
||||
case .notConfigured: return .secondary.opacity(0.4)
|
||||
case .error: return .red
|
||||
}
|
||||
}
|
||||
|
||||
private func statusDescription(_ status: PlatformConnectivity) -> String? {
|
||||
switch status {
|
||||
case .connected: return "Connected"
|
||||
case .configured: return "Configured · not running"
|
||||
case .notConfigured: return "Not configured"
|
||||
case .error(let msg): return "Error: \(msg)"
|
||||
}
|
||||
}
|
||||
|
||||
private var toolsList: some View {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 1) {
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
struct HermesWebhook: Identifiable, Sendable, Equatable {
|
||||
var id: String { name }
|
||||
let name: String
|
||||
let description: String
|
||||
let deliver: String
|
||||
let events: [String]
|
||||
let routeSuffix: String // The URL suffix shown by hermes after subscription
|
||||
}
|
||||
|
||||
@Observable
|
||||
final class WebhooksViewModel {
|
||||
private let logger = Logger(subsystem: "com.scarf", category: "WebhooksViewModel")
|
||||
private let fileService = HermesFileService()
|
||||
|
||||
var webhooks: [HermesWebhook] = []
|
||||
var isLoading = false
|
||||
var message: String?
|
||||
|
||||
/// True when hermes's webhook gateway isn't configured. In that state,
|
||||
/// `hermes webhook list` returns setup instructions rather than a list of
|
||||
/// subscriptions — the UI should show a "Setup required" panel instead of
|
||||
/// trying to parse the output as webhook entries.
|
||||
var webhookPlatformNotEnabled: Bool = false
|
||||
|
||||
func load() {
|
||||
isLoading = true
|
||||
Task.detached { [fileService] in
|
||||
let result = fileService.runHermesCLI(args: ["webhook", "list"], timeout: 30)
|
||||
let notEnabled = Self.detectNotEnabled(result.output)
|
||||
let parsed = notEnabled ? [] : Self.parseWebhookList(result.output)
|
||||
await MainActor.run {
|
||||
self.isLoading = false
|
||||
self.webhookPlatformNotEnabled = notEnabled
|
||||
self.webhooks = parsed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Detect the "not enabled" state by the setup-instructions marker hermes emits.
|
||||
/// Checked before parsing so we don't synthesize bogus entries from instructional
|
||||
/// text.
|
||||
nonisolated private static func detectNotEnabled(_ output: String) -> Bool {
|
||||
let lower = output.lowercased()
|
||||
return lower.contains("webhook platform is not enabled")
|
||||
|| lower.contains("run the gateway setup wizard")
|
||||
|| lower.contains("webhook_enabled=true")
|
||||
}
|
||||
|
||||
func subscribe(name: String, prompt: String, events: String, description: String, skills: String, deliver: String, chatID: String, secret: String) {
|
||||
guard !name.isEmpty else { return }
|
||||
var args = ["webhook", "subscribe", name]
|
||||
if !prompt.isEmpty { args += ["--prompt", prompt] }
|
||||
if !events.isEmpty { args += ["--events", events] }
|
||||
if !description.isEmpty { args += ["--description", description] }
|
||||
if !skills.isEmpty { args += ["--skills", skills] }
|
||||
if !deliver.isEmpty { args += ["--deliver", deliver] }
|
||||
if !chatID.isEmpty { args += ["--deliver-chat-id", chatID] }
|
||||
if !secret.isEmpty { args += ["--secret", secret] }
|
||||
runAndReload(args, success: "Subscribed /\(name)")
|
||||
}
|
||||
|
||||
func remove(_ webhook: HermesWebhook) {
|
||||
runAndReload(["webhook", "remove", webhook.name], success: "Removed")
|
||||
}
|
||||
|
||||
func test(_ webhook: HermesWebhook) {
|
||||
Task.detached { [fileService] in
|
||||
let result = fileService.runHermesCLI(args: ["webhook", "test", webhook.name], timeout: 30)
|
||||
await MainActor.run {
|
||||
self.message = result.exitCode == 0 ? "Test fired — check logs" : "Test failed"
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
||||
self?.message = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func runAndReload(_ args: [String], success: String) {
|
||||
Task.detached { [fileService] in
|
||||
let result = fileService.runHermesCLI(args: args, timeout: 60)
|
||||
await MainActor.run {
|
||||
self.message = result.exitCode == 0 ? success : "Failed"
|
||||
self.load()
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
|
||||
self?.message = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Tolerant parser for `hermes webhook list`. The CLI output format is evolving,
|
||||
/// so we extract what we can and degrade gracefully for unknown shapes.
|
||||
/// `nonisolated` so it can be invoked from `Task.detached`.
|
||||
nonisolated private static func parseWebhookList(_ output: String) -> [HermesWebhook] {
|
||||
var results: [HermesWebhook] = []
|
||||
var currentName = ""
|
||||
var currentDesc = ""
|
||||
var currentDeliver = ""
|
||||
var currentEvents: [String] = []
|
||||
var currentRoute = ""
|
||||
|
||||
func flush() {
|
||||
if !currentName.isEmpty {
|
||||
results.append(HermesWebhook(
|
||||
name: currentName,
|
||||
description: currentDesc,
|
||||
deliver: currentDeliver,
|
||||
events: currentEvents,
|
||||
routeSuffix: currentRoute.isEmpty ? "/webhooks/\(currentName)" : currentRoute
|
||||
))
|
||||
}
|
||||
currentName = ""; currentDesc = ""; currentDeliver = ""
|
||||
currentEvents = []; currentRoute = ""
|
||||
}
|
||||
|
||||
for raw in output.components(separatedBy: "\n") {
|
||||
let line = raw
|
||||
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||
if trimmed.isEmpty { continue }
|
||||
// New webhook block: non-indented, alphanumeric/underscore.
|
||||
if !line.hasPrefix(" ") && !line.hasPrefix("\t") {
|
||||
flush()
|
||||
let candidate = trimmed.trimmingCharacters(in: CharacterSet(charactersIn: ":"))
|
||||
if candidate.range(of: "^[A-Za-z0-9_-]+$", options: .regularExpression) != nil {
|
||||
currentName = candidate
|
||||
}
|
||||
continue
|
||||
}
|
||||
if trimmed.lowercased().hasPrefix("description:") {
|
||||
currentDesc = String(trimmed.dropFirst("description:".count)).trimmingCharacters(in: .whitespaces)
|
||||
} else if trimmed.lowercased().hasPrefix("deliver:") {
|
||||
currentDeliver = String(trimmed.dropFirst("deliver:".count)).trimmingCharacters(in: .whitespaces)
|
||||
} else if trimmed.lowercased().hasPrefix("events:") {
|
||||
let list = String(trimmed.dropFirst("events:".count)).trimmingCharacters(in: .whitespaces)
|
||||
currentEvents = list.components(separatedBy: ",").map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty }
|
||||
} else if trimmed.lowercased().hasPrefix("url:") || trimmed.lowercased().hasPrefix("route:") {
|
||||
currentRoute = trimmed.components(separatedBy: ":").dropFirst().joined(separator: ":").trimmingCharacters(in: .whitespaces)
|
||||
}
|
||||
}
|
||||
flush()
|
||||
return results
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
import SwiftUI
|
||||
import AppKit
|
||||
|
||||
struct WebhooksView: View {
|
||||
@State private var viewModel = WebhooksViewModel()
|
||||
@State private var showAddSheet = false
|
||||
@State private var pendingRemove: HermesWebhook?
|
||||
|
||||
// Add form state
|
||||
@State private var addName = ""
|
||||
@State private var addPrompt = ""
|
||||
@State private var addEvents = ""
|
||||
@State private var addDescription = ""
|
||||
@State private var addSkills = ""
|
||||
@State private var addDeliver = "log"
|
||||
@State private var addChatID = ""
|
||||
@State private var addSecret = ""
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
header
|
||||
Divider()
|
||||
if viewModel.isLoading && viewModel.webhooks.isEmpty {
|
||||
ProgressView().padding()
|
||||
} else if viewModel.webhookPlatformNotEnabled {
|
||||
setupRequiredState
|
||||
} else if viewModel.webhooks.isEmpty {
|
||||
emptyState
|
||||
} else {
|
||||
list
|
||||
}
|
||||
}
|
||||
.navigationTitle("Webhooks")
|
||||
.onAppear { viewModel.load() }
|
||||
.sheet(isPresented: $showAddSheet) { addSheet }
|
||||
.confirmationDialog(
|
||||
pendingRemove.map { "Remove webhook \($0.name)?" } ?? "",
|
||||
isPresented: Binding(get: { pendingRemove != nil }, set: { if !$0 { pendingRemove = nil } })
|
||||
) {
|
||||
Button("Remove", role: .destructive) {
|
||||
if let w = pendingRemove { viewModel.remove(w) }
|
||||
pendingRemove = nil
|
||||
}
|
||||
Button("Cancel", role: .cancel) { pendingRemove = nil }
|
||||
}
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack {
|
||||
if let msg = viewModel.message {
|
||||
Label(msg, systemImage: "info.circle.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.green)
|
||||
}
|
||||
Spacer()
|
||||
Button {
|
||||
resetAddForm()
|
||||
showAddSheet = true
|
||||
} label: {
|
||||
Label("Subscribe", systemImage: "plus")
|
||||
}
|
||||
.controlSize(.small)
|
||||
Button("Reload") { viewModel.load() }
|
||||
.controlSize(.small)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
|
||||
/// Shown when hermes reports the webhook platform isn't enabled. Direct users
|
||||
/// to the interactive setup wizard instead of showing a misleading empty list.
|
||||
private var setupRequiredState: some View {
|
||||
VStack(spacing: 12) {
|
||||
Image(systemName: "exclamationmark.triangle")
|
||||
.font(.largeTitle)
|
||||
.foregroundStyle(.orange)
|
||||
Text("Webhook platform not enabled")
|
||||
.font(.title3.bold())
|
||||
Text("Hermes needs a global webhook secret and port before subscriptions can receive traffic. Run the gateway setup wizard or edit ~/.hermes/config.yaml manually.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: 500)
|
||||
HStack(spacing: 8) {
|
||||
Button {
|
||||
openGatewaySetupInTerminal()
|
||||
} label: {
|
||||
Label("Run Setup in Terminal", systemImage: "terminal")
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.small)
|
||||
Button {
|
||||
NSWorkspace.shared.open(URL(fileURLWithPath: HermesPaths.configYAML))
|
||||
} label: {
|
||||
Label("Edit config.yaml", systemImage: "doc.text")
|
||||
}
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.padding()
|
||||
}
|
||||
|
||||
private func openGatewaySetupInTerminal() {
|
||||
guard let hermes = HermesFileService().hermesBinaryPath() else { return }
|
||||
let script = "tell application \"Terminal\"\n activate\n do script \"\(hermes) gateway setup\"\nend tell"
|
||||
let appleScript = NSAppleScript(source: script)
|
||||
var err: NSDictionary?
|
||||
appleScript?.executeAndReturnError(&err)
|
||||
}
|
||||
|
||||
private var emptyState: some View {
|
||||
VStack(spacing: 8) {
|
||||
Image(systemName: "arrow.up.right.square")
|
||||
.font(.largeTitle)
|
||||
.foregroundStyle(.secondary)
|
||||
Text("No webhook subscriptions")
|
||||
.foregroundStyle(.secondary)
|
||||
Text("Webhooks let external services trigger agent responses. Each subscription gets its own URL endpoint.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: 440)
|
||||
Button("Create Subscription") {
|
||||
resetAddForm()
|
||||
showAddSheet = true
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.small)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.padding()
|
||||
}
|
||||
|
||||
private var list: some View {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 1) {
|
||||
ForEach(viewModel.webhooks) { webhook in
|
||||
row(webhook)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
private func row(_ webhook: HermesWebhook) -> some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "arrow.up.right.square")
|
||||
.foregroundStyle(.blue)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(webhook.name)
|
||||
.font(.system(.body, design: .monospaced, weight: .medium))
|
||||
if !webhook.description.isEmpty {
|
||||
Text(webhook.description)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
HStack(spacing: 6) {
|
||||
Text(webhook.routeSuffix)
|
||||
.font(.caption.monospaced())
|
||||
.textSelection(.enabled)
|
||||
.foregroundStyle(.tertiary)
|
||||
if !webhook.deliver.isEmpty {
|
||||
Text(webhook.deliver)
|
||||
.font(.caption2.bold())
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 1)
|
||||
.background(.quaternary)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
ForEach(webhook.events, id: \.self) { event in
|
||||
Text(event)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.blue)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 1)
|
||||
.background(.blue.opacity(0.12))
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
Button("Test") { viewModel.test(webhook) }
|
||||
.controlSize(.small)
|
||||
Button("Remove", role: .destructive) { pendingRemove = webhook }
|
||||
.controlSize(.small)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
}
|
||||
|
||||
private var addSheet: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("New Webhook Subscription")
|
||||
.font(.headline)
|
||||
formField("Name (URL suffix)", text: $addName, placeholder: "github_push", mono: true)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Prompt").font(.caption).foregroundStyle(.secondary)
|
||||
Text("Use {dot.notation} to reference fields in the webhook payload.")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
TextEditor(text: $addPrompt)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.frame(minHeight: 90)
|
||||
.padding(4)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
formField("Events (comma separated)", text: $addEvents, placeholder: "push, pull_request", mono: true)
|
||||
formField("Description", text: $addDescription, placeholder: "Optional human description")
|
||||
formField("Skills (comma separated)", text: $addSkills, placeholder: "github-auth, pr-review", mono: true)
|
||||
formField("Deliver", text: $addDeliver, placeholder: "log | telegram | discord | slack")
|
||||
formField("Chat ID", text: $addChatID, placeholder: "Required for cross-platform delivery")
|
||||
formField("Secret", text: $addSecret, placeholder: "HMAC secret (auto-generated if empty)", mono: true)
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("Cancel") { showAddSheet = false }
|
||||
Button("Subscribe") {
|
||||
viewModel.subscribe(
|
||||
name: addName,
|
||||
prompt: addPrompt,
|
||||
events: addEvents,
|
||||
description: addDescription,
|
||||
skills: addSkills,
|
||||
deliver: addDeliver,
|
||||
chatID: addChatID,
|
||||
secret: addSecret
|
||||
)
|
||||
showAddSheet = false
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(addName.trimmingCharacters(in: .whitespaces).isEmpty)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.frame(minWidth: 560, minHeight: 560)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func formField(_ label: String, text: Binding<String>, placeholder: String, mono: Bool = false) -> some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(label).font(.caption).foregroundStyle(.secondary)
|
||||
TextField(placeholder, text: text)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(mono ? .system(.caption, design: .monospaced) : .caption)
|
||||
}
|
||||
}
|
||||
|
||||
private func resetAddForm() {
|
||||
addName = ""; addPrompt = ""; addEvents = ""; addDescription = ""
|
||||
addSkills = ""; addDeliver = "log"; addChatID = ""; addSecret = ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Scarf</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>LSApplicationCategoryType</key>
|
||||
<string>public.app-category.utilities</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
<string></string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>Scarf uses the microphone for Hermes voice chat.</string>
|
||||
<key>SUFeedURL</key>
|
||||
<string>https://awizemann.github.io/scarf/appcast.xml</string>
|
||||
<key>SUPublicEDKey</key>
|
||||
<string>sxHR0OGLmx9I4Fyx1GdPANR9WUiVAz/rI38x3cLYnMU=</string>
|
||||
<key>SUEnableAutomaticChecks</key>
|
||||
<true/>
|
||||
<key>SUScheduledCheckInterval</key>
|
||||
<integer>86400</integer>
|
||||
<key>SUEnableInstallerLauncherService</key>
|
||||
<false/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -1,14 +1,26 @@
|
||||
import Foundation
|
||||
|
||||
enum SidebarSection: String, CaseIterable, Identifiable {
|
||||
// Monitor
|
||||
case dashboard = "Dashboard"
|
||||
case insights = "Insights"
|
||||
case sessions = "Sessions"
|
||||
case activity = "Activity"
|
||||
// Projects
|
||||
case projects = "Projects"
|
||||
// Interact
|
||||
case chat = "Chat"
|
||||
case memory = "Memory"
|
||||
case skills = "Skills"
|
||||
// Configure (Phase 2/3 additions)
|
||||
case platforms = "Platforms"
|
||||
case personalities = "Personalities"
|
||||
case quickCommands = "Quick Commands"
|
||||
case credentialPools = "Credential Pools"
|
||||
case plugins = "Plugins"
|
||||
case webhooks = "Webhooks"
|
||||
case profiles = "Profiles"
|
||||
// Manage
|
||||
case tools = "Tools"
|
||||
case mcpServers = "MCP Servers"
|
||||
case gateway = "Gateway"
|
||||
@@ -29,6 +41,13 @@ enum SidebarSection: String, CaseIterable, Identifiable {
|
||||
case .chat: return "text.bubble"
|
||||
case .memory: return "brain"
|
||||
case .skills: return "lightbulb"
|
||||
case .platforms: return "dot.radiowaves.left.and.right"
|
||||
case .personalities: return "theatermasks"
|
||||
case .quickCommands: return "command.square"
|
||||
case .credentialPools: return "key.horizontal"
|
||||
case .plugins: return "app.badge.checkmark"
|
||||
case .webhooks: return "arrow.up.right.square"
|
||||
case .profiles: return "person.2.crop.square.stack"
|
||||
case .tools: return "wrench.and.screwdriver"
|
||||
case .mcpServers: return "puzzlepiece.extension"
|
||||
case .gateway: return "antenna.radiowaves.left.and.right"
|
||||
|
||||
@@ -24,6 +24,12 @@ struct SidebarView: View {
|
||||
.tag(section)
|
||||
}
|
||||
}
|
||||
Section("Configure") {
|
||||
ForEach([SidebarSection.platforms, .personalities, .quickCommands, .credentialPools, .plugins, .webhooks, .profiles]) { section in
|
||||
Label(section.rawValue, systemImage: section.icon)
|
||||
.tag(section)
|
||||
}
|
||||
}
|
||||
Section("Manage") {
|
||||
ForEach([SidebarSection.tools, .mcpServers, .gateway, .cron, .health, .logs, .settings]) { section in
|
||||
Label(section.rawValue, systemImage: section.icon)
|
||||
|
||||
@@ -4,5 +4,7 @@
|
||||
<dict>
|
||||
<key>com.apple.security.device.audio-input</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.disable-library-validation</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -6,6 +6,7 @@ struct ScarfApp: App {
|
||||
@State private var fileWatcher = HermesFileWatcher()
|
||||
@State private var menuBarStatus = MenuBarStatus()
|
||||
@State private var chatViewModel = ChatViewModel()
|
||||
@State private var updater = UpdaterService()
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
@@ -13,6 +14,7 @@ struct ScarfApp: App {
|
||||
.environment(coordinator)
|
||||
.environment(fileWatcher)
|
||||
.environment(chatViewModel)
|
||||
.environment(updater)
|
||||
.onAppear {
|
||||
fileWatcher.startWatching()
|
||||
menuBarStatus.startPolling()
|
||||
@@ -23,9 +25,14 @@ struct ScarfApp: App {
|
||||
}
|
||||
}
|
||||
.defaultSize(width: 1100, height: 700)
|
||||
.commands {
|
||||
CommandGroup(after: .appInfo) {
|
||||
Button("Check for Updates…") { updater.checkForUpdates() }
|
||||
}
|
||||
}
|
||||
|
||||
MenuBarExtra("Scarf", systemImage: menuBarStatus.icon) {
|
||||
MenuBarMenu(status: menuBarStatus, coordinator: coordinator)
|
||||
MenuBarMenu(status: menuBarStatus, coordinator: coordinator, updater: updater)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -90,6 +97,7 @@ final class MenuBarStatus {
|
||||
struct MenuBarMenu: View {
|
||||
let status: MenuBarStatus
|
||||
let coordinator: AppCoordinator
|
||||
let updater: UpdaterService
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
@@ -116,6 +124,8 @@ struct MenuBarMenu: View {
|
||||
NSApplication.shared.activate()
|
||||
}
|
||||
Divider()
|
||||
Button("Check for Updates…") { updater.checkForUpdates() }
|
||||
Divider()
|
||||
Button("Quit Scarf") {
|
||||
NSApplication.shared.terminate(nil)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>method</key>
|
||||
<string>developer-id</string>
|
||||
<key>teamID</key>
|
||||
<string>3Q6X2L86C4</string>
|
||||
<key>signingStyle</key>
|
||||
<string>manual</string>
|
||||
<key>signingCertificate</key>
|
||||
<string>Developer ID Application</string>
|
||||
<key>destination</key>
|
||||
<string>export</string>
|
||||
<key>stripSwiftSymbols</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
Executable
+267
@@ -0,0 +1,267 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Scarf release pipeline — local, manual, repeatable.
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/release.sh 1.7.0 # full release: build, sign, notarize,
|
||||
# # appcast push, GitHub release, tag
|
||||
# ./scripts/release.sh 1.7.0 --draft # everything builds + notarizes, but the
|
||||
# # GitHub release is created as draft, the
|
||||
# # appcast is NOT updated, and main is NOT
|
||||
# # tagged. Promote later with --promote.
|
||||
#
|
||||
# Release notes:
|
||||
# If `releases/v<VERSION>/RELEASE_NOTES.md` exists, it is committed alongside the
|
||||
# version bump and used as the GitHub release body. Otherwise a minimal autogenerated
|
||||
# note is used.
|
||||
#
|
||||
# Prerequisites (one-time setup):
|
||||
# 1. Developer ID Application cert installed in login Keychain.
|
||||
# security find-identity -v -p codesigning | grep "Developer ID Application"
|
||||
# 2. App Store Connect API key stored for notarytool as profile "scarf-notary":
|
||||
# xcrun notarytool store-credentials "scarf-notary" \
|
||||
# --key ~/.private/AuthKey_XXXX.p8 --key-id <KEY_ID> --issuer <ISSUER_ID>
|
||||
# 3. Sparkle EdDSA keypair generated (private key in Keychain item "https://sparkle-project.org"):
|
||||
# ./scripts/sparkle/generate_keys # or similar, from Sparkle SPM artifacts
|
||||
# 4. gh-pages branch exists with an appcast.xml and GitHub Pages enabled.
|
||||
# 5. gh CLI authed: `gh auth status`.
|
||||
# 6. GH_PAGES_WORKTREE env var pointing at a gh-pages checkout, OR let the
|
||||
# script create one automatically at .gh-pages-worktree/ via `git worktree add`.
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
# ---------- arg parsing ----------
|
||||
VERSION=""
|
||||
DRAFT=0
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--draft) DRAFT=1 ;;
|
||||
-h|--help) sed -n '2,30p' "$0"; exit 0 ;;
|
||||
-*) printf '[ERR] unknown flag: %s\n' "$arg" >&2; exit 1 ;;
|
||||
*) [[ -z "$VERSION" ]] && VERSION="$arg" || { printf '[ERR] unexpected arg: %s\n' "$arg" >&2; exit 1; } ;;
|
||||
esac
|
||||
done
|
||||
[[ -n "$VERSION" ]] || { printf 'usage: ./scripts/release.sh <marketing-version> [--draft]\n' >&2; exit 1; }
|
||||
|
||||
# ---------- config ----------
|
||||
TEAM_ID="3Q6X2L86C4"
|
||||
BUNDLE_ID="com.scarf.app"
|
||||
SCHEME="scarf"
|
||||
PROJECT="scarf/scarf.xcodeproj"
|
||||
NOTARY_PROFILE="scarf-notary"
|
||||
SIGNING_IDENTITY="Developer ID Application"
|
||||
APPCAST_URL="https://awizemann.github.io/scarf/appcast.xml"
|
||||
DOWNLOAD_URL_BASE="https://github.com/awizemann/scarf/releases/download"
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
BUILD_DIR="$REPO_ROOT/build"
|
||||
ARCHIVE_PATH="$BUILD_DIR/scarf.xcarchive"
|
||||
EXPORT_DIR="$BUILD_DIR/export"
|
||||
EXPORT_OPTIONS="$REPO_ROOT/scripts/ExportOptions.plist"
|
||||
RELEASE_DIR="$REPO_ROOT/releases/v${VERSION}"
|
||||
GH_PAGES_WORKTREE="${GH_PAGES_WORKTREE:-$REPO_ROOT/.gh-pages-worktree}"
|
||||
|
||||
# ---------- helpers ----------
|
||||
log() { printf '\033[1;34m==> %s\033[0m\n' "$*"; }
|
||||
warn() { printf '\033[1;33m[WARN] %s\033[0m\n' "$*" >&2; }
|
||||
die() { printf '\033[1;31m[ERR] %s\033[0m\n' "$*" >&2; exit 1; }
|
||||
|
||||
require_cmd() { command -v "$1" >/dev/null 2>&1 || die "missing required tool: $1"; }
|
||||
|
||||
# ---------- preflight ----------
|
||||
log "Preflight checks"
|
||||
require_cmd xcodebuild
|
||||
require_cmd xcrun
|
||||
require_cmd ditto
|
||||
require_cmd gh
|
||||
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
# git must be clean and on main
|
||||
if [[ -n "$(git status --porcelain)" ]]; then
|
||||
die "working tree not clean — commit or stash first"
|
||||
fi
|
||||
CUR_BRANCH="$(git rev-parse --abbrev-ref HEAD)"
|
||||
[[ "$CUR_BRANCH" == "main" ]] || die "not on main (on $CUR_BRANCH)"
|
||||
|
||||
# identity present
|
||||
security find-identity -v -p codesigning | grep -q "$SIGNING_IDENTITY" \
|
||||
|| die "'$SIGNING_IDENTITY' certificate not in Keychain — create at developer.apple.com"
|
||||
|
||||
# notary profile present (can't list, only test by dry-running submit help)
|
||||
xcrun notarytool history --keychain-profile "$NOTARY_PROFILE" --output-format json >/dev/null 2>&1 \
|
||||
|| die "notarytool profile '$NOTARY_PROFILE' not set up — see script header"
|
||||
|
||||
# locate sign_update (ships with Sparkle SPM artifacts)
|
||||
SIGN_UPDATE="$(find ~/Library/Developer/Xcode/DerivedData -name sign_update -type f -perm +111 2>/dev/null | head -n1 || true)"
|
||||
[[ -x "${SIGN_UPDATE:-}" ]] || die "sign_update not found — build the project once in Xcode so Sparkle artifacts resolve, then re-run"
|
||||
|
||||
# ---------- bump version ----------
|
||||
log "Bumping version to $VERSION"
|
||||
PBXPROJ="$PROJECT/project.pbxproj"
|
||||
# CURRENT_PROJECT_VERSION (build number) bumps by 1 from existing
|
||||
CUR_BUILD="$(awk -F'= ' '/CURRENT_PROJECT_VERSION/ {gsub(/[; ]/,"",$2); print $2; exit}' "$PBXPROJ")"
|
||||
NEW_BUILD=$((CUR_BUILD + 1))
|
||||
sed -i '' -E "s/MARKETING_VERSION = [0-9]+\.[0-9]+\.[0-9]+;/MARKETING_VERSION = ${VERSION};/g" "$PBXPROJ"
|
||||
sed -i '' -E "s/CURRENT_PROJECT_VERSION = [0-9]+;/CURRENT_PROJECT_VERSION = ${NEW_BUILD};/g" "$PBXPROJ"
|
||||
git add "$PBXPROJ"
|
||||
# Include release notes in the bump commit if user prepared them ahead of time.
|
||||
NOTES_FILE="$RELEASE_DIR/RELEASE_NOTES.md"
|
||||
if [[ -f "$NOTES_FILE" ]]; then
|
||||
git add "$NOTES_FILE"
|
||||
fi
|
||||
git commit -m "chore: Bump version to ${VERSION}"
|
||||
|
||||
# ---------- build ----------
|
||||
log "Clean build directory"
|
||||
rm -rf "$BUILD_DIR"
|
||||
mkdir -p "$BUILD_DIR"
|
||||
|
||||
log "Archive (universal arm64+x86_64)"
|
||||
xcodebuild \
|
||||
-project "$PROJECT" \
|
||||
-scheme "$SCHEME" \
|
||||
-configuration Release \
|
||||
-archivePath "$ARCHIVE_PATH" \
|
||||
-destination "generic/platform=macOS" \
|
||||
ONLY_ACTIVE_ARCH=NO \
|
||||
ARCHS="arm64 x86_64" \
|
||||
archive
|
||||
|
||||
log "Export signed .app"
|
||||
xcodebuild \
|
||||
-exportArchive \
|
||||
-archivePath "$ARCHIVE_PATH" \
|
||||
-exportPath "$EXPORT_DIR" \
|
||||
-exportOptionsPlist "$EXPORT_OPTIONS"
|
||||
|
||||
# Xcode exports as scarf.app (PRODUCT_NAME = $TARGET_NAME = "scarf"). Rename the
|
||||
# wrapper to Scarf.app so users see properly-cased app in /Applications. Renaming
|
||||
# the bundle directory does NOT invalidate the signature (codesign signs contents,
|
||||
# not the wrapper folder name).
|
||||
if [[ -d "$EXPORT_DIR/scarf.app" && ! -d "$EXPORT_DIR/Scarf.app" ]]; then
|
||||
mv "$EXPORT_DIR/scarf.app" "$EXPORT_DIR/Scarf.app"
|
||||
fi
|
||||
APP_PATH="$EXPORT_DIR/Scarf.app"
|
||||
[[ -d "$APP_PATH" ]] || die "exported app not found at $APP_PATH"
|
||||
|
||||
# ---------- verify signature ----------
|
||||
log "Verify signature"
|
||||
codesign --verify --deep --strict --verbose=2 "$APP_PATH"
|
||||
# spctl will fail here (not yet notarized) — that's fine, we check after stapling
|
||||
spctl --assess --type execute --verbose "$APP_PATH" || true
|
||||
|
||||
# ---------- notarize ----------
|
||||
log "Zip for notarization"
|
||||
NOTARIZE_ZIP="$BUILD_DIR/Scarf-notarize.zip"
|
||||
ditto -c -k --keepParent "$APP_PATH" "$NOTARIZE_ZIP"
|
||||
|
||||
log "Submit to notarytool (blocking)"
|
||||
xcrun notarytool submit "$NOTARIZE_ZIP" \
|
||||
--keychain-profile "$NOTARY_PROFILE" \
|
||||
--wait \
|
||||
--timeout 30m
|
||||
|
||||
log "Staple notarization ticket"
|
||||
xcrun stapler staple "$APP_PATH"
|
||||
xcrun stapler validate "$APP_PATH"
|
||||
|
||||
log "Final gatekeeper assessment"
|
||||
spctl --assess --type execute --verbose "$APP_PATH"
|
||||
|
||||
# ---------- package distribution artifacts ----------
|
||||
log "Package distribution zips"
|
||||
mkdir -p "$RELEASE_DIR"
|
||||
UNIVERSAL_ZIP="$RELEASE_DIR/Scarf-v${VERSION}-Universal.zip"
|
||||
ditto -c -k --keepParent "$APP_PATH" "$UNIVERSAL_ZIP"
|
||||
|
||||
# ---------- sign appcast entry ----------
|
||||
log "Sign appcast entry with EdDSA"
|
||||
# sign_update prints: sparkle:edSignature="..." length="..."
|
||||
SIG_OUTPUT="$("$SIGN_UPDATE" "$UNIVERSAL_ZIP")"
|
||||
ED_SIGNATURE="$(echo "$SIG_OUTPUT" | sed -nE 's/.*sparkle:edSignature="([^"]+)".*/\1/p')"
|
||||
FILE_LENGTH="$(echo "$SIG_OUTPUT" | sed -nE 's/.*length="([^"]+)".*/\1/p')"
|
||||
[[ -n "$ED_SIGNATURE" && -n "$FILE_LENGTH" ]] || die "sign_update did not produce signature: $SIG_OUTPUT"
|
||||
|
||||
DOWNLOAD_URL="$DOWNLOAD_URL_BASE/v${VERSION}/Scarf-v${VERSION}-Universal.zip"
|
||||
PUB_DATE="$(LC_TIME=en_US.UTF-8 date -u +"%a, %d %b %Y %H:%M:%S +0000")"
|
||||
APPCAST_ITEM=$(cat <<EOF
|
||||
<item>
|
||||
<title>Version ${VERSION}</title>
|
||||
<sparkle:version>${NEW_BUILD}</sparkle:version>
|
||||
<sparkle:shortVersionString>${VERSION}</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>14.6</sparkle:minimumSystemVersion>
|
||||
<pubDate>${PUB_DATE}</pubDate>
|
||||
<enclosure url="${DOWNLOAD_URL}"
|
||||
sparkle:edSignature="${ED_SIGNATURE}"
|
||||
length="${FILE_LENGTH}"
|
||||
type="application/octet-stream" />
|
||||
</item>
|
||||
EOF
|
||||
)
|
||||
|
||||
# ---------- update appcast on gh-pages (skipped for drafts) ----------
|
||||
if [[ $DRAFT -eq 0 ]]; then
|
||||
log "Update appcast.xml on gh-pages worktree"
|
||||
if [[ ! -d "$GH_PAGES_WORKTREE" ]]; then
|
||||
git worktree add "$GH_PAGES_WORKTREE" gh-pages
|
||||
fi
|
||||
(
|
||||
cd "$GH_PAGES_WORKTREE"
|
||||
git pull --ff-only origin gh-pages
|
||||
# Insert new item after <language>en</language> line
|
||||
python3 - "$APPCAST_ITEM" <<'PY'
|
||||
import sys, pathlib
|
||||
new_item = sys.argv[1]
|
||||
p = pathlib.Path("appcast.xml")
|
||||
xml = p.read_text()
|
||||
marker = "<language>en</language>"
|
||||
if marker not in xml:
|
||||
sys.exit("appcast.xml missing <language>en</language> marker")
|
||||
xml = xml.replace(marker, marker + "\n" + new_item, 1)
|
||||
p.write_text(xml)
|
||||
PY
|
||||
git add appcast.xml
|
||||
git commit -m "release: v${VERSION}"
|
||||
git push origin gh-pages
|
||||
)
|
||||
else
|
||||
log "Draft mode — skipping appcast push. Saving entry to $RELEASE_DIR/appcast-entry.xml for later promotion."
|
||||
printf '%s\n' "$APPCAST_ITEM" > "$RELEASE_DIR/appcast-entry.xml"
|
||||
fi
|
||||
|
||||
# ---------- github release ----------
|
||||
log "Create GitHub release and upload artifacts"
|
||||
GH_FLAGS=()
|
||||
[[ $DRAFT -eq 1 ]] && GH_FLAGS+=(--draft)
|
||||
if [[ -f "$NOTES_FILE" ]]; then
|
||||
GH_FLAGS+=(--notes-file "$NOTES_FILE")
|
||||
else
|
||||
GH_FLAGS+=(--notes "Release v${VERSION}. See commit history for details.")
|
||||
fi
|
||||
gh release create "v${VERSION}" \
|
||||
--title "Scarf v${VERSION}" \
|
||||
"${GH_FLAGS[@]}" \
|
||||
"$UNIVERSAL_ZIP"
|
||||
|
||||
# ---------- tag main (skipped for drafts) ----------
|
||||
if [[ $DRAFT -eq 0 ]]; then
|
||||
log "Tag main and push"
|
||||
git tag "v${VERSION}"
|
||||
git push origin main --tags
|
||||
else
|
||||
log "Draft mode — skipping tag. Bump commit is local only; push manually with: git push origin main"
|
||||
fi
|
||||
|
||||
if [[ $DRAFT -eq 1 ]]; then
|
||||
log "Draft release v${VERSION} ready"
|
||||
log " Review: https://github.com/awizemann/scarf/releases"
|
||||
log " Promote: in GitHub UI, edit the draft and uncheck 'Set as a pre-release / draft' → Publish."
|
||||
log " Then commit + push appcast-entry.xml to gh-pages, and tag main:"
|
||||
log " git push origin main"
|
||||
log " git tag v${VERSION} && git push origin v${VERSION}"
|
||||
log " (manually merge $RELEASE_DIR/appcast-entry.xml into gh-pages branch's appcast.xml after <language>en</language>)"
|
||||
else
|
||||
log "Release v${VERSION} complete"
|
||||
log " Download: $DOWNLOAD_URL"
|
||||
log " Appcast: $APPCAST_URL"
|
||||
fi
|
||||
Reference in New Issue
Block a user