import Foundation // MARK: - Claude Code CLI as the chat brain // // Every message runs the user's own `claude` binary headless: // claude -p --output-format stream-json --verbose --include-partial-messages // [--resume ] [--model ] // in the chat folder (Settings → Chat), on the user's Claude subscription — no API key. // The session id from the first reply is kept, so the next message continues the same // conversation; "New conversation" forgets it. MCP servers, skills, CLAUDE.md and memory // all come along because it is the real CLI. Tool permissions go through the usual // Claude Code hooks, so they show up as Allow / Deny cards in the notch. enum ClaudeCodeCLIError: LocalizedError { case notFound case failed(String) var errorDescription: String? { switch self { case .notFound: "Claude Code CLI не найден. Установите его или укажите путь в Настройки → Чат." case .failed(let message): message } } } @MainActor final class ClaudeCodeCLI { static let shared = ClaudeCodeCLI() /// Models offered in the picker. Empty id = whatever the CLI is configured to use. static let models: [(id: String, label: String)] = [ ("", "По умолчанию"), ("opus", "Opus"), ("sonnet", "Sonnet"), ("haiku", "Haiku"), ] private(set) var sessionID: String? private var process: Process? var isRunning: Bool { process?.isRunning == true } func newConversation() { cancel() sessionID = nil } func cancel() { if let p = process, p.isRunning { p.terminate() } process = nil } // MARK: Locating the binary /// The `claude` binary: the path from Settings when set, else the usual install spots. /// GUI apps don't get the shell's PATH, so we look ourselves. nonisolated static func resolveBinary(custom: String) -> String? { let fm = FileManager.default let trimmed = (custom as NSString).expandingTildeInPath .trimmingCharacters(in: .whitespacesAndNewlines) if !trimmed.isEmpty { return fm.isExecutableFile(atPath: trimmed) ? trimmed : nil } let home = fm.homeDirectoryForCurrentUser.path let candidates = [ "\(home)/.local/bin/claude", "\(home)/.claude/local/claude", "/opt/homebrew/bin/claude", "/usr/local/bin/claude", "\(home)/.npm-global/bin/claude", ] return candidates.first { fm.isExecutableFile(atPath: $0) } } /// The child's environment: ours, minus the markers that make `claude` refuse to start /// inside another Claude Code session, plus the common bin folders on PATH so MCP /// servers (node, uvx, python…) resolve like they do in a terminal. nonisolated static func childEnvironment() -> [String: String] { var env = ProcessInfo.processInfo.environment env.removeValue(forKey: "CLAUDECODE") env.removeValue(forKey: "CLAUDE_CODE_ENTRYPOINT") let home = FileManager.default.homeDirectoryForCurrentUser.path let extra = ["\(home)/.local/bin", "/opt/homebrew/bin", "/opt/homebrew/sbin", "/usr/local/bin", "/usr/bin", "/bin", "/usr/sbin", "/sbin"] var parts = (env["PATH"] ?? "").split(separator: ":").map(String.init) for dir in extra where !parts.contains(dir) { parts.append(dir) } env["PATH"] = parts.joined(separator: ":") return env } // MARK: Running a turn /// Sends `prompt` and streams the visible reply through `onUpdate` (the full text so far). /// Returns the final text. Throws on a missing binary, a crash or a CLI-reported error. func send(prompt: String, model: String, workingDirectory: String, binaryPath: String, permissionMode: String, onUpdate: @escaping @MainActor (String) -> Void) async throws -> String { cancel() guard let binary = Self.resolveBinary(custom: binaryPath) else { throw ClaudeCodeCLIError.notFound } var args = ["-p", prompt, "--output-format", "stream-json", "--verbose", "--include-partial-messages", "--append-system-prompt", Self.notchPrompt] if let sessionID { args += ["--resume", sessionID] } if !model.isEmpty { args += ["--model", model] } // Never bypass: the chat gets untrusted input (dropped files, window titles, URLs). let allowedModes: Set = ["acceptEdits", "plan"] if allowedModes.contains(permissionMode) { args += ["--permission-mode", permissionMode] } let p = Process() p.executableURL = URL(fileURLWithPath: binary) p.arguments = args p.environment = Self.childEnvironment() let dir = (workingDirectory as NSString).expandingTildeInPath var isDir: ObjCBool = false p.currentDirectoryURL = FileManager.default.fileExists(atPath: dir, isDirectory: &isDir) && isDir.boolValue ? URL(fileURLWithPath: dir) : FileManager.default.homeDirectoryForCurrentUser p.standardInput = FileHandle.nullDevice let out = Pipe(), err = Pipe() p.standardOutput = out p.standardError = err process = p let stream = AsyncStream { continuation in let handle = out.fileHandleForReading Task.detached { do { for try await line in handle.bytes.lines { continuation.yield(line) } } catch {} continuation.finish() } } try p.run() var parser = StreamParser() for await line in stream { if let update = parser.consume(line) { if let id = update.sessionID { sessionID = id } if update.textChanged { onUpdate(parser.visibleText) } } } p.waitUntilExit() if process === p { process = nil } if let error = parser.errorMessage { if error.localizedCaseInsensitiveContains("authenticate") || error.localizedCaseInsensitiveContains("login") { AgentAuth.shared.reportAuthError(.claude) throw ClaudeCodeCLIError.failed("Claude Code не авторизован. Выполните `claude /login` в терминале.") } // A resume that points at a gone session: start fresh next time. if error.localizedCaseInsensitiveContains("No conversation found") { sessionID = nil } throw ClaudeCodeCLIError.failed(error) } if p.terminationReason == .uncaughtSignal { throw CancellationError() } if parser.finalText == nil, p.terminationStatus != 0 { let stderr = String(data: err.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" let last = stderr.split(separator: "\n").last.map(String.init) ?? "код выхода \(p.terminationStatus)" throw ClaudeCodeCLIError.failed("claude: \(last)") } return parser.finalText ?? parser.visibleText } private static let notchPrompt = """ You are talking through BroV, a tiny assistant living in the notch of the user's Mac. \ The chat window is small: keep answers compact, use light Markdown (short paragraphs, lists, \ **bold**, `code`), avoid tables and big headings. Respond in the user's language. """ } // MARK: - stream-json parsing /// Turns the CLI's stream-json lines into the text shown in the chat bubble: /// assistant text as it streams, plus a short `⚙ Tool` line for each tool call. struct StreamParser { struct Update { var sessionID: String? var textChanged = false } private(set) var visibleText = "" private(set) var finalText: String? private(set) var errorMessage: String? /// Text of the message currently streaming (partial deltas), not yet committed. private var liveText = "" private var committed = "" mutating func consume(_ line: String) -> Update? { guard let data = line.data(using: .utf8), let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let type = json["type"] as? String else { return nil } var update = Update(sessionID: json["session_id"] as? String) switch type { case "stream_event": guard let event = json["event"] as? [String: Any] else { break } if event["type"] as? String == "content_block_delta", let delta = event["delta"] as? [String: Any], delta["type"] as? String == "text_delta", let text = delta["text"] as? String { liveText += text update.textChanged = refresh() } else if event["type"] as? String == "message_start" { liveText = "" } case "assistant": // The complete message: commit its text (replacing the partial deltas) and tools. guard let message = json["message"] as? [String: Any], let content = message["content"] as? [[String: Any]] else { break } if json["error"] != nil || message["model"] as? String == "" { if let text = content.first(where: { $0["type"] as? String == "text" })?["text"] as? String { errorMessage = text } break } var piece = "" for block in content { switch block["type"] as? String { case "text": if let t = block["text"] as? String { piece += t } case "tool_use": let name = block["name"] as? String ?? "tool" piece += (piece.isEmpty ? "" : "\n\n") + "`⚙ \(Self.toolLabel(name, block["input"]))`" default: break } } if !piece.isEmpty { committed += (committed.isEmpty ? "" : "\n\n") + piece.trimmingCharacters(in: .whitespacesAndNewlines) } liveText = "" update.textChanged = refresh() case "result": let isError = json["is_error"] as? Bool ?? false let result = json["result"] as? String if isError { if errorMessage == nil { errorMessage = result ?? "Claude Code вернул ошибку." } } else { // Keep the tool trail when there was one; otherwise the clean final answer. finalText = committed.isEmpty ? (result ?? "") : committed } default: break } return update } private mutating func refresh() -> Bool { let live = liveText.trimmingCharacters(in: .whitespacesAndNewlines) let next = live.isEmpty ? committed : committed + (committed.isEmpty ? "" : "\n\n") + live guard next != visibleText else { return false } visibleText = next return true } /// "Bash: git status", "Read: Foo.swift", or just the tool name. static func toolLabel(_ name: String, _ input: Any?) -> String { guard let input = input as? [String: Any] else { return name } let detail: String? if let command = input["command"] as? String { detail = command } else if let path = (input["file_path"] ?? input["path"] ?? input["notebook_path"]) as? String { detail = (path as NSString).lastPathComponent } else if let pattern = input["pattern"] as? String { detail = pattern } else if let url = input["url"] as? String { detail = url } else if let query = input["query"] as? String { detail = query } else { detail = nil } guard var d = detail?.replacingOccurrences(of: "\n", with: " "), !d.isEmpty else { return name } if d.count > 60 { d = String(d.prefix(57)) + "…" } d = d.replacingOccurrences(of: "`", with: "'") return "\(name): \(d)" } }