Files
brov-macbook/NotchBuddy/Sources/App/ClaudeCodeCLI.swift
T
2026-10-06 19:49:32 +03:00

284 lines
12 KiB
Swift

import Foundation
// MARK: - Claude Code CLI as the chat brain
//
// Every message runs the user's own `claude` binary headless:
// claude -p <text> --output-format stream-json --verbose --include-partial-messages
// [--resume <session id>] [--model <alias>]
// 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 not found. Install it, or set its path in Settings → Chat."
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)] = [
("", "Default"),
("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] }
if !permissionMode.isEmpty, permissionMode != "default" { 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<String> { 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") {
throw ClaudeCodeCLIError.failed("Claude Code isn't logged in. Run `claude /login` in a terminal.")
}
// 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) ?? "exit code \(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 == "<synthetic>" {
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 returned an error." }
} 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)"
}
}