BroV: single Mac target, chat brain = local claude CLI (stream-json, resume)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
maksarsanjeev
2026-10-06 19:49:32 +03:00
parent 53b4f60da1
commit 0f77ec8704
25 changed files with 471 additions and 1007 deletions
+26 -2
View File
@@ -89,9 +89,23 @@ final class AppState: ObservableObject {
}
// In-chat provider + model — picked via the model selector in the prompt view
@Published var chatProvider: ChatProvider = .anthropic {
@Published var chatProvider: ChatProvider = .claudeCode {
didSet { UserDefaults.standard.set(chatProvider.rawValue, forKey: "chatProvider") }
}
// Claude Code CLI chat (BroV): model alias ("" = CLI default), folder it runs in,
// optional path to the binary, and the permission mode passed to `claude`.
@Published var claudeCodeModel: String = "" {
didSet { UserDefaults.standard.set(claudeCodeModel, forKey: "claudeCodeModel") }
}
@Published var chatWorkingDirectory: String = "~" {
didSet { UserDefaults.standard.set(chatWorkingDirectory, forKey: "chatWorkingDirectory") }
}
@Published var claudeBinaryPath: String = "" {
didSet { UserDefaults.standard.set(claudeBinaryPath, forKey: "claudeBinaryPath") }
}
@Published var claudePermissionMode: String = "default" {
didSet { UserDefaults.standard.set(claudePermissionMode, forKey: "claudePermissionMode") }
}
@Published var googleChatModel: String = ChatProvider.google.defaultModel {
didSet { UserDefaults.standard.set(googleChatModel, forKey: "googleChatModel") }
}
@@ -126,6 +140,10 @@ final class AppState: ObservableObject {
func fetchModelsIfNeeded(for provider: ChatProvider) {
guard !loadingProviderModels.contains(provider),
fetchedProviderModels[provider] == nil else { return }
if provider == .claudeCode {
fetchedProviderModels[provider] = ClaudeCodeCLI.models
return
}
// Local providers: fetch from server URL (no API key needed)
if provider.isLocal {
let baseURL = provider == .ollama ? ollamaServerURL : lmstudioServerURL
@@ -170,6 +188,7 @@ final class AppState: ObservableObject {
Task {
let models: [(id: String, label: String)]
switch provider {
case .claudeCode: models = ClaudeCodeCLI.models
case .anthropic: models = await ClaudeService.fetchModels(apiKey: apiKey)
case .google: models = await ClaudeService.fetchGoogleModels(apiKey: apiKey)
case .openai: models = await ClaudeService.fetchOpenAIModels(apiKey: apiKey)
@@ -193,7 +212,7 @@ final class AppState: ObservableObject {
if !models.contains(where: { $0.id == openAIChatModel }) {
openAIChatModel = models.first(where: { $0.id.contains("mini") })?.id ?? models.first!.id
}
case .ollama, .lmstudio: break
case .ollama, .lmstudio, .claudeCode: break
}
}
}
@@ -202,6 +221,7 @@ final class AppState: ObservableObject {
/// The model currently active for chat (provider-aware).
var activeChatModel: String {
switch chatProvider {
case .claudeCode: return claudeCodeModel
case .anthropic: return claudeModel
case .google: return googleChatModel
case .openai: return openAIChatModel
@@ -409,6 +429,10 @@ final class AppState: ObservableObject {
if let v = ud.string(forKey: "claudeModel"),
!v.trimmingCharacters(in: .whitespaces).isEmpty { claudeModel = v }
if let v = ud.string(forKey: "chatProvider"), let p = ChatProvider(rawValue: v) { chatProvider = p }
if let v = ud.string(forKey: "claudeCodeModel") { claudeCodeModel = v }
if let v = ud.string(forKey: "chatWorkingDirectory"), !v.isEmpty { chatWorkingDirectory = v }
if let v = ud.string(forKey: "claudeBinaryPath") { claudeBinaryPath = v }
if let v = ud.string(forKey: "claudePermissionMode"), !v.isEmpty { claudePermissionMode = v }
if let v = ud.string(forKey: "googleChatModel"), !v.isEmpty { googleChatModel = v }
if let v = ud.string(forKey: "openAIChatModel"), !v.isEmpty { openAIChatModel = v }
if let v = ud.string(forKey: "ollamaChatModel"), !v.isEmpty { ollamaChatModel = v }
+283
View File
@@ -0,0 +1,283 @@
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)"
}
}
+65 -1
View File
@@ -217,6 +217,10 @@ final class ClaudeService {
// MARK: - Chat (multi-turn, natural text + web search)
func chat(query: String, context: PromptContext?, state: AppState) async {
if state.chatProvider == .claudeCode {
await chatClaudeCode(query: query, context: context, state: state)
return
}
guard state.chatProvider == .anthropic else {
await chatOpenAICompatible(query: query, context: context, state: state)
return
@@ -264,6 +268,66 @@ final class ClaudeService {
}
}
// MARK: - Claude Code CLI chat (BroV: the user's own `claude`, no API key)
func chatClaudeCode(query: String, context: PromptContext?, state: AppState) async {
let cli = ClaudeCodeCLI.shared
var prompt = query
// Context goes with the first message of a conversation, like the API chat.
if cli.sessionID == nil, let context {
switch context {
case .window(let app, let title, let url):
var prefix = "Context — App: \(app), Window: \(title)"
if let url { prefix += ", URL: \(url)" }
prompt = prefix + "\n\n" + query
case .file(let name, let fileURL):
// The CLI reads files itself: hand it the path.
if let fileURL {
prompt = "Attached file: \(fileURL.path)\n(read it before answering)\n\n" + query
} else {
prompt = "File: \(name)\n\n" + query
}
}
}
let placeholder = ChatMessage(role: .assistant, content: "")
let msgId = placeholder.id
state.chatHistory.append(placeholder)
state.stateOverride = .thinking
do {
let final = try await cli.send(
prompt: prompt,
model: state.claudeCodeModel,
workingDirectory: state.chatWorkingDirectory,
binaryPath: state.claudeBinaryPath,
permissionMode: state.claudePermissionMode
) { [state, msgId] visible in
if !visible.isEmpty, state.stateOverride == .thinking {
state.stateOverride = nil // hide typing dots on first visible text
}
if let idx = state.chatHistory.firstIndex(where: { $0.id == msgId }) {
state.chatHistory[idx].content = visible
}
}
let trimmed = final.trimmingCharacters(in: .whitespacesAndNewlines)
if let idx = state.chatHistory.firstIndex(where: { $0.id == msgId }) {
if trimmed.isEmpty { state.chatHistory.remove(at: idx) }
else { state.chatHistory[idx].content = trimmed }
}
state.stateOverride = nil
state.view = .prompt
NotificationCenter.default.post(name: .triggerEmote, object: BotEmote.happy)
} catch is CancellationError {
state.chatHistory.removeAll { $0.id == msgId && $0.content.isEmpty }
state.stateOverride = nil
} catch {
state.chatHistory.removeAll { $0.id == msgId && $0.content.isEmpty }
state.stateOverride = nil
await showError(error.localizedDescription, state: state)
}
}
// MARK: - OpenAI-compatible chat (Google Gemini / OpenAI / Ollama / LM Studio)
func chatOpenAICompatible(query: String, context: PromptContext?, state: AppState) async {
@@ -279,7 +343,7 @@ final class ClaudeService {
switch provider {
case .google: baseURL = "https://generativelanguage.googleapis.com/v1beta/openai"
case .openai: baseURL = "https://api.openai.com/v1"
case .anthropic, .ollama, .lmstudio: baseURL = ""
case .anthropic, .ollama, .lmstudio, .claudeCode: baseURL = ""
}
}
@@ -1243,6 +1243,7 @@ struct PromptView: View {
text = ""
state.chatHistory = []
ClaudeService.shared.clearConversation()
ClaudeCodeCLI.shared.newConversation()
focused = true
}
}
@@ -1384,6 +1385,7 @@ struct ModelPickerView: View {
ForEach(models, id: \.id) { model in
Button {
switch state.chatProvider {
case .claudeCode: state.claudeCodeModel = model.id
case .anthropic: state.claudeModel = model.id
case .google: state.googleChatModel = model.id
case .openai: state.openAIChatModel = model.id
+42 -2
View File
@@ -598,6 +598,46 @@ struct SettingsView: View {
// MARK: - Chat section
@ViewBuilder private var chatSection: some View {
GroupBox("Claude Code (your subscription, no API key)") {
VStack(alignment: .leading, spacing: 8) {
HStack {
TextField("Folder the chat runs in", text: $state.chatWorkingDirectory)
.textFieldStyle(.roundedBorder)
Button("Choose…") {
let panel = NSOpenPanel()
panel.canChooseDirectories = true
panel.canChooseFiles = false
panel.allowsMultipleSelection = false
if panel.runModal() == .OK, let url = panel.url {
state.chatWorkingDirectory = url.path
ClaudeCodeCLI.shared.newConversation()
}
}
}
Picker("Model", selection: $state.claudeCodeModel) {
ForEach(ClaudeCodeCLI.models, id: \.id) { m in
Text(m.label).tag(m.id)
}
}
Picker("Permissions", selection: $state.claudePermissionMode) {
Text("Ask in the notch").tag("default")
Text("Accept edits").tag("acceptEdits")
Text("Plan only").tag("plan")
Text("Bypass (dangerous)").tag("bypassPermissions")
}
TextField("Path to claude (empty = auto)", text: $state.claudeBinaryPath)
.textFieldStyle(.roundedBorder)
let found = ClaudeCodeCLI.resolveBinary(custom: state.claudeBinaryPath)
Text(found.map { "✓ Using \($0)" } ?? "✗ claude not found — install Claude Code or set the path.")
.font(.system(size: 11))
.foregroundColor(found == nil ? .red : .secondary)
Text("The chat runs `claude -p` in this folder and keeps one conversation until you start a new one. Your MCP servers, skills and CLAUDE.md come along.")
.font(.system(size: 11))
.foregroundColor(.secondary)
}
.padding(6)
}
GroupBox("Anthropic API") {
VStack(alignment: .leading, spacing: 8) {
SecureField("API key (sk-ant-…)", text: $apiKey)
@@ -705,7 +745,7 @@ struct SettingsView: View {
ollamaURL = ""
state.fetchedProviderModels[.ollama] = nil
state.providerModelFetchError[.ollama] = nil
if state.chatProvider == .ollama { state.chatProvider = .anthropic }
if state.chatProvider == .ollama { state.chatProvider = .claudeCode }
statusMessage = "Ollama disconnected."
}
.buttonStyle(.bordered)
@@ -740,7 +780,7 @@ struct SettingsView: View {
lmstudioURL = ""
state.fetchedProviderModels[.lmstudio] = nil
state.providerModelFetchError[.lmstudio] = nil
if state.chatProvider == .lmstudio { state.chatProvider = .anthropic }
if state.chatProvider == .lmstudio { state.chatProvider = .claudeCode }
statusMessage = "LM Studio disconnected."
}
.buttonStyle(.bordered)
@@ -71,6 +71,7 @@ enum AgentSource: Equatable {
// MARK: - Chat provider
enum ChatProvider: String, CaseIterable, Codable {
case claudeCode = "claudecode" // the user's own `claude` CLI (BroV)
case anthropic = "anthropic"
case google = "google"
case openai = "openai"
@@ -79,6 +80,7 @@ enum ChatProvider: String, CaseIterable, Codable {
var displayName: String {
switch self {
case .claudeCode: "Claude Code"
case .anthropic: "Anthropic"
case .google: "Google"
case .openai: "OpenAI"
@@ -89,6 +91,7 @@ enum ChatProvider: String, CaseIterable, Codable {
var accentHex: String {
switch self {
case .claudeCode: "#D97757"
case .anthropic: "#E07950"
case .google: "#4285F4"
case .openai: "#10A37F"
@@ -99,6 +102,7 @@ enum ChatProvider: String, CaseIterable, Codable {
var defaultModel: String {
switch self {
case .claudeCode: ""
case .anthropic: "claude-sonnet-4-6"
case .google: "gemini-2.0-flash"
case .openai: "gpt-4o"
@@ -109,6 +113,7 @@ enum ChatProvider: String, CaseIterable, Codable {
var keychainKey: String {
switch self {
case .claudeCode: ""
case .anthropic: "anthropic-api-key"
case .google: "google-api-key"
case .openai: "openai-api-key"
@@ -123,6 +128,7 @@ enum ChatProvider: String, CaseIterable, Codable {
var pillID: String {
switch self {
case .claudeCode: "ai_claudecode"
case .anthropic: "ai_anthropic"
case .google: "ai_google"
case .openai: "ai_openai"
@@ -133,6 +139,7 @@ enum ChatProvider: String, CaseIterable, Codable {
init?(pillID: String) {
switch pillID {
case "ai_claudecode": self = .claudeCode
case "ai_anthropic": self = .anthropic
case "ai_google": self = .google
case "ai_openai": self = .openai