diff --git a/oAI/Services/CLIServerService.swift b/oAI/Services/CLIServerService.swift new file mode 100644 index 0000000..dcd6017 --- /dev/null +++ b/oAI/Services/CLIServerService.swift @@ -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 +// 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) + } +} diff --git a/oAI/Services/SettingsService.swift b/oAI/Services/SettingsService.swift index f188df8..1ca30b3 100644 --- a/oAI/Services/SettingsService.swift +++ b/oAI/Services/SettingsService.swift @@ -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 { get { cache["emailSubjectIdentifier"] ?? "[OAIBOT]" } set { diff --git a/oAI/Utilities/Logging.swift b/oAI/Utilities/Logging.swift index 7fe0bcf..0dff966 100644 --- a/oAI/Utilities/Logging.swift +++ b/oAI/Utilities/Logging.swift @@ -152,4 +152,5 @@ enum Log { nonisolated static let ui = AppLogger(subsystem: subsystem, category: "ui") nonisolated static let general = AppLogger(subsystem: subsystem, category: "general") nonisolated static let extMcp = AppLogger(subsystem: subsystem, category: "ext-mcp") + nonisolated static let cli = AppLogger(subsystem: subsystem, category: "cli") } diff --git a/oAI/Views/Screens/SettingsView.swift b/oAI/Views/Screens/SettingsView.swift index d0cc9c1..1c30b9b 100644 --- a/oAI/Views/Screens/SettingsView.swift +++ b/oAI/Views/Screens/SettingsView.swift @@ -113,6 +113,11 @@ struct SettingsView: View { @State private var isTestingEmailConnection = false @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 // 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() externalMCPSection + // MARK: CLI Access + Divider() + cliServerSection + // MARK: Personal Data if !PersonalDataTools.isHiddenPendingAppleFix { 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 private var addExternalMCPServerSheet: some View { VStack(alignment: .leading, spacing: 20) { diff --git a/oAI/oAIApp.swift b/oAI/oAIApp.swift index f74a147..9e830e1 100644 --- a/oAI/oAIApp.swift +++ b/oAI/oAIApp.swift @@ -73,6 +73,9 @@ struct oAIApp: App { // Start email handler on app launch EmailHandlerService.shared.start() + // Start the local CLI server (Settings > Advanced > CLI Access) — no-op if disabled + CLIServerService.shared.start() + // Start external MCP servers Task { @MainActor in ExternalMCPManager.shared.startAll() } @@ -106,6 +109,7 @@ struct oAIApp: App { } .onReceive(NotificationCenter.default.publisher(for: NSApplication.willTerminateNotification)) { _ in Task { @MainActor in ExternalMCPManager.shared.stopAll() } + CLIServerService.shared.stop() } #endif } diff --git a/oAITests/CLIServerServiceTests.swift b/oAITests/CLIServerServiceTests.swift new file mode 100644 index 0000000..4f5b663 --- /dev/null +++ b/oAITests/CLIServerServiceTests.swift @@ -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) + } +}