// // CLIServerService.swift // Confab // // Local Unix-socket server for one-shot shell/CLI access to a single fixed model // // SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 // Copyright (C) 2026 Rune Olsen // // This file is part of Confab. // // Confab is licensed under the PolyForm Noncommercial License 1.0.0. // You may use, study, modify, and share it for any noncommercial // purpose. Commercial use — including selling Confab or any part of // it, standalone or bundled into another product or service — // requires a separate commercial license from the copyright holder. // // See the LICENSE file or // for // the full license text. For commercial licensing, contact Rune // Olsen via . import Foundation import Network import os /// Local Unix-domain-socket server exposing one-shot, non-streaming text completions from a /// single fixed model (Settings → MCP → CLI Access) — e.g. an `ai "prompt"` shell function. /// Deliberately minimal for v1: no tool-calling, no streaming, no per-request model override. /// Speaks a minimal HTTP/1.1 subset (just enough for `curl --unix-socket`) rather than a custom /// protocol, so it stays curl-friendly and trivially extensible — new fields can be added to the /// JSON request/response bodies later without changing the transport. final class CLIServerService { static let shared = CLIServerService() private init() {} private let log = Logger(subsystem: Log.subsystem, category: "cli") /// All listener/connection callbacks run on this single serial queue, so `activeConnections` /// never needs a lock — Network.framework callbacks don't run on the main thread. private let queue = DispatchQueue(label: "com.oai.Confab.cliserver") private var listener: NWListener? private var activeConnections: [ObjectIdentifier: NWConnection] = [:] static let socketPath: String = { (("~/Library/Application Support/oAI" as NSString).expandingTildeInPath as NSString) .appendingPathComponent("cli.sock") }() // MARK: - Lifecycle func start() { guard SettingsService.shared.cliServerEnabled else { log.info("CLI server disabled") return } startListening() } func stop() { listener?.cancel() listener = nil for (_, connection) in activeConnections { connection.cancel() } activeConnections.removeAll() try? FileManager.default.removeItem(atPath: Self.socketPath) log.info("CLI server stopped") } /// Call after toggling cliServerEnabled or changing the provider/model, so the change takes /// effect immediately instead of requiring an app relaunch. func restart() { stop() start() } private func startListening() { // Remove a stale socket file left behind by an unclean shutdown (bind() fails on an // existing path even if nothing is listening on it anymore). try? FileManager.default.removeItem(atPath: Self.socketPath) let params = NWParameters() params.defaultProtocolStack.transportProtocol = NWProtocolTCP.Options() params.requiredLocalEndpoint = NWEndpoint.unix(path: Self.socketPath) params.allowLocalEndpointReuse = true do { let newListener = try NWListener(using: params) newListener.newConnectionHandler = { [weak self] connection in self?.handle(connection: connection) } newListener.stateUpdateHandler = { [weak self] state in switch state { case .ready: self?.log.info("CLI server listening at \(Self.socketPath)") case .failed(let error): self?.log.error("CLI server listener failed: \(error.localizedDescription)") default: break } } newListener.start(queue: queue) listener = newListener } catch { log.error("Failed to start CLI server: \(error.localizedDescription)") } } // MARK: - Connection handling private func handle(connection: NWConnection) { let id = ObjectIdentifier(connection) activeConnections[id] = connection connection.stateUpdateHandler = { [weak self] state in switch state { case .failed, .cancelled: self?.activeConnections.removeValue(forKey: id) default: break } } connection.start(queue: queue) readRequest(on: connection, buffer: Data()) } private func readRequest(on connection: NWConnection, buffer: Data) { connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { [weak self] data, _, isComplete, error in guard let self else { return } var buffer = buffer if let data { buffer.append(data) } if let body = Self.parseRequestBody(from: buffer) { Task { let responseData = await self.processRequest(body) self.sendAndClose(responseData, on: connection) } return } if isComplete || error != nil { connection.cancel() return } self.readRequest(on: connection, buffer: buffer) } } private func sendAndClose(_ data: Data, on connection: NWConnection) { let id = ObjectIdentifier(connection) connection.send(content: data, completion: .contentProcessed { [weak self] _ in connection.cancel() self?.activeConnections.removeValue(forKey: id) }) } // MARK: - Request processing private struct AskRequest: Decodable { let prompt: String } private func processRequest(_ body: Data) async -> Data { guard let req = try? JSONDecoder().decode(AskRequest.self, from: body), !req.prompt.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return Self.errorResponse("Request body must be JSON: {\"prompt\": \"...\"}") } let settings = SettingsService.shared guard settings.cliServerEnabled else { return Self.errorResponse("CLI server is disabled in Settings") } guard let providerType = Settings.Provider(rawValue: settings.cliServerProvider), let provider = ProviderRegistry.shared.getProvider(for: providerType) else { return Self.errorResponse("No provider configured — set one in Settings > MCP > CLI Access") } guard !settings.cliServerModel.isEmpty else { return Self.errorResponse("No model configured — set one in Settings > MCP > CLI Access") } do { let request = ChatRequest( messages: [Message(role: .user, content: req.prompt)], model: settings.cliServerModel, stream: false ) let response = try await provider.chat(request: request) return Self.successResponse(response.content) } catch { log.error("CLI request failed: \(error.localizedDescription)") return Self.errorResponse(error.localizedDescription, statusCode: 500) } } // MARK: - Pure helpers (unit tested — see CLIServerServiceTests.swift) struct AskResponseBody: Codable { let response: String? let error: String? } /// Parses a minimal HTTP/1.1 request out of `buffer`, returning the body once the full /// header block and (per Content-Length) body have arrived, or nil if more data is needed. /// Not a general-purpose HTTP parser — this server only ever talks to `curl --unix-socket`, /// so the method/path/most headers are read past rather than validated. static func parseRequestBody(from buffer: Data) -> Data? { let headerTerminator = Data("\r\n\r\n".utf8) guard let headerEndRange = buffer.range(of: headerTerminator) else { return nil } let headerData = buffer.subdata(in: buffer.startIndex..= contentLength else { return nil } let bodyEnd = buffer.index(bodyStart, offsetBy: contentLength) return buffer.subdata(in: bodyStart.. Data { let header = "HTTP/1.1 \(statusCode) \(statusText)\r\nContent-Type: application/json\r\nContent-Length: \(jsonBody.count)\r\nConnection: close\r\n\r\n" var data = Data(header.utf8) data.append(jsonBody) return data } static func successResponse(_ text: String) -> Data { let body = (try? JSONEncoder().encode(AskResponseBody(response: text, error: nil))) ?? Data() return makeHTTPResponse(statusCode: 200, statusText: "OK", jsonBody: body) } static func errorResponse(_ message: String, statusCode: Int = 400) -> Data { let body = (try? JSONEncoder().encode(AskResponseBody(response: nil, error: message))) ?? Data() let statusText = statusCode == 400 ? "Bad Request" : "Internal Server Error" return makeHTTPResponse(statusCode: statusCode, statusText: statusText, jsonBody: body) } }