mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 18:44:45 +00:00
2e0eb63ea4
`hermesPIDResult()` was running `pgrep -f hermes`, which matched any
process with "hermes" anywhere in its argv — `hermes acp` chat
sessions Scarf itself spawns, `hermes -z` one-shots, log tails, even
this very file in an editor. The Dashboard "Hermes is running" badge
read true even when the gateway daemon was down.
Narrow the match to the gateway shape specifically. Two alternations
cover both invocation forms used in the wild:
- `python -m hermes_cli.main gateway run …` (the launchctl form)
- `/path/to/hermes gateway run …` (the script-path form)
Verified locally against an actual gateway PID:
cmd=/Users/.../python -m hermes_cli.main gateway run --replace
The first alternation matches via the `-m hermes_cli.main gateway run`
boundary. All callers — `stopHermes()`, `DashboardViewModel`,
`HealthViewModel`, `SettingsViewModel`, `scarfApp` — semantically
want the gateway PID specifically, so the narrower match is the
right shape, not a behavior change.
Cherry-picked from #76 with thanks to @unixwzrd for the diagnosis
and the regex.
Co-Authored-By: M S <unixwzrd.register@mac.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1763 lines
78 KiB
Swift
1763 lines
78 KiB
Swift
import Foundation
|
||
import ScarfCore
|
||
import os
|
||
|
||
struct HermesFileService: Sendable {
|
||
|
||
nonisolated static let logger = Logger(subsystem: "com.scarf", category: "HermesFileService")
|
||
|
||
let context: ServerContext
|
||
let transport: any ServerTransport
|
||
|
||
nonisolated init(context: ServerContext = .local) {
|
||
self.context = context
|
||
self.transport = context.makeTransport()
|
||
}
|
||
|
||
// MARK: - Config
|
||
|
||
nonisolated func loadConfig() -> HermesConfig {
|
||
// ScarfMon — when Full mode is on, log a window of stack
|
||
// frames above this call so mystery callers (e.g. config
|
||
// reads with no user action) can be identified by tailing
|
||
// `log stream --predicate 'subsystem == "com.scarf.mon"'`.
|
||
// The window spans frames 1..8: SwiftUI / ObservableObject
|
||
// body re-eval chains burn 4–6 frames before reaching the
|
||
// user code, so dropping fewer than that hides the real
|
||
// caller. Each frame is on its own line, prefixed with "#N",
|
||
// so a single `log stream` line carries the full breadcrumb.
|
||
// Symbol-only — no addresses, no PII. Backtrace alloc is
|
||
// gated on isActive so it's free outside Full mode.
|
||
if ScarfMon.isActive {
|
||
let frames = Thread.callStackSymbols.prefix(10)
|
||
.enumerated()
|
||
.map { "#\($0.offset) \($0.element)" }
|
||
.joined(separator: " | ")
|
||
Self.perfLogger.debug("loadConfig stack: \(frames, privacy: .public)")
|
||
}
|
||
return ScarfMon.measure(.diskIO, "loadConfig") {
|
||
guard let content = readFile(context.paths.configYAML) else { return .empty }
|
||
return parseConfig(content)
|
||
}
|
||
}
|
||
|
||
private static let perfLogger = Logger(subsystem: "com.scarf.mon", category: "HermesFileService")
|
||
|
||
/// Error-surfacing config load. Used by Dashboard to show the user a
|
||
/// specific reason when config.yaml can't be read on a remote host
|
||
/// (permission denied, missing file, sqlite3 not installed, etc.)
|
||
/// instead of silently falling back to `.empty`.
|
||
nonisolated func loadConfigResult() -> Result<HermesConfig, Error> {
|
||
readFileResult(context.paths.configYAML).map { parseConfig($0) }
|
||
}
|
||
|
||
nonisolated private func parseConfig(_ yaml: String) -> HermesConfig {
|
||
let parsed = Self.parseNestedYAML(yaml)
|
||
let values = parsed.values
|
||
let lists = parsed.lists
|
||
let maps = parsed.maps
|
||
|
||
func bool(_ key: String, default def: Bool) -> Bool {
|
||
guard let v = values[key] else { return def }
|
||
return v == "true"
|
||
}
|
||
func int(_ key: String, default def: Int) -> Int {
|
||
Int(values[key] ?? "") ?? def
|
||
}
|
||
func double(_ key: String, default def: Double) -> Double {
|
||
Double(values[key] ?? "") ?? def
|
||
}
|
||
func str(_ key: String, default def: String = "") -> String {
|
||
// Strip quotes added by Hermes's YAML dumper around strings with special chars.
|
||
let raw = values[key] ?? def
|
||
return Self.stripYAMLQuotes(raw)
|
||
}
|
||
|
||
let dockerEnv = maps["terminal.docker_env"] ?? [:]
|
||
let commandAllowlist = lists["permanent_allowlist"] ?? lists["command_allowlist"] ?? []
|
||
|
||
let display = DisplaySettings(
|
||
skin: str("display.skin", default: "default"),
|
||
compact: bool("display.compact", default: false),
|
||
resumeDisplay: str("display.resume_display", default: "full"),
|
||
bellOnComplete: bool("display.bell_on_complete", default: false),
|
||
inlineDiffs: bool("display.inline_diffs", default: true),
|
||
toolProgressCommand: bool("display.tool_progress_command", default: false),
|
||
toolPreviewLength: int("display.tool_preview_length", default: 0),
|
||
busyInputMode: str("display.busy_input_mode", default: "interrupt")
|
||
)
|
||
|
||
let terminal = TerminalSettings(
|
||
cwd: str("terminal.cwd", default: "."),
|
||
timeout: int("terminal.timeout", default: 180),
|
||
envPassthrough: lists["terminal.env_passthrough"] ?? [],
|
||
persistentShell: bool("terminal.persistent_shell", default: true),
|
||
dockerImage: str("terminal.docker_image"),
|
||
dockerMountCwdToWorkspace: bool("terminal.docker_mount_cwd_to_workspace", default: false),
|
||
dockerForwardEnv: lists["terminal.docker_forward_env"] ?? [],
|
||
dockerVolumes: lists["terminal.docker_volumes"] ?? [],
|
||
containerCPU: int("terminal.container_cpu", default: 0),
|
||
containerMemory: int("terminal.container_memory", default: 0),
|
||
containerDisk: int("terminal.container_disk", default: 0),
|
||
containerPersistent: bool("terminal.container_persistent", default: false),
|
||
modalImage: str("terminal.modal_image"),
|
||
modalMode: str("terminal.modal_mode", default: "auto"),
|
||
daytonaImage: str("terminal.daytona_image"),
|
||
singularityImage: str("terminal.singularity_image")
|
||
)
|
||
|
||
let browser = BrowserSettings(
|
||
inactivityTimeout: int("browser.inactivity_timeout", default: 120),
|
||
commandTimeout: int("browser.command_timeout", default: 30),
|
||
recordSessions: bool("browser.record_sessions", default: false),
|
||
allowPrivateURLs: bool("browser.allow_private_urls", default: false),
|
||
camofoxManagedPersistence: bool("browser.camofox.managed_persistence", default: false)
|
||
)
|
||
|
||
let voice = VoiceSettings(
|
||
recordKey: str("voice.record_key", default: "ctrl+b"),
|
||
maxRecordingSeconds: int("voice.max_recording_seconds", default: 120),
|
||
silenceDuration: double("voice.silence_duration", default: 3.0),
|
||
ttsProvider: str("tts.provider", default: "edge"),
|
||
ttsEdgeVoice: str("tts.edge.voice", default: "en-US-AriaNeural"),
|
||
ttsElevenLabsVoiceID: str("tts.elevenlabs.voice_id"),
|
||
ttsElevenLabsModelID: str("tts.elevenlabs.model_id", default: "eleven_multilingual_v2"),
|
||
ttsOpenAIModel: str("tts.openai.model", default: "gpt-4o-mini-tts"),
|
||
ttsOpenAIVoice: str("tts.openai.voice", default: "alloy"),
|
||
ttsNeuTTSModel: str("tts.neutts.model"),
|
||
ttsNeuTTSDevice: str("tts.neutts.device", default: "cpu"),
|
||
sttEnabled: bool("stt.enabled", default: true),
|
||
sttProvider: str("stt.provider", default: "local"),
|
||
sttLocalModel: str("stt.local.model", default: "base"),
|
||
sttLocalLanguage: str("stt.local.language"),
|
||
sttOpenAIModel: str("stt.openai.model", default: "whisper-1"),
|
||
sttMistralModel: str("stt.mistral.model", default: "voxtral-mini-latest")
|
||
)
|
||
|
||
func aux(_ name: String) -> AuxiliaryModel {
|
||
AuxiliaryModel(
|
||
provider: str("auxiliary.\(name).provider", default: "auto"),
|
||
model: str("auxiliary.\(name).model"),
|
||
baseURL: str("auxiliary.\(name).base_url"),
|
||
apiKey: str("auxiliary.\(name).api_key"),
|
||
timeout: int("auxiliary.\(name).timeout", default: 30)
|
||
)
|
||
}
|
||
let auxiliary = AuxiliarySettings(
|
||
vision: aux("vision"),
|
||
webExtract: aux("web_extract"),
|
||
compression: aux("compression"),
|
||
sessionSearch: aux("session_search"),
|
||
skillsHub: aux("skills_hub"),
|
||
approval: aux("approval"),
|
||
mcp: aux("mcp"),
|
||
flushMemories: aux("flush_memories"),
|
||
curator: aux("curator")
|
||
)
|
||
|
||
let security = SecuritySettings(
|
||
redactSecrets: bool("security.redact_secrets", default: true),
|
||
redactPII: bool("privacy.redact_pii", default: false),
|
||
tirithEnabled: bool("security.tirith_enabled", default: true),
|
||
tirithPath: str("security.tirith_path", default: "tirith"),
|
||
tirithTimeout: int("security.tirith_timeout", default: 5),
|
||
tirithFailOpen: bool("security.tirith_fail_open", default: true),
|
||
blocklistEnabled: bool("security.website_blocklist.enabled", default: false),
|
||
blocklistDomains: lists["security.website_blocklist.domains"] ?? []
|
||
)
|
||
|
||
let humanDelay = HumanDelaySettings(
|
||
mode: str("human_delay.mode", default: "off"),
|
||
minMS: int("human_delay.min_ms", default: 800),
|
||
maxMS: int("human_delay.max_ms", default: 2500)
|
||
)
|
||
|
||
let compression = CompressionSettings(
|
||
enabled: bool("compression.enabled", default: true),
|
||
threshold: double("compression.threshold", default: 0.5),
|
||
targetRatio: double("compression.target_ratio", default: 0.2),
|
||
protectLastN: int("compression.protect_last_n", default: 20)
|
||
)
|
||
|
||
let checkpoints = CheckpointSettings(
|
||
enabled: bool("checkpoints.enabled", default: true),
|
||
maxSnapshots: int("checkpoints.max_snapshots", default: 50)
|
||
)
|
||
|
||
let logging = LoggingSettings(
|
||
level: str("logging.level", default: "INFO"),
|
||
maxSizeMB: int("logging.max_size_mb", default: 5),
|
||
backupCount: int("logging.backup_count", default: 3)
|
||
)
|
||
|
||
let delegation = DelegationSettings(
|
||
model: str("delegation.model"),
|
||
provider: str("delegation.provider"),
|
||
baseURL: str("delegation.base_url"),
|
||
apiKey: str("delegation.api_key"),
|
||
maxIterations: int("delegation.max_iterations", default: 50)
|
||
)
|
||
|
||
let discord = DiscordSettings(
|
||
requireMention: bool("discord.require_mention", default: true),
|
||
freeResponseChannels: str("discord.free_response_channels"),
|
||
autoThread: bool("discord.auto_thread", default: true),
|
||
reactions: bool("discord.reactions", default: true)
|
||
)
|
||
|
||
let telegram = TelegramSettings(
|
||
requireMention: bool("telegram.require_mention", default: true),
|
||
reactions: bool("telegram.reactions", default: false)
|
||
)
|
||
|
||
// Slack fields live under both `platforms.slack.*` (newer) and `slack.*`
|
||
// (legacy) in config.yaml. Prefer the newer path but fall back.
|
||
let slack = SlackSettings(
|
||
replyToMode: values["platforms.slack.reply_to_mode"] ?? values["slack.reply_to_mode"] ?? "first",
|
||
requireMention: (values["platforms.slack.require_mention"] ?? values["slack.require_mention"]) != "false",
|
||
replyInThread: (values["platforms.slack.extra.reply_in_thread"] ?? "true") != "false",
|
||
replyBroadcast: (values["platforms.slack.extra.reply_broadcast"] ?? "false") == "true"
|
||
)
|
||
|
||
let matrix = MatrixSettings(
|
||
requireMention: bool("matrix.require_mention", default: true),
|
||
autoThread: bool("matrix.auto_thread", default: true),
|
||
dmMentionThreads: bool("matrix.dm_mention_threads", default: false)
|
||
)
|
||
|
||
let mattermost = MattermostSettings(
|
||
requireMention: bool("mattermost.require_mention", default: true),
|
||
replyMode: str("mattermost.reply_mode", default: "off")
|
||
)
|
||
|
||
let whatsapp = WhatsAppSettings(
|
||
unauthorizedDMBehavior: str("whatsapp.unauthorized_dm_behavior", default: "pair"),
|
||
replyPrefix: str("whatsapp.reply_prefix")
|
||
)
|
||
|
||
// `platform_toolsets.<platform>` is a dict of lists in config.yaml —
|
||
// parseNestedYAML flattens nested lists into dotted-path keys. Pull
|
||
// every key under the prefix and strip it.
|
||
var platformToolsets: [String: [String]] = [:]
|
||
for (key, items) in lists where key.hasPrefix("platform_toolsets.") {
|
||
let platform = String(key.dropFirst("platform_toolsets.".count))
|
||
guard !platform.isEmpty else { continue }
|
||
platformToolsets[platform] = items
|
||
}
|
||
|
||
// Home Assistant lives under `platforms.homeassistant.extra.*`.
|
||
let homeAssistant = HomeAssistantSettings(
|
||
watchDomains: lists["platforms.homeassistant.extra.watch_domains"] ?? [],
|
||
watchEntities: lists["platforms.homeassistant.extra.watch_entities"] ?? [],
|
||
watchAll: bool("platforms.homeassistant.extra.watch_all", default: false),
|
||
ignoreEntities: lists["platforms.homeassistant.extra.ignore_entities"] ?? [],
|
||
cooldownSeconds: int("platforms.homeassistant.extra.cooldown_seconds", default: 30)
|
||
)
|
||
|
||
return HermesConfig(
|
||
model: str("model.default", default: "unknown"),
|
||
provider: str("model.provider", default: "unknown"),
|
||
maxTurns: int("agent.max_turns", default: 0),
|
||
personality: str("display.personality", default: "default"),
|
||
terminalBackend: str("terminal.backend", default: "local"),
|
||
memoryEnabled: bool("memory.memory_enabled", default: false),
|
||
memoryCharLimit: int("memory.memory_char_limit", default: 0),
|
||
userCharLimit: int("memory.user_char_limit", default: 0),
|
||
nudgeInterval: int("memory.nudge_interval", default: 0),
|
||
streaming: values["display.streaming"] != "false",
|
||
showReasoning: bool("display.show_reasoning", default: false),
|
||
verbose: bool("agent.verbose", default: false),
|
||
autoTTS: values["voice.auto_tts"] != "false",
|
||
silenceThreshold: int("voice.silence_threshold", default: QueryDefaults.defaultSilenceThreshold),
|
||
reasoningEffort: str("agent.reasoning_effort", default: "medium"),
|
||
showCost: bool("display.show_cost", default: false),
|
||
approvalMode: str("approvals.mode", default: "manual"),
|
||
browserBackend: str("browser.backend"),
|
||
memoryProvider: str("memory.provider"),
|
||
dockerEnv: dockerEnv,
|
||
commandAllowlist: commandAllowlist,
|
||
memoryProfile: str("memory.profile"),
|
||
serviceTier: str("agent.service_tier", default: "normal"),
|
||
gatewayNotifyInterval: int("agent.gateway_notify_interval", default: 600),
|
||
forceIPv4: bool("network.force_ipv4", default: false),
|
||
contextEngine: str("context.engine", default: "compressor"),
|
||
interimAssistantMessages: values["display.interim_assistant_messages"] != "false",
|
||
honchoInitOnSessionStart: bool("honcho.initOnSessionStart", default: false),
|
||
timezone: str("timezone"),
|
||
userProfileEnabled: bool("memory.user_profile_enabled", default: true),
|
||
toolUseEnforcement: str("agent.tool_use_enforcement", default: "auto"),
|
||
gatewayTimeout: int("agent.gateway_timeout", default: 1800),
|
||
approvalTimeout: int("approvals.timeout", default: 60),
|
||
fileReadMaxChars: int("file_read_max_chars", default: 100_000),
|
||
cronWrapResponse: bool("cron.wrap_response", default: true),
|
||
prefillMessagesFile: str("prefill_messages_file"),
|
||
skillsExternalDirs: lists["skills.external_dirs"] ?? [],
|
||
platformToolsets: platformToolsets,
|
||
display: display,
|
||
terminal: terminal,
|
||
browser: browser,
|
||
voice: voice,
|
||
auxiliary: auxiliary,
|
||
security: security,
|
||
humanDelay: humanDelay,
|
||
compression: compression,
|
||
checkpoints: checkpoints,
|
||
logging: logging,
|
||
delegation: delegation,
|
||
discord: discord,
|
||
telegram: telegram,
|
||
slack: slack,
|
||
matrix: matrix,
|
||
mattermost: mattermost,
|
||
whatsapp: whatsapp,
|
||
homeAssistant: homeAssistant,
|
||
cacheTTL: str("prompt_caching.cache_ttl", default: "5m"),
|
||
redactionEnabled: bool("redaction.enabled", default: false),
|
||
runtimeMetadataFooter: bool("agent.runtime_metadata_footer", default: false)
|
||
)
|
||
}
|
||
|
||
/// Parsed YAML result bundle.
|
||
struct ParsedYAML: Sendable {
|
||
var values: [String: String] // "section.key" -> scalar string
|
||
var lists: [String: [String]] // "section.key" -> items from a bullet list
|
||
var maps: [String: [String: String]] // "section.key" -> nested key-value map
|
||
}
|
||
|
||
/// Parse a subset of YAML into flat dotted paths.
|
||
///
|
||
/// Supports:
|
||
/// - Scalar key-value pairs at any indent level → `values["a.b.c"] = "..."`
|
||
/// - Empty-valued section headers → acts as a path prefix for nested scalars
|
||
/// - Bullet lists (`- item`) nested under a `key:` → `lists["a.b"]`
|
||
/// - Nested maps where a header has no value and children are `k: v` pairs →
|
||
/// captured as `maps["a.b"]` AND each child as `values["a.b.k"]`.
|
||
///
|
||
/// This is sufficient for Hermes config; we do not attempt full YAML compliance.
|
||
nonisolated static func parseNestedYAML(_ yaml: String) -> ParsedYAML {
|
||
var values: [String: String] = [:]
|
||
var lists: [String: [String]] = [:]
|
||
var maps: [String: [String: String]] = [:]
|
||
// Path stack: each entry is (indent, name). Pop when indent shrinks.
|
||
var stack: [(indent: Int, name: String)] = []
|
||
|
||
func currentPath(joinedWith child: String? = nil) -> String {
|
||
var parts = stack.map(\.name)
|
||
if let child { parts.append(child) }
|
||
return parts.joined(separator: ".")
|
||
}
|
||
|
||
let rawLines = yaml.components(separatedBy: "\n")
|
||
for line in rawLines {
|
||
// Skip comment-only and blank lines but preserve indent semantics.
|
||
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||
if trimmed.isEmpty || trimmed.hasPrefix("#") { continue }
|
||
|
||
let indent = line.prefix(while: { $0 == " " }).count
|
||
let isListItem = trimmed.hasPrefix("- ")
|
||
|
||
// Pop stack entries with indent >= current indent.
|
||
// Exception: a list item at the same indent as its parent key is
|
||
// valid block-style YAML ("toolsets:\n- hermes-cli") — keep the
|
||
// parent so the item is attributed to it.
|
||
while let top = stack.last {
|
||
let shouldPop: Bool
|
||
if isListItem && top.indent == indent {
|
||
shouldPop = false
|
||
} else {
|
||
shouldPop = top.indent >= indent
|
||
}
|
||
if shouldPop { stack.removeLast() } else { break }
|
||
}
|
||
|
||
if isListItem {
|
||
let item = String(trimmed.dropFirst(2)).trimmingCharacters(in: .whitespaces)
|
||
let stripped = stripYAMLQuotes(item)
|
||
let path = currentPath()
|
||
guard !path.isEmpty else { continue }
|
||
lists[path, default: []].append(stripped)
|
||
continue
|
||
}
|
||
|
||
// Key-value or section line.
|
||
guard let colonIdx = trimmed.firstIndex(of: ":") else { continue }
|
||
let key = String(trimmed[trimmed.startIndex..<colonIdx]).trimmingCharacters(in: .whitespaces)
|
||
let afterColon = String(trimmed[trimmed.index(after: colonIdx)...]).trimmingCharacters(in: .whitespaces)
|
||
|
||
let path = currentPath(joinedWith: key)
|
||
|
||
if afterColon.isEmpty || afterColon == "|" || afterColon == ">" {
|
||
// Section header or empty-valued key — push onto stack so children nest.
|
||
stack.append((indent: indent, name: key))
|
||
continue
|
||
}
|
||
|
||
// Inline `{}` / `[]` literals → treat as empty.
|
||
if afterColon == "{}" {
|
||
values[path] = ""
|
||
maps[path] = [:]
|
||
continue
|
||
}
|
||
if afterColon == "[]" {
|
||
values[path] = ""
|
||
lists[path] = []
|
||
continue
|
||
}
|
||
|
||
values[path] = afterColon
|
||
|
||
// Also record as a map entry under the parent, so we can treat blocks
|
||
// like `terminal.docker_env` as `[String: String]` without a separate scan.
|
||
if !stack.isEmpty {
|
||
let parentPath = currentPath()
|
||
maps[parentPath, default: [:]][key] = stripYAMLQuotes(afterColon)
|
||
}
|
||
}
|
||
return ParsedYAML(values: values, lists: lists, maps: maps)
|
||
}
|
||
|
||
/// Strip a single layer of surrounding single or double quotes from a YAML scalar.
|
||
nonisolated static func stripYAMLQuotes(_ s: String) -> String {
|
||
guard s.count >= 2 else { return s }
|
||
let first = s.first!
|
||
let last = s.last!
|
||
if (first == "'" && last == "'") || (first == "\"" && last == "\"") {
|
||
return String(s.dropFirst().dropLast())
|
||
}
|
||
return s
|
||
}
|
||
|
||
// MARK: - Gateway State
|
||
|
||
nonisolated func loadGatewayState() -> GatewayState? {
|
||
guard let data = readFileData(context.paths.gatewayStateJSON) else { return nil }
|
||
do {
|
||
return try JSONDecoder().decode(GatewayState.self, from: data)
|
||
} catch {
|
||
Self.logger.warning("Failed to decode gateway state: \(error.localizedDescription, privacy: .public)")
|
||
return nil
|
||
}
|
||
}
|
||
|
||
/// Error-surfacing gateway-state load. `.success(nil)` means the file
|
||
/// doesn't exist yet (gateway hasn't written state — normal when Hermes
|
||
/// is stopped). `.failure` means the file exists but couldn't be read
|
||
/// (permission denied, connection down, JSON corruption).
|
||
nonisolated func loadGatewayStateResult() -> Result<GatewayState?, Error> {
|
||
// Distinguish "file doesn't exist yet" (normal, returns .success(nil))
|
||
// from "file exists but we can't read or parse it" (error).
|
||
if !transport.fileExists(context.paths.gatewayStateJSON) {
|
||
return .success(nil)
|
||
}
|
||
switch readFileDataResult(context.paths.gatewayStateJSON) {
|
||
case .success(let data):
|
||
do {
|
||
return .success(try JSONDecoder().decode(GatewayState.self, from: data))
|
||
} catch {
|
||
Self.logger.warning("Failed to decode gateway state: \(error.localizedDescription, privacy: .public)")
|
||
return .failure(error)
|
||
}
|
||
case .failure(let err):
|
||
return .failure(err)
|
||
}
|
||
}
|
||
|
||
// MARK: - Memory
|
||
|
||
nonisolated func loadMemoryProfiles() -> [String] {
|
||
guard let entries = try? transport.listDirectory(context.paths.memoriesDir) else { return [] }
|
||
return entries.filter { name in
|
||
let path = context.paths.memoriesDir + "/" + name
|
||
return transport.stat(path)?.isDirectory == true
|
||
}.sorted()
|
||
}
|
||
|
||
nonisolated func loadMemory(profile: String = "") -> String {
|
||
let path = memoryPath(profile: profile, file: "MEMORY.md")
|
||
return readFile(path) ?? ""
|
||
}
|
||
|
||
nonisolated func loadUserProfile(profile: String = "") -> String {
|
||
let path = memoryPath(profile: profile, file: "USER.md")
|
||
return readFile(path) ?? ""
|
||
}
|
||
|
||
nonisolated func saveMemory(_ content: String, profile: String = "") {
|
||
let path = memoryPath(profile: profile, file: "MEMORY.md")
|
||
writeFile(path, content: content)
|
||
}
|
||
|
||
nonisolated func saveUserProfile(_ content: String, profile: String = "") {
|
||
let path = memoryPath(profile: profile, file: "USER.md")
|
||
writeFile(path, content: content)
|
||
}
|
||
|
||
nonisolated private func memoryPath(profile: String, file: String) -> String {
|
||
if profile.isEmpty {
|
||
return context.paths.memoriesDir + "/" + file
|
||
}
|
||
return context.paths.memoriesDir + "/" + profile + "/" + file
|
||
}
|
||
|
||
// MARK: - Cron
|
||
|
||
nonisolated func loadCronJobs() -> [HermesCronJob] {
|
||
ScarfMon.measure(.diskIO, "loadCronJobs") {
|
||
guard let data = readFileData(context.paths.cronJobsJSON) else { return [] }
|
||
do {
|
||
let file = try JSONDecoder().decode(CronJobsFile.self, from: data)
|
||
return file.jobs
|
||
} catch {
|
||
print("[Scarf] Failed to decode cron jobs: \(error.localizedDescription)")
|
||
return []
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Read the most-recent run output for a cron job. Hermes writes
|
||
/// `~/.hermes/cron/output/<jobId>/<YYYY-MM-DD_HH-MM-SS>.md` per run
|
||
/// (one file per execution); we resolve the per-job subdir, take
|
||
/// the lexicographically-last filename (which is the newest given
|
||
/// the timestamp prefix), and return its contents. Returns nil
|
||
/// when the subdir is missing, empty, or the read fails — the cron
|
||
/// detail surface treats nil as "no output yet."
|
||
///
|
||
/// A legacy flat-file layout (`<dir>/<filename containing jobId>`)
|
||
/// is checked as a fallback so older Hermes installs that used a
|
||
/// non-nested layout still surface their last run.
|
||
nonisolated func loadCronOutput(jobId: String) -> String? {
|
||
let dir = context.paths.cronOutputDir
|
||
let perJobDir = dir + "/" + jobId
|
||
if let runs = try? transport.listDirectory(perJobDir),
|
||
let latest = runs.sorted().last {
|
||
if let content = readFile(perJobDir + "/" + latest) {
|
||
return content
|
||
}
|
||
}
|
||
// Legacy fallback: pre-subdir layouts had files like
|
||
// `<jobId>-<timestamp>.log` directly under cronOutputDir. Keep
|
||
// matching them so users on older Hermes versions still see
|
||
// their tail.
|
||
if let files = try? transport.listDirectory(dir),
|
||
let matching = files.filter({ $0.contains(jobId) }).sorted().last {
|
||
return readFile(dir + "/" + matching)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// MARK: - Skills
|
||
|
||
/// Walks `~/.hermes/skills/<category>/<name>/`. v2.5 delegates to
|
||
/// the shared ScarfCore `SkillsScanner` so iOS and Mac use byte-
|
||
/// identical scan logic — including the v0.11 frontmatter parsing
|
||
/// that populates `HermesSkill.allowedTools` / `relatedSkills` /
|
||
/// `dependencies`.
|
||
nonisolated func loadSkills() -> [HermesSkillCategory] {
|
||
SkillsScanner.scan(context: context, transport: transport)
|
||
}
|
||
|
||
nonisolated func loadSkillContent(path: String) -> String {
|
||
guard isValidSkillPath(path) else { return "" }
|
||
return readFile(path) ?? ""
|
||
}
|
||
|
||
nonisolated func saveSkillContent(path: String, content: String) {
|
||
guard isValidSkillPath(path) else { return }
|
||
writeFile(path, content: content)
|
||
}
|
||
|
||
nonisolated private func isValidSkillPath(_ path: String) -> Bool {
|
||
guard !path.contains(".."), path.hasPrefix(context.paths.skillsDir) else {
|
||
print("[Scarf] Rejected skill path outside skills directory: \(path)")
|
||
return false
|
||
}
|
||
return true
|
||
}
|
||
|
||
// MARK: - MCP Servers
|
||
|
||
nonisolated func loadMCPServers() -> [HermesMCPServer] {
|
||
guard let yaml = readFile(context.paths.configYAML) else { return [] }
|
||
let parsed = parseMCPServersBlock(yaml: yaml)
|
||
return parsed.map { server in
|
||
let tokenPath = context.paths.mcpTokensDir + "/" + server.name + ".json"
|
||
let hasToken = transport.fileExists(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
|
||
nonisolated 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
|
||
nonisolated 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
|
||
nonisolated func setMCPServerArgs(name: String, args: [String]) -> Bool {
|
||
patchMCPServerField(name: name) { entryLines in
|
||
Self.replaceOrInsertList(header: "args", items: args, in: &entryLines)
|
||
}
|
||
}
|
||
|
||
@discardableResult
|
||
nonisolated 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)
|
||
// hermes mcp test exits 0 even when the inner connection fails — it
|
||
// reports the failure on stdout instead. Look for explicit failure
|
||
// markers so the UI doesn't show a green check on a broken server.
|
||
let output = result.1
|
||
let hasFailureMarker = output.contains("✗")
|
||
|| output.range(of: "Connection failed", options: .caseInsensitive) != nil
|
||
|| output.range(of: "No such file or directory", options: .caseInsensitive) != nil
|
||
|| output.range(of: "Error:", options: .caseInsensitive) != nil
|
||
return MCPTestResult(
|
||
serverName: name,
|
||
succeeded: result.0 == 0 && !hasFailureMarker,
|
||
output: output,
|
||
tools: tools,
|
||
elapsed: elapsed
|
||
)
|
||
}
|
||
|
||
nonisolated 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
|
||
nonisolated func toggleMCPServerEnabled(name: String, enabled: Bool) -> Bool {
|
||
patchMCPServerField(name: name) { entryLines in
|
||
Self.replaceOrInsertScalar(key: "enabled", value: enabled ? "true" : "false", in: &entryLines)
|
||
}
|
||
}
|
||
|
||
@discardableResult
|
||
nonisolated func setMCPServerEnv(name: String, env: [String: String]) -> Bool {
|
||
patchMCPServerField(name: name) { entryLines in
|
||
Self.replaceOrInsertSubMap(header: "env", map: env, in: &entryLines)
|
||
}
|
||
}
|
||
|
||
@discardableResult
|
||
nonisolated func setMCPServerHeaders(name: String, headers: [String: String]) -> Bool {
|
||
patchMCPServerField(name: name) { entryLines in
|
||
Self.replaceOrInsertSubMap(header: "headers", map: headers, in: &entryLines)
|
||
}
|
||
}
|
||
|
||
@discardableResult
|
||
nonisolated 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
|
||
nonisolated 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
|
||
nonisolated func deleteMCPOAuthToken(name: String) -> Bool {
|
||
let path = context.paths.mcpTokensDir + "/" + name + ".json"
|
||
do {
|
||
try transport.removeFile(path)
|
||
return true
|
||
} catch {
|
||
return false
|
||
}
|
||
}
|
||
|
||
@discardableResult
|
||
nonisolated 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]
|
||
}
|
||
|
||
nonisolated 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: [])
|
||
}
|
||
// Trim trailing blank lines and comments from the block — they belong
|
||
// to the file footer, not the mcp_servers section. Without this, when
|
||
// mcp_servers is the last top-level key, the block would extend to EOF
|
||
// and any inserted content (args, env, headers, tools) would land
|
||
// after the trailing comments.
|
||
while blockEnd > blockStart + 1 {
|
||
let line = lines[blockEnd - 1]
|
||
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||
if trimmed.isEmpty || trimmed.hasPrefix("#") {
|
||
blockEnd -= 1
|
||
} else {
|
||
break
|
||
}
|
||
}
|
||
return MCPBlockLocation(
|
||
prefix: Array(lines[0..<blockStart]),
|
||
block: Array(lines[blockStart..<blockEnd]),
|
||
suffix: Array(lines[blockEnd..<lines.count])
|
||
)
|
||
}
|
||
|
||
nonisolated 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
|
||
|
||
nonisolated private func patchMCPServerField(name: String, mutate: (inout [String]) -> Void) -> Bool {
|
||
guard let yaml = readFile(context.paths.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 }
|
||
|
||
// Trim trailing blank lines and comments off the entry so inserts land
|
||
// immediately after the entry's last real key, not after intervening
|
||
// comments that conceptually belong to the next entry (or the file
|
||
// footer when this is the last entry in the block).
|
||
while entryEnd > entryStart + 1 {
|
||
let line = block[entryEnd - 1]
|
||
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||
if trimmed.isEmpty || trimmed.hasPrefix("#") {
|
||
entryEnd -= 1
|
||
} else {
|
||
break
|
||
}
|
||
}
|
||
|
||
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(context.paths.configYAML, content: newYAML)
|
||
return true
|
||
}
|
||
|
||
// MARK: - MCP YAML: mutators
|
||
|
||
nonisolated 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)
|
||
}
|
||
|
||
nonisolated 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)
|
||
}
|
||
}
|
||
|
||
nonisolated 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)
|
||
}
|
||
}
|
||
|
||
nonisolated 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)
|
||
}
|
||
}
|
||
|
||
nonisolated 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)
|
||
}
|
||
}
|
||
|
||
nonisolated private static func yamlScalar(_ value: String) -> String {
|
||
if value.isEmpty { return "\"\"" }
|
||
// YAML 1.2 reserved indicators that change meaning at the start of a
|
||
// scalar: @ * & ? | > ! % , [ ] { } < ` ' " — plus space (would be
|
||
// trimmed) and dash (looks like a sequence). Anything starting with
|
||
// one of these must be quoted or YAML treats the value as an alias,
|
||
// tag, flow collection, etc., and parsing breaks.
|
||
let reservedFirstChars: Set<Character> = [
|
||
"@", "*", "&", "?", "|", ">", "!", "%", ",",
|
||
"[", "]", "{", "}", "<", "`", "'", "\""
|
||
]
|
||
let firstCharNeedsQuoting = value.first.map { reservedFirstChars.contains($0) } ?? false
|
||
let needsQuoting = value.contains(":") || value.contains("#") || value.contains("\"")
|
||
|| value.hasPrefix(" ") || value.hasSuffix(" ") || value.hasPrefix("-")
|
||
|| ["true", "false", "null", "yes", "no"].contains(value.lowercased())
|
||
|| firstCharNeedsQuoting
|
||
if needsQuoting {
|
||
let escaped = value.replacingOccurrences(of: "\\", with: "\\\\")
|
||
.replacingOccurrences(of: "\"", with: "\\\"")
|
||
return "\"\(escaped)\""
|
||
}
|
||
return value
|
||
}
|
||
|
||
nonisolated 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
|
||
|
||
nonisolated func isHermesRunning() -> Bool {
|
||
hermesPID() != nil
|
||
}
|
||
|
||
nonisolated func hermesPID() -> pid_t? {
|
||
switch hermesPIDResult() {
|
||
case .success(let pid): return pid
|
||
case .failure: return nil
|
||
}
|
||
}
|
||
|
||
/// Error-surfacing variant. `.success(nil)` means `pgrep` ran successfully
|
||
/// and found no Hermes gateway process (Hermes is genuinely not running).
|
||
/// `.failure` means we couldn't probe at all (pgrep missing, connection
|
||
/// down, permission issue) — a *different* UX from "not running".
|
||
///
|
||
/// The regex narrows the match to the gateway daemon shape so unrelated
|
||
/// commands that happen to contain "hermes" — `hermes acp` chat sessions,
|
||
/// `hermes -z` one-shots, log tails, README readers — don't get flagged
|
||
/// as "Hermes is running" in the dashboard banner. Two alternations cover
|
||
/// both invocation forms: the python-module path (`python -m
|
||
/// hermes_cli.main gateway run …`) and the script-path form
|
||
/// (`/usr/local/bin/hermes gateway run …`). All callers semantically
|
||
/// want the gateway PID specifically — `stopHermes()` issues
|
||
/// `hermes gateway stop` first and only falls back to killing this
|
||
/// PID, and the dashboard health probe only cares about the gateway.
|
||
nonisolated func hermesPIDResult() -> Result<pid_t?, Error> {
|
||
do {
|
||
let result = try transport.runProcess(
|
||
executable: "/usr/bin/pgrep",
|
||
args: ["-f", #"(^|[[:space:]])-m[[:space:]]+hermes_cli\.main[[:space:]]+gateway[[:space:]]+run([[:space:]]|$)|(^|[[:space:]/])hermes[[:space:]]+gateway[[:space:]]+run([[:space:]]|$)"#],
|
||
stdin: nil,
|
||
timeout: 5
|
||
)
|
||
// pgrep exits 1 when nothing matches — that's "not running", NOT an
|
||
// error. Anything else (127=command not found, 255=ssh failure) is.
|
||
if result.exitCode == 0 {
|
||
if let firstLine = result.stdoutString
|
||
.components(separatedBy: "\n")
|
||
.first(where: { !$0.isEmpty }),
|
||
let pid = pid_t(firstLine.trimmingCharacters(in: .whitespaces)) {
|
||
return .success(pid)
|
||
}
|
||
return .success(nil)
|
||
} else if result.exitCode == 1 {
|
||
return .success(nil) // genuinely not running
|
||
} else {
|
||
let err = TransportError.commandFailed(exitCode: result.exitCode, stderr: result.stderrString)
|
||
Self.logger.warning("pgrep failed (exit \(result.exitCode)): \(result.stderrString, privacy: .public)")
|
||
return .failure(err)
|
||
}
|
||
} catch {
|
||
Self.logger.warning("pgrep transport error: \(error.localizedDescription, privacy: .public)")
|
||
return .failure(error)
|
||
}
|
||
}
|
||
|
||
@discardableResult
|
||
nonisolated 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 }
|
||
// For remote we can't issue a raw `kill(2)` — route through `kill(1)`
|
||
// via the transport. Local uses the syscall for its minimal overhead.
|
||
if context.isRemote {
|
||
let result = try? transport.runProcess(
|
||
executable: "/bin/kill",
|
||
args: ["-TERM", String(pid)],
|
||
stdin: nil,
|
||
timeout: 5
|
||
)
|
||
return (result?.exitCode ?? -1) == 0
|
||
}
|
||
return kill(pid, SIGTERM) == 0
|
||
}
|
||
|
||
nonisolated func hermesBinaryPath() -> String? {
|
||
// Single source of truth for install-location candidates lives in
|
||
// HermesPathSet.hermesBinaryCandidates — keeps pipx/brew/manual lookups
|
||
// consistent across the app.
|
||
return HermesPathSet.hermesBinaryCandidates
|
||
.first { FileManager.default.isExecutableFile(atPath: $0) }
|
||
}
|
||
|
||
/// Keys queried from the user's login shell. PATH is needed because .app
|
||
/// bundles launched from Finder/Dock get a minimal PATH (no Homebrew, no
|
||
/// nvm, no asdf, no mise). The credential keys are needed because Hermes
|
||
/// resolves AI provider auth by reading env vars — a GUI-launched Scarf
|
||
/// subprocess sees none of the `export ANTHROPIC_API_KEY=…` lines from
|
||
/// the user's shell init files.
|
||
nonisolated private static let shellEnvKeys: [String] = [
|
||
"PATH",
|
||
"ANTHROPIC_API_KEY", "ANTHROPIC_TOKEN", "ANTHROPIC_BASE_URL",
|
||
"OPENAI_API_KEY", "OPENAI_BASE_URL",
|
||
"OPENROUTER_API_KEY",
|
||
"GEMINI_API_KEY", "GOOGLE_API_KEY",
|
||
"GROQ_API_KEY", "MISTRAL_API_KEY", "XAI_API_KEY",
|
||
"CLAUDE_CODE_OAUTH_TOKEN",
|
||
// SSH agent socket — set by 1Password / Secretive / a manual
|
||
// `ssh-add` in the user's shell rc. GUI-launched apps don't inherit
|
||
// these by default, so without harvesting them here, `ssh` spawned
|
||
// from Scarf can't reach the agent and authentication fails with
|
||
// "Permission denied" (exit 255) even though terminal ssh works.
|
||
"SSH_AUTH_SOCK", "SSH_AGENT_PID"
|
||
]
|
||
|
||
/// Env vars harvested from the user's login shell. Computed once and cached.
|
||
///
|
||
/// Probing strategy — two attempts, best result wins:
|
||
/// 1. `zsh -l -i` (login + interactive) — sources BOTH `.zprofile` and
|
||
/// `.zshrc`, which is required for nvm/asdf/mise PATH on most setups
|
||
/// (those tools inject PATH from `.zshrc`, not `.zprofile`).
|
||
/// Interactive mode can hang on prompt frameworks (oh-my-zsh,
|
||
/// powerlevel10k, starship) so we suppress prompts via env and bound
|
||
/// with a 5-second timeout.
|
||
/// 2. If that yields no PATH (timed out / prompt framework broke it),
|
||
/// fall back to `zsh -l` (login only) with a 3-second timeout.
|
||
/// 3. If that also fails, hardcoded sane-default PATH; no credentials.
|
||
nonisolated private static let enrichedShellEnv: [String: String] = {
|
||
// Build a shell script that prints `KEY\0VALUE\0` for each key.
|
||
// Using printf with \0 as separator lets us unambiguously split the
|
||
// output even if a value contains newlines.
|
||
let script = shellEnvKeys.map { key in
|
||
#"printf '%s\0%s\0' "\#(key)" "$\#(key)""#
|
||
}.joined(separator: "; ")
|
||
|
||
// Attempt 1: login + interactive (covers nvm/asdf/mise in .zshrc).
|
||
if let result = runShellProbe(script: script, interactive: true, timeout: 5.0),
|
||
result["PATH"] != nil {
|
||
return result
|
||
}
|
||
// Attempt 2: login only (safe fallback if interactive hangs).
|
||
if let result = runShellProbe(script: script, interactive: false, timeout: 3.0),
|
||
result["PATH"] != nil {
|
||
return result
|
||
}
|
||
|
||
// Fallback when the login shell can't be queried (zsh missing,
|
||
// sandbox restriction, timeout). Covers Apple Silicon + Intel
|
||
// Homebrew plus the standard system paths. No credential env is
|
||
// inferred — the user will see the missing-credentials hint instead.
|
||
let home = NSHomeDirectory()
|
||
let fallbackPath = [
|
||
"\(home)/.local/bin",
|
||
"/opt/homebrew/bin",
|
||
"/usr/local/bin",
|
||
"/usr/bin",
|
||
"/bin",
|
||
"/usr/sbin",
|
||
"/sbin"
|
||
].joined(separator: ":")
|
||
return ["PATH": fallbackPath]
|
||
}()
|
||
|
||
/// Runs a zsh probe with the given script and returns the parsed
|
||
/// `KEY\0VALUE\0`-delimited output. Returns nil on timeout/failure.
|
||
/// When `interactive` is true, injects env vars that suppress common
|
||
/// prompt frameworks so the shell doesn't hang waiting for terminal setup.
|
||
nonisolated private static func runShellProbe(script: String, interactive: Bool, timeout: TimeInterval) -> [String: String]? {
|
||
let pipe = Pipe()
|
||
let errPipe = Pipe()
|
||
let process = Process()
|
||
process.executableURL = URL(fileURLWithPath: "/bin/zsh")
|
||
process.arguments = interactive ? ["-l", "-i", "-c", script] : ["-l", "-c", script]
|
||
process.standardOutput = pipe
|
||
process.standardError = errPipe
|
||
|
||
if interactive {
|
||
// Defang prompt frameworks so -i doesn't hang on async prompt init.
|
||
// We still inherit the parent env (HOME, USER etc.) so rc files resolve.
|
||
var env = ProcessInfo.processInfo.environment
|
||
env["TERM"] = "dumb" // disables fancy prompt setup
|
||
env["PS1"] = ""
|
||
env["PROMPT"] = ""
|
||
env["RPROMPT"] = ""
|
||
env["POWERLEVEL9K_INSTANT_PROMPT"] = "off" // p10k
|
||
env["STARSHIP_DISABLE"] = "1" // starship (some versions)
|
||
env["ZSH_DISABLE_COMPFIX"] = "true" // oh-my-zsh compaudit hang
|
||
process.environment = env
|
||
}
|
||
|
||
defer {
|
||
try? pipe.fileHandleForReading.close()
|
||
try? pipe.fileHandleForWriting.close()
|
||
try? errPipe.fileHandleForReading.close()
|
||
try? errPipe.fileHandleForWriting.close()
|
||
}
|
||
do {
|
||
try process.run()
|
||
let deadline = Date().addingTimeInterval(timeout)
|
||
while process.isRunning && Date() < deadline {
|
||
Thread.sleep(forTimeInterval: 0.05)
|
||
}
|
||
if process.isRunning {
|
||
process.terminate()
|
||
// Brief grace period for SIGTERM to take; then the defer
|
||
// cleanup closes the pipes regardless.
|
||
Thread.sleep(forTimeInterval: 0.1)
|
||
return nil
|
||
}
|
||
process.waitUntilExit()
|
||
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
||
guard process.terminationStatus == 0, !data.isEmpty else { return nil }
|
||
var result: [String: String] = [:]
|
||
let parts = data.split(separator: 0, omittingEmptySubsequences: false)
|
||
var i = 0
|
||
while i + 1 < parts.count {
|
||
if let key = String(data: Data(parts[i]), encoding: .utf8),
|
||
let value = String(data: Data(parts[i + 1]), encoding: .utf8),
|
||
!key.isEmpty, !value.isEmpty {
|
||
result[key] = value
|
||
}
|
||
i += 2
|
||
}
|
||
return result.isEmpty ? nil : result
|
||
} catch {
|
||
return nil
|
||
}
|
||
}
|
||
|
||
/// Environment to hand any subprocess that may itself spawn user-installed
|
||
/// binaries (Hermes spawning MCP servers, ACP tool calls, etc.). Starts
|
||
/// from ProcessInfo.environment and overlays PATH + allowlisted credential
|
||
/// env vars harvested from the user's login shell.
|
||
nonisolated static func enrichedEnvironment() -> [String: String] {
|
||
var env = ProcessInfo.processInfo.environment
|
||
for (key, value) in enrichedShellEnv where !value.isEmpty {
|
||
// Shell wins for PATH (we explicitly want the enriched one). For
|
||
// credential keys, also let the shell win — GUI env rarely has
|
||
// them, and if it does, the shell-exported value is usually the
|
||
// one the user actually maintains.
|
||
env[key] = value
|
||
}
|
||
return env
|
||
}
|
||
|
||
/// True if any known AI-provider credential is reachable. Hermes itself
|
||
/// resolves credentials from four locations at runtime, so the preflight
|
||
/// mirrors that set to avoid false "no credentials" warnings:
|
||
/// 1. Current process env + login-shell env (queried once at startup)
|
||
/// 2. `~/.hermes/.env`
|
||
/// 3. `~/.hermes/auth.json` — Credential Pools (v1.6+ blessed flow)
|
||
/// 4. `~/.hermes/config.yaml` — embedded `api_key:` for auxiliary /
|
||
/// delegation tasks
|
||
/// Used by Chat to warn the user before `hermes acp` fails on send with
|
||
/// "No Anthropic credentials found".
|
||
///
|
||
/// **Local context:** also checks Scarf's process / login-shell env.
|
||
/// **Remote context:** skips that step — our process env has nothing to
|
||
/// do with the remote `hermes acp`'s runtime env. The remote `.env` /
|
||
/// `auth.json` / `config.yaml` are still checked through the transport.
|
||
nonisolated func hasAnyAICredential() -> Bool {
|
||
let credentialKeys = Self.shellEnvKeys.filter { $0 != "PATH" && $0 != "ANTHROPIC_BASE_URL" && $0 != "OPENAI_BASE_URL" }
|
||
|
||
if !context.isRemote {
|
||
let env = Self.enrichedEnvironment()
|
||
for key in credentialKeys {
|
||
if let value = env[key], !value.isEmpty {
|
||
return true
|
||
}
|
||
}
|
||
}
|
||
// Scan .env (via transport — local file or scp) for KEY= lines.
|
||
// Uses a simple substring check — good enough for a preflight hint;
|
||
// hermes itself does the real parse.
|
||
if let envText = readFile(context.paths.envFile) {
|
||
for line in envText.split(separator: "\n") {
|
||
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||
if trimmed.isEmpty || trimmed.hasPrefix("#") { continue }
|
||
for key in credentialKeys where trimmed.hasPrefix("\(key)=") || trimmed.hasPrefix("export \(key)=") {
|
||
// Must have a non-empty value after `=`
|
||
if let eq = trimmed.firstIndex(of: "="),
|
||
trimmed.index(after: eq) < trimmed.endIndex {
|
||
let value = trimmed[trimmed.index(after: eq)...]
|
||
.trimmingCharacters(in: CharacterSet(charactersIn: "\"' "))
|
||
if !value.isEmpty { return true }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
// Scan auth.json. Two shapes need to count as "credential present":
|
||
//
|
||
// 1. credential_pool.<provider>[].access_token
|
||
// — written by Configure → Credential Pools (manual key entry,
|
||
// round-robin / least-used routing).
|
||
//
|
||
// 2. providers.<name>.access_token
|
||
// — written by `hermes auth add <name>` for OAuth-authed
|
||
// providers (Nous Portal, Spotify, GitHub Copilot ACP, etc.).
|
||
// Pre-fix this was ignored, so a user with only Nous OAuth
|
||
// kept seeing the "No AI provider credentials" banner even
|
||
// after a successful Nous sign-in.
|
||
//
|
||
// Defensive parse: malformed input falls through to the next check.
|
||
if let data = readFileData(context.paths.authJSON),
|
||
let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
|
||
{
|
||
if let pool = root["credential_pool"] as? [String: Any] {
|
||
for (_, entries) in pool {
|
||
guard let list = entries as? [[String: Any]] else { continue }
|
||
for cred in list {
|
||
if let token = cred["access_token"] as? String, !token.isEmpty {
|
||
return true
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if let providers = root["providers"] as? [String: Any] {
|
||
for (_, value) in providers {
|
||
guard let entry = value as? [String: Any] else { continue }
|
||
if let token = entry["access_token"] as? String, !token.isEmpty {
|
||
return true
|
||
}
|
||
// Some auth records (Spotify) carry only a refresh
|
||
// token until the first access-token mint — count
|
||
// that too so we don't false-negative seconds-old
|
||
// OAuth flows.
|
||
if let refresh = entry["refresh_token"] as? String, !refresh.isEmpty {
|
||
return true
|
||
}
|
||
}
|
||
}
|
||
}
|
||
// Scan config.yaml for `api_key:` lines with a non-empty value.
|
||
// Covers both `auxiliary.<task>.api_key` and `delegation.api_key`
|
||
// without needing to parse YAML structure.
|
||
if let text = readFile(context.paths.configYAML) {
|
||
for line in text.split(separator: "\n") {
|
||
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||
guard trimmed.hasPrefix("api_key:") else { continue }
|
||
let value = trimmed.dropFirst("api_key:".count)
|
||
.trimmingCharacters(in: CharacterSet(charactersIn: "\"' "))
|
||
if !value.isEmpty { return true }
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
/// Persist the primary model + provider to `config.yaml` in one call.
|
||
/// Used by the chat-start preflight when the user picks a model from
|
||
/// the picker sheet — we need to write both keys before re-attempting
|
||
/// `client.start()`. Wraps two `hermes config set` invocations because
|
||
/// Hermes doesn't expose a combined "set model" command.
|
||
///
|
||
/// Returns `true` only if both writes succeed. If the second write
|
||
/// fails the first is left in place — `model.default` without a
|
||
/// matching `model.provider` is no worse than the all-empty state we
|
||
/// started in, and the next preflight pass will re-prompt anyway.
|
||
@discardableResult
|
||
nonisolated func setModelAndProvider(model: String, provider: String) -> Bool {
|
||
let trimmedModel = model.trimmingCharacters(in: .whitespaces)
|
||
let trimmedProvider = provider.trimmingCharacters(in: .whitespaces)
|
||
guard !trimmedProvider.isEmpty else { return false }
|
||
|
||
let providerResult = runHermesCLI(args: ["config", "set", "model.provider", trimmedProvider], timeout: 30)
|
||
guard providerResult.exitCode == 0 else {
|
||
Self.logger.warning("hermes config set model.provider failed: \(providerResult.output, privacy: .public)")
|
||
return false
|
||
}
|
||
// Subscription-gated overlay providers (Nous Portal) accept an
|
||
// empty model — Hermes picks its own default. Skip the model
|
||
// write in that case rather than persisting the empty string,
|
||
// which Hermes would treat as "unset" and the preflight would
|
||
// catch again on the next start.
|
||
guard !trimmedModel.isEmpty else { return true }
|
||
|
||
let modelResult = runHermesCLI(args: ["config", "set", "model.default", trimmedModel], timeout: 30)
|
||
guard modelResult.exitCode == 0 else {
|
||
Self.logger.warning("hermes config set model.default failed: \(modelResult.output, privacy: .public)")
|
||
return false
|
||
}
|
||
return true
|
||
}
|
||
|
||
@discardableResult
|
||
nonisolated func runHermesCLI(args: [String], timeout: TimeInterval = 60, stdinInput: String? = nil) -> (exitCode: Int32, output: String) {
|
||
// Resolve the executable path — for remote, prefer the cached
|
||
// `hermesBinaryHint` on the SSHConfig (populated by the Test
|
||
// Connection probe) and fall back to bare `hermes` which relies on
|
||
// the remote user's `$PATH`.
|
||
let binary: String
|
||
if context.isRemote {
|
||
binary = context.paths.hermesBinary
|
||
} else {
|
||
guard let local = hermesBinaryPath() else { return (-1, "") }
|
||
binary = local
|
||
}
|
||
|
||
let stdinData = stdinInput?.data(using: .utf8)
|
||
do {
|
||
let result = try transport.runProcess(
|
||
executable: binary,
|
||
args: args,
|
||
stdin: stdinData,
|
||
timeout: timeout
|
||
)
|
||
// Match the legacy signature: combined stdout+stderr in one
|
||
// String so callers that grep through output don't need to
|
||
// change. Stderr after stdout mirrors what the old Process impl
|
||
// produced since both pipes were drained in that order.
|
||
let combined = result.stdoutString + result.stderrString
|
||
return (result.exitCode, combined)
|
||
} catch let error as TransportError {
|
||
return (-1, error.diagnosticStderr.isEmpty
|
||
? (error.errorDescription ?? "transport error")
|
||
: error.diagnosticStderr)
|
||
} catch {
|
||
return (-1, error.localizedDescription)
|
||
}
|
||
}
|
||
|
||
/// Split-stream variant of `runHermesCLI`. Use this when you need to
|
||
/// parse stdout (e.g. JSON output) without stderr contamination, and
|
||
/// surface stderr separately as a user-facing error message. Transport
|
||
/// failures land in `stderr` with an empty `stdout`.
|
||
@discardableResult
|
||
nonisolated func runHermesCLISplit(args: [String], timeout: TimeInterval = 60, stdinInput: String? = nil) -> (exitCode: Int32, stdout: String, stderr: String) {
|
||
let binary: String
|
||
if context.isRemote {
|
||
binary = context.paths.hermesBinary
|
||
} else {
|
||
guard let local = hermesBinaryPath() else { return (-1, "", "hermes binary not found") }
|
||
binary = local
|
||
}
|
||
|
||
let stdinData = stdinInput?.data(using: .utf8)
|
||
do {
|
||
let result = try transport.runProcess(
|
||
executable: binary,
|
||
args: args,
|
||
stdin: stdinData,
|
||
timeout: timeout
|
||
)
|
||
return (result.exitCode, result.stdoutString, result.stderrString)
|
||
} catch let error as TransportError {
|
||
let message = error.diagnosticStderr.isEmpty
|
||
? (error.errorDescription ?? "transport error")
|
||
: error.diagnosticStderr
|
||
return (-1, "", message)
|
||
} catch {
|
||
return (-1, "", error.localizedDescription)
|
||
}
|
||
}
|
||
|
||
// MARK: - File I/O
|
||
|
||
/// Read a UTF-8 text file through the transport. Missing files and any
|
||
/// transport error surface as `nil` — callers that don't need the
|
||
/// specific error reason keep using this. New call sites that want to
|
||
/// show a user-actionable message should use `readFileResult`.
|
||
nonisolated private func readFile(_ path: String) -> String? {
|
||
switch readFileResult(path) {
|
||
case .success(let s):
|
||
return s
|
||
case .failure:
|
||
return nil
|
||
}
|
||
}
|
||
|
||
nonisolated private func readFileData(_ path: String) -> Data? {
|
||
switch readFileDataResult(path) {
|
||
case .success(let d):
|
||
return d
|
||
case .failure:
|
||
return nil
|
||
}
|
||
}
|
||
|
||
/// Error-surfacing read. Returns the decoded text on success, or the
|
||
/// underlying `TransportError` (or raw error for local failures) on
|
||
/// failure. Every failure is also logged via `os.Logger` — the warning
|
||
/// trail in Console.app is how we diagnose "connection green, data
|
||
/// empty" bug reports without needing to wire the error through every
|
||
/// existing call site.
|
||
nonisolated func readFileResult(_ path: String) -> Result<String, Error> {
|
||
switch readFileDataResult(path) {
|
||
case .success(let data):
|
||
guard let s = String(data: data, encoding: .utf8) else {
|
||
let err = TransportError.fileIO(path: path, underlying: "file is not valid UTF-8")
|
||
Self.logger.warning("readFile(\(path, privacy: .public)): not UTF-8")
|
||
return .failure(err)
|
||
}
|
||
return .success(s)
|
||
case .failure(let err):
|
||
return .failure(err)
|
||
}
|
||
}
|
||
|
||
nonisolated func readFileDataResult(_ path: String) -> Result<Data, Error> {
|
||
do {
|
||
let data = try transport.readFile(path)
|
||
return .success(data)
|
||
} catch {
|
||
// Don't log "No such file" — that's a routine, expected case
|
||
// for optional files (skill.yaml, gateway_state.json before
|
||
// Hermes starts, ~/.hermes/memories/USER.md on fresh installs,
|
||
// etc.). The caller still gets the Result.failure so it can
|
||
// distinguish missing from present-but-unreadable.
|
||
// Log everything else — permission denied, connection drops,
|
||
// sqlite3 missing — since those are actionable diagnostics.
|
||
if !Self.isFileNotFound(error) {
|
||
Self.logger.warning("readFile(\(path, privacy: .public)) failed: \(error.localizedDescription, privacy: .public)")
|
||
}
|
||
return .failure(error)
|
||
}
|
||
}
|
||
|
||
/// `true` iff the error represents "file does not exist" as opposed to
|
||
/// a permission / transport / parse failure. Used to suppress routine
|
||
/// logging for optional files while still surfacing real problems.
|
||
nonisolated private static func isFileNotFound(_ error: Error) -> Bool {
|
||
if let transportErr = error as? TransportError,
|
||
case .fileIO(_, let underlying) = transportErr {
|
||
return underlying.lowercased().contains("no such file")
|
||
}
|
||
// Cocoa NSFileNoSuchFileError (returned by LocalTransport when
|
||
// reading a missing file via FileManager).
|
||
let ns = error as NSError
|
||
if ns.domain == NSCocoaErrorDomain && ns.code == 260 { return true }
|
||
if ns.domain == NSPOSIXErrorDomain && ns.code == 2 { return true } // ENOENT
|
||
return false
|
||
}
|
||
|
||
/// Write a UTF-8 text file atomically through the transport. Matches the
|
||
/// old pre-transport behavior (print + swallow on error) because the
|
||
/// callers don't have a UI path for surfacing I/O failures — that's
|
||
/// planned for Phase 4.
|
||
nonisolated private func writeFile(_ path: String, content: String) {
|
||
guard let data = content.data(using: .utf8) else { return }
|
||
do {
|
||
try transport.writeFile(path, data: data)
|
||
} catch {
|
||
print("[Scarf] Failed to write \(path): \(error.localizedDescription)")
|
||
}
|
||
}
|
||
}
|