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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1043,6 +1043,36 @@ class SettingsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - CLI Server
|
||||||
|
|
||||||
|
/// Local Unix-socket server (CLIServerService) for one-shot, non-streaming shell access to a
|
||||||
|
/// single fixed model — deliberately separate from the chat UI's defaultProvider/defaultModel
|
||||||
|
/// so switching models in the GUI never changes shell-tool behavior, same reasoning as the
|
||||||
|
/// email handler's own dedicated provider/model pair above.
|
||||||
|
var cliServerEnabled: Bool {
|
||||||
|
get { cache["cliServerEnabled"] == "true" }
|
||||||
|
set {
|
||||||
|
cache["cliServerEnabled"] = String(newValue)
|
||||||
|
DatabaseService.shared.setSetting(key: "cliServerEnabled", value: String(newValue))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var cliServerProvider: String {
|
||||||
|
get { cache["cliServerProvider"] ?? "openrouter" }
|
||||||
|
set {
|
||||||
|
cache["cliServerProvider"] = newValue
|
||||||
|
DatabaseService.shared.setSetting(key: "cliServerProvider", value: newValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var cliServerModel: String {
|
||||||
|
get { cache["cliServerModel"] ?? "" }
|
||||||
|
set {
|
||||||
|
cache["cliServerModel"] = newValue
|
||||||
|
DatabaseService.shared.setSetting(key: "cliServerModel", value: newValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var emailSubjectIdentifier: String {
|
var emailSubjectIdentifier: String {
|
||||||
get { cache["emailSubjectIdentifier"] ?? "[OAIBOT]" }
|
get { cache["emailSubjectIdentifier"] ?? "[OAIBOT]" }
|
||||||
set {
|
set {
|
||||||
|
|||||||
@@ -152,4 +152,5 @@ enum Log {
|
|||||||
nonisolated static let ui = AppLogger(subsystem: subsystem, category: "ui")
|
nonisolated static let ui = AppLogger(subsystem: subsystem, category: "ui")
|
||||||
nonisolated static let general = AppLogger(subsystem: subsystem, category: "general")
|
nonisolated static let general = AppLogger(subsystem: subsystem, category: "general")
|
||||||
nonisolated static let extMcp = AppLogger(subsystem: subsystem, category: "ext-mcp")
|
nonisolated static let extMcp = AppLogger(subsystem: subsystem, category: "ext-mcp")
|
||||||
|
nonisolated static let cli = AppLogger(subsystem: subsystem, category: "cli")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -113,6 +113,11 @@ struct SettingsView: View {
|
|||||||
@State private var isTestingEmailConnection = false
|
@State private var isTestingEmailConnection = false
|
||||||
@State private var emailConnectionTestResult: String?
|
@State private var emailConnectionTestResult: String?
|
||||||
|
|
||||||
|
// CLI server state
|
||||||
|
@State private var showCLIModelSelector = false
|
||||||
|
@State private var cliAvailableModels: [ModelInfo] = []
|
||||||
|
@State private var isLoadingCLIModels = false
|
||||||
|
|
||||||
private let labelWidth: CGFloat = 160
|
private let labelWidth: CGFloat = 160
|
||||||
|
|
||||||
// Default system prompt - generic for all models
|
// Default system prompt - generic for all models
|
||||||
@@ -911,6 +916,10 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
|
|||||||
Divider()
|
Divider()
|
||||||
externalMCPSection
|
externalMCPSection
|
||||||
|
|
||||||
|
// MARK: CLI Access
|
||||||
|
Divider()
|
||||||
|
cliServerSection
|
||||||
|
|
||||||
// MARK: Personal Data
|
// MARK: Personal Data
|
||||||
if !PersonalDataTools.isHiddenPendingAppleFix {
|
if !PersonalDataTools.isHiddenPendingAppleFix {
|
||||||
Divider()
|
Divider()
|
||||||
@@ -1095,6 +1104,160 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - CLI Access Section
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var cliServerSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: "terminal")
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundStyle(.green)
|
||||||
|
Text("CLI Access")
|
||||||
|
.font(.system(size: 18, weight: .semibold))
|
||||||
|
}
|
||||||
|
Text("Expose a local socket so a shell command (like a zsh \"ai\" function) can get a one-shot text reply from a single fixed model, without opening the app window. Confab must be running.")
|
||||||
|
.font(.system(size: 14))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
.padding(.bottom, 4)
|
||||||
|
.onAppear {
|
||||||
|
Task { await loadCLIModels() }
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showCLIModelSelector) {
|
||||||
|
ModelSelectorView(
|
||||||
|
models: cliAvailableModels.sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending },
|
||||||
|
selectedModel: cliAvailableModels.first(where: { $0.id == settingsService.cliServerModel })
|
||||||
|
) { selectedModel in
|
||||||
|
settingsService.cliServerModel = selectedModel.id
|
||||||
|
showCLIModelSelector = false
|
||||||
|
CLIServerService.shared.restart()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
sectionHeader("Status")
|
||||||
|
formSection {
|
||||||
|
row("Enable CLI Access") {
|
||||||
|
Toggle("", isOn: $settingsService.cliServerEnabled)
|
||||||
|
.toggleStyle(.switch)
|
||||||
|
.onChange(of: settingsService.cliServerEnabled) {
|
||||||
|
CLIServerService.shared.restart()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if settingsService.cliServerEnabled {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
sectionHeader("Model")
|
||||||
|
formSection {
|
||||||
|
row("Provider") {
|
||||||
|
Picker("", selection: $settingsService.cliServerProvider) {
|
||||||
|
ForEach(ProviderRegistry.shared.configuredProviders, id: \.self) { provider in
|
||||||
|
Text(provider.displayName).tag(provider.rawValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.labelsHidden()
|
||||||
|
.frame(width: 250)
|
||||||
|
.onChange(of: settingsService.cliServerProvider) {
|
||||||
|
Task { await loadCLIModels() }
|
||||||
|
CLIServerService.shared.restart()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rowDivider()
|
||||||
|
row("Model") {
|
||||||
|
if isLoadingCLIModels {
|
||||||
|
ProgressView().scaleEffect(0.7).frame(width: 250, alignment: .leading)
|
||||||
|
} else if cliAvailableModels.isEmpty {
|
||||||
|
Text("No models available")
|
||||||
|
.font(.system(size: settingsService.guiTextSize))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.frame(width: 250, alignment: .leading)
|
||||||
|
} else {
|
||||||
|
Button(action: { showCLIModelSelector = true }) {
|
||||||
|
HStack {
|
||||||
|
Text(cliAvailableModels.first(where: { $0.id == settingsService.cliServerModel })?.name ?? "Select model...")
|
||||||
|
.font(.system(size: settingsService.guiTextSize))
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: "chevron.up.chevron.down")
|
||||||
|
.font(.system(size: 10))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.frame(width: 250)
|
||||||
|
.background(Color.secondary.opacity(0.1))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
sectionHeader("Shell Function")
|
||||||
|
Text("Add this to your ~/.zshrc, then run `ai \"your prompt\"` in Terminal:")
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text(Self.cliShellFunctionSnippet)
|
||||||
|
.font(.system(size: 11, design: .monospaced))
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
.textSelection(.enabled)
|
||||||
|
.padding(10)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(Color.secondary.opacity(0.08))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
Text("Requires jq (brew install jq).")
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static let cliShellFunctionSnippet = """
|
||||||
|
ai() {
|
||||||
|
curl -s --unix-socket "$HOME/Library/Application Support/oAI/cli.sock" \\
|
||||||
|
-H "Content-Type: application/json" \\
|
||||||
|
-d "$(jq -n --arg p "$*" '{prompt: $p}')" \\
|
||||||
|
http://localhost/ | jq -r 'if .error then "Error: " + .error else .response end'
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
private func loadCLIModels() async {
|
||||||
|
guard settingsService.cliServerEnabled else {
|
||||||
|
cliAvailableModels = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let providerRawValue = settingsService.cliServerProvider
|
||||||
|
guard let providerType = Settings.Provider(rawValue: providerRawValue),
|
||||||
|
let provider = ProviderRegistry.shared.getProvider(for: providerType) else {
|
||||||
|
cliAvailableModels = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoadingCLIModels = true
|
||||||
|
defer { isLoadingCLIModels = false }
|
||||||
|
|
||||||
|
do {
|
||||||
|
let models = try await provider.listModels()
|
||||||
|
cliAvailableModels = models
|
||||||
|
|
||||||
|
if !models.contains(where: { $0.id == settingsService.cliServerModel }) {
|
||||||
|
if let firstModel = models.first {
|
||||||
|
settingsService.cliServerModel = firstModel.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
Log.ui.error("Failed to load CLI server models: \(error.localizedDescription)")
|
||||||
|
cliAvailableModels = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var addExternalMCPServerSheet: some View {
|
private var addExternalMCPServerSheet: some View {
|
||||||
VStack(alignment: .leading, spacing: 20) {
|
VStack(alignment: .leading, spacing: 20) {
|
||||||
|
|||||||
@@ -73,6 +73,9 @@ struct oAIApp: App {
|
|||||||
// Start email handler on app launch
|
// Start email handler on app launch
|
||||||
EmailHandlerService.shared.start()
|
EmailHandlerService.shared.start()
|
||||||
|
|
||||||
|
// Start the local CLI server (Settings > Advanced > CLI Access) — no-op if disabled
|
||||||
|
CLIServerService.shared.start()
|
||||||
|
|
||||||
// Start external MCP servers
|
// Start external MCP servers
|
||||||
Task { @MainActor in ExternalMCPManager.shared.startAll() }
|
Task { @MainActor in ExternalMCPManager.shared.startAll() }
|
||||||
|
|
||||||
@@ -106,6 +109,7 @@ struct oAIApp: App {
|
|||||||
}
|
}
|
||||||
.onReceive(NotificationCenter.default.publisher(for: NSApplication.willTerminateNotification)) { _ in
|
.onReceive(NotificationCenter.default.publisher(for: NSApplication.willTerminateNotification)) { _ in
|
||||||
Task { @MainActor in ExternalMCPManager.shared.stopAll() }
|
Task { @MainActor in ExternalMCPManager.shared.stopAll() }
|
||||||
|
CLIServerService.shared.stop()
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
//
|
||||||
|
// CLIServerServiceTests.swift
|
||||||
|
// oAITests
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||||
|
// Copyright (C) 2026 Rune Olsen
|
||||||
|
|
||||||
|
import Testing
|
||||||
|
import Foundation
|
||||||
|
@testable import Confab
|
||||||
|
|
||||||
|
@Suite("CLIServerService pure HTTP helpers")
|
||||||
|
struct CLIServerServiceTests {
|
||||||
|
|
||||||
|
// MARK: - parseRequestBody
|
||||||
|
|
||||||
|
@Test("Returns the body once headers and full Content-Length body have arrived")
|
||||||
|
func parsesCompleteRequest() {
|
||||||
|
let body = "{\"prompt\":\"hi\"}"
|
||||||
|
let raw = "POST / HTTP/1.1\r\nHost: localhost\r\nContent-Length: \(body.utf8.count)\r\n\r\n\(body)"
|
||||||
|
let result = CLIServerService.parseRequestBody(from: Data(raw.utf8))
|
||||||
|
#expect(result.map { String(data: $0, encoding: .utf8) } == body)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Returns nil when the header block hasn't fully arrived yet")
|
||||||
|
func returnsNilForIncompleteHeaders() {
|
||||||
|
let raw = "POST / HTTP/1.1\r\nHost: localhost\r\nContent-Length: 5"
|
||||||
|
#expect(CLIServerService.parseRequestBody(from: Data(raw.utf8)) == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Returns nil when the body hasn't fully arrived yet")
|
||||||
|
func returnsNilForIncompleteBody() {
|
||||||
|
let raw = "POST / HTTP/1.1\r\nContent-Length: 20\r\n\r\n{\"prompt\":\"hi\"}"
|
||||||
|
#expect(CLIServerService.parseRequestBody(from: Data(raw.utf8)) == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Content-Length header name match is case-insensitive")
|
||||||
|
func headerNameIsCaseInsensitive() {
|
||||||
|
let body = "abc"
|
||||||
|
let raw = "POST / HTTP/1.1\r\ncontent-length: 3\r\n\r\n\(body)"
|
||||||
|
let result = CLIServerService.parseRequestBody(from: Data(raw.utf8))
|
||||||
|
#expect(result.map { String(data: $0, encoding: .utf8) } == body)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Missing Content-Length is treated as a zero-length body")
|
||||||
|
func missingContentLengthIsEmptyBody() {
|
||||||
|
let raw = "GET / HTTP/1.1\r\nHost: localhost\r\n\r\n"
|
||||||
|
let result = CLIServerService.parseRequestBody(from: Data(raw.utf8))
|
||||||
|
#expect(result?.isEmpty == true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Extra trailing bytes beyond Content-Length don't prevent extraction (pipelined data ignored)")
|
||||||
|
func extraTrailingBytesStillExtractsBody() {
|
||||||
|
let body = "abc"
|
||||||
|
let raw = "POST / HTTP/1.1\r\nContent-Length: 3\r\n\r\n\(body)EXTRA"
|
||||||
|
let result = CLIServerService.parseRequestBody(from: Data(raw.utf8))
|
||||||
|
#expect(result.map { String(data: $0, encoding: .utf8) } == body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Response encoding
|
||||||
|
|
||||||
|
@Test("successResponse produces a 200 with the response field set")
|
||||||
|
func successResponseShape() throws {
|
||||||
|
let data = CLIServerService.successResponse("hello world")
|
||||||
|
let text = String(data: data, encoding: .utf8)!
|
||||||
|
#expect(text.hasPrefix("HTTP/1.1 200 OK\r\n"))
|
||||||
|
#expect(text.contains("Content-Type: application/json"))
|
||||||
|
|
||||||
|
let bodyStart = text.range(of: "\r\n\r\n")!.upperBound
|
||||||
|
let bodyJSON = Data(text[bodyStart...].utf8)
|
||||||
|
let decoded = try JSONDecoder().decode(CLIServerService.AskResponseBody.self, from: bodyJSON)
|
||||||
|
#expect(decoded.response == "hello world")
|
||||||
|
#expect(decoded.error == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("errorResponse defaults to 400 Bad Request")
|
||||||
|
func errorResponseDefaultStatus() throws {
|
||||||
|
let data = CLIServerService.errorResponse("bad input")
|
||||||
|
let text = String(data: data, encoding: .utf8)!
|
||||||
|
#expect(text.hasPrefix("HTTP/1.1 400 Bad Request\r\n"))
|
||||||
|
|
||||||
|
let bodyStart = text.range(of: "\r\n\r\n")!.upperBound
|
||||||
|
let bodyJSON = Data(text[bodyStart...].utf8)
|
||||||
|
let decoded = try JSONDecoder().decode(CLIServerService.AskResponseBody.self, from: bodyJSON)
|
||||||
|
#expect(decoded.error == "bad input")
|
||||||
|
#expect(decoded.response == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("errorResponse supports a custom status code")
|
||||||
|
func errorResponseCustomStatus() {
|
||||||
|
let data = CLIServerService.errorResponse("provider failed", statusCode: 500)
|
||||||
|
let text = String(data: data, encoding: .utf8)!
|
||||||
|
#expect(text.hasPrefix("HTTP/1.1 500 Internal Server Error\r\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Content-Length in the response header matches the actual JSON body byte count")
|
||||||
|
func responseContentLengthMatchesBody() {
|
||||||
|
let data = CLIServerService.successResponse("hello")
|
||||||
|
let text = String(data: data, encoding: .utf8)!
|
||||||
|
let headerEnd = text.range(of: "\r\n\r\n")!.upperBound
|
||||||
|
let bodyByteCount = Data(text[headerEnd...].utf8).count
|
||||||
|
|
||||||
|
let lengthLine = text.split(separator: "\r\n").first { $0.hasPrefix("Content-Length:") }!
|
||||||
|
let declaredLength = Int(lengthLine.split(separator: " ")[1])!
|
||||||
|
#expect(declaredLength == bodyByteCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user