diff --git a/scarf/scarf/Core/Models/HermesConstants.swift b/scarf/scarf/Core/Models/HermesConstants.swift index f43a81c..f304dfd 100644 --- a/scarf/scarf/Core/Models/HermesConstants.swift +++ b/scarf/scarf/Core/Models/HermesConstants.swift @@ -19,10 +19,30 @@ enum HermesPaths: Sendable { nonisolated static let errorsLog: String = home + "/logs/errors.log" nonisolated static let agentLog: String = home + "/logs/agent.log" nonisolated static let gatewayLog: String = home + "/logs/gateway.log" - nonisolated static let hermesBinary: String = userHome + "/.local/bin/hermes" nonisolated static let scarfDir: String = home + "/scarf" nonisolated static let projectsRegistry: String = scarfDir + "/projects.json" nonisolated static let mcpTokensDir: String = home + "/mcp-tokens" + + /// Install locations we look for the `hermes` binary in, in priority order. + /// Checked every access so a user installing via a different method doesn't + /// need to relaunch Scarf. + nonisolated static let hermesBinaryCandidates: [String] = [ + userHome + "/.local/bin/hermes", // pipx / pip --user (default) + "/opt/homebrew/bin/hermes", // Homebrew on Apple Silicon + "/usr/local/bin/hermes", // Homebrew on Intel / manual install + userHome + "/.hermes/bin/hermes" // Some self-install layouts + ] + + /// Resolved path to the `hermes` executable. Returns the first candidate + /// that exists and is executable; falls back to the pipx default so error + /// messages ("Expected at …") still make sense on a fresh machine. + nonisolated static var hermesBinary: String { + for path in hermesBinaryCandidates + where FileManager.default.isExecutableFile(atPath: path) { + return path + } + return hermesBinaryCandidates[0] + } } // MARK: - SQLite Constants diff --git a/scarf/scarf/Core/Services/ACPClient.swift b/scarf/scarf/Core/Services/ACPClient.swift index c3f959a..e510683 100644 --- a/scarf/scarf/Core/Services/ACPClient.swift +++ b/scarf/scarf/Core/Services/ACPClient.swift @@ -24,6 +24,27 @@ actor ACPClient { private(set) var currentSessionId: String? private(set) var statusMessage = "" + /// Ring buffer of recent stderr lines from `hermes acp` — used to attach + /// a diagnostic tail to user-visible errors. Capped to avoid unbounded + /// growth when the subprocess logs heavily. + private var stderrBuffer: [String] = [] + private static let stderrBufferMaxLines = 50 + + /// Returns the last ~`stderrBufferMaxLines` stderr lines captured from the + /// `hermes acp` subprocess, joined by newlines. + var recentStderr: String { + stderrBuffer.joined(separator: "\n") + } + + fileprivate func appendStderr(_ text: String) { + for line in text.split(separator: "\n", omittingEmptySubsequences: true) { + stderrBuffer.append(String(line)) + } + if stderrBuffer.count > Self.stderrBufferMaxLines { + stderrBuffer.removeFirst(stderrBuffer.count - Self.stderrBufferMaxLines) + } + } + /// Check if the underlying process is still alive and connected. var isHealthy: Bool { guard isConnected, let process else { return false } @@ -398,7 +419,8 @@ actor ACPClient { await self?.handleReadLoopEnded() } - // Read stderr in background for diagnostic logging + // Read stderr in background for diagnostic logging AND ring-buffer + // capture so we can attach a tail to user-visible errors. stderrTask = Task.detached { [weak self] in let handle = stderr.fileHandleForReading while !Task.isCancelled { @@ -407,6 +429,7 @@ actor ACPClient { if let text = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines), !text.isEmpty { await self?.logger.info("ACP stderr: \(text.prefix(500))") + await self?.appendStderr(text) } } } @@ -516,3 +539,35 @@ enum ACPClientError: Error, LocalizedError { } } } + +/// Maps a raw error message (RPC message or captured stderr) to a short +/// human-readable hint for the chat UI. Pattern-matches the most common +/// fresh-install failure modes. Returns nil when no known pattern matches. +enum ACPErrorHint { + static func classify(errorMessage: String, stderrTail: String) -> String? { + let haystack = errorMessage + "\n" + stderrTail + if haystack.range(of: #"No\s+(Anthropic|OpenAI|OpenRouter|Gemini|Google|Groq|Mistral|XAI)?\s*credentials\s+found"#, + options: .regularExpression) != nil + || haystack.contains("ANTHROPIC_API_KEY") + || haystack.contains("ANTHROPIC_TOKEN") + || haystack.contains("claude setup-token") + || haystack.contains("claude /login") { + return "Hermes can't find your AI provider credentials. Set `ANTHROPIC_API_KEY` (or similar) in `~/.hermes/.env` or your shell profile, then restart Scarf." + } + if let match = haystack.range(of: #"No such file or directory:\s*'([^']+)'"#, + options: .regularExpression) { + let matched = String(haystack[match]) + if let nameStart = matched.range(of: "'"), + let nameEnd = matched.range(of: "'", range: nameStart.upperBound.. String? { - let candidates = [ - ("\(NSHomeDirectory())/.local/bin/hermes"), - "/opt/homebrew/bin/hermes", - "/usr/local/bin/hermes" - ] - return candidates.first { FileManager.default.isExecutableFile(atPath: $0) } + // Single source of truth for install-location candidates lives in + // HermesPaths.hermesBinaryCandidates — keeps pipx/brew/manual lookups + // consistent across the app. + return HermesPaths.hermesBinaryCandidates + .first { FileManager.default.isExecutableFile(atPath: $0) } } - /// PATH cobbled together from the user's login shell — needed because - /// .app bundles launched from Finder/Dock get a minimal PATH (no Homebrew, - /// no nvm, no asdf, no mise). Without this, MCP servers using `npx`, - /// `node`, `python`, `uv`, etc. fail to launch with `[Errno 2] No such - /// file or directory`. Computed once and cached. - private static let enrichedPath: String = { - let pipe = Pipe() - let errPipe = Pipe() - let process = Process() - process.executableURL = URL(fileURLWithPath: "/bin/zsh") - // -l sources the user's login files (.zprofile, .zshrc via /etc/zshrc - // chain on macOS) so PATH manipulations made there are picked up. - // Skip -i to avoid hangs from interactive prompts. - process.arguments = ["-l", "-c", "echo $PATH"] - process.standardOutput = pipe - process.standardError = errPipe - defer { - try? pipe.fileHandleForReading.close() - try? pipe.fileHandleForWriting.close() - try? errPipe.fileHandleForReading.close() - try? errPipe.fileHandleForWriting.close() + /// Keys queried from the user's login shell. PATH is needed because .app + /// bundles launched from Finder/Dock get a minimal PATH (no Homebrew, no + /// nvm, no asdf, no mise). The credential keys are needed because Hermes + /// resolves AI provider auth by reading env vars — a GUI-launched Scarf + /// subprocess sees none of the `export ANTHROPIC_API_KEY=…` lines from + /// the user's shell init files. + private static let shellEnvKeys: [String] = [ + "PATH", + "ANTHROPIC_API_KEY", "ANTHROPIC_TOKEN", "ANTHROPIC_BASE_URL", + "OPENAI_API_KEY", "OPENAI_BASE_URL", + "OPENROUTER_API_KEY", + "GEMINI_API_KEY", "GOOGLE_API_KEY", + "GROQ_API_KEY", "MISTRAL_API_KEY", "XAI_API_KEY", + "CLAUDE_CODE_OAUTH_TOKEN" + ] + + /// Env vars harvested from the user's login shell. Computed once and cached. + /// + /// Probing strategy — two attempts, best result wins: + /// 1. `zsh -l -i` (login + interactive) — sources BOTH `.zprofile` and + /// `.zshrc`, which is required for nvm/asdf/mise PATH on most setups + /// (those tools inject PATH from `.zshrc`, not `.zprofile`). + /// Interactive mode can hang on prompt frameworks (oh-my-zsh, + /// powerlevel10k, starship) so we suppress prompts via env and bound + /// with a 5-second timeout. + /// 2. If that yields no PATH (timed out / prompt framework broke it), + /// fall back to `zsh -l` (login only) with a 3-second timeout. + /// 3. If that also fails, hardcoded sane-default PATH; no credentials. + private static let enrichedShellEnv: [String: String] = { + // Build a shell script that prints `KEY\0VALUE\0` for each key. + // Using printf with \0 as separator lets us unambiguously split the + // output even if a value contains newlines. + let script = shellEnvKeys.map { key in + #"printf '%s\0%s\0' "\#(key)" "$\#(key)""# + }.joined(separator: "; ") + + // Attempt 1: login + interactive (covers nvm/asdf/mise in .zshrc). + if let result = runShellProbe(script: script, interactive: true, timeout: 5.0), + result["PATH"] != nil { + return result } - do { - try process.run() - let deadline = Date().addingTimeInterval(3) - while process.isRunning && Date() < deadline { - Thread.sleep(forTimeInterval: 0.05) - } - if process.isRunning { process.terminate() } - process.waitUntilExit() - let data = pipe.fileHandleForReading.readDataToEndOfFile() - let path = (String(data: data, encoding: .utf8) ?? "") - .trimmingCharacters(in: .whitespacesAndNewlines) - if process.terminationStatus == 0 && !path.isEmpty { - return path - } - } catch { - // Fall through to default below. + // 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. + // Homebrew plus the standard system paths. No credential env is + // inferred — the user will see the missing-credentials hint instead. let home = NSHomeDirectory() - return [ + let fallbackPath = [ "\(home)/.local/bin", "/opt/homebrew/bin", "/usr/local/bin", @@ -1263,18 +1271,124 @@ struct HermesFileService: Sendable { "/usr/sbin", "/sbin" ].joined(separator: ":") + return ["PATH": fallbackPath] }() + /// Runs a zsh probe with the given script and returns the parsed + /// `KEY\0VALUE\0`-delimited output. Returns nil on timeout/failure. + /// When `interactive` is true, injects env vars that suppress common + /// prompt frameworks so the shell doesn't hang waiting for terminal setup. + private static func runShellProbe(script: String, interactive: Bool, timeout: TimeInterval) -> [String: String]? { + let pipe = Pipe() + let errPipe = Pipe() + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/zsh") + process.arguments = interactive ? ["-l", "-i", "-c", script] : ["-l", "-c", script] + process.standardOutput = pipe + process.standardError = errPipe + + if interactive { + // Defang prompt frameworks so -i doesn't hang on async prompt init. + // We still inherit the parent env (HOME, USER etc.) so rc files resolve. + var env = ProcessInfo.processInfo.environment + env["TERM"] = "dumb" // disables fancy prompt setup + env["PS1"] = "" + env["PROMPT"] = "" + env["RPROMPT"] = "" + env["POWERLEVEL9K_INSTANT_PROMPT"] = "off" // p10k + env["STARSHIP_DISABLE"] = "1" // starship (some versions) + env["ZSH_DISABLE_COMPFIX"] = "true" // oh-my-zsh compaudit hang + process.environment = env + } + + defer { + try? pipe.fileHandleForReading.close() + try? pipe.fileHandleForWriting.close() + try? errPipe.fileHandleForReading.close() + try? errPipe.fileHandleForWriting.close() + } + do { + try process.run() + let deadline = Date().addingTimeInterval(timeout) + while process.isRunning && Date() < deadline { + Thread.sleep(forTimeInterval: 0.05) + } + if process.isRunning { + process.terminate() + // Brief grace period for SIGTERM to take; then the defer + // cleanup closes the pipes regardless. + Thread.sleep(forTimeInterval: 0.1) + return nil + } + process.waitUntilExit() + let data = pipe.fileHandleForReading.readDataToEndOfFile() + guard process.terminationStatus == 0, !data.isEmpty else { return nil } + var result: [String: String] = [:] + let parts = data.split(separator: 0, omittingEmptySubsequences: false) + var i = 0 + while i + 1 < parts.count { + if let key = String(data: Data(parts[i]), encoding: .utf8), + let value = String(data: Data(parts[i + 1]), encoding: .utf8), + !key.isEmpty, !value.isEmpty { + result[key] = value + } + i += 2 + } + return result.isEmpty ? nil : result + } catch { + return nil + } + } + /// Environment to hand any subprocess that may itself spawn user-installed - /// binaries (Hermes spawning MCP servers, ACP tool calls, etc.). Identical - /// to ProcessInfo.processInfo.environment but with PATH replaced by the - /// login-shell PATH. + /// 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 - env["PATH"] = enrichedPath + for (key, value) in enrichedShellEnv where !value.isEmpty { + // Shell wins for PATH (we explicitly want the enriched one). For + // credential keys, also let the shell win — GUI env rarely has + // them, and if it does, the shell-exported value is usually the + // one the user actually maintains. + env[key] = value + } return env } + /// True if any known AI-provider credential is reachable — either already + /// in the current process env, present in the login-shell env we queried, + /// or present in `~/.hermes/.env`. Used by Chat to warn the user before + /// `hermes acp` fails on send with "No Anthropic credentials found". + nonisolated static func hasAnyAICredential() -> Bool { + let credentialKeys = shellEnvKeys.filter { $0 != "PATH" && $0 != "ANTHROPIC_BASE_URL" && $0 != "OPENAI_BASE_URL" } + let env = enrichedEnvironment() + for key in credentialKeys { + if let value = env[key], !value.isEmpty { + return true + } + } + // Scan ~/.hermes/.env for KEY= lines. Uses a simple substring check — + // good enough for a preflight hint; hermes itself does the real parse. + let envPath = HermesPaths.home + "/.env" + if let data = try? String(contentsOfFile: envPath, encoding: .utf8) { + for line in data.split(separator: "\n") { + let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed.isEmpty || trimmed.hasPrefix("#") { continue } + for key in credentialKeys where trimmed.hasPrefix("\(key)=") || trimmed.hasPrefix("export \(key)=") { + // Must have a non-empty value after `=` + if let eq = trimmed.firstIndex(of: "="), + trimmed.index(after: eq) < trimmed.endIndex { + let value = trimmed[trimmed.index(after: eq)...] + .trimmingCharacters(in: CharacterSet(charactersIn: "\"' ")) + if !value.isEmpty { return true } + } + } + } + } + return false + } + @discardableResult nonisolated func runHermesCLI(args: [String], timeout: TimeInterval = 60, stdinInput: String? = nil) -> (exitCode: Int32, output: String) { guard let binary = hermesBinaryPath() else { return (-1, "") } diff --git a/scarf/scarf/Features/Chat/ViewModels/ChatViewModel.swift b/scarf/scarf/Features/Chat/ViewModels/ChatViewModel.swift index 0a296b3..3d240d8 100644 --- a/scarf/scarf/Features/Chat/ViewModels/ChatViewModel.swift +++ b/scarf/scarf/Features/Chat/ViewModels/ChatViewModel.swift @@ -30,6 +30,14 @@ final class ChatViewModel { var isACPConnected: Bool { acpClient != nil && hasActiveProcess } var acpStatus: String = "" var acpError: String? + /// Human-readable hint derived from error + stderr (e.g. "set ANTHROPIC_API_KEY"). + /// Shown above the raw error in the UI when present. + var acpErrorHint: String? + /// Tail of stderr captured from `hermes acp` at the time of the last + /// failure — shown in a collapsible details section so users can copy/paste. + var acpErrorDetails: String? + /// True when `hasAnyAICredential()` returned false at last preflight. + var missingCredentials: Bool = false private static let maxReconnectAttempts = 5 private static let reconnectBaseDelay: UInt64 = 1_000_000_000 // 1 second @@ -39,6 +47,34 @@ final class ChatViewModel { FileManager.default.fileExists(atPath: HermesPaths.hermesBinary) } + /// Re-checks env + `~/.hermes/.env` for AI-provider credentials and + /// updates `missingCredentials`. Cheap — safe to call from view `.task`. + func refreshCredentialPreflight() { + missingCredentials = !HermesFileService.hasAnyAICredential() + } + + /// Clears the error/hint/details triplet so future failures overwrite + /// cleanly instead of stacking on top of stale state. + private func clearACPErrorState() { + acpError = nil + acpErrorHint = nil + acpErrorDetails = nil + } + + /// Populates acpError, acpErrorHint, acpErrorDetails from an error + the + /// stderr tail the ACP client captured, and logs the failure with a + /// site-specific context label. Call on any failure path. + @MainActor + private func recordACPFailure(_ error: Error, client: ACPClient?, context: String) async { + let msg = error.localizedDescription + logger.error("\(context): \(msg)") + let stderrTail = await client?.recentStderr ?? "" + let hint = ACPErrorHint.classify(errorMessage: msg, stderrTail: stderrTail) + acpError = msg + acpErrorHint = hint + acpErrorDetails = stderrTail.isEmpty ? nil : stderrTail + } + // MARK: - Session Lifecycle func startNewSession() { @@ -157,10 +193,8 @@ final class ChatViewModel { // Now send the queued prompt sendViaACP(client: client, text: text) } catch { - let msg = error.localizedDescription - logger.error("Auto-start ACP failed: \(msg)") acpStatus = "Failed" - acpError = msg + await recordACPFailure(error, client: client, context: "Auto-start ACP failed") hasActiveProcess = false acpClient = nil } @@ -169,6 +203,7 @@ final class ChatViewModel { private func sendViaACP(client: ACPClient, text: String) { guard let sessionId = richChatViewModel.sessionId else { + clearACPErrorState() acpError = "No session ID — cannot send" return } @@ -192,10 +227,8 @@ final class ChatViewModel { } catch is CancellationError { acpStatus = "Cancelled" } catch { - let msg = error.localizedDescription - logger.error("ACP prompt failed: \(msg)") acpStatus = "Error" - acpError = msg + await recordACPFailure(error, client: client, context: "ACP prompt failed") richChatViewModel.handleACPEvent( .promptComplete(sessionId: sessionId, response: ACPPromptResult( stopReason: "error", @@ -211,7 +244,7 @@ final class ChatViewModel { private func startACPSession(resume sessionId: String?) { stopACP() - acpError = nil + clearACPErrorState() acpStatus = "Starting..." let client = ACPClient() @@ -259,10 +292,8 @@ final class ChatViewModel { logger.info("ACP session ready: \(resolvedSessionId)") } catch { - let msg = error.localizedDescription - logger.error("Failed to start ACP session: \(msg)") acpStatus = "Failed" - acpError = msg + await recordACPFailure(error, client: client, context: "Failed to start ACP session") hasActiveProcess = false acpClient = nil } @@ -333,7 +364,7 @@ final class ChatViewModel { private func attemptReconnect(sessionId: String) { reconnectTask?.cancel() - acpError = nil + clearACPErrorState() reconnectTask = Task { @MainActor [weak self] in guard let self else { return } @@ -379,7 +410,7 @@ final class ChatViewModel { await richChatViewModel.reconcileWithDB(sessionId: resolvedSessionId) acpStatus = "Reconnected (\(resolvedSessionId.prefix(12)))" - acpError = nil + clearACPErrorState() startACPEventLoop(client: client) startHealthMonitor(client: client) @@ -404,6 +435,7 @@ final class ChatViewModel { private func showConnectionFailure() { richChatViewModel.handleACPEvent(.connectionLost(reason: "The ACP process terminated unexpectedly")) acpStatus = "Connection lost" + clearACPErrorState() acpError = "Connection lost. Use the Session menu to reconnect." } diff --git a/scarf/scarf/Features/Chat/Views/ChatView.swift b/scarf/scarf/Features/Chat/Views/ChatView.swift index 5e5e51d..edcb995 100644 --- a/scarf/scarf/Features/Chat/Views/ChatView.swift +++ b/scarf/scarf/Features/Chat/Views/ChatView.swift @@ -3,18 +3,113 @@ import SwiftUI struct ChatView: View { @Environment(ChatViewModel.self) private var viewModel @Environment(HermesFileWatcher.self) private var fileWatcher + @State private var showErrorDetails = false var body: some View { @Bindable var vm = viewModel VStack(spacing: 0) { toolbar Divider() + errorBanner chatArea } .navigationTitle("Chat") - .task { await viewModel.loadRecentSessions() } + .task { + await viewModel.loadRecentSessions() + viewModel.refreshCredentialPreflight() + } .onChange(of: fileWatcher.lastChangeDate) { Task { await viewModel.loadRecentSessions() } + viewModel.refreshCredentialPreflight() + } + } + + /// Banner rendered between the toolbar and the chat area when either + /// (a) a preflight credential check failed, or (b) the ACP subprocess + /// returned an error we captured. Shows a short hint + expandable raw + /// details (stderr tail) that the user can copy to the clipboard. + @ViewBuilder + private var errorBanner: some View { + if let err = viewModel.acpError { + VStack(alignment: .leading, spacing: 6) { + HStack(alignment: .top, spacing: 8) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + VStack(alignment: .leading, spacing: 2) { + if let hint = viewModel.acpErrorHint { + Text(hint) + .font(.callout) + .textSelection(.enabled) + } + Text(err) + .font(.caption) + .foregroundStyle(.secondary) + .textSelection(.enabled) + .lineLimit(showErrorDetails ? nil : 2) + } + Spacer() + if viewModel.acpErrorDetails != nil { + Button(showErrorDetails ? "Hide details" : "Show details") { + showErrorDetails.toggle() + } + .buttonStyle(.borderless) + .controlSize(.small) + } + Button { + let payload = [viewModel.acpErrorHint, err, viewModel.acpErrorDetails] + .compactMap { $0 } + .joined(separator: "\n\n") + let pb = NSPasteboard.general + pb.clearContents() + pb.setString(payload, forType: .string) + } label: { + Image(systemName: "doc.on.doc") + } + .buttonStyle(.borderless) + .help("Copy error details") + } + if showErrorDetails, let details = viewModel.acpErrorDetails { + ScrollView { + Text(details) + .font(.system(.caption2, design: .monospaced)) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(maxHeight: 160) + .padding(8) + .background(Color(nsColor: .textBackgroundColor)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } + } + .padding(10) + .background(Color.orange.opacity(0.08)) + .overlay( + Rectangle() + .fill(Color.orange.opacity(0.25)) + .frame(height: 1), + alignment: .bottom + ) + } else if viewModel.missingCredentials && !viewModel.hasActiveProcess { + HStack(spacing: 8) { + Image(systemName: "key.fill") + .foregroundStyle(.orange) + VStack(alignment: .leading, spacing: 2) { + Text("No AI provider credentials detected") + .font(.callout) + Text("Add `ANTHROPIC_API_KEY` (or similar) to `~/.hermes/.env` or your shell profile, then restart Scarf.") + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + } + .padding(10) + .background(Color.orange.opacity(0.08)) + .overlay( + Rectangle() + .fill(Color.orange.opacity(0.25)) + .frame(height: 1), + alignment: .bottom + ) } }