diff --git a/CHANGELOG.md b/CHANGELOG.md index b4169a1f1..373d0874c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Cursor as an AI provider: paste a Cursor API key, or sign in with the Cursor CLI (no key), to run AI chat and inline suggestions on your Cursor subscription. - Sign in with ChatGPT to power AI chat and inline suggestions from your ChatGPT subscription (Plus, Pro, Business, or Enterprise) without an API key. Existing Codex CLI logins can be imported. (#1617) - libSQL / Turso connections can open a local database file: pick Local File mode in the connection form, browse to the file, and work with it offline, transactions included. (#1607) diff --git a/TablePro/Core/AI/Cursor/CursorAI.swift b/TablePro/Core/AI/Cursor/CursorAI.swift new file mode 100644 index 000000000..95cc32fd6 --- /dev/null +++ b/TablePro/Core/AI/Cursor/CursorAI.swift @@ -0,0 +1,25 @@ +// +// CursorAI.swift +// TablePro +// + +import Foundation + +enum CursorAI { + static let baseURL = "https://api.cursor.com" + + static let curatedModels: [(id: String, name: String)] = [ + ("composer-2.5", "Composer 2.5"), + ("auto", "Auto"), + ("composer-2", "Composer 2"), + ("claude-4.5-sonnet", "Claude 4.5 Sonnet"), + ("claude-opus-4.8", "Claude Opus 4.8"), + ("gpt-5.5", "GPT-5.5"), + ("gpt-5.4", "GPT-5.4"), + ("gemini-3-pro", "Gemini 3 Pro") + ] + + static var curatedModelIDs: [String] { + curatedModels.map { $0.id } + } +} diff --git a/TablePro/Core/AI/Cursor/CursorAgentCLI.swift b/TablePro/Core/AI/Cursor/CursorAgentCLI.swift new file mode 100644 index 000000000..d6ad1e718 --- /dev/null +++ b/TablePro/Core/AI/Cursor/CursorAgentCLI.swift @@ -0,0 +1,134 @@ +// +// CursorAgentCLI.swift +// TablePro +// + +import Foundation +import os + +enum CursorAgentError: Error, LocalizedError { + case notInstalled + case launchFailed(String) + + var errorDescription: String? { + switch self { + case .notInstalled: + return String(localized: "The Cursor CLI is not installed. Install it, then sign in.") + case .launchFailed(let detail): + return String(format: String(localized: "Cursor CLI failed: %@"), detail) + } + } +} + +struct CursorAgentCLI: Sendable { + static let installCommand = "curl https://cursor.com/install -fsS | bash" + + private static let logger = Logger(subsystem: "com.TablePro", category: "CursorAgentCLI") + + static func executableURL() -> URL? { + let home = FileManager.default.homeDirectoryForCurrentUser + let candidates = [ + home.appending(path: ".local/bin/agent"), + URL(fileURLWithPath: "/usr/local/bin/agent"), + URL(fileURLWithPath: "/opt/homebrew/bin/agent") + ] + return candidates.first { FileManager.default.isExecutableFile(atPath: $0.path) } + } + + var isInstalled: Bool { Self.executableURL() != nil } + + func run(_ arguments: [String]) async throws -> (code: Int32, output: String) { + guard let executable = Self.executableURL() else { throw CursorAgentError.notInstalled } + let process = Process() + Self.configure(process, executable: executable, arguments: arguments) + let stdout = Pipe() + process.standardOutput = stdout + process.standardError = stdout + Self.logger.debug("Running agent \(arguments.first ?? "", privacy: .public)") + do { + try process.run() + } catch { + throw CursorAgentError.launchFailed(error.localizedDescription) + } + let data = try await Task.detached { + try stdout.fileHandleForReading.readToEnd() ?? Data() + }.value + process.waitUntilExit() + let output = String(data: data, encoding: .utf8) ?? "" + return (process.terminationStatus, output) + } + + func stream(_ arguments: [String]) -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + guard let executable = Self.executableURL() else { + continuation.finish(throwing: CursorAgentError.notInstalled) + return + } + let process = Process() + Self.configure(process, executable: executable, arguments: arguments) + let stdout = Pipe() + let stderr = Pipe() + process.standardOutput = stdout + process.standardError = stderr + + let stderrReader = Task { + do { + for try await line in stderr.fileHandleForReading.bytes.lines where !line.isEmpty { + Self.logger.error("agent stderr: \(line, privacy: .public)") + } + } catch {} + } + process.terminationHandler = { proc in + Self.logger.debug("agent exited code=\(proc.terminationStatus)") + } + + Self.logger.debug("Streaming agent (\(arguments.count) args)") + do { + try process.run() + } catch { + stderrReader.cancel() + continuation.finish(throwing: CursorAgentError.launchFailed(error.localizedDescription)) + return + } + + let reader = Task { + do { + for try await line in stdout.fileHandleForReading.bytes.lines { + if Task.isCancelled { break } + continuation.yield(line) + } + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + + continuation.onTermination = { _ in + reader.cancel() + stderrReader.cancel() + if process.isRunning { process.terminate() } + } + } + } + + private static func configure(_ process: Process, executable: URL, arguments: [String]) { + process.executableURL = executable + process.arguments = arguments + process.standardInput = FileHandle.nullDevice + process.environment = makeEnvironment() + } + + private static func makeEnvironment() -> [String: String] { + var environment = ProcessInfo.processInfo.environment + let home = FileManager.default.homeDirectoryForCurrentUser.path + let preferred = [ + "\(home)/.local/bin", "/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin", "/usr/sbin", "/sbin" + ] + let current = (environment["PATH"] ?? "").split(separator: ":").map(String.init) + var seen = Set() + environment["PATH"] = (preferred + current) + .filter { seen.insert($0).inserted } + .joined(separator: ":") + return environment + } +} diff --git a/TablePro/Core/AI/Cursor/CursorAgentProvider.swift b/TablePro/Core/AI/Cursor/CursorAgentProvider.swift new file mode 100644 index 000000000..57c3c8d93 --- /dev/null +++ b/TablePro/Core/AI/Cursor/CursorAgentProvider.swift @@ -0,0 +1,98 @@ +// +// CursorAgentProvider.swift +// TablePro +// + +import Foundation +import os + +final class CursorAgentProvider: ChatTransport { + private static let logger = Logger(subsystem: "com.TablePro", category: "CursorAgentProvider") + + private let model: String + private let cli: CursorAgentCLI + + init(model: String, cli: CursorAgentCLI = CursorAgentCLI()) { + self.model = model.trimmingCharacters(in: .whitespacesAndNewlines) + self.cli = cli + } + + func streamChat( + turns: [ChatTurnWire], + options: ChatTransportOptions + ) -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + let workspace = Self.makeWorkspace() + let prompt = CursorProvider.renderPrompt(turns: turns, options: options) + let lines = cli.stream(Self.inferenceArguments(prompt: prompt, model: model, workspace: workspace?.path)) + + let task = Task { + do { + for try await line in lines { + if Task.isCancelled { break } + guard let json = Self.decodeJSON(line), + json["type"] as? String == "assistant", + json["timestamp_ms"] != nil, + let text = Self.assistantText(json), !text.isEmpty else { continue } + continuation.yield(.textDelta(text)) + } + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + Self.removeWorkspace(workspace) + } + + continuation.onTermination = { _ in + task.cancel() + } + } + } + + func fetchAvailableModels() async throws -> [String] { + CursorAI.curatedModelIDs + } + + func testConnection() async throws -> Bool { + let result = try await cli.run(["status"]) + if result.code == 0 { return true } + throw AIProviderError.authenticationFailed(String(localized: "Not signed in to Cursor. Sign in first.")) + } + + static func inferenceArguments(prompt: String, model: String, workspace: String?) -> [String] { + var arguments = ["-p", "--output-format", "stream-json", "--stream-partial-output", "--trust"] + if let workspace { + arguments += ["--workspace", workspace] + } + if !model.isEmpty { + arguments += ["--model", model] + } + arguments += ["--", prompt] + return arguments + } + + static func assistantText(_ json: [String: Any]) -> String? { + guard let message = json["message"] as? [String: Any], + let content = message["content"] as? [[String: Any]], + let text = content.first?["text"] as? String else { + return nil + } + return text + } + + private static func decodeJSON(_ line: String) -> [String: Any]? { + guard let data = line.data(using: .utf8) else { return nil } + return try? JSONSerialization.jsonObject(with: data) as? [String: Any] + } + + private static func makeWorkspace() -> URL? { + let url = FileManager.default.temporaryDirectory.appending(path: "cursor-agent-\(UUID().uuidString)") + try? FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return FileManager.default.fileExists(atPath: url.path) ? url : nil + } + + private static func removeWorkspace(_ url: URL?) { + guard let url else { return } + try? FileManager.default.removeItem(at: url) + } +} diff --git a/TablePro/Core/AI/Cursor/CursorAgentService.swift b/TablePro/Core/AI/Cursor/CursorAgentService.swift new file mode 100644 index 000000000..8b87bbea8 --- /dev/null +++ b/TablePro/Core/AI/Cursor/CursorAgentService.swift @@ -0,0 +1,85 @@ +// +// CursorAgentService.swift +// TablePro +// + +import Foundation +import os + +@MainActor @Observable +final class CursorAgentService { + static let shared = CursorAgentService() + + private static let logger = Logger(subsystem: "com.TablePro", category: "CursorAgentService") + + enum AuthState: Sendable, Equatable { + case notInstalled + case signedOut + case signingIn + case signedIn(account: String) + + var isSignedIn: Bool { + if case .signedIn = self { return true } + return false + } + } + + private(set) var authState: AuthState = .signedOut + private(set) var errorMessage: String? + + @ObservationIgnored private let cli: CursorAgentCLI + + init(cli: CursorAgentCLI = CursorAgentCLI()) { + self.cli = cli + } + + func refreshStatus() async { + guard cli.isInstalled else { + authState = .notInstalled + return + } + do { + let result = try await cli.run(["status"]) + authState = result.code == 0 + ? .signedIn(account: Self.parseAccount(result.output)) + : .signedOut + } catch { + authState = cli.isInstalled ? .signedOut : .notInstalled + } + } + + func signIn() async { + guard cli.isInstalled else { + authState = .notInstalled + return + } + errorMessage = nil + authState = .signingIn + do { + let result = try await cli.run(["login"]) + if result.code != 0 { + errorMessage = result.output.isEmpty + ? String(localized: "Cursor sign-in failed.") + : result.output + } + } catch { + Self.logger.error("Cursor CLI sign-in failed: \(error.localizedDescription, privacy: .public)") + errorMessage = error.localizedDescription + } + await refreshStatus() + } + + func signOut() async { + _ = try? await cli.run(["logout"]) + errorMessage = nil + await refreshStatus() + } + + private static func parseAccount(_ output: String) -> String { + let tokens = output.split(whereSeparator: { $0 == " " || $0 == "\n" || $0 == "\t" || $0 == "\r" }) + for token in tokens where token.contains("@") && token.contains(".") { + return String(token.trimmingCharacters(in: CharacterSet(charactersIn: "<>(),"))) + } + return "" + } +} diff --git a/TablePro/Core/AI/Cursor/CursorProvider.swift b/TablePro/Core/AI/Cursor/CursorProvider.swift new file mode 100644 index 000000000..f48d35c20 --- /dev/null +++ b/TablePro/Core/AI/Cursor/CursorProvider.swift @@ -0,0 +1,212 @@ +// +// CursorProvider.swift +// TablePro +// + +import Foundation +import os + +final class CursorProvider: ChatTransport { + private static let logger = Logger(subsystem: "com.TablePro", category: "CursorProvider") + + private let apiKey: String + private let model: String + private let session: URLSession + + init(apiKey: String, model: String, session: URLSession = URLSession(configuration: .ephemeral)) { + self.apiKey = apiKey.trimmingCharacters(in: .whitespacesAndNewlines) + self.model = model.trimmingCharacters(in: .whitespacesAndNewlines) + self.session = session + } + + func streamChat( + turns: [ChatTurnWire], + options: ChatTransportOptions + ) -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + let task = Task { + do { + let run = try await launchAgent(turns: turns, options: options) + let request = try streamRequest(run: run) + let (bytes, response) = try await session.bytes(for: request) + guard let httpResponse = response as? HTTPURLResponse else { + throw AIProviderError.networkError("Invalid response") + } + guard httpResponse.statusCode == 200 else { + let body = try await AIProvider.collectErrorBody(from: bytes) + throw AIProviderError.mapHTTPError(statusCode: httpResponse.statusCode, body: body) + } + + var event = "" + var emittedText = false + events: for try await line in bytes.lines { + if Task.isCancelled { break } + if line.isEmpty { event = ""; continue } + if let name = Self.sseField(line, "event") { event = name; continue } + guard let payload = Self.sseField(line, "data"), + let json = Self.decodeJSON(payload) else { continue } + switch event { + case "assistant": + if let text = json["text"] as? String, !text.isEmpty { + emittedText = true + continuation.yield(.textDelta(text)) + } + case "result": + if !emittedText, let text = json["text"] as? String, !text.isEmpty { + continuation.yield(.textDelta(text)) + } + case "done": + break events + default: + continue + } + } + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + + continuation.onTermination = { _ in + task.cancel() + } + } + } + + func fetchAvailableModels() async throws -> [String] { + var request = URLRequest(url: try Self.url("/v1/models")) + request.timeoutInterval = AIProvider.modelListTimeout + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + let data: Data + let response: URLResponse + do { + (data, response) = try await session.data(for: request) + } catch { + Self.logger.warning("Cursor model fetch failed: \(error.localizedDescription, privacy: .public)") + throw AIProviderError.networkError("Failed to fetch models") + } + guard let httpResponse = response as? HTTPURLResponse, + httpResponse.statusCode == 200, + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let items = json["items"] as? [[String: Any]] + else { + throw AIProviderError.networkError("Failed to fetch models") + } + return items.compactMap { $0["id"] as? String }.sorted() + } + + func testConnection() async throws -> Bool { + var request = URLRequest(url: try Self.url("/v1/models")) + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + let (data, response) = try await session.data(for: request) + guard let httpResponse = response as? HTTPURLResponse else { return false } + if httpResponse.statusCode == 200 { + return true + } + if httpResponse.statusCode == 401 { + throw AIProviderError.authenticationFailed("") + } + let body = String(data: data, encoding: .utf8) ?? "" + throw AIProviderError.mapHTTPError(statusCode: httpResponse.statusCode, body: body) + } + + private func launchAgent( + turns: [ChatTurnWire], + options: ChatTransportOptions + ) async throws -> (agentID: String, runID: String) { + var request = URLRequest(url: try Self.url("/v1/agents")) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + let body = Self.launchBody(prompt: Self.renderPrompt(turns: turns, options: options), model: model) + request.httpBody = try JSONSerialization.data(withJSONObject: body) + + let (data, response) = try await session.data(for: request) + guard let httpResponse = response as? HTTPURLResponse else { + throw AIProviderError.networkError("Invalid response") + } + guard (200..<300).contains(httpResponse.statusCode) else { + throw AIProviderError.mapHTTPError( + statusCode: httpResponse.statusCode, + body: String(data: data, encoding: .utf8) ?? "" + ) + } + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let agent = json["agent"] as? [String: Any], + let agentID = agent["id"] as? String, + let run = json["run"] as? [String: Any], + let runID = run["id"] as? String + else { + throw AIProviderError.networkError("Cursor did not return an agent run") + } + return (agentID, runID) + } + + private func streamRequest(run: (agentID: String, runID: String)) throws -> URLRequest { + var request = URLRequest(url: try Self.url("/v1/agents/\(run.agentID)/runs/\(run.runID)/stream")) + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + request.setValue("text/event-stream", forHTTPHeaderField: "Accept") + return request + } + + static func renderPrompt(turns: [ChatTurnWire], options: ChatTransportOptions) -> String { + var sections: [String] = [] + if let systemPrompt = options.systemPrompt, !systemPrompt.isEmpty { + sections.append(systemPrompt) + } + for turn in turns { + let text = turnText(turn) + guard !text.isEmpty else { continue } + switch turn.role { + case .user: + sections.append("User: \(text)") + case .assistant: + sections.append("Assistant: \(text)") + case .system: + sections.append(text) + } + } + return sections.joined(separator: "\n\n") + } + + static func launchBody(prompt: String, model: String) -> [String: Any] { + var body: [String: Any] = ["prompt": ["text": prompt]] + if !model.isEmpty { + body["model"] = ["id": model] + } + return body + } + + private static func turnText(_ turn: ChatTurnWire) -> String { + var parts: [String] = [] + for block in turn.blocks { + switch block.kind { + case .text(let text): + if !text.isEmpty { parts.append(text) } + case .toolResult(let result): + if !result.content.isEmpty { parts.append("Result: \(result.content)") } + case .toolUse, .attachment, .reasoning, .image: + continue + } + } + return parts.joined(separator: "\n") + } + + private static func url(_ path: String) throws -> URL { + guard let url = URL(string: CursorAI.baseURL + path) else { + throw AIProviderError.invalidEndpoint(CursorAI.baseURL) + } + return url + } + + private static func sseField(_ line: String, _ name: String) -> String? { + let prefix = "\(name):" + guard line.hasPrefix(prefix) else { return nil } + return String(line.dropFirst(prefix.count)).trimmingCharacters(in: .whitespaces) + } + + private static func decodeJSON(_ payload: String) -> [String: Any]? { + guard let data = payload.data(using: .utf8) else { return nil } + return try? JSONSerialization.jsonObject(with: data) as? [String: Any] + } +} diff --git a/TablePro/Core/AI/Registry/AIProviderRegistration.swift b/TablePro/Core/AI/Registry/AIProviderRegistration.swift index a9a66265d..9a72b3329 100644 --- a/TablePro/Core/AI/Registry/AIProviderRegistration.swift +++ b/TablePro/Core/AI/Registry/AIProviderRegistration.swift @@ -110,6 +110,25 @@ enum AIProviderRegistration { ChatGPTCodexProvider(model: config.model) } )) + + registry.register(AIProviderDescriptor( + typeID: AIProviderType.cursor.rawValue, + displayName: AIProviderType.cursor.displayName, + defaultEndpoint: "", + capabilities: [.chat, .inline, .models, .modelListFetchable], + symbolName: AIProviderType.cursor.symbolName, + curatedModels: cursorCuratedModels, + makeProvider: { config, apiKey in + if let apiKey, !apiKey.isEmpty { + return CursorProvider(apiKey: apiKey, model: config.model) + } + return CursorAgentProvider(model: config.model) + } + )) + } + + private static let cursorCuratedModels: [CuratedModel] = CursorAI.curatedModels.map { + CuratedModel(id: $0.id, displayName: $0.name) } private static let chatGPTCodexCuratedModels: [CuratedModel] = [ diff --git a/TablePro/Models/AI/AIModels.swift b/TablePro/Models/AI/AIModels.swift index f627e3d05..a04758aa2 100644 --- a/TablePro/Models/AI/AIModels.swift +++ b/TablePro/Models/AI/AIModels.swift @@ -10,6 +10,7 @@ import Foundation enum AIProviderType: String, Codable, CaseIterable, Identifiable, Sendable { case copilot case chatgptCodex + case cursor case claude case openAI case openRouter @@ -24,6 +25,7 @@ enum AIProviderType: String, Codable, CaseIterable, Identifiable, Sendable { switch self { case .copilot: return "GitHub Copilot" case .chatgptCodex: return "ChatGPT" + case .cursor: return "Cursor" case .claude: return "Claude" case .openAI: return "OpenAI" case .openRouter: return "OpenRouter" @@ -38,6 +40,7 @@ enum AIProviderType: String, Codable, CaseIterable, Identifiable, Sendable { switch self { case .copilot: return "" case .chatgptCodex: return "" + case .cursor: return "" case .claude: return "https://api.anthropic.com" case .openAI: return "https://api.openai.com" case .openRouter: return "https://openrouter.ai/api" @@ -58,6 +61,7 @@ enum AIProviderType: String, Codable, CaseIterable, Identifiable, Sendable { switch self { case .copilot: return .oauth case .chatgptCodex: return .oauth + case .cursor: return .optionalApiKey case .ollama: return .none case .openCode: return .optionalApiKey default: return .apiKey @@ -68,6 +72,7 @@ enum AIProviderType: String, Codable, CaseIterable, Identifiable, Sendable { switch self { case .copilot: return "chevron.left.forwardslash.chevron.right" case .chatgptCodex: return "bubble.left.and.bubble.right" + case .cursor: return "cursorarrow" case .claude: return "brain" case .openAI: return "cpu" case .openRouter: return "globe" diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index 952881864..c3006c2ab 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -15754,6 +15754,9 @@ } } } + }, + "Copy install command" : { + }, "Copy JSON" : { "localizations" : { @@ -17552,6 +17555,9 @@ } } } + }, + "Cursor CLI failed: %@" : { + }, "Cursor position %d exceeds SQL length (%d)" : { "localizations" : { @@ -17609,6 +17615,9 @@ } } } + }, + "Cursor sign-in failed." : { + }, "Cursor style:" : { "extractionState" : "stale", @@ -40651,6 +40660,9 @@ }, "Not signed in to ChatGPT." : { + }, + "Not signed in to Cursor. Sign in first." : { + }, "Not signed in to GitHub Copilot" : { "localizations" : { @@ -42411,6 +42423,9 @@ } } } + }, + "Optional. A key from the Cursor dashboard is used instead of signing in." : { + }, "Options" : { "localizations" : { @@ -47735,6 +47750,9 @@ } } } + }, + "Recheck" : { + }, "Reconnect" : { "extractionState" : "stale", @@ -54867,6 +54885,9 @@ }, "Sign in with ChatGPT" : { + }, + "Sign in with Cursor" : { + }, "Sign in with GitHub" : { "localizations" : { @@ -55008,6 +55029,9 @@ } } } + }, + "Signed in with Cursor" : { + }, "Signing in…" : { "localizations" : { @@ -59909,6 +59933,12 @@ }, "The Codex CLI login could not be read." : { + }, + "The Cursor CLI is not installed." : { + + }, + "The Cursor CLI is not installed. Install it, then sign in." : { + }, "The destructive query to execute" : { "localizations" : { @@ -64814,6 +64844,9 @@ } } } + }, + "Use your Cursor subscription with no API key. Requires the Cursor CLI." : { + }, "User" : { "localizations" : { diff --git a/TablePro/Views/Settings/AIProviderDetailSheet.swift b/TablePro/Views/Settings/AIProviderDetailSheet.swift index a397b0731..21f9a8eca 100644 --- a/TablePro/Views/Settings/AIProviderDetailSheet.swift +++ b/TablePro/Views/Settings/AIProviderDetailSheet.swift @@ -5,6 +5,7 @@ // Drill-down detail sheet for configuring a single AI provider. // +import AppKit import SwiftUI struct AIProviderDetailSheet: View { @@ -29,6 +30,8 @@ struct AIProviderDetailSheet: View { @State private var chatGPTCodexService = ChatGPTCodexService.shared + @State private var cursorAgentService = CursorAgentService.shared + @State private var showRemoveConfirmation = false enum TestResult: Equatable { @@ -92,6 +95,9 @@ struct AIProviderDetailSheet: View { if draft.type == .chatgptCodex { Task { await chatGPTCodexService.refreshAuthState() } } + if draft.type == .cursor { + Task { await cursorAgentService.refreshStatus() } + } fetchModels() } } @@ -142,7 +148,11 @@ struct AIProviderDetailSheet: View { private var authSection: some View { switch draft.type.authStyle { case .apiKey, .optionalApiKey: - apiKeyAuthSection + if draft.type == .cursor { + cursorAuthSection + } else { + apiKeyAuthSection + } case .oauth: switch descriptor?.oauthFlowKind { case .deviceCode: @@ -192,6 +202,137 @@ struct AIProviderDetailSheet: View { } } + @ViewBuilder + private var cursorAuthSection: some View { + cursorAPIKeySection + cursorSignInSection + } + + private var cursorAPIKeySection: some View { + Section { + SecureField(String(localized: "API Key"), text: $apiKey) + .onChange(of: apiKey) { testResult = nil } + HStack { + Spacer() + Button { + testProvider() + } label: { + HStack(spacing: 6) { + if isTesting { ProgressView().controlSize(.small) } + Text("Test Connection") + } + } + .disabled(isTesting || apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + if case .success = testResult { + Label(String(localized: "Connection successful"), systemImage: "checkmark.circle.fill") + .foregroundStyle(.green) + .font(.caption) + } else if case .failure(let message) = testResult { + Label(message, systemImage: "xmark.circle.fill") + .foregroundStyle(.red) + .font(.caption) + .lineLimit(3) + } + } header: { + Text("API Key") + } footer: { + Text("Optional. A key from the Cursor dashboard is used instead of signing in.") + .font(.caption) + .foregroundStyle(.secondary) + } + } + + @ViewBuilder + private var cursorSignInSection: some View { + Section { + cursorSignInContent + if let message = cursorAgentService.errorMessage { + Label(message, systemImage: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + .font(.caption) + .lineLimit(3) + } + } header: { + Text("Sign in with Cursor") + } footer: { + Text("Use your Cursor subscription with no API key. Requires the Cursor CLI.") + .font(.caption) + .foregroundStyle(.secondary) + } + } + + @ViewBuilder + private var cursorSignInContent: some View { + switch cursorAgentService.authState { + case .notInstalled: + LabeledContent { + Button { + copyToPasteboard(CursorAgentCLI.installCommand) + } label: { + Image(systemName: "doc.on.doc") + } + .buttonStyle(.borderless) + .help(String(localized: "Copy install command")) + } label: { + Text(CursorAgentCLI.installCommand) + .font(.system(.body, design: .monospaced)) + .textSelection(.enabled) + .lineLimit(1) + .truncationMode(.middle) + } + HStack { + Text("The Cursor CLI is not installed.") + .font(.caption) + .foregroundStyle(.secondary) + Spacer() + Button(String(localized: "Recheck")) { + Task { await cursorAgentService.refreshStatus() } + } + .controlSize(.small) + } + + case .signedOut: + HStack { + Text("Not signed in") + .foregroundStyle(.secondary) + Spacer() + Button(String(localized: "Sign in with Cursor")) { + Task { await cursorAgentService.signIn() } + } + .buttonStyle(.borderedProminent) + } + + case .signingIn: + HStack(spacing: 8) { + ProgressView().controlSize(.small) + Text("Opening your browser to sign in…") + .font(.caption) + .foregroundStyle(.secondary) + } + + case .signedIn(let account): + LabeledContent { + Button(String(localized: "Sign Out")) { + Task { await cursorAgentService.signOut() } + } + } label: { + Label( + account.isEmpty + ? String(localized: "Signed in") + : String(format: String(localized: "Signed in as %@"), account), + systemImage: "checkmark.circle.fill" + ) + .foregroundStyle(.green) + } + } + } + + private func copyToPasteboard(_ string: String) { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(string, forType: .string) + } + private var copilotAuthSection: some View { Section { switch copilotService.authState { diff --git a/TablePro/Views/Settings/AISettingsView.swift b/TablePro/Views/Settings/AISettingsView.swift index efa0ed614..aad96a40f 100644 --- a/TablePro/Views/Settings/AISettingsView.swift +++ b/TablePro/Views/Settings/AISettingsView.swift @@ -319,6 +319,9 @@ struct AISettingsView: View { if provider.type == .custom { return customStatusText(for: provider) } + if provider.type == .cursor { + return cursorStatusText(for: provider) + } return providersWithKey.contains(provider.id) ? String(localized: "API key set") : String(localized: "Not configured") @@ -349,6 +352,16 @@ struct AISettingsView: View { } } + private func cursorStatusText(for provider: AIProviderConfig) -> String { + if providersWithKey.contains(provider.id) { + return String(localized: "API key set") + } + if CursorAgentService.shared.authState.isSignedIn { + return String(localized: "Signed in with Cursor") + } + return String(localized: "Not configured") + } + private func customStatusText(for provider: AIProviderConfig) -> String { if providersWithKey.contains(provider.id) { return String(localized: "API key set") diff --git a/TableProTests/Core/AI/CursorAgentProviderTests.swift b/TableProTests/Core/AI/CursorAgentProviderTests.swift new file mode 100644 index 000000000..2e8a5503b --- /dev/null +++ b/TableProTests/Core/AI/CursorAgentProviderTests.swift @@ -0,0 +1,60 @@ +// +// CursorAgentProviderTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("CursorAgentProvider") +struct CursorAgentProviderTests { + @Test("Inference arguments stream JSON, pass model and workspace, and end with the prompt") + func inferenceArgumentsFull() { + let args = CursorAgentProvider.inferenceArguments( + prompt: "count the users", + model: "composer-2", + workspace: "/tmp/ws" + ) + #expect(args.contains("-p")) + #expect(args.contains("stream-json")) + #expect(args.contains("--stream-partial-output")) + #expect(args.contains("--trust")) + #expect(args.contains("--workspace")) + #expect(args.contains("/tmp/ws")) + #expect(args.contains("--model")) + #expect(args.contains("composer-2")) + #expect(args.last == "count the users") + #expect(args.dropLast().last == "--", "Prompt must be guarded by -- to prevent argv flag smuggling") + } + + @Test("A prompt that looks like a flag stays a positional argument") + func promptThatLooksLikeFlagIsNotParsedAsOption() { + let args = CursorAgentProvider.inferenceArguments(prompt: "--yolo --workspace /etc", model: "", workspace: nil) + #expect(args.last == "--yolo --workspace /etc") + #expect(args.dropLast().last == "--") + } + + @Test("Inference arguments omit model and workspace when not provided") + func inferenceArgumentsMinimal() { + let args = CursorAgentProvider.inferenceArguments(prompt: "hi", model: "", workspace: nil) + #expect(!args.contains("--model")) + #expect(!args.contains("--workspace")) + #expect(args.last == "hi") + } + + @Test("Assistant text is read from message.content[0].text") + func assistantTextParsing() { + let event: [String: Any] = [ + "type": "assistant", + "message": ["content": [["text": "SELECT count(*) FROM users"]]] + ] + #expect(CursorAgentProvider.assistantText(event) == "SELECT count(*) FROM users") + } + + @Test("Non-assistant events have no assistant text") + func resultEventHasNoText() { + let result: [String: Any] = ["type": "result", "duration_ms": 1_234] + #expect(CursorAgentProvider.assistantText(result) == nil) + } +} diff --git a/TableProTests/Core/AI/CursorProviderEncodingTests.swift b/TableProTests/Core/AI/CursorProviderEncodingTests.swift new file mode 100644 index 000000000..b7dc79ff2 --- /dev/null +++ b/TableProTests/Core/AI/CursorProviderEncodingTests.swift @@ -0,0 +1,42 @@ +// +// CursorProviderEncodingTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("CursorProvider request encoding") +struct CursorProviderEncodingTests { + @Test("Prompt renders the system prompt and the role-tagged conversation") + func renderPrompt() { + let options = ChatTransportOptions(model: "composer-2", systemPrompt: "You are a SQL helper.") + let turns = [ + ChatTurnWire(role: .user, blocks: [.text("List the users")]), + ChatTurnWire(role: .assistant, blocks: [.text("SELECT * FROM users")]), + ChatTurnWire(role: .user, blocks: [.text("Count them")]) + ] + let prompt = CursorProvider.renderPrompt(turns: turns, options: options) + #expect(prompt.contains("You are a SQL helper.")) + #expect(prompt.contains("User: List the users")) + #expect(prompt.contains("Assistant: SELECT * FROM users")) + #expect(prompt.contains("User: Count them")) + } + + @Test("Launch body carries prompt text and model id") + func launchBodyWithModel() { + let body = CursorProvider.launchBody(prompt: "hello", model: "composer-2") + let prompt = body["prompt"] as? [String: Any] + #expect(prompt?["text"] as? String == "hello") + let model = body["model"] as? [String: Any] + #expect(model?["id"] as? String == "composer-2") + } + + @Test("Launch body omits the model when none is selected") + func launchBodyWithoutModel() { + let body = CursorProvider.launchBody(prompt: "hi", model: "") + #expect(body["model"] == nil) + #expect((body["prompt"] as? [String: Any])?["text"] as? String == "hi") + } +} diff --git a/TableProTests/Core/AI/CursorRegistrationTests.swift b/TableProTests/Core/AI/CursorRegistrationTests.swift new file mode 100644 index 000000000..3db5d5faa --- /dev/null +++ b/TableProTests/Core/AI/CursorRegistrationTests.swift @@ -0,0 +1,53 @@ +// +// CursorRegistrationTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("Cursor provider registration") +struct CursorRegistrationTests { + init() { + AIProviderRegistration.registerAll() + } + + private func descriptor() -> AIProviderDescriptor? { + AIProviderRegistry.shared.descriptor(for: AIProviderType.cursor.rawValue) + } + + @Test("Cursor allows an optional API key and is a known type") + func authStyleAndType() { + #expect(AIProviderType.cursor.authStyle == .optionalApiKey) + #expect(AIProviderType.allCases.contains(.cursor)) + #expect(AIProviderType(rawValue: "cursor") == .cursor) + } + + @Test("Cursor descriptor capabilities") + func capabilities() { + let cursor = descriptor() + #expect(cursor != nil) + #expect(cursor?.fetchesModelList == true) + #expect(cursor?.allowsEndpointConfiguration == false) + #expect(cursor?.allowsMaxOutputTokens == false) + #expect(cursor?.oauthFlowKind == nil) + #expect(cursor?.showsTelemetryToggle == false) + } + + @Test("Cursor offers several curated models, not just one") + func curatedModels() { + let ids = descriptor()?.curatedModels.map(\.id) ?? [] + #expect(ids.count > 1) + #expect(ids.contains("composer-2.5")) + #expect(ids.contains("auto")) + } + + @Test("A key selects the REST provider, no key selects the CLI agent provider") + func makeProviderBranchesOnKey() { + let config = AIProviderConfig(type: .cursor, model: "composer-2") + #expect(descriptor()?.makeProvider(config, "sk-cursor-test") is CursorProvider) + #expect(descriptor()?.makeProvider(config, nil) is CursorAgentProvider) + #expect(descriptor()?.makeProvider(config, "") is CursorAgentProvider) + } +} diff --git a/docs/features/ai-assistant.mdx b/docs/features/ai-assistant.mdx index b64cf1d81..a1c09da8a 100644 --- a/docs/features/ai-assistant.mdx +++ b/docs/features/ai-assistant.mdx @@ -1,6 +1,6 @@ --- title: AI Assistant -description: "Built-in AI for SQL: chat with tool calling, inline suggestions, explain, optimize, fix-error. 9 providers." +description: "Built-in AI for SQL: chat with tool calling, inline suggestions, explain, optimize, fix-error. 10 providers." --- # AI Assistant @@ -28,7 +28,7 @@ Open **Settings** (`Cmd+,`) > **AI**. The tab is modeled on Xcode's Intelligence ### Add a Provider -1. Click **Add Provider...** and pick a type: GitHub Copilot, ChatGPT, Claude, OpenAI, OpenRouter, OpenCode Zen, Gemini, Ollama, or a custom OpenAI-compatible endpoint. +1. Click **Add Provider...** and pick a type: GitHub Copilot, ChatGPT, Cursor, Claude, OpenAI, OpenRouter, OpenCode Zen, Gemini, Ollama, or a custom OpenAI-compatible endpoint. 2. Enter the API key, or sign in for Copilot and ChatGPT. 3. Enter a model name, or pick one from the fetched list. Click **Reload** if needed. 4. Click **Test Connection**. @@ -51,6 +51,17 @@ Sign in with your ChatGPT account to use the Codex quota included with Plus, Pro The active provider then serves chat and inline suggestions from the subscription models (GPT-5.5, GPT-5.4, GPT-5.4 Mini). This uses an unofficial OpenAI interface that may change, and access follows OpenAI's terms. +### Cursor + +Use your Cursor subscription for AI chat and inline suggestions, two ways: + +- **API key:** paste a key from the Cursor dashboard. Stored in the macOS Keychain like other provider keys. Calls Cursor's Cloud Agents API. +- **Sign in with the Cursor CLI:** leave the key blank and click **Sign in with Cursor**. This needs the Cursor CLI installed (`curl https://cursor.com/install -fsS | bash`); the button runs `agent login` in your browser, and chat runs through the local `agent` command. No key to paste. + +Pick a model such as Composer 2, or one of the frontier models Cursor proxies. + +Cursor runs as an agent rather than a chat-completions endpoint. Two limits follow: the AI cannot call TablePro's database tools (chat answers from the schema context TablePro sends, but Edit and Agent modes do not run queries through Cursor), and responses can be slower than the other providers. Using the API or CLI in your own app is permitted by Cursor's terms. + ## Chat Press `Cmd+Shift+L`, click the inspector toggle and pick **AI Chat**, or use **View** > **Toggle AI Chat**. The right inspector has a Details / AI Chat segmented picker at the top.