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:
2026-08-05 13:33:02 +02:00
parent 2adce758f1
commit 897bfdce10
6 changed files with 552 additions and 0 deletions
+163
View File
@@ -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) {