mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 18:44:45 +00:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0a584f6722 | |||
| 219bca264e | |||
| c7e6a809ed | |||
| c5d6116f99 |
@@ -38,3 +38,7 @@ scarf/scarf/ Xcode project root (PBXFileSystemSynchronizedRootGroup
|
|||||||
```bash
|
```bash
|
||||||
xcodebuild -project scarf/scarf.xcodeproj -scheme scarf -configuration Debug build
|
xcodebuild -project scarf/scarf.xcodeproj -scheme scarf -configuration Debug build
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Hermes Version
|
||||||
|
|
||||||
|
Targets Hermes v0.9.0 (v2026.4.13). Log lines may carry an optional `[session_id]` tag between the level and logger name — `HermesLogService.parseLine` treats the session tag as an optional capture group, so older untagged lines still parse.
|
||||||
|
|||||||
@@ -23,15 +23,16 @@
|
|||||||
- **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)
|
- **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
|
- **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
|
- **Activity Feed** — Recent tool execution log with filtering by kind and session, detail inspector with pretty-printed arguments and tool output display
|
||||||
- **Live Chat** — Two modes: **Rich Chat** streams responses in real-time via the Agent Client Protocol (ACP) with iMessage-style bubbles, markdown rendering, tool call visualization, thinking/reasoning display, and permission request dialogs; **Terminal** runs `hermes chat` with full ANSI color and Rich formatting via [SwiftTerm](https://github.com/migueldeicaza/SwiftTerm). Both modes support session persistence, resume/continue previous sessions, auto-reconnection with session recovery, and voice mode controls
|
- **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
|
- **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
|
- **Skills Browser** — Browse and edit installed skills by category with file content viewer, file switcher, and required config warnings for skills that need specific settings
|
||||||
- **Tools Manager** — Enable/disable toolsets per platform (CLI, Telegram, Discord, Slack, WhatsApp, Signal, Email, Home Assistant, Webhook, Matrix, Feishu, Mattermost) with toggle switches and segmented platform picker, MCP server status
|
- **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
|
||||||
|
- **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)
|
- **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
|
||||||
- **Log Viewer** — Real-time log tailing for agent.log, errors.log, and gateway.log with level filtering and text search
|
- **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
|
- **Project Dashboards** — Custom, agent-generated dashboards for any project. Define stat boxes, charts, tables, progress bars, checklists, rich text, and embedded web views in a simple JSON file — Scarf renders them with live refresh. Let your Hermes agent build and maintain project-specific visualizations automatically
|
||||||
- **Settings** — Structured config editor for all Hermes settings including model/provider selection, browser backend, reasoning effort, approval mode, cost display, Docker environment, command allowlist, credential management, and more
|
- **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`
|
||||||
- **Hermes Process Control** — Start, stop, and restart the Hermes agent directly from Scarf
|
- **Hermes Process Control** — Start, stop, and restart the Hermes agent directly from Scarf
|
||||||
- **Menu Bar** — Status icon showing Hermes running state with quick actions
|
- **Menu Bar** — Status icon showing Hermes running state with quick actions
|
||||||
|
|
||||||
@@ -39,7 +40,7 @@
|
|||||||
|
|
||||||
- macOS 14.6+ (Sonoma)
|
- macOS 14.6+ (Sonoma)
|
||||||
- Xcode 16.0+
|
- Xcode 16.0+
|
||||||
- [Hermes agent](https://github.com/hermes-ai/hermes-agent) v0.6.0+ installed at `~/.hermes/` (v0.8.0 recommended for full feature support)
|
- [Hermes agent](https://github.com/hermes-ai/hermes-agent) v0.6.0+ installed at `~/.hermes/` (v0.9.0 recommended for full feature support)
|
||||||
|
|
||||||
### Compatibility
|
### Compatibility
|
||||||
|
|
||||||
@@ -49,7 +50,8 @@ Scarf reads Hermes's SQLite database and parses CLI output from `hermes status`,
|
|||||||
|----------------|--------|
|
|----------------|--------|
|
||||||
| v0.6.0 (2026-03-30) | Verified |
|
| v0.6.0 (2026-03-30) | Verified |
|
||||||
| v0.7.0 (2026-04-03) | Verified |
|
| v0.7.0 (2026-04-03) | Verified |
|
||||||
| v0.8.0 (2026-04-08, latest) | Verified |
|
| v0.8.0 (2026-04-08) | Verified |
|
||||||
|
| v0.9.0 (2026-04-13, latest) | Verified |
|
||||||
|
|
||||||
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.
|
If a Hermes update changes the database schema or CLI output format, Scarf may need to be updated. Check the [Health](#features) view for compatibility warnings.
|
||||||
|
|
||||||
@@ -57,11 +59,13 @@ If a Hermes update changes the database schema or CLI output format, Scarf may n
|
|||||||
|
|
||||||
### Pre-built Binary (no Xcode required)
|
### Pre-built Binary (no Xcode required)
|
||||||
|
|
||||||
Download the latest universal binary (Apple Silicon + Intel) from [Releases](https://github.com/awizemann/scarf/releases):
|
Download the latest build from [Releases](https://github.com/awizemann/scarf/releases):
|
||||||
|
|
||||||
1. Download `Scarf-vX.X.X-Universal.zip`
|
- `Scarf-vX.X.X-Universal.zip` — Apple Silicon + Intel (recommended)
|
||||||
2. Unzip and drag **Scarf.app** to Applications
|
- `Scarf-vX.X.X-ARM64.zip` — Apple Silicon only (smaller)
|
||||||
3. On first launch, right-click and choose **Open** (or go to System Settings → Privacy & Security → Open Anyway)
|
|
||||||
|
1. Unzip and drag **Scarf.app** to Applications
|
||||||
|
2. On first launch, right-click and choose **Open** (or go to System Settings → Privacy & Security → Open Anyway)
|
||||||
|
|
||||||
### Build from Source
|
### Build from Source
|
||||||
|
|
||||||
@@ -96,6 +100,7 @@ scarf/
|
|||||||
Memory/ Memory viewer and editor
|
Memory/ Memory viewer and editor
|
||||||
Skills/ Skill browser by category
|
Skills/ Skill browser by category
|
||||||
Tools/ Toolset management per platform
|
Tools/ Toolset management per platform
|
||||||
|
MCPServers/ MCP server registry, presets, OAuth, tool filters, test runner
|
||||||
Gateway/ Messaging gateway control and pairing
|
Gateway/ Messaging gateway control and pairing
|
||||||
Cron/ Scheduled job viewer
|
Cron/ Scheduled job viewer
|
||||||
Logs/ Real-time log viewer
|
Logs/ Real-time log viewer
|
||||||
@@ -122,6 +127,8 @@ Scarf reads Hermes data directly from `~/.hermes/`:
|
|||||||
| `hermes sessions` | CLI commands | Rename/Delete/Export |
|
| `hermes sessions` | CLI commands | Rename/Delete/Export |
|
||||||
| `hermes gateway` | CLI commands | Start/Stop/Restart |
|
| `hermes gateway` | CLI commands | Start/Stop/Restart |
|
||||||
| `hermes pairing` | CLI commands | Approve/Revoke |
|
| `hermes pairing` | CLI commands | Approve/Revoke |
|
||||||
|
| `hermes mcp` | CLI commands | Add/Remove/Test MCP servers |
|
||||||
|
| `mcp-tokens/*.json` | JSON (per-server OAuth) | Detect/Delete |
|
||||||
| `.scarf/dashboard.json` | JSON (per-project) | Read-only |
|
| `.scarf/dashboard.json` | JSON (per-project) | Read-only |
|
||||||
| `scarf/projects.json` | JSON (registry) | Read/Write |
|
| `scarf/projects.json` | JSON (registry) | Read/Write |
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -407,7 +407,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = scarf/scarf.entitlements;
|
CODE_SIGN_ENTITLEMENTS = scarf/scarf.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
COMBINE_HIDPI_IMAGES = YES;
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
CURRENT_PROJECT_VERSION = 6;
|
CURRENT_PROJECT_VERSION = 8;
|
||||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||||
ENABLE_APP_SANDBOX = NO;
|
ENABLE_APP_SANDBOX = NO;
|
||||||
ENABLE_HARDENED_RUNTIME = YES;
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
@@ -422,7 +422,7 @@
|
|||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
);
|
);
|
||||||
MACOSX_DEPLOYMENT_TARGET = 14.6;
|
MACOSX_DEPLOYMENT_TARGET = 14.6;
|
||||||
MARKETING_VERSION = 1.5.2;
|
MARKETING_VERSION = 1.5.6;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarf;
|
PRODUCT_BUNDLE_IDENTIFIER = com.scarf;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
REGISTER_APP_GROUPS = YES;
|
REGISTER_APP_GROUPS = YES;
|
||||||
@@ -444,7 +444,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = scarf/scarf.entitlements;
|
CODE_SIGN_ENTITLEMENTS = scarf/scarf.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
COMBINE_HIDPI_IMAGES = YES;
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
CURRENT_PROJECT_VERSION = 6;
|
CURRENT_PROJECT_VERSION = 8;
|
||||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||||
ENABLE_APP_SANDBOX = NO;
|
ENABLE_APP_SANDBOX = NO;
|
||||||
ENABLE_HARDENED_RUNTIME = YES;
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
@@ -459,7 +459,7 @@
|
|||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
);
|
);
|
||||||
MACOSX_DEPLOYMENT_TARGET = 14.6;
|
MACOSX_DEPLOYMENT_TARGET = 14.6;
|
||||||
MARKETING_VERSION = 1.5.2;
|
MARKETING_VERSION = 1.5.6;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarf;
|
PRODUCT_BUNDLE_IDENTIFIER = com.scarf;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
REGISTER_APP_GROUPS = YES;
|
REGISTER_APP_GROUPS = YES;
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ struct ContentView: View {
|
|||||||
SkillsView()
|
SkillsView()
|
||||||
case .tools:
|
case .tools:
|
||||||
ToolsView()
|
ToolsView()
|
||||||
|
case .mcpServers:
|
||||||
|
MCPServersView()
|
||||||
case .gateway:
|
case .gateway:
|
||||||
GatewayView()
|
GatewayView()
|
||||||
case .cron:
|
case .cron:
|
||||||
|
|||||||
@@ -23,6 +23,12 @@ struct HermesConfig: Sendable {
|
|||||||
var dockerEnv: [String: String]
|
var dockerEnv: [String: String]
|
||||||
var commandAllowlist: [String]
|
var commandAllowlist: [String]
|
||||||
var memoryProfile: String
|
var memoryProfile: String
|
||||||
|
var serviceTier: String
|
||||||
|
var gatewayNotifyInterval: Int
|
||||||
|
var forceIPv4: Bool
|
||||||
|
var contextEngine: String
|
||||||
|
var interimAssistantMessages: Bool
|
||||||
|
var honchoInitOnSessionStart: Bool
|
||||||
|
|
||||||
static let empty = HermesConfig(
|
static let empty = HermesConfig(
|
||||||
model: "unknown",
|
model: "unknown",
|
||||||
@@ -46,7 +52,13 @@ struct HermesConfig: Sendable {
|
|||||||
memoryProvider: "",
|
memoryProvider: "",
|
||||||
dockerEnv: [:],
|
dockerEnv: [:],
|
||||||
commandAllowlist: [],
|
commandAllowlist: [],
|
||||||
memoryProfile: ""
|
memoryProfile: "",
|
||||||
|
serviceTier: "normal",
|
||||||
|
gatewayNotifyInterval: 600,
|
||||||
|
forceIPv4: false,
|
||||||
|
contextEngine: "compressor",
|
||||||
|
interimAssistantMessages: true,
|
||||||
|
honchoInitOnSessionStart: false
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ enum HermesPaths: Sendable {
|
|||||||
nonisolated static let hermesBinary: String = userHome + "/.local/bin/hermes"
|
nonisolated static let hermesBinary: String = userHome + "/.local/bin/hermes"
|
||||||
nonisolated static let scarfDir: String = home + "/scarf"
|
nonisolated static let scarfDir: String = home + "/scarf"
|
||||||
nonisolated static let projectsRegistry: String = scarfDir + "/projects.json"
|
nonisolated static let projectsRegistry: String = scarfDir + "/projects.json"
|
||||||
|
nonisolated static let mcpTokensDir: String = home + "/mcp-tokens"
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - SQLite Constants
|
// MARK: - SQLite Constants
|
||||||
|
|||||||
@@ -41,6 +41,21 @@ struct HermesCronJob: Identifiable, Sendable, Codable {
|
|||||||
default: return "questionmark.circle"
|
default: return "questionmark.circle"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var deliveryDisplay: String? {
|
||||||
|
guard let deliver, !deliver.isEmpty else { return nil }
|
||||||
|
// v0.9.0 extends Discord routing to threads: `discord:<chat>:<thread>`.
|
||||||
|
if deliver.hasPrefix("discord:") {
|
||||||
|
let parts = deliver.dropFirst("discord:".count).split(separator: ":", maxSplits: 1, omittingEmptySubsequences: false)
|
||||||
|
if parts.count == 2 {
|
||||||
|
return "Discord thread \(parts[1]) in \(parts[0])"
|
||||||
|
}
|
||||||
|
if parts.count == 1 {
|
||||||
|
return "Discord \(parts[0])"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return deliver
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct CronSchedule: Sendable, Codable {
|
struct CronSchedule: Sendable, Codable {
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum MCPTransport: String, Sendable, Equatable, CaseIterable, Identifiable {
|
||||||
|
case stdio
|
||||||
|
case http
|
||||||
|
|
||||||
|
var id: String { rawValue }
|
||||||
|
|
||||||
|
var displayName: String {
|
||||||
|
switch self {
|
||||||
|
case .stdio: return "Local (stdio)"
|
||||||
|
case .http: return "Remote (HTTP)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct HermesMCPServer: Identifiable, Sendable, Equatable {
|
||||||
|
let name: String
|
||||||
|
let transport: MCPTransport
|
||||||
|
let command: String?
|
||||||
|
let args: [String]
|
||||||
|
let url: String?
|
||||||
|
let auth: String?
|
||||||
|
let env: [String: String]
|
||||||
|
let headers: [String: String]
|
||||||
|
let timeout: Int?
|
||||||
|
let connectTimeout: Int?
|
||||||
|
let enabled: Bool
|
||||||
|
let toolsInclude: [String]
|
||||||
|
let toolsExclude: [String]
|
||||||
|
let resourcesEnabled: Bool
|
||||||
|
let promptsEnabled: Bool
|
||||||
|
let hasOAuthToken: Bool
|
||||||
|
|
||||||
|
var id: String { name }
|
||||||
|
|
||||||
|
var summary: String {
|
||||||
|
switch transport {
|
||||||
|
case .stdio:
|
||||||
|
let argString = args.isEmpty ? "" : " " + args.joined(separator: " ")
|
||||||
|
return (command ?? "") + argString
|
||||||
|
case .http:
|
||||||
|
return url ?? ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MCPTestResult: Sendable, Equatable {
|
||||||
|
let serverName: String
|
||||||
|
let succeeded: Bool
|
||||||
|
let output: String
|
||||||
|
let tools: [String]
|
||||||
|
let elapsed: TimeInterval
|
||||||
|
}
|
||||||
@@ -30,6 +30,7 @@ enum KnownPlatforms {
|
|||||||
HermesToolPlatform(name: "matrix", displayName: "Matrix", icon: "lock.rectangle.stack"),
|
HermesToolPlatform(name: "matrix", displayName: "Matrix", icon: "lock.rectangle.stack"),
|
||||||
HermesToolPlatform(name: "feishu", displayName: "Feishu", icon: "message.badge.circle"),
|
HermesToolPlatform(name: "feishu", displayName: "Feishu", icon: "message.badge.circle"),
|
||||||
HermesToolPlatform(name: "mattermost", displayName: "Mattermost", icon: "bubble.left.and.exclamationmark.bubble.right"),
|
HermesToolPlatform(name: "mattermost", displayName: "Mattermost", icon: "bubble.left.and.exclamationmark.bubble.right"),
|
||||||
|
HermesToolPlatform(name: "imessage", displayName: "iMessage", icon: "message.fill"),
|
||||||
]
|
]
|
||||||
|
|
||||||
static func icon(for platform: String) -> String {
|
static func icon(for platform: String) -> String {
|
||||||
@@ -46,6 +47,7 @@ enum KnownPlatforms {
|
|||||||
case "matrix": return "lock.rectangle.stack"
|
case "matrix": return "lock.rectangle.stack"
|
||||||
case "feishu": return "message.badge.circle"
|
case "feishu": return "message.badge.circle"
|
||||||
case "mattermost": return "bubble.left.and.exclamationmark.bubble.right"
|
case "mattermost": return "bubble.left.and.exclamationmark.bubble.right"
|
||||||
|
case "imessage": return "message.fill"
|
||||||
default: return "bubble.left"
|
default: return "bubble.left"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct MCPServerPreset: Identifiable, Sendable, Equatable {
|
||||||
|
let id: String
|
||||||
|
let displayName: String
|
||||||
|
let description: String
|
||||||
|
let category: String
|
||||||
|
let iconSystemName: String
|
||||||
|
let transport: MCPTransport
|
||||||
|
let command: String?
|
||||||
|
let args: [String]
|
||||||
|
let url: String?
|
||||||
|
let auth: String?
|
||||||
|
let requiredEnvKeys: [String]
|
||||||
|
let optionalEnvKeys: [String]
|
||||||
|
let pathArgPrompt: String?
|
||||||
|
let docsURL: String
|
||||||
|
|
||||||
|
static let gallery: [MCPServerPreset] = [
|
||||||
|
MCPServerPreset(
|
||||||
|
id: "filesystem",
|
||||||
|
displayName: "Filesystem",
|
||||||
|
description: "Read and write files under a root directory you choose.",
|
||||||
|
category: "Built-in",
|
||||||
|
iconSystemName: "folder",
|
||||||
|
transport: .stdio,
|
||||||
|
command: "npx",
|
||||||
|
args: ["-y", "@modelcontextprotocol/server-filesystem"],
|
||||||
|
url: nil,
|
||||||
|
auth: nil,
|
||||||
|
requiredEnvKeys: [],
|
||||||
|
optionalEnvKeys: [],
|
||||||
|
pathArgPrompt: "Root directory (absolute path)",
|
||||||
|
docsURL: "https://github.com/modelcontextprotocol/servers/tree/main/src/filesystem"
|
||||||
|
),
|
||||||
|
MCPServerPreset(
|
||||||
|
id: "github",
|
||||||
|
displayName: "GitHub",
|
||||||
|
description: "Issues, pull requests, code search, and file operations via GitHub API.",
|
||||||
|
category: "Dev",
|
||||||
|
iconSystemName: "chevron.left.forwardslash.chevron.right",
|
||||||
|
transport: .stdio,
|
||||||
|
command: "npx",
|
||||||
|
args: ["-y", "@modelcontextprotocol/server-github"],
|
||||||
|
url: nil,
|
||||||
|
auth: nil,
|
||||||
|
requiredEnvKeys: ["GITHUB_PERSONAL_ACCESS_TOKEN"],
|
||||||
|
optionalEnvKeys: [],
|
||||||
|
pathArgPrompt: nil,
|
||||||
|
docsURL: "https://github.com/modelcontextprotocol/servers/tree/main/src/github"
|
||||||
|
),
|
||||||
|
MCPServerPreset(
|
||||||
|
id: "postgres",
|
||||||
|
displayName: "Postgres",
|
||||||
|
description: "Read-only SQL access against a Postgres database.",
|
||||||
|
category: "Data",
|
||||||
|
iconSystemName: "cylinder.split.1x2",
|
||||||
|
transport: .stdio,
|
||||||
|
command: "npx",
|
||||||
|
args: ["-y", "@modelcontextprotocol/server-postgres"],
|
||||||
|
url: nil,
|
||||||
|
auth: nil,
|
||||||
|
requiredEnvKeys: [],
|
||||||
|
optionalEnvKeys: [],
|
||||||
|
pathArgPrompt: "Connection URL (postgres://user:pass@host/db)",
|
||||||
|
docsURL: "https://github.com/modelcontextprotocol/servers/tree/main/src/postgres"
|
||||||
|
),
|
||||||
|
MCPServerPreset(
|
||||||
|
id: "slack",
|
||||||
|
displayName: "Slack",
|
||||||
|
description: "Read channels, post messages, and search your Slack workspace.",
|
||||||
|
category: "Productivity",
|
||||||
|
iconSystemName: "bubble.left.and.bubble.right",
|
||||||
|
transport: .stdio,
|
||||||
|
command: "npx",
|
||||||
|
args: ["-y", "@modelcontextprotocol/server-slack"],
|
||||||
|
url: nil,
|
||||||
|
auth: nil,
|
||||||
|
requiredEnvKeys: ["SLACK_BOT_TOKEN", "SLACK_TEAM_ID"],
|
||||||
|
optionalEnvKeys: [],
|
||||||
|
pathArgPrompt: nil,
|
||||||
|
docsURL: "https://github.com/modelcontextprotocol/servers/tree/main/src/slack"
|
||||||
|
),
|
||||||
|
MCPServerPreset(
|
||||||
|
id: "linear",
|
||||||
|
displayName: "Linear",
|
||||||
|
description: "Query and update Linear issues. Uses OAuth — no token needed.",
|
||||||
|
category: "Productivity",
|
||||||
|
iconSystemName: "list.bullet.rectangle",
|
||||||
|
transport: .http,
|
||||||
|
command: nil,
|
||||||
|
args: [],
|
||||||
|
url: "https://mcp.linear.app/sse",
|
||||||
|
auth: "oauth",
|
||||||
|
requiredEnvKeys: [],
|
||||||
|
optionalEnvKeys: [],
|
||||||
|
pathArgPrompt: nil,
|
||||||
|
docsURL: "https://linear.app/docs/mcp"
|
||||||
|
),
|
||||||
|
MCPServerPreset(
|
||||||
|
id: "sentry",
|
||||||
|
displayName: "Sentry",
|
||||||
|
description: "Investigate errors and performance issues from Sentry.",
|
||||||
|
category: "Dev",
|
||||||
|
iconSystemName: "exclamationmark.triangle",
|
||||||
|
transport: .stdio,
|
||||||
|
command: "npx",
|
||||||
|
args: ["-y", "@sentry/mcp-server"],
|
||||||
|
url: nil,
|
||||||
|
auth: nil,
|
||||||
|
requiredEnvKeys: ["SENTRY_AUTH_TOKEN", "SENTRY_ORG"],
|
||||||
|
optionalEnvKeys: [],
|
||||||
|
pathArgPrompt: nil,
|
||||||
|
docsURL: "https://docs.sentry.io/product/mcp/"
|
||||||
|
),
|
||||||
|
MCPServerPreset(
|
||||||
|
id: "puppeteer",
|
||||||
|
displayName: "Puppeteer",
|
||||||
|
description: "Headless browser automation — navigate pages, click, screenshot.",
|
||||||
|
category: "Automation",
|
||||||
|
iconSystemName: "safari",
|
||||||
|
transport: .stdio,
|
||||||
|
command: "npx",
|
||||||
|
args: ["-y", "@modelcontextprotocol/server-puppeteer"],
|
||||||
|
url: nil,
|
||||||
|
auth: nil,
|
||||||
|
requiredEnvKeys: [],
|
||||||
|
optionalEnvKeys: [],
|
||||||
|
pathArgPrompt: nil,
|
||||||
|
docsURL: "https://github.com/modelcontextprotocol/servers/tree/main/src/puppeteer"
|
||||||
|
),
|
||||||
|
MCPServerPreset(
|
||||||
|
id: "memory",
|
||||||
|
displayName: "Memory (Knowledge Graph)",
|
||||||
|
description: "Persistent knowledge graph of entities and relations across sessions.",
|
||||||
|
category: "Built-in",
|
||||||
|
iconSystemName: "brain",
|
||||||
|
transport: .stdio,
|
||||||
|
command: "npx",
|
||||||
|
args: ["-y", "@modelcontextprotocol/server-memory"],
|
||||||
|
url: nil,
|
||||||
|
auth: nil,
|
||||||
|
requiredEnvKeys: [],
|
||||||
|
optionalEnvKeys: ["MEMORY_FILE_PATH"],
|
||||||
|
pathArgPrompt: nil,
|
||||||
|
docsURL: "https://github.com/modelcontextprotocol/servers/tree/main/src/memory"
|
||||||
|
),
|
||||||
|
MCPServerPreset(
|
||||||
|
id: "fetch",
|
||||||
|
displayName: "Fetch",
|
||||||
|
description: "Retrieve and convert web pages to markdown.",
|
||||||
|
category: "Built-in",
|
||||||
|
iconSystemName: "arrow.down.circle",
|
||||||
|
transport: .stdio,
|
||||||
|
command: "npx",
|
||||||
|
args: ["-y", "@modelcontextprotocol/server-fetch"],
|
||||||
|
url: nil,
|
||||||
|
auth: nil,
|
||||||
|
requiredEnvKeys: [],
|
||||||
|
optionalEnvKeys: [],
|
||||||
|
pathArgPrompt: nil,
|
||||||
|
docsURL: "https://github.com/modelcontextprotocol/servers/tree/main/src/fetch"
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
static var categories: [String] {
|
||||||
|
var seen = Set<String>()
|
||||||
|
return gallery.compactMap { p in seen.insert(p.category).inserted ? p.category : nil }
|
||||||
|
}
|
||||||
|
|
||||||
|
static func byCategory(_ category: String) -> [MCPServerPreset] {
|
||||||
|
gallery.filter { $0.category == category }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -87,7 +87,13 @@ struct HermesFileService: Sendable {
|
|||||||
memoryProvider: values["memory.provider"] ?? "",
|
memoryProvider: values["memory.provider"] ?? "",
|
||||||
dockerEnv: dockerEnv,
|
dockerEnv: dockerEnv,
|
||||||
commandAllowlist: commandAllowlist,
|
commandAllowlist: commandAllowlist,
|
||||||
memoryProfile: values["memory.profile"] ?? ""
|
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"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -240,6 +246,605 @@ struct HermesFileService: Sendable {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - MCP Servers
|
||||||
|
|
||||||
|
func loadMCPServers() -> [HermesMCPServer] {
|
||||||
|
guard let yaml = readFile(HermesPaths.configYAML) else { return [] }
|
||||||
|
let parsed = parseMCPServersBlock(yaml: yaml)
|
||||||
|
let fm = FileManager.default
|
||||||
|
return parsed.map { server in
|
||||||
|
let tokenPath = HermesPaths.mcpTokensDir + "/" + server.name + ".json"
|
||||||
|
let hasToken = fm.fileExists(atPath: tokenPath)
|
||||||
|
guard hasToken != server.hasOAuthToken else { return server }
|
||||||
|
return HermesMCPServer(
|
||||||
|
name: server.name,
|
||||||
|
transport: server.transport,
|
||||||
|
command: server.command,
|
||||||
|
args: server.args,
|
||||||
|
url: server.url,
|
||||||
|
auth: server.auth,
|
||||||
|
env: server.env,
|
||||||
|
headers: server.headers,
|
||||||
|
timeout: server.timeout,
|
||||||
|
connectTimeout: server.connectTimeout,
|
||||||
|
enabled: server.enabled,
|
||||||
|
toolsInclude: server.toolsInclude,
|
||||||
|
toolsExclude: server.toolsExclude,
|
||||||
|
resourcesEnabled: server.resourcesEnabled,
|
||||||
|
promptsEnabled: server.promptsEnabled,
|
||||||
|
hasOAuthToken: hasToken
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates the server entry via `hermes mcp add` with only the command (no args).
|
||||||
|
/// Args are written separately via `setMCPServerArgs` to avoid argparse issues with `-`-prefixed args like `-y`.
|
||||||
|
/// Pipes `y\n` because the CLI prompts to save even when the initial connection check fails (which it will, since we intentionally add no args first).
|
||||||
|
@discardableResult
|
||||||
|
func addMCPServerStdio(name: String, command: String, args: [String]) -> (exitCode: Int32, output: String) {
|
||||||
|
let addResult = runHermesCLI(
|
||||||
|
args: ["mcp", "add", name, "--command", command],
|
||||||
|
timeout: 45,
|
||||||
|
stdinInput: "y\ny\ny\n"
|
||||||
|
)
|
||||||
|
guard addResult.exitCode == 0 else { return addResult }
|
||||||
|
if !args.isEmpty {
|
||||||
|
_ = setMCPServerArgs(name: name, args: args)
|
||||||
|
}
|
||||||
|
return addResult
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func addMCPServerHTTP(name: String, url: String, auth: String?) -> (exitCode: Int32, output: String) {
|
||||||
|
var cliArgs: [String] = ["mcp", "add", name, "--url", url]
|
||||||
|
if let auth, !auth.isEmpty {
|
||||||
|
cliArgs.append(contentsOf: ["--auth", auth])
|
||||||
|
}
|
||||||
|
return runHermesCLI(args: cliArgs, timeout: 45, stdinInput: "y\ny\ny\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func setMCPServerArgs(name: String, args: [String]) -> Bool {
|
||||||
|
patchMCPServerField(name: name) { entryLines in
|
||||||
|
Self.replaceOrInsertList(header: "args", items: args, in: &entryLines)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func removeMCPServer(name: String) -> (exitCode: Int32, output: String) {
|
||||||
|
runHermesCLI(args: ["mcp", "remove", name], timeout: 30)
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated func testMCPServer(name: String) async -> MCPTestResult {
|
||||||
|
let started = Date()
|
||||||
|
let service = self
|
||||||
|
let result = await Task.detached { () -> (Int32, String) in
|
||||||
|
service.runHermesCLI(args: ["mcp", "test", name], timeout: 30)
|
||||||
|
}.value
|
||||||
|
let elapsed = Date().timeIntervalSince(started)
|
||||||
|
let tools = Self.parseToolListFromTestOutput(result.1)
|
||||||
|
return MCPTestResult(
|
||||||
|
serverName: name,
|
||||||
|
succeeded: result.0 == 0,
|
||||||
|
output: result.1,
|
||||||
|
tools: tools,
|
||||||
|
elapsed: elapsed
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func parseToolListFromTestOutput(_ output: String) -> [String] {
|
||||||
|
var tools: [String] = []
|
||||||
|
for rawLine in output.components(separatedBy: "\n") {
|
||||||
|
let line = rawLine.trimmingCharacters(in: .whitespaces)
|
||||||
|
guard line.hasPrefix("- ") || line.hasPrefix("* ") else { continue }
|
||||||
|
let candidate = String(line.dropFirst(2)).trimmingCharacters(in: .whitespaces)
|
||||||
|
// Take only the identifier before any separator (":" or whitespace).
|
||||||
|
let token = candidate.split(whereSeparator: { ":(".contains($0) || $0.isWhitespace }).first.map(String.init) ?? candidate
|
||||||
|
if !token.isEmpty, token.allSatisfy({ $0.isLetter || $0.isNumber || $0 == "_" || $0 == "-" }) {
|
||||||
|
tools.append(token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tools
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func toggleMCPServerEnabled(name: String, enabled: Bool) -> Bool {
|
||||||
|
patchMCPServerField(name: name) { entryLines in
|
||||||
|
Self.replaceOrInsertScalar(key: "enabled", value: enabled ? "true" : "false", in: &entryLines)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func setMCPServerEnv(name: String, env: [String: String]) -> Bool {
|
||||||
|
patchMCPServerField(name: name) { entryLines in
|
||||||
|
Self.replaceOrInsertSubMap(header: "env", map: env, in: &entryLines)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func setMCPServerHeaders(name: String, headers: [String: String]) -> Bool {
|
||||||
|
patchMCPServerField(name: name) { entryLines in
|
||||||
|
Self.replaceOrInsertSubMap(header: "headers", map: headers, in: &entryLines)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func updateMCPToolFilters(name: String, include: [String], exclude: [String], resources: Bool, prompts: Bool) -> Bool {
|
||||||
|
patchMCPServerField(name: name) { entryLines in
|
||||||
|
Self.replaceOrInsertToolsBlock(include: include, exclude: exclude, resources: resources, prompts: prompts, in: &entryLines)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func setMCPServerTimeouts(name: String, timeout: Int?, connectTimeout: Int?) -> Bool {
|
||||||
|
patchMCPServerField(name: name) { entryLines in
|
||||||
|
if let timeout {
|
||||||
|
Self.replaceOrInsertScalar(key: "timeout", value: String(timeout), in: &entryLines)
|
||||||
|
} else {
|
||||||
|
Self.removeScalar(key: "timeout", in: &entryLines)
|
||||||
|
}
|
||||||
|
if let connectTimeout {
|
||||||
|
Self.replaceOrInsertScalar(key: "connect_timeout", value: String(connectTimeout), in: &entryLines)
|
||||||
|
} else {
|
||||||
|
Self.removeScalar(key: "connect_timeout", in: &entryLines)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func deleteMCPOAuthToken(name: String) -> Bool {
|
||||||
|
let path = HermesPaths.mcpTokensDir + "/" + name + ".json"
|
||||||
|
do {
|
||||||
|
try FileManager.default.removeItem(atPath: path)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func restartGateway() -> (exitCode: Int32, output: String) {
|
||||||
|
runHermesCLI(args: ["gateway", "restart"], timeout: 30)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - MCP YAML: block extractor + parser
|
||||||
|
|
||||||
|
private struct MCPBlockLocation {
|
||||||
|
let prefix: [String]
|
||||||
|
let block: [String] // includes the "mcp_servers:" header line
|
||||||
|
let suffix: [String]
|
||||||
|
}
|
||||||
|
|
||||||
|
private func extractMCPBlock(yaml: String) -> MCPBlockLocation {
|
||||||
|
let lines = yaml.components(separatedBy: "\n")
|
||||||
|
var blockStart = -1
|
||||||
|
var blockEnd = lines.count
|
||||||
|
for (index, line) in lines.enumerated() {
|
||||||
|
if blockStart < 0 {
|
||||||
|
if line.hasPrefix("mcp_servers:") {
|
||||||
|
blockStart = index
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||||
|
if trimmed.isEmpty || trimmed.hasPrefix("#") { continue }
|
||||||
|
let indent = line.prefix(while: { $0 == " " }).count
|
||||||
|
if indent == 0 && trimmed.contains(":") {
|
||||||
|
blockEnd = index
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if blockStart < 0 {
|
||||||
|
return MCPBlockLocation(prefix: lines, block: [], suffix: [])
|
||||||
|
}
|
||||||
|
return MCPBlockLocation(
|
||||||
|
prefix: Array(lines[0..<blockStart]),
|
||||||
|
block: Array(lines[blockStart..<blockEnd]),
|
||||||
|
suffix: Array(lines[blockEnd..<lines.count])
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fileprivate func parseMCPServersBlock(yaml: String) -> [HermesMCPServer] {
|
||||||
|
let location = extractMCPBlock(yaml: yaml)
|
||||||
|
guard location.block.count > 1 else { return [] }
|
||||||
|
|
||||||
|
var servers: [HermesMCPServer] = []
|
||||||
|
|
||||||
|
var currentName: String?
|
||||||
|
var fields: [String: String] = [:]
|
||||||
|
var argsList: [String] = []
|
||||||
|
var envMap: [String: String] = [:]
|
||||||
|
var headersMap: [String: String] = [:]
|
||||||
|
var includeList: [String] = []
|
||||||
|
var excludeList: [String] = []
|
||||||
|
var resources = false
|
||||||
|
var prompts = false
|
||||||
|
var subSection: String?
|
||||||
|
|
||||||
|
func flush() {
|
||||||
|
guard let name = currentName else { return }
|
||||||
|
let transport: MCPTransport = fields["url"] != nil ? .http : .stdio
|
||||||
|
let enabledStr = fields["enabled"]?.lowercased()
|
||||||
|
let enabled = enabledStr != "false"
|
||||||
|
let timeout = fields["timeout"].flatMap(Int.init)
|
||||||
|
let connectTimeout = fields["connect_timeout"].flatMap(Int.init)
|
||||||
|
let server = HermesMCPServer(
|
||||||
|
name: name,
|
||||||
|
transport: transport,
|
||||||
|
command: fields["command"].map { Self.unquote($0) },
|
||||||
|
args: argsList,
|
||||||
|
url: fields["url"].map { Self.unquote($0) },
|
||||||
|
auth: fields["auth"].map { Self.unquote($0) },
|
||||||
|
env: envMap,
|
||||||
|
headers: headersMap,
|
||||||
|
timeout: timeout,
|
||||||
|
connectTimeout: connectTimeout,
|
||||||
|
enabled: enabled,
|
||||||
|
toolsInclude: includeList,
|
||||||
|
toolsExclude: excludeList,
|
||||||
|
resourcesEnabled: resources,
|
||||||
|
promptsEnabled: prompts,
|
||||||
|
hasOAuthToken: false
|
||||||
|
)
|
||||||
|
servers.append(server)
|
||||||
|
|
||||||
|
currentName = nil
|
||||||
|
fields = [:]
|
||||||
|
argsList = []
|
||||||
|
envMap = [:]
|
||||||
|
headersMap = [:]
|
||||||
|
includeList = []
|
||||||
|
excludeList = []
|
||||||
|
resources = false
|
||||||
|
prompts = false
|
||||||
|
subSection = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for rawLine in location.block.dropFirst() {
|
||||||
|
let trimmed = rawLine.trimmingCharacters(in: .whitespaces)
|
||||||
|
if trimmed.isEmpty || trimmed.hasPrefix("#") { continue }
|
||||||
|
let indent = rawLine.prefix(while: { $0 == " " }).count
|
||||||
|
|
||||||
|
if indent == 2 && trimmed.hasSuffix(":") && !trimmed.contains(" ") {
|
||||||
|
flush()
|
||||||
|
currentName = String(trimmed.dropLast())
|
||||||
|
subSection = nil
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
guard currentName != nil else { continue }
|
||||||
|
|
||||||
|
if indent == 4 {
|
||||||
|
if trimmed.hasPrefix("- ") && subSection == "args" {
|
||||||
|
argsList.append(Self.unquote(String(trimmed.dropFirst(2))))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
subSection = nil
|
||||||
|
if trimmed.hasSuffix(":") {
|
||||||
|
subSection = String(trimmed.dropLast())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if let colonIdx = trimmed.firstIndex(of: ":") {
|
||||||
|
let key = String(trimmed[..<colonIdx]).trimmingCharacters(in: .whitespaces)
|
||||||
|
let value = String(trimmed[trimmed.index(after: colonIdx)...]).trimmingCharacters(in: .whitespaces)
|
||||||
|
fields[key] = value
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if indent >= 6 {
|
||||||
|
switch subSection {
|
||||||
|
case "args":
|
||||||
|
if trimmed.hasPrefix("- ") {
|
||||||
|
argsList.append(Self.unquote(String(trimmed.dropFirst(2))))
|
||||||
|
}
|
||||||
|
case "env":
|
||||||
|
if let colonIdx = trimmed.firstIndex(of: ":") {
|
||||||
|
let key = String(trimmed[..<colonIdx]).trimmingCharacters(in: .whitespaces)
|
||||||
|
let value = String(trimmed[trimmed.index(after: colonIdx)...]).trimmingCharacters(in: .whitespaces)
|
||||||
|
envMap[key] = Self.unquote(value)
|
||||||
|
}
|
||||||
|
case "headers":
|
||||||
|
if let colonIdx = trimmed.firstIndex(of: ":") {
|
||||||
|
let key = String(trimmed[..<colonIdx]).trimmingCharacters(in: .whitespaces)
|
||||||
|
let value = String(trimmed[trimmed.index(after: colonIdx)...]).trimmingCharacters(in: .whitespaces)
|
||||||
|
headersMap[key] = Self.unquote(value)
|
||||||
|
}
|
||||||
|
case "tools":
|
||||||
|
if trimmed == "include:" {
|
||||||
|
subSection = "tools.include"
|
||||||
|
} else if trimmed == "exclude:" {
|
||||||
|
subSection = "tools.exclude"
|
||||||
|
} else if trimmed.hasPrefix("resources:") {
|
||||||
|
resources = trimmed.lowercased().hasSuffix("true")
|
||||||
|
} else if trimmed.hasPrefix("prompts:") {
|
||||||
|
prompts = trimmed.lowercased().hasSuffix("true")
|
||||||
|
}
|
||||||
|
case "tools.include":
|
||||||
|
if trimmed.hasPrefix("- ") {
|
||||||
|
includeList.append(Self.unquote(String(trimmed.dropFirst(2))))
|
||||||
|
}
|
||||||
|
case "tools.exclude":
|
||||||
|
if trimmed.hasPrefix("- ") {
|
||||||
|
excludeList.append(Self.unquote(String(trimmed.dropFirst(2))))
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
flush()
|
||||||
|
return servers
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - MCP YAML: surgical patcher
|
||||||
|
|
||||||
|
private func patchMCPServerField(name: String, mutate: (inout [String]) -> Void) -> Bool {
|
||||||
|
guard let yaml = readFile(HermesPaths.configYAML) else { return false }
|
||||||
|
let location = extractMCPBlock(yaml: yaml)
|
||||||
|
guard !location.block.isEmpty else { return false }
|
||||||
|
|
||||||
|
var block = location.block
|
||||||
|
|
||||||
|
var entryStart = -1
|
||||||
|
var entryEnd = block.count
|
||||||
|
for (index, line) in block.enumerated() {
|
||||||
|
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||||
|
let indent = line.prefix(while: { $0 == " " }).count
|
||||||
|
if entryStart < 0 {
|
||||||
|
if indent == 2 && trimmed == "\(name):" {
|
||||||
|
entryStart = index
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if trimmed.isEmpty || trimmed.hasPrefix("#") { continue }
|
||||||
|
if indent <= 2 {
|
||||||
|
entryEnd = index
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
guard entryStart >= 0 else { return false }
|
||||||
|
|
||||||
|
var entryLines = Array(block[entryStart..<entryEnd])
|
||||||
|
mutate(&entryLines)
|
||||||
|
|
||||||
|
block.replaceSubrange(entryStart..<entryEnd, with: entryLines)
|
||||||
|
|
||||||
|
var combined: [String] = []
|
||||||
|
combined.append(contentsOf: location.prefix)
|
||||||
|
combined.append(contentsOf: block)
|
||||||
|
combined.append(contentsOf: location.suffix)
|
||||||
|
let newYAML = combined.joined(separator: "\n")
|
||||||
|
writeFile(HermesPaths.configYAML, content: newYAML)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - MCP YAML: mutators
|
||||||
|
|
||||||
|
private static func replaceOrInsertScalar(key: String, value: String, in lines: inout [String]) {
|
||||||
|
// entry header is at lines[0] at indent 2. Scalars live at indent 4.
|
||||||
|
for index in 1..<lines.count {
|
||||||
|
let line = lines[index]
|
||||||
|
let indent = line.prefix(while: { $0 == " " }).count
|
||||||
|
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||||
|
if indent == 4, trimmed.hasPrefix(key + ":") || trimmed == key + ":" {
|
||||||
|
lines[index] = " \(key): \(value)"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if indent <= 2 && !trimmed.isEmpty && !trimmed.hasPrefix("#") {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Insert right after header.
|
||||||
|
lines.insert(" \(key): \(value)", at: 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func removeScalar(key: String, in lines: inout [String]) {
|
||||||
|
var removeIndex: Int?
|
||||||
|
for index in 1..<lines.count {
|
||||||
|
let line = lines[index]
|
||||||
|
let indent = line.prefix(while: { $0 == " " }).count
|
||||||
|
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||||
|
if indent == 4, trimmed.hasPrefix(key + ":") || trimmed == key + ":" {
|
||||||
|
removeIndex = index
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if indent <= 2 && !trimmed.isEmpty && !trimmed.hasPrefix("#") {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let removeIndex {
|
||||||
|
lines.remove(at: removeIndex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func replaceOrInsertList(header: String, items: [String], in lines: inout [String]) {
|
||||||
|
var headerIndex: Int?
|
||||||
|
var removeEnd: Int?
|
||||||
|
for index in 1..<lines.count {
|
||||||
|
let line = lines[index]
|
||||||
|
let indent = line.prefix(while: { $0 == " " }).count
|
||||||
|
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||||
|
if indent == 4 && trimmed == "\(header):" {
|
||||||
|
headerIndex = index
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if headerIndex != nil {
|
||||||
|
// List items can appear at indent 4 (as " - item") OR indent 6 depending on style.
|
||||||
|
if trimmed.hasPrefix("- ") && indent >= 4 {
|
||||||
|
continue
|
||||||
|
} else if trimmed.isEmpty || trimmed.hasPrefix("#") {
|
||||||
|
continue
|
||||||
|
} else if indent >= 6 {
|
||||||
|
continue
|
||||||
|
} else {
|
||||||
|
removeEnd = index
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if items.isEmpty {
|
||||||
|
if let headerIndex, let end = removeEnd {
|
||||||
|
lines.removeSubrange(headerIndex..<end)
|
||||||
|
} else if let headerIndex {
|
||||||
|
lines.removeSubrange(headerIndex..<lines.count)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var newLines: [String] = [" \(header):"]
|
||||||
|
for item in items {
|
||||||
|
newLines.append(" - \(yamlScalar(item))")
|
||||||
|
}
|
||||||
|
|
||||||
|
if let headerIndex {
|
||||||
|
let end = removeEnd ?? lines.count
|
||||||
|
lines.replaceSubrange(headerIndex..<end, with: newLines)
|
||||||
|
} else {
|
||||||
|
var insertAt = lines.count
|
||||||
|
for index in 1..<lines.count {
|
||||||
|
let line = lines[index]
|
||||||
|
let indent = line.prefix(while: { $0 == " " }).count
|
||||||
|
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||||
|
if indent <= 2 && !trimmed.isEmpty && !trimmed.hasPrefix("#") {
|
||||||
|
insertAt = index
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lines.insert(contentsOf: newLines, at: insertAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func replaceOrInsertSubMap(header: String, map: [String: String], in lines: inout [String]) {
|
||||||
|
var headerIndex: Int?
|
||||||
|
var removeEnd: Int?
|
||||||
|
for index in 1..<lines.count {
|
||||||
|
let line = lines[index]
|
||||||
|
let indent = line.prefix(while: { $0 == " " }).count
|
||||||
|
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||||
|
if indent == 4 && trimmed == "\(header):" {
|
||||||
|
headerIndex = index
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if headerIndex != nil {
|
||||||
|
if indent >= 6 {
|
||||||
|
continue
|
||||||
|
} else if trimmed.isEmpty || trimmed.hasPrefix("#") {
|
||||||
|
continue
|
||||||
|
} else {
|
||||||
|
removeEnd = index
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var newLines: [String] = []
|
||||||
|
if map.isEmpty {
|
||||||
|
if let headerIndex, let end = removeEnd {
|
||||||
|
lines.removeSubrange(headerIndex..<end)
|
||||||
|
} else if let headerIndex {
|
||||||
|
lines.removeSubrange(headerIndex..<lines.count)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
newLines.append(" \(header):")
|
||||||
|
for key in map.keys.sorted() {
|
||||||
|
let value = map[key] ?? ""
|
||||||
|
newLines.append(" \(key): \(yamlScalar(value))")
|
||||||
|
}
|
||||||
|
|
||||||
|
if let headerIndex {
|
||||||
|
let end = removeEnd ?? lines.count
|
||||||
|
lines.replaceSubrange(headerIndex..<end, with: newLines)
|
||||||
|
} else {
|
||||||
|
// Insert just before the first indent<=2 line we find after the header, else at end.
|
||||||
|
var insertAt = lines.count
|
||||||
|
for index in 1..<lines.count {
|
||||||
|
let line = lines[index]
|
||||||
|
let indent = line.prefix(while: { $0 == " " }).count
|
||||||
|
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||||
|
if indent <= 2 && !trimmed.isEmpty && !trimmed.hasPrefix("#") {
|
||||||
|
insertAt = index
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lines.insert(contentsOf: newLines, at: insertAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func replaceOrInsertToolsBlock(include: [String], exclude: [String], resources: Bool, prompts: Bool, in lines: inout [String]) {
|
||||||
|
var headerIndex: Int?
|
||||||
|
var removeEnd: Int?
|
||||||
|
for index in 1..<lines.count {
|
||||||
|
let line = lines[index]
|
||||||
|
let indent = line.prefix(while: { $0 == " " }).count
|
||||||
|
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||||
|
if indent == 4 && trimmed == "tools:" {
|
||||||
|
headerIndex = index
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if headerIndex != nil {
|
||||||
|
if indent >= 6 {
|
||||||
|
continue
|
||||||
|
} else if trimmed.isEmpty || trimmed.hasPrefix("#") {
|
||||||
|
continue
|
||||||
|
} else {
|
||||||
|
removeEnd = index
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var newLines: [String] = [" tools:"]
|
||||||
|
newLines.append(" include:")
|
||||||
|
for tool in include { newLines.append(" - \(yamlScalar(tool))") }
|
||||||
|
newLines.append(" exclude:")
|
||||||
|
for tool in exclude { newLines.append(" - \(yamlScalar(tool))") }
|
||||||
|
newLines.append(" resources: \(resources ? "true" : "false")")
|
||||||
|
newLines.append(" prompts: \(prompts ? "true" : "false")")
|
||||||
|
|
||||||
|
if let headerIndex {
|
||||||
|
let end = removeEnd ?? lines.count
|
||||||
|
lines.replaceSubrange(headerIndex..<end, with: newLines)
|
||||||
|
} else {
|
||||||
|
var insertAt = lines.count
|
||||||
|
for index in 1..<lines.count {
|
||||||
|
let line = lines[index]
|
||||||
|
let indent = line.prefix(while: { $0 == " " }).count
|
||||||
|
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||||
|
if indent <= 2 && !trimmed.isEmpty && !trimmed.hasPrefix("#") {
|
||||||
|
insertAt = index
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lines.insert(contentsOf: newLines, at: insertAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func yamlScalar(_ value: String) -> String {
|
||||||
|
if value.isEmpty { return "\"\"" }
|
||||||
|
let needsQuoting = value.contains(":") || value.contains("#") || value.contains("\"")
|
||||||
|
|| value.hasPrefix(" ") || value.hasSuffix(" ") || value.hasPrefix("-")
|
||||||
|
|| ["true", "false", "null", "yes", "no"].contains(value.lowercased())
|
||||||
|
if needsQuoting {
|
||||||
|
let escaped = value.replacingOccurrences(of: "\\", with: "\\\\")
|
||||||
|
.replacingOccurrences(of: "\"", with: "\\\"")
|
||||||
|
return "\"\(escaped)\""
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func unquote(_ value: String) -> String {
|
||||||
|
var v = value
|
||||||
|
if (v.hasPrefix("\"") && v.hasSuffix("\"") && v.count >= 2) || (v.hasPrefix("'") && v.hasSuffix("'") && v.count >= 2) {
|
||||||
|
v = String(v.dropFirst().dropLast())
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Hermes Process
|
// MARK: - Hermes Process
|
||||||
|
|
||||||
func isHermesRunning() -> Bool {
|
func isHermesRunning() -> Bool {
|
||||||
@@ -268,10 +873,65 @@ struct HermesFileService: Sendable {
|
|||||||
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
func stopHermes() -> Bool {
|
func stopHermes() -> Bool {
|
||||||
|
// v0.9.0 fixed `hermes gateway stop` so it issues `launchctl bootout` and
|
||||||
|
// waits for exit. Use the CLI to avoid racing launchd's KeepAlive respawn.
|
||||||
|
if runHermesCLI(args: ["gateway", "stop"]).exitCode == 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
guard let pid = hermesPID() else { return false }
|
guard let pid = hermesPID() else { return false }
|
||||||
return kill(pid, SIGTERM) == 0
|
return kill(pid, SIGTERM) == 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
nonisolated func runHermesCLI(args: [String], timeout: TimeInterval = 60, stdinInput: String? = nil) -> (exitCode: Int32, output: String) {
|
||||||
|
guard let binary = hermesBinaryPath() else { return (-1, "") }
|
||||||
|
let stdoutPipe = Pipe()
|
||||||
|
let stderrPipe = Pipe()
|
||||||
|
let stdinPipe: Pipe? = stdinInput != nil ? Pipe() : nil
|
||||||
|
let process = Process()
|
||||||
|
process.executableURL = URL(fileURLWithPath: binary)
|
||||||
|
process.arguments = args
|
||||||
|
process.standardOutput = stdoutPipe
|
||||||
|
process.standardError = stderrPipe
|
||||||
|
if let stdinPipe { process.standardInput = stdinPipe }
|
||||||
|
defer {
|
||||||
|
try? stdoutPipe.fileHandleForReading.close()
|
||||||
|
try? stdoutPipe.fileHandleForWriting.close()
|
||||||
|
try? stderrPipe.fileHandleForReading.close()
|
||||||
|
try? stderrPipe.fileHandleForWriting.close()
|
||||||
|
try? stdinPipe?.fileHandleForReading.close()
|
||||||
|
try? stdinPipe?.fileHandleForWriting.close()
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
try process.run()
|
||||||
|
if let stdinInput, let stdinPipe, let data = stdinInput.data(using: .utf8) {
|
||||||
|
stdinPipe.fileHandleForWriting.write(data)
|
||||||
|
try? stdinPipe.fileHandleForWriting.close()
|
||||||
|
}
|
||||||
|
let deadline = Date().addingTimeInterval(timeout)
|
||||||
|
while process.isRunning && Date() < deadline {
|
||||||
|
Thread.sleep(forTimeInterval: 0.05)
|
||||||
|
}
|
||||||
|
if process.isRunning { process.terminate() }
|
||||||
|
process.waitUntilExit()
|
||||||
|
let outData = stdoutPipe.fileHandleForReading.readDataToEndOfFile()
|
||||||
|
let errData = stderrPipe.fileHandleForReading.readDataToEndOfFile()
|
||||||
|
let combined = (String(data: outData, encoding: .utf8) ?? "") + (String(data: errData, encoding: .utf8) ?? "")
|
||||||
|
return (process.terminationStatus, combined)
|
||||||
|
} catch {
|
||||||
|
return (-1, error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - File I/O
|
// MARK: - File I/O
|
||||||
|
|
||||||
private func readFile(_ path: String) -> String? {
|
private func readFile(_ path: String) -> String? {
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ final class HermesFileWatcher {
|
|||||||
HermesPaths.agentLog,
|
HermesPaths.agentLog,
|
||||||
HermesPaths.errorsLog,
|
HermesPaths.errorsLog,
|
||||||
HermesPaths.gatewayLog,
|
HermesPaths.gatewayLog,
|
||||||
HermesPaths.projectsRegistry
|
HermesPaths.projectsRegistry,
|
||||||
|
HermesPaths.mcpTokensDir
|
||||||
]
|
]
|
||||||
|
|
||||||
for path in paths {
|
for path in paths {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ struct LogEntry: Identifiable, Sendable {
|
|||||||
let id: Int
|
let id: Int
|
||||||
let timestamp: String
|
let timestamp: String
|
||||||
let level: LogLevel
|
let level: LogLevel
|
||||||
|
let sessionId: String?
|
||||||
let logger: String
|
let logger: String
|
||||||
let message: String
|
let message: String
|
||||||
let raw: String
|
let raw: String
|
||||||
@@ -72,23 +73,30 @@ actor HermesLogService {
|
|||||||
|
|
||||||
private func parseLine(_ line: String) -> LogEntry {
|
private func parseLine(_ line: String) -> LogEntry {
|
||||||
entryCounter += 1
|
entryCounter += 1
|
||||||
// Format: YYYY-MM-DD HH:MM:SS,MMM LEVEL logger: message
|
// Format (v0.9.0+): YYYY-MM-DD HH:MM:SS,MMM LEVEL [session_id] logger: message
|
||||||
let pattern = #"^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2},\d{3})\s+(DEBUG|INFO|WARNING|ERROR|CRITICAL)\s+(\S+?):\s+(.*)$"#
|
// Session tag is optional — earlier Hermes releases and out-of-session lines omit it.
|
||||||
|
let pattern = #"^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2},\d{3})\s+(DEBUG|INFO|WARNING|ERROR|CRITICAL)\s+(?:\[([^\]]+)\]\s+)?(\S+?):\s+(.*)$"#
|
||||||
if let regex = try? NSRegularExpression(pattern: pattern),
|
if let regex = try? NSRegularExpression(pattern: pattern),
|
||||||
let match = regex.firstMatch(in: line, range: NSRange(line.startIndex..., in: line)) {
|
let match = regex.firstMatch(in: line, range: NSRange(line.startIndex..., in: line)) {
|
||||||
let timestamp = String(line[Range(match.range(at: 1), in: line)!])
|
let timestamp = String(line[Range(match.range(at: 1), in: line)!])
|
||||||
let levelStr = String(line[Range(match.range(at: 2), in: line)!])
|
let levelStr = String(line[Range(match.range(at: 2), in: line)!])
|
||||||
let logger = String(line[Range(match.range(at: 3), in: line)!])
|
let sessionId: String? = {
|
||||||
let message = String(line[Range(match.range(at: 4), in: line)!])
|
let range = match.range(at: 3)
|
||||||
|
guard range.location != NSNotFound, let r = Range(range, in: line) else { return nil }
|
||||||
|
return String(line[r])
|
||||||
|
}()
|
||||||
|
let logger = String(line[Range(match.range(at: 4), in: line)!])
|
||||||
|
let message = String(line[Range(match.range(at: 5), in: line)!])
|
||||||
return LogEntry(
|
return LogEntry(
|
||||||
id: entryCounter,
|
id: entryCounter,
|
||||||
timestamp: timestamp,
|
timestamp: timestamp,
|
||||||
level: LogEntry.LogLevel(rawValue: levelStr) ?? .info,
|
level: LogEntry.LogLevel(rawValue: levelStr) ?? .info,
|
||||||
|
sessionId: sessionId,
|
||||||
logger: logger,
|
logger: logger,
|
||||||
message: message,
|
message: message,
|
||||||
raw: line
|
raw: line
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return LogEntry(id: entryCounter, timestamp: "", level: .info, logger: "", message: line, raw: line)
|
return LogEntry(id: entryCounter, timestamp: "", level: .info, sessionId: nil, logger: "", message: line, raw: line)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,11 @@ final class RichChatViewModel {
|
|||||||
private(set) var acpThoughtTokens = 0
|
private(set) var acpThoughtTokens = 0
|
||||||
private(set) var acpCachedReadTokens = 0
|
private(set) var acpCachedReadTokens = 0
|
||||||
|
|
||||||
|
/// Slash commands advertised by the ACP server via `available_commands_update`.
|
||||||
|
private(set) var availableCommandNames: Set<String> = []
|
||||||
|
|
||||||
|
var supportsCompress: Bool { availableCommandNames.contains("compress") }
|
||||||
|
|
||||||
var hasMessages: Bool { !messages.isEmpty }
|
var hasMessages: Bool { !messages.isEmpty }
|
||||||
|
|
||||||
func requestScrollToBottom() {
|
func requestScrollToBottom() {
|
||||||
@@ -93,6 +98,7 @@ final class RichChatViewModel {
|
|||||||
acpOutputTokens = 0
|
acpOutputTokens = 0
|
||||||
acpThoughtTokens = 0
|
acpThoughtTokens = 0
|
||||||
acpCachedReadTokens = 0
|
acpCachedReadTokens = 0
|
||||||
|
availableCommandNames = []
|
||||||
pendingPermission = nil
|
pendingPermission = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,7 +173,16 @@ final class RichChatViewModel {
|
|||||||
handlePromptComplete(response: response)
|
handlePromptComplete(response: response)
|
||||||
case .connectionLost(let reason):
|
case .connectionLost(let reason):
|
||||||
handleConnectionLost(reason: reason)
|
handleConnectionLost(reason: reason)
|
||||||
case .availableCommands, .unknown:
|
case .availableCommands(_, let commands):
|
||||||
|
var names: Set<String> = []
|
||||||
|
for entry in commands {
|
||||||
|
if let name = entry["name"] as? String {
|
||||||
|
// Hermes sends names either as "compress" or "/compress"
|
||||||
|
names.insert(name.trimmingCharacters(in: CharacterSet(charactersIn: "/")))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
availableCommandNames = names
|
||||||
|
case .unknown:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,12 +3,29 @@ import SwiftUI
|
|||||||
struct RichChatInputBar: View {
|
struct RichChatInputBar: View {
|
||||||
let onSend: (String) -> Void
|
let onSend: (String) -> Void
|
||||||
let isEnabled: Bool
|
let isEnabled: Bool
|
||||||
|
var supportsCompress: Bool = false
|
||||||
|
|
||||||
@State private var text = ""
|
@State private var text = ""
|
||||||
|
@State private var showCompressSheet = false
|
||||||
|
@State private var compressFocus = ""
|
||||||
@FocusState private var isFocused: Bool
|
@FocusState private var isFocused: Bool
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(alignment: .bottom, spacing: 8) {
|
HStack(alignment: .bottom, spacing: 8) {
|
||||||
|
if supportsCompress {
|
||||||
|
Button {
|
||||||
|
compressFocus = ""
|
||||||
|
showCompressSheet = true
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "rectangle.compress.vertical")
|
||||||
|
.font(.title3)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.disabled(!isEnabled)
|
||||||
|
.help("Compress conversation (/compress)")
|
||||||
|
}
|
||||||
|
|
||||||
TextEditor(text: $text)
|
TextEditor(text: $text)
|
||||||
.font(.body)
|
.font(.body)
|
||||||
.scrollContentBackground(.hidden)
|
.scrollContentBackground(.hidden)
|
||||||
@@ -50,6 +67,34 @@ struct RichChatInputBar: View {
|
|||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
.padding(.vertical, 8)
|
.padding(.vertical, 8)
|
||||||
.background(.bar)
|
.background(.bar)
|
||||||
|
.sheet(isPresented: $showCompressSheet) {
|
||||||
|
compressSheet
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var compressSheet: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Text("Compress Conversation")
|
||||||
|
.font(.headline)
|
||||||
|
Text("Optionally focus the summary on a specific topic. Leave blank to compress evenly.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
TextField("Focus topic (optional)", text: $compressFocus)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
HStack {
|
||||||
|
Spacer()
|
||||||
|
Button("Cancel") { showCompressSheet = false }
|
||||||
|
Button("Compress") {
|
||||||
|
let focus = compressFocus.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let command = focus.isEmpty ? "/compress" : "/compress \(focus)"
|
||||||
|
onSend(command)
|
||||||
|
showCompressSheet = false
|
||||||
|
}
|
||||||
|
.keyboardShortcut(.defaultAction)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(20)
|
||||||
|
.frame(width: 360)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var canSend: Bool {
|
private var canSend: Bool {
|
||||||
|
|||||||
@@ -41,7 +41,8 @@ struct RichChatView: View {
|
|||||||
onSend: { text in
|
onSend: { text in
|
||||||
onSend(text)
|
onSend(text)
|
||||||
},
|
},
|
||||||
isEnabled: isEnabled
|
isEnabled: isEnabled,
|
||||||
|
supportsCompress: richChat.supportsCompress
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// DB polling fallback for terminal mode only — never overwrite ACP messages
|
// DB polling fallback for terminal mode only — never overwrite ACP messages
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ struct CronView: View {
|
|||||||
Label(job.state, systemImage: job.stateIcon)
|
Label(job.state, systemImage: job.stateIcon)
|
||||||
Label(job.schedule.display ?? job.schedule.kind, systemImage: "clock")
|
Label(job.schedule.display ?? job.schedule.kind, systemImage: "clock")
|
||||||
Label(job.enabled ? "Enabled" : "Disabled", systemImage: job.enabled ? "checkmark.circle" : "xmark.circle")
|
Label(job.enabled ? "Enabled" : "Disabled", systemImage: job.enabled ? "checkmark.circle" : "xmark.circle")
|
||||||
if let deliver = job.deliver {
|
if let deliver = job.deliveryDisplay {
|
||||||
Label("Deliver: \(deliver)", systemImage: "paperplane")
|
Label("Deliver: \(deliver)", systemImage: "paperplane")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ final class LogsViewModel {
|
|||||||
var entries: [LogEntry] = []
|
var entries: [LogEntry] = []
|
||||||
var selectedLogFile: LogFile = .agent
|
var selectedLogFile: LogFile = .agent
|
||||||
var filterLevel: LogEntry.LogLevel?
|
var filterLevel: LogEntry.LogLevel?
|
||||||
|
var selectedComponent: LogComponent = .all
|
||||||
var searchText = ""
|
var searchText = ""
|
||||||
private var pollTimer: Timer?
|
private var pollTimer: Timer?
|
||||||
|
|
||||||
@@ -26,11 +27,37 @@ final class LogsViewModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum LogComponent: String, CaseIterable, Identifiable {
|
||||||
|
case all = "All"
|
||||||
|
case gateway = "Gateway"
|
||||||
|
case agent = "Agent"
|
||||||
|
case tools = "Tools"
|
||||||
|
case cli = "CLI"
|
||||||
|
case cron = "Cron"
|
||||||
|
|
||||||
|
var id: String { rawValue }
|
||||||
|
|
||||||
|
var loggerPrefix: String? {
|
||||||
|
switch self {
|
||||||
|
case .all: return nil
|
||||||
|
case .gateway: return "gateway"
|
||||||
|
case .agent: return "agent"
|
||||||
|
case .tools: return "tools"
|
||||||
|
case .cli: return "cli"
|
||||||
|
case .cron: return "cron"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var filteredEntries: [LogEntry] {
|
var filteredEntries: [LogEntry] {
|
||||||
entries.filter { entry in
|
entries.filter { entry in
|
||||||
let levelOk = filterLevel == nil || entry.level == filterLevel
|
let levelOk = filterLevel == nil || entry.level == filterLevel
|
||||||
let searchOk = searchText.isEmpty || entry.raw.localizedCaseInsensitiveContains(searchText)
|
let searchOk = searchText.isEmpty || entry.raw.localizedCaseInsensitiveContains(searchText)
|
||||||
return levelOk && searchOk
|
let componentOk: Bool = {
|
||||||
|
guard let prefix = selectedComponent.loggerPrefix else { return true }
|
||||||
|
return entry.logger.hasPrefix(prefix)
|
||||||
|
}()
|
||||||
|
return levelOk && searchOk && componentOk
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,13 @@ struct LogsView: View {
|
|||||||
.pickerStyle(.segmented)
|
.pickerStyle(.segmented)
|
||||||
.frame(maxWidth: 300)
|
.frame(maxWidth: 300)
|
||||||
|
|
||||||
|
Picker("Component", selection: $viewModel.selectedComponent) {
|
||||||
|
ForEach(LogsViewModel.LogComponent.allCases) { component in
|
||||||
|
Text(component.rawValue).tag(component)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: 140)
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
Picker("Level", selection: $viewModel.filterLevel) {
|
Picker("Level", selection: $viewModel.filterLevel) {
|
||||||
@@ -58,6 +65,27 @@ struct LogsView: View {
|
|||||||
.font(.caption.monospaced().bold())
|
.font(.caption.monospaced().bold())
|
||||||
.foregroundStyle(colorForLevel(entry.level))
|
.foregroundStyle(colorForLevel(entry.level))
|
||||||
.frame(width: 60, alignment: .leading)
|
.frame(width: 60, alignment: .leading)
|
||||||
|
if let sessionId = entry.sessionId {
|
||||||
|
Button {
|
||||||
|
viewModel.searchText = sessionId
|
||||||
|
} label: {
|
||||||
|
Text(sessionId)
|
||||||
|
.font(.system(.caption2, design: .monospaced))
|
||||||
|
.padding(.horizontal, 4)
|
||||||
|
.padding(.vertical, 1)
|
||||||
|
.background(Color.accentColor.opacity(0.15))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 3))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.help("Filter to session \(sessionId)")
|
||||||
|
}
|
||||||
|
Text(entry.logger)
|
||||||
|
.font(.system(.caption2, design: .monospaced))
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
.lineLimit(1)
|
||||||
|
.truncationMode(.middle)
|
||||||
|
.frame(maxWidth: 140, alignment: .leading)
|
||||||
Text(entry.message)
|
Text(entry.message)
|
||||||
.font(.system(.caption, design: .monospaced))
|
.font(.system(.caption, design: .monospaced))
|
||||||
.textSelection(.enabled)
|
.textSelection(.enabled)
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
@Observable
|
||||||
|
final class MCPServerEditorViewModel {
|
||||||
|
struct KeyValueRow: Identifiable, Equatable {
|
||||||
|
let id = UUID()
|
||||||
|
var key: String
|
||||||
|
var value: String
|
||||||
|
}
|
||||||
|
|
||||||
|
private let fileService = HermesFileService()
|
||||||
|
let server: HermesMCPServer
|
||||||
|
|
||||||
|
var envDraft: [KeyValueRow]
|
||||||
|
var headersDraft: [KeyValueRow]
|
||||||
|
var includeDraft: String
|
||||||
|
var excludeDraft: String
|
||||||
|
var resourcesEnabled: Bool
|
||||||
|
var promptsEnabled: Bool
|
||||||
|
var timeoutDraft: String
|
||||||
|
var connectTimeoutDraft: String
|
||||||
|
var showSecrets: Bool = false
|
||||||
|
var isSaving: Bool = false
|
||||||
|
var saveError: String?
|
||||||
|
|
||||||
|
init(server: HermesMCPServer) {
|
||||||
|
self.server = server
|
||||||
|
self.envDraft = server.env.keys.sorted().map { KeyValueRow(key: $0, value: server.env[$0] ?? "") }
|
||||||
|
self.headersDraft = server.headers.keys.sorted().map { KeyValueRow(key: $0, value: server.headers[$0] ?? "") }
|
||||||
|
self.includeDraft = server.toolsInclude.joined(separator: ", ")
|
||||||
|
self.excludeDraft = server.toolsExclude.joined(separator: ", ")
|
||||||
|
self.resourcesEnabled = server.resourcesEnabled
|
||||||
|
self.promptsEnabled = server.promptsEnabled
|
||||||
|
self.timeoutDraft = server.timeout.map { String($0) } ?? ""
|
||||||
|
self.connectTimeoutDraft = server.connectTimeout.map { String($0) } ?? ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendEnvRow() {
|
||||||
|
envDraft.append(KeyValueRow(key: "", value: ""))
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeEnvRow(id: UUID) {
|
||||||
|
envDraft.removeAll { $0.id == id }
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendHeaderRow() {
|
||||||
|
headersDraft.append(KeyValueRow(key: "", value: ""))
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeHeaderRow(id: UUID) {
|
||||||
|
headersDraft.removeAll { $0.id == id }
|
||||||
|
}
|
||||||
|
|
||||||
|
func save(completion: @escaping (Bool) -> Void) {
|
||||||
|
isSaving = true
|
||||||
|
saveError = nil
|
||||||
|
|
||||||
|
let envMap = Dictionary(uniqueKeysWithValues: envDraft
|
||||||
|
.filter { !$0.key.trimmingCharacters(in: .whitespaces).isEmpty }
|
||||||
|
.map { ($0.key.trimmingCharacters(in: .whitespaces), $0.value) })
|
||||||
|
let headerMap = Dictionary(uniqueKeysWithValues: headersDraft
|
||||||
|
.filter { !$0.key.trimmingCharacters(in: .whitespaces).isEmpty }
|
||||||
|
.map { ($0.key.trimmingCharacters(in: .whitespaces), $0.value) })
|
||||||
|
let include = includeDraft.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty }
|
||||||
|
let exclude = excludeDraft.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty }
|
||||||
|
let timeoutValue = Int(timeoutDraft.trimmingCharacters(in: .whitespaces))
|
||||||
|
let connectValue = Int(connectTimeoutDraft.trimmingCharacters(in: .whitespaces))
|
||||||
|
|
||||||
|
let service = fileService
|
||||||
|
let transport = server.transport
|
||||||
|
let name = server.name
|
||||||
|
let resources = resourcesEnabled
|
||||||
|
let prompts = promptsEnabled
|
||||||
|
|
||||||
|
Task.detached {
|
||||||
|
var success = true
|
||||||
|
switch transport {
|
||||||
|
case .stdio:
|
||||||
|
if !service.setMCPServerEnv(name: name, env: envMap) { success = false }
|
||||||
|
case .http:
|
||||||
|
if !service.setMCPServerHeaders(name: name, headers: headerMap) { success = false }
|
||||||
|
}
|
||||||
|
if !service.updateMCPToolFilters(
|
||||||
|
name: name,
|
||||||
|
include: include,
|
||||||
|
exclude: exclude,
|
||||||
|
resources: resources,
|
||||||
|
prompts: prompts
|
||||||
|
) { success = false }
|
||||||
|
if !service.setMCPServerTimeouts(name: name, timeout: timeoutValue, connectTimeout: connectValue) {
|
||||||
|
success = false
|
||||||
|
}
|
||||||
|
await MainActor.run {
|
||||||
|
self.isSaving = false
|
||||||
|
if !success {
|
||||||
|
self.saveError = "One or more fields could not be written. Check \(HermesPaths.configYAML)."
|
||||||
|
}
|
||||||
|
completion(success)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearOAuthToken(completion: @escaping (Bool) -> Void) {
|
||||||
|
let service = fileService
|
||||||
|
let name = server.name
|
||||||
|
Task.detached {
|
||||||
|
let ok = service.deleteMCPOAuthToken(name: name)
|
||||||
|
await MainActor.run { completion(ok) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
@Observable
|
||||||
|
final class MCPServersViewModel {
|
||||||
|
private let fileService = HermesFileService()
|
||||||
|
|
||||||
|
var servers: [HermesMCPServer] = []
|
||||||
|
var selectedServerName: String?
|
||||||
|
var searchText = ""
|
||||||
|
var isLoading = false
|
||||||
|
var statusMessage: String?
|
||||||
|
var showPresetPicker = false
|
||||||
|
var showAddCustom = false
|
||||||
|
var showRestartBanner = false
|
||||||
|
var testResults: [String: MCPTestResult] = [:]
|
||||||
|
var testingNames: Set<String> = []
|
||||||
|
var activeError: String?
|
||||||
|
var editingServer: HermesMCPServer?
|
||||||
|
|
||||||
|
var filteredServers: [HermesMCPServer] {
|
||||||
|
guard !searchText.isEmpty else { return servers }
|
||||||
|
let query = searchText.lowercased()
|
||||||
|
return servers.filter { server in
|
||||||
|
server.name.lowercased().contains(query) ||
|
||||||
|
server.summary.lowercased().contains(query)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var stdioServers: [HermesMCPServer] {
|
||||||
|
filteredServers.filter { $0.transport == .stdio }
|
||||||
|
}
|
||||||
|
|
||||||
|
var httpServers: [HermesMCPServer] {
|
||||||
|
filteredServers.filter { $0.transport == .http }
|
||||||
|
}
|
||||||
|
|
||||||
|
var selectedServer: HermesMCPServer? {
|
||||||
|
guard let name = selectedServerName else { return nil }
|
||||||
|
return servers.first(where: { $0.name == name })
|
||||||
|
}
|
||||||
|
|
||||||
|
func load() {
|
||||||
|
isLoading = true
|
||||||
|
servers = fileService.loadMCPServers()
|
||||||
|
isLoading = false
|
||||||
|
if let name = selectedServerName, !servers.contains(where: { $0.name == name }) {
|
||||||
|
selectedServerName = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectServer(name: String?) {
|
||||||
|
selectedServerName = name
|
||||||
|
}
|
||||||
|
|
||||||
|
func beginEdit() {
|
||||||
|
editingServer = selectedServer
|
||||||
|
}
|
||||||
|
|
||||||
|
func finishEdit(reload: Bool) {
|
||||||
|
editingServer = nil
|
||||||
|
if reload {
|
||||||
|
load()
|
||||||
|
showRestartBanner = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteServer(name: String) {
|
||||||
|
let fileService = self.fileService
|
||||||
|
Task.detached {
|
||||||
|
let result = fileService.removeMCPServer(name: name)
|
||||||
|
await MainActor.run {
|
||||||
|
if result.exitCode == 0 {
|
||||||
|
self.flashStatus("Removed \(name)")
|
||||||
|
if self.selectedServerName == name {
|
||||||
|
self.selectedServerName = nil
|
||||||
|
}
|
||||||
|
self.testResults.removeValue(forKey: name)
|
||||||
|
self.load()
|
||||||
|
self.showRestartBanner = true
|
||||||
|
} else {
|
||||||
|
self.activeError = "Remove failed: \(result.output)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toggleEnabled(name: String) {
|
||||||
|
guard let server = servers.first(where: { $0.name == name }) else { return }
|
||||||
|
let newValue = !server.enabled
|
||||||
|
let fileService = self.fileService
|
||||||
|
Task.detached {
|
||||||
|
let ok = fileService.toggleMCPServerEnabled(name: name, enabled: newValue)
|
||||||
|
await MainActor.run {
|
||||||
|
if ok {
|
||||||
|
self.flashStatus(newValue ? "Enabled \(name)" : "Disabled \(name)")
|
||||||
|
self.load()
|
||||||
|
self.showRestartBanner = true
|
||||||
|
} else {
|
||||||
|
self.activeError = "Could not update \(name)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testServer(name: String) {
|
||||||
|
guard !testingNames.contains(name) else { return }
|
||||||
|
testingNames.insert(name)
|
||||||
|
let fileService = self.fileService
|
||||||
|
Task.detached {
|
||||||
|
let result = await fileService.testMCPServer(name: name)
|
||||||
|
await MainActor.run {
|
||||||
|
self.testingNames.remove(name)
|
||||||
|
self.testResults[name] = result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAll() {
|
||||||
|
let targets = servers.map(\.name)
|
||||||
|
let fileService = self.fileService
|
||||||
|
Task.detached {
|
||||||
|
for name in targets {
|
||||||
|
let result = await fileService.testMCPServer(name: name)
|
||||||
|
await MainActor.run {
|
||||||
|
self.testResults[name] = result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func addFromPreset(preset: MCPServerPreset, name: String, pathArg: String?, envValues: [String: String]) {
|
||||||
|
let fileService = self.fileService
|
||||||
|
let allArgs: [String] = {
|
||||||
|
var base = preset.args
|
||||||
|
if let pathArg, !pathArg.isEmpty { base.append(pathArg) }
|
||||||
|
return base
|
||||||
|
}()
|
||||||
|
Task.detached {
|
||||||
|
let addResult: (exitCode: Int32, output: String)
|
||||||
|
switch preset.transport {
|
||||||
|
case .stdio:
|
||||||
|
addResult = fileService.addMCPServerStdio(
|
||||||
|
name: name,
|
||||||
|
command: preset.command ?? "",
|
||||||
|
args: allArgs
|
||||||
|
)
|
||||||
|
case .http:
|
||||||
|
addResult = fileService.addMCPServerHTTP(
|
||||||
|
name: name,
|
||||||
|
url: preset.url ?? "",
|
||||||
|
auth: preset.auth
|
||||||
|
)
|
||||||
|
}
|
||||||
|
guard addResult.exitCode == 0 else {
|
||||||
|
await MainActor.run {
|
||||||
|
self.activeError = "Add failed: \(addResult.output)"
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !envValues.isEmpty {
|
||||||
|
_ = fileService.setMCPServerEnv(name: name, env: envValues)
|
||||||
|
}
|
||||||
|
await MainActor.run {
|
||||||
|
self.flashStatus("Added \(name)")
|
||||||
|
self.load()
|
||||||
|
self.selectedServerName = name
|
||||||
|
self.showRestartBanner = true
|
||||||
|
self.showPresetPicker = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func addCustom(name: String, transport: MCPTransport, command: String, args: [String], url: String, auth: String?) {
|
||||||
|
let fileService = self.fileService
|
||||||
|
Task.detached {
|
||||||
|
let result: (exitCode: Int32, output: String)
|
||||||
|
switch transport {
|
||||||
|
case .stdio:
|
||||||
|
result = fileService.addMCPServerStdio(name: name, command: command, args: args)
|
||||||
|
case .http:
|
||||||
|
result = fileService.addMCPServerHTTP(name: name, url: url, auth: auth)
|
||||||
|
}
|
||||||
|
await MainActor.run {
|
||||||
|
if result.exitCode == 0 {
|
||||||
|
self.flashStatus("Added \(name)")
|
||||||
|
self.load()
|
||||||
|
self.selectedServerName = name
|
||||||
|
self.showRestartBanner = true
|
||||||
|
self.showAddCustom = false
|
||||||
|
} else {
|
||||||
|
self.activeError = "Add failed: \(result.output)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func restartGateway() {
|
||||||
|
let fileService = self.fileService
|
||||||
|
Task.detached {
|
||||||
|
let result = fileService.restartGateway()
|
||||||
|
await MainActor.run {
|
||||||
|
if result.exitCode == 0 {
|
||||||
|
self.flashStatus("Gateway restarted")
|
||||||
|
self.showRestartBanner = false
|
||||||
|
} else {
|
||||||
|
self.activeError = "Restart failed: \(result.output)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func flashStatus(_ message: String) {
|
||||||
|
statusMessage = message
|
||||||
|
Task {
|
||||||
|
try? await Task.sleep(nanoseconds: 3_000_000_000)
|
||||||
|
await MainActor.run {
|
||||||
|
if self.statusMessage == message {
|
||||||
|
self.statusMessage = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct MCPServerAddCustomView: View {
|
||||||
|
let viewModel: MCPServersViewModel
|
||||||
|
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@State private var name: String = ""
|
||||||
|
@State private var transport: MCPTransport = .stdio
|
||||||
|
@State private var command: String = "npx"
|
||||||
|
@State private var argsText: String = ""
|
||||||
|
@State private var url: String = ""
|
||||||
|
@State private var auth: String = "none"
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
HStack {
|
||||||
|
Text("Add Custom MCP Server")
|
||||||
|
.font(.headline)
|
||||||
|
Spacer()
|
||||||
|
Button("Cancel") { dismiss() }
|
||||||
|
Button("Add") {
|
||||||
|
submit()
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.disabled(!canSubmit)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
sectionBox(title: "Identity") {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text("Name").font(.caption.bold())
|
||||||
|
TextField("my_server", text: $name)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.font(.system(.body, design: .monospaced))
|
||||||
|
Text("Becomes the key under mcp_servers: in config.yaml.")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sectionBox(title: "Transport") {
|
||||||
|
Picker("", selection: $transport) {
|
||||||
|
ForEach(MCPTransport.allCases) { t in
|
||||||
|
Text(t.displayName).tag(t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
.labelsHidden()
|
||||||
|
}
|
||||||
|
if transport == .stdio {
|
||||||
|
stdioSection
|
||||||
|
} else {
|
||||||
|
httpSection
|
||||||
|
}
|
||||||
|
Text("Env vars, headers, and tool filters can be edited after the server is added.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(minWidth: 560, minHeight: 500)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var stdioSection: some View {
|
||||||
|
sectionBox(title: "Command") {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text("Command").font(.caption.bold())
|
||||||
|
TextField("npx", text: $command)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.font(.system(.body, design: .monospaced))
|
||||||
|
}
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text("Args (one per line)").font(.caption.bold())
|
||||||
|
TextEditor(text: $argsText)
|
||||||
|
.font(.system(.body, design: .monospaced))
|
||||||
|
.frame(minHeight: 80)
|
||||||
|
.padding(4)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 6).stroke(Color.secondary.opacity(0.25))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var httpSection: some View {
|
||||||
|
sectionBox(title: "Endpoint") {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text("URL").font(.caption.bold())
|
||||||
|
TextField("https://...", text: $url)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.font(.system(.body, design: .monospaced))
|
||||||
|
}
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text("Auth").font(.caption.bold())
|
||||||
|
Picker("", selection: $auth) {
|
||||||
|
Text("None").tag("none")
|
||||||
|
Text("OAuth 2.1").tag("oauth")
|
||||||
|
Text("Header").tag("header")
|
||||||
|
}
|
||||||
|
.labelsHidden()
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var canSubmit: Bool {
|
||||||
|
let trimmedName = name.trimmingCharacters(in: .whitespaces)
|
||||||
|
guard !trimmedName.isEmpty else { return false }
|
||||||
|
switch transport {
|
||||||
|
case .stdio:
|
||||||
|
return !command.trimmingCharacters(in: .whitespaces).isEmpty
|
||||||
|
case .http:
|
||||||
|
return !url.trimmingCharacters(in: .whitespaces).isEmpty
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func submit() {
|
||||||
|
let trimmedName = name.trimmingCharacters(in: .whitespaces)
|
||||||
|
let args = argsText
|
||||||
|
.split(separator: "\n")
|
||||||
|
.map { $0.trimmingCharacters(in: .whitespaces) }
|
||||||
|
.filter { !$0.isEmpty }
|
||||||
|
let resolvedAuth: String? = (auth == "none") ? nil : auth
|
||||||
|
viewModel.addCustom(
|
||||||
|
name: trimmedName,
|
||||||
|
transport: transport,
|
||||||
|
command: command.trimmingCharacters(in: .whitespaces),
|
||||||
|
args: args,
|
||||||
|
url: url.trimmingCharacters(in: .whitespaces),
|
||||||
|
auth: resolvedAuth
|
||||||
|
)
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func sectionBox<Content: View>(title: String, @ViewBuilder content: () -> Content) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text(title)
|
||||||
|
.font(.subheadline.bold())
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
.padding(12)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(Color.secondary.opacity(0.06))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct MCPServerDetailView: View {
|
||||||
|
let server: HermesMCPServer
|
||||||
|
let testResult: MCPTestResult?
|
||||||
|
let isTesting: Bool
|
||||||
|
let onTest: () -> Void
|
||||||
|
let onToggleEnabled: () -> Void
|
||||||
|
let onEdit: () -> Void
|
||||||
|
let onDelete: () -> Void
|
||||||
|
|
||||||
|
@State private var showDeleteConfirm = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
header
|
||||||
|
overview
|
||||||
|
if server.transport == .stdio {
|
||||||
|
envSection
|
||||||
|
} else {
|
||||||
|
headersSection
|
||||||
|
}
|
||||||
|
toolsSection
|
||||||
|
timeoutsSection
|
||||||
|
if let result = testResult {
|
||||||
|
MCPServerTestResultView(result: result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||||
|
}
|
||||||
|
.confirmationDialog(
|
||||||
|
"Remove \(server.name)?",
|
||||||
|
isPresented: $showDeleteConfirm,
|
||||||
|
titleVisibility: .visible
|
||||||
|
) {
|
||||||
|
Button("Remove", role: .destructive) { onDelete() }
|
||||||
|
Button("Cancel", role: .cancel) {}
|
||||||
|
} message: {
|
||||||
|
Text("This removes the server from config.yaml and deletes any OAuth token.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var header: some View {
|
||||||
|
HStack(alignment: .top) {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: server.transport == .http ? "network" : "terminal")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text(server.name)
|
||||||
|
.font(.title2.bold())
|
||||||
|
if !server.enabled {
|
||||||
|
Text("Disabled")
|
||||||
|
.font(.caption)
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
.background(Color.secondary.opacity(0.2))
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
if server.hasOAuthToken {
|
||||||
|
Label("OAuth", systemImage: "key.fill")
|
||||||
|
.font(.caption)
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
.background(Color.green.opacity(0.15))
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(server.transport.displayName)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Button {
|
||||||
|
onTest()
|
||||||
|
} label: {
|
||||||
|
if isTesting {
|
||||||
|
ProgressView().controlSize(.small)
|
||||||
|
} else {
|
||||||
|
Label("Test", systemImage: "bolt.horizontal")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(isTesting)
|
||||||
|
Button {
|
||||||
|
onToggleEnabled()
|
||||||
|
} label: {
|
||||||
|
Label(server.enabled ? "Disable" : "Enable", systemImage: server.enabled ? "pause.circle" : "play.circle")
|
||||||
|
}
|
||||||
|
Button {
|
||||||
|
onEdit()
|
||||||
|
} label: {
|
||||||
|
Label("Edit", systemImage: "pencil")
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
Button(role: .destructive) {
|
||||||
|
showDeleteConfirm = true
|
||||||
|
} label: {
|
||||||
|
Label("Remove", systemImage: "trash")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var overview: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text("Connection")
|
||||||
|
.font(.caption.bold())
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
switch server.transport {
|
||||||
|
case .stdio:
|
||||||
|
summaryRow(label: "Command", value: server.command ?? "—")
|
||||||
|
if !server.args.isEmpty {
|
||||||
|
summaryRow(label: "Args", value: server.args.joined(separator: " "))
|
||||||
|
}
|
||||||
|
case .http:
|
||||||
|
summaryRow(label: "URL", value: server.url ?? "—")
|
||||||
|
if let auth = server.auth, !auth.isEmpty {
|
||||||
|
summaryRow(label: "Auth", value: auth)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(12)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(Color.secondary.opacity(0.06))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func summaryRow(label: String, value: String) -> some View {
|
||||||
|
HStack(alignment: .top) {
|
||||||
|
Text(label)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.frame(width: 80, alignment: .leading)
|
||||||
|
Text(value)
|
||||||
|
.font(.system(.caption, design: .monospaced))
|
||||||
|
.textSelection(.enabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var envSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text("Environment Variables")
|
||||||
|
.font(.caption.bold())
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
if server.env.isEmpty {
|
||||||
|
Text("No env vars configured.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
} else {
|
||||||
|
ForEach(server.env.keys.sorted(), id: \.self) { key in
|
||||||
|
HStack {
|
||||||
|
Text(key)
|
||||||
|
.font(.system(.caption, design: .monospaced))
|
||||||
|
Spacer()
|
||||||
|
Text(String(repeating: "•", count: 10))
|
||||||
|
.font(.caption.monospaced())
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(12)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(Color.secondary.opacity(0.06))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
}
|
||||||
|
|
||||||
|
private var headersSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text("Headers")
|
||||||
|
.font(.caption.bold())
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
if server.headers.isEmpty {
|
||||||
|
Text("No headers configured.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
} else {
|
||||||
|
ForEach(server.headers.keys.sorted(), id: \.self) { key in
|
||||||
|
HStack {
|
||||||
|
Text(key)
|
||||||
|
.font(.system(.caption, design: .monospaced))
|
||||||
|
Spacer()
|
||||||
|
Text(String(repeating: "•", count: 10))
|
||||||
|
.font(.caption.monospaced())
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(12)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(Color.secondary.opacity(0.06))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
}
|
||||||
|
|
||||||
|
private var toolsSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text("Tool Filters")
|
||||||
|
.font(.caption.bold())
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
summaryRow(label: "Include", value: server.toolsInclude.isEmpty ? "(all)" : server.toolsInclude.joined(separator: ", "))
|
||||||
|
summaryRow(label: "Exclude", value: server.toolsExclude.isEmpty ? "—" : server.toolsExclude.joined(separator: ", "))
|
||||||
|
summaryRow(label: "Resources", value: server.resourcesEnabled ? "enabled" : "disabled")
|
||||||
|
summaryRow(label: "Prompts", value: server.promptsEnabled ? "enabled" : "disabled")
|
||||||
|
}
|
||||||
|
.padding(12)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(Color.secondary.opacity(0.06))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
}
|
||||||
|
|
||||||
|
private var timeoutsSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text("Timeouts")
|
||||||
|
.font(.caption.bold())
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
summaryRow(label: "Connect", value: server.connectTimeout.map { "\($0)s" } ?? "default")
|
||||||
|
summaryRow(label: "Call", value: server.timeout.map { "\($0)s" } ?? "default")
|
||||||
|
}
|
||||||
|
.padding(12)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(Color.secondary.opacity(0.06))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct MCPServerEditorView: View {
|
||||||
|
@State var viewModel: MCPServerEditorViewModel
|
||||||
|
let onSave: (Bool) -> Void
|
||||||
|
let onCancel: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("Edit \(viewModel.server.name)")
|
||||||
|
.font(.headline)
|
||||||
|
Text(viewModel.server.transport.displayName)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Button("Cancel") { onCancel() }
|
||||||
|
.keyboardShortcut(.cancelAction)
|
||||||
|
Button {
|
||||||
|
viewModel.save { changed in
|
||||||
|
if changed { onSave(true) }
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
if viewModel.isSaving {
|
||||||
|
ProgressView().controlSize(.small)
|
||||||
|
} else {
|
||||||
|
Text("Save")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.keyboardShortcut(.defaultAction)
|
||||||
|
.disabled(viewModel.isSaving)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 20) {
|
||||||
|
if let error = viewModel.saveError {
|
||||||
|
Text(error)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
.padding(10)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(Color.red.opacity(0.12))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||||
|
}
|
||||||
|
if viewModel.server.transport == .stdio {
|
||||||
|
envSection
|
||||||
|
} else {
|
||||||
|
headersSection
|
||||||
|
}
|
||||||
|
toolsSection
|
||||||
|
timeoutsSection
|
||||||
|
if viewModel.server.hasOAuthToken {
|
||||||
|
oauthSection
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(minWidth: 640, minHeight: 560)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var envSection: some View {
|
||||||
|
sectionBox(title: "Environment Variables") {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
if viewModel.envDraft.isEmpty {
|
||||||
|
Text("No env vars. Add one with the button below.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
ForEach($viewModel.envDraft) { $row in
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
TextField("KEY", text: $row.key)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.font(.system(.body, design: .monospaced))
|
||||||
|
.frame(maxWidth: 240)
|
||||||
|
if viewModel.showSecrets {
|
||||||
|
TextField("value", text: $row.value)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
} else {
|
||||||
|
SecureField("value", text: $row.value)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
}
|
||||||
|
Button(role: .destructive) {
|
||||||
|
viewModel.removeEnvRow(id: row.id)
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "minus.circle")
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
HStack {
|
||||||
|
Button {
|
||||||
|
viewModel.appendEnvRow()
|
||||||
|
} label: {
|
||||||
|
Label("Add", systemImage: "plus.circle")
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Toggle("Show values", isOn: $viewModel.showSecrets)
|
||||||
|
.toggleStyle(.switch)
|
||||||
|
.controlSize(.small)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var headersSection: some View {
|
||||||
|
sectionBox(title: "Headers") {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
if viewModel.headersDraft.isEmpty {
|
||||||
|
Text("No headers. Add one with the button below.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
ForEach($viewModel.headersDraft) { $row in
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
TextField("Header", text: $row.key)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.frame(maxWidth: 240)
|
||||||
|
TextField("value", text: $row.value)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
Button(role: .destructive) {
|
||||||
|
viewModel.removeHeaderRow(id: row.id)
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "minus.circle")
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Button {
|
||||||
|
viewModel.appendHeaderRow()
|
||||||
|
} label: {
|
||||||
|
Label("Add", systemImage: "plus.circle")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var toolsSection: some View {
|
||||||
|
sectionBox(title: "Tool Filters") {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text("Include (comma-separated — if set, only these are exposed)")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
TextField("tool_a, tool_b", text: $viewModel.includeDraft)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.font(.system(.body, design: .monospaced))
|
||||||
|
}
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text("Exclude")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
TextField("tool_c", text: $viewModel.excludeDraft)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.font(.system(.body, design: .monospaced))
|
||||||
|
}
|
||||||
|
Toggle("Expose resources", isOn: $viewModel.resourcesEnabled)
|
||||||
|
Toggle("Expose prompts", isOn: $viewModel.promptsEnabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var timeoutsSection: some View {
|
||||||
|
sectionBox(title: "Timeouts (seconds)") {
|
||||||
|
HStack(spacing: 16) {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text("Connect timeout")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
TextField("default", text: $viewModel.connectTimeoutDraft)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.frame(maxWidth: 140)
|
||||||
|
}
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text("Call timeout")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
TextField("default", text: $viewModel.timeoutDraft)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.frame(maxWidth: 140)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var oauthSection: some View {
|
||||||
|
sectionBox(title: "OAuth Token") {
|
||||||
|
HStack {
|
||||||
|
Text("Token on disk. Clear to re-authenticate next time the gateway connects.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Spacer()
|
||||||
|
Button("Clear Token", role: .destructive) {
|
||||||
|
viewModel.clearOAuthToken { _ in }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func sectionBox<Content: View>(title: String, @ViewBuilder content: () -> Content) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text(title)
|
||||||
|
.font(.subheadline.bold())
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
.padding(12)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(Color.secondary.opacity(0.06))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct MCPServerPresetPickerView: View {
|
||||||
|
let viewModel: MCPServersViewModel
|
||||||
|
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@State private var selectedPreset: MCPServerPreset?
|
||||||
|
@State private var nameOverride: String = ""
|
||||||
|
@State private var pathArg: String = ""
|
||||||
|
@State private var envValues: [String: String] = [:]
|
||||||
|
@State private var showSecrets: Bool = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
header
|
||||||
|
Divider()
|
||||||
|
if let preset = selectedPreset {
|
||||||
|
configureStep(preset: preset)
|
||||||
|
} else {
|
||||||
|
galleryStep
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(minWidth: 720, minHeight: 560)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var header: some View {
|
||||||
|
HStack {
|
||||||
|
if selectedPreset != nil {
|
||||||
|
Button {
|
||||||
|
selectedPreset = nil
|
||||||
|
} label: {
|
||||||
|
Label("Back", systemImage: "chevron.left")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(selectedPreset?.displayName ?? "Add from Preset")
|
||||||
|
.font(.headline)
|
||||||
|
Text(selectedPreset?.description ?? "Pick an MCP server to add.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Button("Close") { dismiss() }
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
|
||||||
|
private var galleryStep: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
ForEach(MCPServerPreset.categories, id: \.self) { category in
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text(category)
|
||||||
|
.font(.subheadline.bold())
|
||||||
|
LazyVGrid(
|
||||||
|
columns: [GridItem(.adaptive(minimum: 200), spacing: 12)],
|
||||||
|
spacing: 12
|
||||||
|
) {
|
||||||
|
ForEach(MCPServerPreset.byCategory(category)) { preset in
|
||||||
|
presetCard(preset)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func presetCard(_ preset: MCPServerPreset) -> some View {
|
||||||
|
Button {
|
||||||
|
selectedPreset = preset
|
||||||
|
nameOverride = preset.id
|
||||||
|
pathArg = ""
|
||||||
|
envValues = Dictionary(uniqueKeysWithValues: preset.requiredEnvKeys.map { ($0, "") })
|
||||||
|
for key in preset.optionalEnvKeys {
|
||||||
|
envValues[key] = ""
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: preset.iconSystemName)
|
||||||
|
.font(.title3)
|
||||||
|
.foregroundStyle(Color.accentColor)
|
||||||
|
Text(preset.displayName)
|
||||||
|
.font(.body.bold())
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: preset.transport == .http ? "network" : "terminal")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Text(preset.description)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(3)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
if !preset.requiredEnvKeys.isEmpty {
|
||||||
|
Text("Requires: \(preset.requiredEnvKeys.joined(separator: ", "))")
|
||||||
|
.font(.caption2.monospaced())
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(12)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||||
|
.background(Color.secondary.opacity(0.08))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||||
|
.contentShape(RoundedRectangle(cornerRadius: 10))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func configureStep(preset: MCPServerPreset) -> some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
nameField
|
||||||
|
if let prompt = preset.pathArgPrompt {
|
||||||
|
pathArgField(prompt: prompt)
|
||||||
|
}
|
||||||
|
if !preset.requiredEnvKeys.isEmpty || !preset.optionalEnvKeys.isEmpty {
|
||||||
|
envFields(preset: preset)
|
||||||
|
}
|
||||||
|
if !preset.docsURL.isEmpty {
|
||||||
|
Link(destination: URL(string: preset.docsURL) ?? URL(string: "https://modelcontextprotocol.io")!) {
|
||||||
|
Label("Docs", systemImage: "book")
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
HStack {
|
||||||
|
Spacer()
|
||||||
|
Button("Add Server") {
|
||||||
|
submit(preset: preset)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.disabled(!canSubmit(preset: preset))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var nameField: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text("Server name")
|
||||||
|
.font(.caption.bold())
|
||||||
|
TextField("e.g. github", text: $nameOverride)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.font(.system(.body, design: .monospaced))
|
||||||
|
Text("Used as the YAML key. Lowercase, no spaces.")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func pathArgField(prompt: String) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(prompt)
|
||||||
|
.font(.caption.bold())
|
||||||
|
TextField(prompt, text: $pathArg)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.font(.system(.body, design: .monospaced))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func envFields(preset: MCPServerPreset) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack {
|
||||||
|
Text("Environment Variables")
|
||||||
|
.font(.caption.bold())
|
||||||
|
Spacer()
|
||||||
|
Toggle("Show values", isOn: $showSecrets)
|
||||||
|
.toggleStyle(.switch)
|
||||||
|
.controlSize(.small)
|
||||||
|
}
|
||||||
|
ForEach(preset.requiredEnvKeys, id: \.self) { key in
|
||||||
|
envRow(key: key, required: true)
|
||||||
|
}
|
||||||
|
ForEach(preset.optionalEnvKeys, id: \.self) { key in
|
||||||
|
envRow(key: key, required: false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func envRow(key: String, required: Bool) -> some View {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(key)
|
||||||
|
.font(.system(.caption, design: .monospaced))
|
||||||
|
if required {
|
||||||
|
Text("required")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(width: 240, alignment: .leading)
|
||||||
|
if showSecrets {
|
||||||
|
TextField("value", text: bindingForEnv(key))
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
} else {
|
||||||
|
SecureField("value", text: bindingForEnv(key))
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func bindingForEnv(_ key: String) -> Binding<String> {
|
||||||
|
Binding(
|
||||||
|
get: { envValues[key] ?? "" },
|
||||||
|
set: { envValues[key] = $0 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func canSubmit(preset: MCPServerPreset) -> Bool {
|
||||||
|
let trimmedName = nameOverride.trimmingCharacters(in: .whitespaces)
|
||||||
|
guard !trimmedName.isEmpty else { return false }
|
||||||
|
if preset.pathArgPrompt != nil && pathArg.trimmingCharacters(in: .whitespaces).isEmpty {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for key in preset.requiredEnvKeys {
|
||||||
|
if (envValues[key] ?? "").trimmingCharacters(in: .whitespaces).isEmpty { return false }
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private func submit(preset: MCPServerPreset) {
|
||||||
|
let finalName = nameOverride.trimmingCharacters(in: .whitespaces)
|
||||||
|
let finalPath = pathArg.trimmingCharacters(in: .whitespaces)
|
||||||
|
let trimmedEnv = envValues.reduce(into: [String: String]()) { acc, pair in
|
||||||
|
let trimmedValue = pair.value.trimmingCharacters(in: .whitespaces)
|
||||||
|
if !trimmedValue.isEmpty { acc[pair.key] = pair.value }
|
||||||
|
}
|
||||||
|
viewModel.addFromPreset(
|
||||||
|
preset: preset,
|
||||||
|
name: finalName,
|
||||||
|
pathArg: preset.pathArgPrompt != nil ? finalPath : nil,
|
||||||
|
envValues: trimmedEnv
|
||||||
|
)
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct MCPServerTestResultView: View {
|
||||||
|
let result: MCPTestResult
|
||||||
|
@State private var showOutput = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: result.succeeded ? "checkmark.seal.fill" : "xmark.seal.fill")
|
||||||
|
.foregroundStyle(result.succeeded ? .green : .red)
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(result.succeeded ? "Test passed" : "Test failed")
|
||||||
|
.font(.subheadline.bold())
|
||||||
|
Text(String(format: "%.1fs · %d tools", result.elapsed, result.tools.count))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Button {
|
||||||
|
showOutput.toggle()
|
||||||
|
} label: {
|
||||||
|
Label(showOutput ? "Hide Output" : "Show Output", systemImage: showOutput ? "chevron.up" : "chevron.down")
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
}
|
||||||
|
if !result.tools.isEmpty {
|
||||||
|
WrapChips(items: result.tools)
|
||||||
|
}
|
||||||
|
if showOutput {
|
||||||
|
ScrollView {
|
||||||
|
Text(result.output.isEmpty ? "(no output)" : result.output)
|
||||||
|
.font(.system(.caption, design: .monospaced))
|
||||||
|
.textSelection(.enabled)
|
||||||
|
.padding(8)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
.frame(maxHeight: 220)
|
||||||
|
.background(Color.black.opacity(0.05))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(12)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background((result.succeeded ? Color.green : Color.red).opacity(0.08))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct WrapChips: View {
|
||||||
|
let items: [String]
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
LazyVGrid(columns: [GridItem(.adaptive(minimum: 120), spacing: 6)], spacing: 6) {
|
||||||
|
ForEach(items, id: \.self) { item in
|
||||||
|
Text(item)
|
||||||
|
.font(.caption.monospaced())
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.vertical, 3)
|
||||||
|
.background(Color.secondary.opacity(0.15))
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct MCPServersView: View {
|
||||||
|
@State private var viewModel = MCPServersViewModel()
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HSplitView {
|
||||||
|
serversList
|
||||||
|
.frame(minWidth: 260, idealWidth: 300)
|
||||||
|
serverDetail
|
||||||
|
.frame(minWidth: 500)
|
||||||
|
}
|
||||||
|
.navigationTitle("MCP Servers (\(viewModel.servers.count))")
|
||||||
|
.searchable(text: $viewModel.searchText, prompt: "Filter servers...")
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItemGroup(placement: .primaryAction) {
|
||||||
|
Button {
|
||||||
|
viewModel.showPresetPicker = true
|
||||||
|
} label: {
|
||||||
|
Label("Add from Preset", systemImage: "square.grid.2x2")
|
||||||
|
}
|
||||||
|
Button {
|
||||||
|
viewModel.showAddCustom = true
|
||||||
|
} label: {
|
||||||
|
Label("Add Custom", systemImage: "plus")
|
||||||
|
}
|
||||||
|
Button {
|
||||||
|
viewModel.testAll()
|
||||||
|
} label: {
|
||||||
|
Label("Test All", systemImage: "bolt.horizontal")
|
||||||
|
}
|
||||||
|
.disabled(viewModel.servers.isEmpty)
|
||||||
|
Button {
|
||||||
|
viewModel.load()
|
||||||
|
} label: {
|
||||||
|
Label("Reload", systemImage: "arrow.clockwise")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear { viewModel.load() }
|
||||||
|
.sheet(isPresented: $viewModel.showPresetPicker) {
|
||||||
|
MCPServerPresetPickerView(viewModel: viewModel)
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $viewModel.showAddCustom) {
|
||||||
|
MCPServerAddCustomView(viewModel: viewModel)
|
||||||
|
}
|
||||||
|
.sheet(isPresented: Binding(
|
||||||
|
get: { viewModel.editingServer != nil },
|
||||||
|
set: { if !$0 { viewModel.editingServer = nil } }
|
||||||
|
)) {
|
||||||
|
if let server = viewModel.editingServer {
|
||||||
|
MCPServerEditorView(
|
||||||
|
viewModel: MCPServerEditorViewModel(server: server),
|
||||||
|
onSave: { changed in viewModel.finishEdit(reload: changed) },
|
||||||
|
onCancel: { viewModel.finishEdit(reload: false) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.alert("Error", isPresented: Binding(
|
||||||
|
get: { viewModel.activeError != nil },
|
||||||
|
set: { if !$0 { viewModel.activeError = nil } }
|
||||||
|
)) {
|
||||||
|
Button("OK") { viewModel.activeError = nil }
|
||||||
|
} message: {
|
||||||
|
Text(viewModel.activeError ?? "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var serversList: some View {
|
||||||
|
List(selection: Binding(
|
||||||
|
get: { viewModel.selectedServerName },
|
||||||
|
set: { viewModel.selectServer(name: $0) }
|
||||||
|
)) {
|
||||||
|
if !viewModel.stdioServers.isEmpty {
|
||||||
|
Section("Local (stdio)") {
|
||||||
|
ForEach(viewModel.stdioServers) { server in
|
||||||
|
serverRow(server)
|
||||||
|
.tag(server.name as String?)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !viewModel.httpServers.isEmpty {
|
||||||
|
Section("Remote (HTTP)") {
|
||||||
|
ForEach(viewModel.httpServers) { server in
|
||||||
|
serverRow(server)
|
||||||
|
.tag(server.name as String?)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if viewModel.servers.isEmpty && !viewModel.isLoading {
|
||||||
|
Section {
|
||||||
|
Text("No servers configured yet")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listStyle(.sidebar)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func serverRow(_ server: HermesMCPServer) -> some View {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: server.transport == .http ? "network" : "terminal")
|
||||||
|
.foregroundStyle(server.enabled ? Color.accentColor : .secondary)
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(server.name)
|
||||||
|
.font(.body)
|
||||||
|
if !server.enabled {
|
||||||
|
Text("Disabled")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
if viewModel.testingNames.contains(server.name) {
|
||||||
|
ProgressView().controlSize(.small)
|
||||||
|
} else if let result = viewModel.testResults[server.name] {
|
||||||
|
Image(systemName: result.succeeded ? "checkmark.circle.fill" : "xmark.circle.fill")
|
||||||
|
.foregroundStyle(result.succeeded ? .green : .red)
|
||||||
|
.help(result.succeeded ? "\(result.tools.count) tools" : "Test failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var serverDetail: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
if viewModel.showRestartBanner {
|
||||||
|
RestartGatewayBanner(
|
||||||
|
onRestart: { viewModel.restartGateway() },
|
||||||
|
onDismiss: { viewModel.showRestartBanner = false }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if let status = viewModel.statusMessage {
|
||||||
|
Text(status)
|
||||||
|
.font(.caption)
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(Color.accentColor.opacity(0.12))
|
||||||
|
}
|
||||||
|
if let server = viewModel.selectedServer {
|
||||||
|
MCPServerDetailView(
|
||||||
|
server: server,
|
||||||
|
testResult: viewModel.testResults[server.name],
|
||||||
|
isTesting: viewModel.testingNames.contains(server.name),
|
||||||
|
onTest: { viewModel.testServer(name: server.name) },
|
||||||
|
onToggleEnabled: { viewModel.toggleEnabled(name: server.name) },
|
||||||
|
onEdit: { viewModel.beginEdit() },
|
||||||
|
onDelete: { viewModel.deleteServer(name: server.name) }
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
ContentUnavailableView(
|
||||||
|
"Select an MCP Server",
|
||||||
|
systemImage: "puzzlepiece.extension",
|
||||||
|
description: Text("Pick one from the list, or add a new server from the toolbar.")
|
||||||
|
)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct RestartGatewayBanner: View {
|
||||||
|
let onRestart: () -> Void
|
||||||
|
let onDismiss: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Image(systemName: "arrow.triangle.2.circlepath.circle.fill")
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
VStack(alignment: .leading, spacing: 1) {
|
||||||
|
Text("Gateway restart required")
|
||||||
|
.font(.caption.bold())
|
||||||
|
Text("Changes won't take effect until Hermes reloads the config.")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Button("Restart Now") { onRestart() }
|
||||||
|
.controlSize(.small)
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
Button {
|
||||||
|
onDismiss()
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "xmark")
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.background(Color.orange.opacity(0.14))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import AppKit
|
import AppKit
|
||||||
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
@Observable
|
@Observable
|
||||||
final class SettingsViewModel {
|
final class SettingsViewModel {
|
||||||
@@ -58,6 +59,79 @@ final class SettingsViewModel {
|
|||||||
func setShowCost(_ value: Bool) { setSetting("display.show_cost", value: value ? "true" : "false") }
|
func setShowCost(_ value: Bool) { setSetting("display.show_cost", value: value ? "true" : "false") }
|
||||||
func setApprovalMode(_ value: String) { setSetting("approvals.mode", value: value) }
|
func setApprovalMode(_ value: String) { setSetting("approvals.mode", value: value) }
|
||||||
func setBrowserBackend(_ value: String) { setSetting("browser.backend", 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") }
|
||||||
|
// 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: - Backup & Restore (v0.9.0)
|
||||||
|
|
||||||
|
var backupInProgress = false
|
||||||
|
|
||||||
|
func runBackup() {
|
||||||
|
backupInProgress = true
|
||||||
|
Task.detached { [fileService] in
|
||||||
|
let result = fileService.runHermesCLI(args: ["backup"], timeout: 300)
|
||||||
|
let zipPath = Self.extractZipPath(from: result.output)
|
||||||
|
await MainActor.run {
|
||||||
|
self.backupInProgress = false
|
||||||
|
if result.exitCode == 0 {
|
||||||
|
if let zipPath {
|
||||||
|
NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: zipPath)])
|
||||||
|
self.saveMessage = "Backup saved"
|
||||||
|
} else {
|
||||||
|
self.saveMessage = "Backup complete"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.saveMessage = "Backup failed"
|
||||||
|
}
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
|
||||||
|
self?.saveMessage = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runRestore(from url: URL) {
|
||||||
|
backupInProgress = true
|
||||||
|
Task.detached { [fileService] in
|
||||||
|
let result = fileService.runHermesCLI(args: ["import", url.path], timeout: 300)
|
||||||
|
await MainActor.run {
|
||||||
|
self.backupInProgress = false
|
||||||
|
self.saveMessage = result.exitCode == 0 ? "Restore complete — restart Scarf" : "Restore failed"
|
||||||
|
if result.exitCode == 0 {
|
||||||
|
self.load()
|
||||||
|
}
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
||||||
|
self?.saveMessage = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pull the first absolute `.zip` path out of `hermes backup` stdout.
|
||||||
|
/// Hermes prints a line like "Backup saved to /Users/foo/.hermes-backups/hermes-2026-04-14.zip (5.4 MB)".
|
||||||
|
nonisolated static func extractZipPath(from output: String) -> String? {
|
||||||
|
let pattern = #"(/[^\s]+\.zip)"#
|
||||||
|
guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil }
|
||||||
|
let range = NSRange(output.startIndex..., in: output)
|
||||||
|
guard let match = regex.firstMatch(in: output, range: range),
|
||||||
|
let r = Range(match.range(at: 1), in: output) else { return nil }
|
||||||
|
return String(output[r])
|
||||||
|
}
|
||||||
|
|
||||||
|
func presentRestorePicker() -> URL? {
|
||||||
|
let panel = NSOpenPanel()
|
||||||
|
panel.allowedContentTypes = [.zip]
|
||||||
|
panel.canChooseFiles = true
|
||||||
|
panel.canChooseDirectories = false
|
||||||
|
panel.allowsMultipleSelection = false
|
||||||
|
panel.message = "Choose a Hermes backup archive to restore"
|
||||||
|
guard panel.runModal() == .OK, let url = panel.url else { return nil }
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
func removeAuth() {
|
func removeAuth() {
|
||||||
let result = runHermes(["auth", "remove"])
|
let result = runHermes(["auth", "remove"])
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
voiceSection
|
voiceSection
|
||||||
memorySection
|
memorySection
|
||||||
|
performanceSection
|
||||||
|
networkSection
|
||||||
|
advancedSection
|
||||||
|
backupSection
|
||||||
pathsSection
|
pathsSection
|
||||||
rawConfigSection
|
rawConfigSection
|
||||||
}
|
}
|
||||||
@@ -85,6 +89,7 @@ struct SettingsView: View {
|
|||||||
ToggleRow(label: "Streaming", isOn: viewModel.config.streaming) { viewModel.setStreaming($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 Reasoning", isOn: viewModel.config.showReasoning) { viewModel.setShowReasoning($0) }
|
||||||
ToggleRow(label: "Show Cost", isOn: viewModel.config.showCost) { viewModel.setShowCost($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: "Verbose", isOn: viewModel.config.verbose) { viewModel.setVerbose($0) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -139,6 +144,87 @@ struct SettingsView: View {
|
|||||||
StepperRow(label: "Memory Char Limit", value: viewModel.config.memoryCharLimit, range: 500...10000) { viewModel.setMemoryCharLimit($0) }
|
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: "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) }
|
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.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ enum SidebarSection: String, CaseIterable, Identifiable {
|
|||||||
case memory = "Memory"
|
case memory = "Memory"
|
||||||
case skills = "Skills"
|
case skills = "Skills"
|
||||||
case tools = "Tools"
|
case tools = "Tools"
|
||||||
|
case mcpServers = "MCP Servers"
|
||||||
case gateway = "Gateway"
|
case gateway = "Gateway"
|
||||||
case cron = "Cron"
|
case cron = "Cron"
|
||||||
case health = "Health"
|
case health = "Health"
|
||||||
@@ -29,6 +30,7 @@ enum SidebarSection: String, CaseIterable, Identifiable {
|
|||||||
case .memory: return "brain"
|
case .memory: return "brain"
|
||||||
case .skills: return "lightbulb"
|
case .skills: return "lightbulb"
|
||||||
case .tools: return "wrench.and.screwdriver"
|
case .tools: return "wrench.and.screwdriver"
|
||||||
|
case .mcpServers: return "puzzlepiece.extension"
|
||||||
case .gateway: return "antenna.radiowaves.left.and.right"
|
case .gateway: return "antenna.radiowaves.left.and.right"
|
||||||
case .cron: return "clock.arrow.2.circlepath"
|
case .cron: return "clock.arrow.2.circlepath"
|
||||||
case .health: return "stethoscope"
|
case .health: return "stethoscope"
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ struct SidebarView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Section("Manage") {
|
Section("Manage") {
|
||||||
ForEach([SidebarSection.tools, .gateway, .cron, .health, .logs, .settings]) { section in
|
ForEach([SidebarSection.tools, .mcpServers, .gateway, .cron, .health, .logs, .settings]) { section in
|
||||||
Label(section.rawValue, systemImage: section.icon)
|
Label(section.rawValue, systemImage: section.icon)
|
||||||
.tag(section)
|
.tag(section)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user