Add local CLI access via Unix-socket server
New CLIServerService listens on a Unix domain socket (~/Library/Application Support/oAI/cli.sock) speaking a minimal HTTP/1.1 subset, for one-shot non-streaming shell access to a single fixed model — e.g. an `ai "prompt"` zsh function — without opening the app window and without going through the tool-calling loop. Configured in Settings > MCP > CLI Access (toggle, provider, model — deliberately independent of the chat UI's active model). JSON request/response envelope rather than raw text so new fields (model override, streaming, tool support) can be added later without a breaking wire-format change. Verified live end-to-end against a real OpenRouter request, error paths, and clean-shutdown socket cleanup. 10 new unit tests cover the HTTP framing/parsing logic.
This commit is contained in:
@@ -0,0 +1,247 @@
|
||||
//
|
||||
// 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
|
||||
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
|
||||
// the full license text. For commercial licensing, contact Rune
|
||||
// Olsen via <https://confab.no>.
|
||||
|
||||
|
||||
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..<headerEndRange.lowerBound)
|
||||
guard let headerString = String(data: headerData, encoding: .utf8) else { return nil }
|
||||
|
||||
var contentLength = 0
|
||||
for line in headerString.split(separator: "\r\n") {
|
||||
let parts = line.split(separator: ":", maxSplits: 1)
|
||||
guard parts.count == 2,
|
||||
parts[0].trimmingCharacters(in: .whitespaces).caseInsensitiveCompare("Content-Length") == .orderedSame,
|
||||
let value = Int(parts[1].trimmingCharacters(in: .whitespaces)) else { continue }
|
||||
contentLength = value
|
||||
}
|
||||
|
||||
let bodyStart = headerEndRange.upperBound
|
||||
let availableBodyLength = buffer.distance(from: bodyStart, to: buffer.endIndex)
|
||||
guard availableBodyLength >= contentLength else { return nil }
|
||||
|
||||
let bodyEnd = buffer.index(bodyStart, offsetBy: contentLength)
|
||||
return buffer.subdata(in: bodyStart..<bodyEnd)
|
||||
}
|
||||
|
||||
static func makeHTTPResponse(statusCode: Int, statusText: String, jsonBody: Data) -> 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user