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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user