52e1e8288b
- Network core moves to a root-only folder; BroV talks to a narrow root helper (netctl.py) over a user-only socket; install script verifies the mihomo SHA256 and migrates keys (scripts/netcore) - Hardened runtime, no get-task-allow; bypassPermissions removed from the chat; concealed clipboard items are not restored; DangerCheck knows core, LaunchAgents and hook paths; dropped-file copies expire after 7 days - Ported from upstream Coucou: 1h crash fix (d05f22b), safe settings.json writes (918d30e), Escape/fold for pending approvals (6012900, 40e3ba8), auto-close delay + reopen (74984f2, ea244a7), full AskUserQuestion (52b1562) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
287 lines
12 KiB
Swift
287 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 не найден. Установите его или укажите путь в Настройки → Чат."
|
|
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<String> = ["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<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") {
|
|
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 == "<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 вернул ошибку." }
|
|
} 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)"
|
|
}
|
|
}
|