Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
25 changes: 25 additions & 0 deletions TablePro/Core/AI/Cursor/CursorAI.swift
Original file line number Diff line number Diff line change
@@ -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 }
}
}
134 changes: 134 additions & 0 deletions TablePro/Core/AI/Cursor/CursorAgentCLI.swift
Original file line number Diff line number Diff line change
@@ -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<String, Error> {
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()
Comment on lines +94 to +100

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Surface non-zero Cursor CLI failures

When the CLI exits non-zero after launch (for example expired sign-in, invalid model, or quota/plan errors), Cursor's CLI output-format docs state failures write only to stderr and emit no well-formed JSON, but this reader ignores the termination status and finishes successfully as soon as stdout closes. In those cases CursorAgentProvider just sees no assistant JSON and TablePro shows an empty completed response instead of the actionable Cursor error.

Useful? React with 👍 / 👎.

} 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<String>()
environment["PATH"] = (preferred + current)
.filter { seen.insert($0).inserted }
.joined(separator: ":")
return environment
}
}
98 changes: 98 additions & 0 deletions TablePro/Core/AI/Cursor/CursorAgentProvider.swift
Original file line number Diff line number Diff line change
@@ -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<ChatStreamEvent, Error> {
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)
}
}
85 changes: 85 additions & 0 deletions TablePro/Core/AI/Cursor/CursorAgentService.swift
Original file line number Diff line number Diff line change
@@ -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 ""
}
}
Loading
Loading