feat(cursor): added Cursor AI provider with OAuth, HTTP/2 streaming, and tool execution bridge

- Added Cursor AI provider with HTTP/2 streaming support using Connect protocol and protobuf binary serialization.
- Added OAuth authentication flow for Cursor including browser-based login, token refresh, and expiry detection.
- Added 14 Cursor model definitions including Claude 4.5, GPT 5.x, Gemini 3, and Grok variants.
- Added execution bridge for Cursor tool calls supporting read, ls, grep, write, delete, shell, diagnostics, and MCP operations.
- Added cursorExecHandlers and cursorOnToolResult options to Agent for local tool execution with cursor-based streaming.
- Added emitExternalEvent method to Agent class for external event injection into agent state.
This commit is contained in:
can1357
2026-01-11 07:37:57 +01:00
parent 17b7e7217b
commit 242f319cfc
25 changed files with 21805 additions and 3 deletions
+1
View File
@@ -43,6 +43,7 @@
"!**/test-sessions.ts",
"!**/models.generated.ts",
"!**/template.generated.ts",
"!**/gen/agent_pb.ts",
"!packages/web-ui/src/app.css",
"!packages/mom/data/**/*",
"!!**/node_modules"
+18
View File
@@ -10,6 +10,7 @@
},
"devDependencies": {
"@biomejs/biome": "2.3.11",
"@bufbuild/protoc-gen-es": "^2.10.2",
"@types/bun": "^1.2.17",
"@types/node": "^22.10.5",
"@typescript/native-preview": "^7.0.0-dev.20260102.1",
@@ -38,6 +39,9 @@
},
"dependencies": {
"@anthropic-ai/sdk": "0.71.2",
"@bufbuild/protobuf": "^2.10.2",
"@connectrpc/connect": "^2.1.1",
"@connectrpc/connect-node": "^2.1.1",
"@google/genai": "1.34.0",
"@mistralai/mistralai": "1.10.0",
"@sinclair/typebox": "^0.34.41",
@@ -143,8 +147,18 @@
"@borewit/text-codec": ["@borewit/text-codec@0.2.1", "", {}, "sha512-k7vvKPbf7J2fZ5klGRD9AeKfUvojuZIQ3BT5u7Jfv+puwXkUBUT5PVyMDfJZpy30CBDXGMgw7fguK/lpOMBvgw=="],
"@bufbuild/protobuf": ["@bufbuild/protobuf@2.10.2", "", {}, "sha512-uFsRXwIGyu+r6AMdz+XijIIZJYpoWeYzILt5yZ2d3mCjQrWUTVpVD9WL/jZAbvp+Ed04rOhrsk7FiTcEDseB5A=="],
"@bufbuild/protoc-gen-es": ["@bufbuild/protoc-gen-es@2.10.2", "", { "dependencies": { "@bufbuild/protobuf": "2.10.2", "@bufbuild/protoplugin": "2.10.2" }, "bin": { "protoc-gen-es": "bin/protoc-gen-es" } }, "sha512-vbjPsuofbtZwZXuOP7Y16CQsxrwCjuRONffmJSBEhoC7PQu/Cabp0+Fu/poLPm9CNM0tDCQA0xvgobgudaEYxQ=="],
"@bufbuild/protoplugin": ["@bufbuild/protoplugin@2.10.2", "", { "dependencies": { "@bufbuild/protobuf": "2.10.2", "@typescript/vfs": "^1.6.2", "typescript": "5.4.5" } }, "sha512-RAWVs9tCzRqSS3tUtaFhOcauOAazCrm7tlGh0WHFq/44n5Fj6YgefdlZEPIaAK6VAA+FdOoFgtOJK2Ji5U24pw=="],
"@colors/colors": ["@colors/colors@1.6.0", "", {}, "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA=="],
"@connectrpc/connect": ["@connectrpc/connect@2.1.1", "", { "peerDependencies": { "@bufbuild/protobuf": "^2.7.0" } }, "sha512-JzhkaTvM73m2K1URT6tv53k2RwngSmCXLZJgK580qNQOXRzZRR/BCMfZw3h+90JpnG6XksP5bYT+cz0rpUzUWQ=="],
"@connectrpc/connect-node": ["@connectrpc/connect-node@2.1.1", "", { "peerDependencies": { "@bufbuild/protobuf": "^2.7.0", "@connectrpc/connect": "2.1.1" } }, "sha512-s3TfsI1XF+n+1z6MBS9rTnFsxxR4Rw5wmdEnkQINli81ESGxcsfaEet8duzq8LVuuCupmhUsgpRo0Nv9pZkufg=="],
"@dabh/diagnostics": ["@dabh/diagnostics@2.0.8", "", { "dependencies": { "@so-ric/colorspace": "^1.1.6", "enabled": "2.0.x", "kuler": "^2.0.0" } }, "sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q=="],
"@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
@@ -385,6 +399,8 @@
"@typescript/native-preview-win32-x64": ["@typescript/native-preview-win32-x64@7.0.0-dev.20260109.1", "", { "os": "win32", "cpu": "x64" }, "sha512-da44CbC8ktr741ISLvCQlz3Gv2UqO2M+rB585xCFNjcz+0IyOKkBGr9eR++f6uy46/QKFK4w44x0cK71PVqk9g=="],
"@typescript/vfs": ["@typescript/vfs@1.6.2", "", { "dependencies": { "debug": "^4.1.1" }, "peerDependencies": { "typescript": "*" } }, "sha512-hoBwJwcbKHmvd2QVebiytN1aELvpk9B74B4L1mFm/XT1Q/VOYAWl2vQ9AWRFtQq8zmz6enTpfTV8WRc4ATjW/g=="],
"@vitest/expect": ["@vitest/expect@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig=="],
"@vitest/mocker": ["@vitest/mocker@3.2.4", "", { "dependencies": { "@vitest/spy": "3.2.4", "estree-walker": "^3.0.3", "magic-string": "^0.30.17" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ=="],
@@ -913,6 +929,8 @@
"type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="],
"typescript": ["typescript@5.4.5", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ=="],
"uglify-js": ["uglify-js@3.19.3", "", { "bin": { "uglifyjs": "bin/uglifyjs" } }, "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ=="],
"uint8array-extras": ["uint8array-extras@1.5.0", "", {}, "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A=="],
+1
View File
@@ -21,6 +21,7 @@
},
"devDependencies": {
"@biomejs/biome": "2.3.11",
"@bufbuild/protoc-gen-es": "^2.10.2",
"@types/bun": "^1.2.17",
"@types/node": "^22.10.5",
"@typescript/native-preview": "^7.0.0-dev.20260102.1",
+4
View File
@@ -1,6 +1,10 @@
# Changelog
## [Unreleased]
### Added
- Added `cursorExecHandlers` and `cursorOnToolResult` options for local tool execution with cursor-based streaming
- Added `emitExternalEvent` method to allow external event injection into the agent state
## [4.2.3] - 2026-01-11
+64
View File
@@ -4,6 +4,8 @@
*/
import {
type CursorExecHandlers,
type CursorToolResultHandler,
getModel,
type ImageContent,
type Message,
@@ -11,6 +13,7 @@ import {
streamSimple,
type TextContent,
type ThinkingBudgets,
type ToolResultMessage,
} from "@oh-my-pi/pi-ai";
import { agentLoop, agentLoopContinue } from "./agent-loop";
import type {
@@ -91,6 +94,16 @@ export interface AgentOptions {
* Use for late-bound UI or session state access.
*/
getToolContext?: () => AgentToolContext | undefined;
/**
* Cursor exec handlers for local tool execution.
*/
cursorExecHandlers?: CursorExecHandlers;
/**
* Cursor tool result callback for exec tool responses.
*/
cursorOnToolResult?: CursorToolResultHandler;
}
export class Agent {
@@ -120,6 +133,8 @@ export class Agent {
private _thinkingBudgets?: ThinkingBudgets;
public getApiKey?: (provider: string) => Promise<string | undefined> | string | undefined;
private getToolContext?: () => AgentToolContext | undefined;
private cursorExecHandlers?: CursorExecHandlers;
private cursorOnToolResult?: CursorToolResultHandler;
private runningPrompt?: Promise<void>;
private resolveRunningPrompt?: () => void;
@@ -135,6 +150,8 @@ export class Agent {
this._thinkingBudgets = opts.thinkingBudgets;
this.getApiKey = opts.getApiKey;
this.getToolContext = opts.getToolContext;
this.cursorExecHandlers = opts.cursorExecHandlers;
this.cursorOnToolResult = opts.cursorOnToolResult;
}
/**
@@ -175,6 +192,33 @@ export class Agent {
return () => this.listeners.delete(fn);
}
emitExternalEvent(event: AgentEvent) {
switch (event.type) {
case "message_start":
case "message_update":
this._state.streamMessage = event.message;
break;
case "message_end":
this._state.streamMessage = null;
this.appendMessage(event.message);
break;
case "tool_execution_start": {
const pending = new Set(this._state.pendingToolCalls);
pending.add(event.toolCallId);
this._state.pendingToolCalls = pending;
break;
}
case "tool_execution_end": {
const pending = new Set(this._state.pendingToolCalls);
pending.delete(event.toolCallId);
this._state.pendingToolCalls = pending;
break;
}
}
this.emit(event);
}
// State mutators
setSystemPrompt(v: string) {
this._state.systemPrompt = v;
@@ -382,6 +426,24 @@ export class Agent {
tools: this._state.tools,
};
const cursorOnToolResult =
this.cursorExecHandlers || this.cursorOnToolResult
? async (message: ToolResultMessage) => {
let finalMessage = message;
if (this.cursorOnToolResult) {
try {
const updated = await this.cursorOnToolResult(message);
if (updated) {
finalMessage = updated;
}
} catch {}
}
this.emitExternalEvent({ type: "message_start", message: finalMessage });
this.emitExternalEvent({ type: "message_end", message: finalMessage });
return finalMessage;
}
: undefined;
const config: AgentLoopConfig = {
model,
reasoning,
@@ -392,6 +454,8 @@ export class Agent {
transformContext: this.transformContext,
getApiKey: this.getApiKey,
getToolContext: this.getToolContext,
cursorExecHandlers: this.cursorExecHandlers,
cursorOnToolResult,
getSteeringMessages: async () => {
if (this.steeringMode === "one-at-a-time") {
if (this.steeringQueue.length > 0) {
+5
View File
@@ -1,8 +1,13 @@
# Changelog
## [Unreleased]
### Added
- Added Cursor provider with support for Claude, GPT, and Gemini models via Cursor's agent API
- Added OAuth authentication flow for Cursor including login, token refresh, and expiry detection
- Added `cursor-agent` API type with streaming support and tool execution handlers
- Added Cursor model definitions including Claude 4.5, GPT-5.x, Gemini 3, and Grok variants
- Added model generation script to automatically fetch and update AI model definitions from models.dev and OpenRouter APIs
## [4.2.3] - 2026-01-11
+3
View File
@@ -18,6 +18,9 @@
},
"dependencies": {
"@anthropic-ai/sdk": "0.71.2",
"@bufbuild/protobuf": "^2.10.2",
"@connectrpc/connect": "^2.1.1",
"@connectrpc/connect-node": "^2.1.1",
"@google/genai": "1.34.0",
"@mistralai/mistralai": "1.10.0",
"@sinclair/typebox": "^0.34.41",
+1
View File
@@ -1,5 +1,6 @@
export * from "./models";
export * from "./providers/anthropic";
export * from "./providers/cursor";
export * from "./providers/google";
export * from "./providers/google-gemini-cli";
export * from "./providers/google-vertex";
+223
View File
@@ -433,6 +433,229 @@ export const MODELS = {
maxTokens: 40000,
} satisfies Model<"openai-completions">,
},
"cursor": {
"claude-4.5-opus-high": {
id: "claude-4.5-opus-high",
name: "Claude 4.5 Opus (Cursor)",
api: "cursor-agent",
provider: "cursor",
baseUrl: "https://api2.cursor.sh",
reasoning: false,
input: ["text", "image"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 200000,
maxTokens: 64000,
} satisfies Model<"cursor-agent">,
"claude-4.5-opus-high-thinking": {
id: "claude-4.5-opus-high-thinking",
name: "Claude 4.5 Opus Thinking (Cursor)",
api: "cursor-agent",
provider: "cursor",
baseUrl: "https://api2.cursor.sh",
reasoning: true,
input: ["text", "image"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 200000,
maxTokens: 64000,
} satisfies Model<"cursor-agent">,
"claude-4.5-sonnet": {
id: "claude-4.5-sonnet",
name: "Claude 4.5 Sonnet (Cursor)",
api: "cursor-agent",
provider: "cursor",
baseUrl: "https://api2.cursor.sh",
reasoning: false,
input: ["text", "image"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 200000,
maxTokens: 64000,
} satisfies Model<"cursor-agent">,
"claude-4.5-sonnet-thinking": {
id: "claude-4.5-sonnet-thinking",
name: "Claude 4.5 Sonnet Thinking (Cursor)",
api: "cursor-agent",
provider: "cursor",
baseUrl: "https://api2.cursor.sh",
reasoning: true,
input: ["text", "image"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 200000,
maxTokens: 64000,
} satisfies Model<"cursor-agent">,
"composer-1": {
id: "composer-1",
name: "Composer 1 (Cursor)",
api: "cursor-agent",
provider: "cursor",
baseUrl: "https://api2.cursor.sh",
reasoning: false,
input: ["text"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 200000,
maxTokens: 64000,
} satisfies Model<"cursor-agent">,
"default": {
id: "default",
name: "Auto (Cursor)",
api: "cursor-agent",
provider: "cursor",
baseUrl: "https://api2.cursor.sh",
reasoning: false,
input: ["text", "image"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 200000,
maxTokens: 64000,
} satisfies Model<"cursor-agent">,
"gemini-3-flash": {
id: "gemini-3-flash",
name: "Gemini 3 Flash (Cursor)",
api: "cursor-agent",
provider: "cursor",
baseUrl: "https://api2.cursor.sh",
reasoning: true,
input: ["text", "image"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 1048576,
maxTokens: 65535,
} satisfies Model<"cursor-agent">,
"gemini-3-pro": {
id: "gemini-3-pro",
name: "Gemini 3 Pro (Cursor)",
api: "cursor-agent",
provider: "cursor",
baseUrl: "https://api2.cursor.sh",
reasoning: true,
input: ["text", "image"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 1048576,
maxTokens: 65535,
} satisfies Model<"cursor-agent">,
"gpt-5.1-codex-max": {
id: "gpt-5.1-codex-max",
name: "GPT-5.1 Codex Max (Cursor)",
api: "cursor-agent",
provider: "cursor",
baseUrl: "https://api2.cursor.sh",
reasoning: true,
input: ["text", "image"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 400000,
maxTokens: 128000,
} satisfies Model<"cursor-agent">,
"gpt-5.1-codex-max-high": {
id: "gpt-5.1-codex-max-high",
name: "GPT-5.1 Codex Max High (Cursor)",
api: "cursor-agent",
provider: "cursor",
baseUrl: "https://api2.cursor.sh",
reasoning: true,
input: ["text", "image"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 400000,
maxTokens: 128000,
} satisfies Model<"cursor-agent">,
"gpt-5.2": {
id: "gpt-5.2",
name: "GPT-5.2 (Cursor)",
api: "cursor-agent",
provider: "cursor",
baseUrl: "https://api2.cursor.sh",
reasoning: true,
input: ["text", "image"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 400000,
maxTokens: 128000,
} satisfies Model<"cursor-agent">,
"gpt-5.2-high": {
id: "gpt-5.2-high",
name: "GPT-5.2 High (Cursor)",
api: "cursor-agent",
provider: "cursor",
baseUrl: "https://api2.cursor.sh",
reasoning: true,
input: ["text", "image"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 400000,
maxTokens: 128000,
} satisfies Model<"cursor-agent">,
"grok-code-fast-1": {
id: "grok-code-fast-1",
name: "Grok (Cursor)",
api: "cursor-agent",
provider: "cursor",
baseUrl: "https://api2.cursor.sh",
reasoning: false,
input: ["text"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 32768,
maxTokens: 8192,
} satisfies Model<"cursor-agent">,
},
"github-copilot": {
"claude-haiku-4.5": {
id: "claude-haiku-4.5",
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,6 @@
version: v2
plugins:
- local: /work/pi/node_modules/.bin/protoc-gen-es
out: ../gen
opt:
- target=ts
@@ -0,0 +1,17 @@
version: v2
modules:
- path: .
lint:
use:
- STANDARD
except:
- COMMENT_FIELD
- COMMENT_MESSAGE
- COMMENT_ENUM
- COMMENT_ENUM_VALUE
- COMMENT_RPC
- COMMENT_SERVICE
- COMMENT_ONEOF
breaking:
use:
- FILE
@@ -0,0 +1,577 @@
#!/usr/bin/env python3
"""
Extracts protobuf definitions from bundled JavaScript generated by @bufbuild/protobuf.
Reconstructs .proto files with comments preserved.
Usage: python proto-extractor.py <bundled-js-file> <output-dir>
"""
import re
import sys
from pathlib import Path
from dataclasses import dataclass, field
from typing import Optional
SCALAR_TYPES = {
1: "double", 2: "float", 3: "int64", 4: "uint64", 5: "int32",
6: "fixed64", 7: "fixed32", 8: "bool", 9: "string", 10: "group",
11: "message", 12: "bytes", 13: "uint32", 14: "enum",
15: "sfixed32", 16: "sfixed64", 17: "sint32", 18: "sint64",
}
WEBPACK_NOISE = [
'__webpack_require__', 'harmony export', 'harmony import',
'use strict', 'WEBPACK_IMPORTED_MODULE', 'binding',
]
@dataclass
class FieldDef:
no: int
name: str
kind: str
type_ref: str
comment: str = ""
opt: bool = False
repeated: bool = False
oneof: Optional[str] = None
map_key: Optional[int] = None
@dataclass
class MessageDef:
type_name: str
fields: list[FieldDef] = field(default_factory=list)
comment: str = ""
@dataclass
class EnumValueDef:
name: str
no: int
comment: str = ""
@dataclass
class EnumDef:
type_name: str
values: list[EnumValueDef] = field(default_factory=list)
comment: str = ""
@dataclass
class MethodDef:
name: str
input_type: str
output_type: str
kind: str
comment: str = ""
@dataclass
class ServiceDef:
type_name: str
methods: list[MethodDef] = field(default_factory=list)
comment: str = ""
@dataclass
class ProtoFile:
path: str
package: str
syntax: str = "proto3"
messages: list[MessageDef] = field(default_factory=list)
enums: list[EnumDef] = field(default_factory=list)
services: list[ServiceDef] = field(default_factory=list)
imports: list[str] = field(default_factory=list)
def is_webpack_noise(text: str) -> bool:
"""Check if text contains webpack bundler noise."""
return any(noise in text for noise in WEBPACK_NOISE)
def extract_jsdoc_comment(text: str) -> str:
"""Extract clean comment from JSDoc block, filtering webpack noise."""
if is_webpack_noise(text):
return ""
lines = []
for line in text.split('\n'):
line = line.strip()
if line.startswith('*'):
line = line[1:].strip()
if line.startswith('/**') or line.endswith('*/'):
continue
if '@generated' in line:
continue
if is_webpack_noise(line):
continue
if line.startswith('case:') or '= { case:' in line:
continue
if line == '/' or line == '//' or len(line) <= 2:
continue
if line:
lines.append(line)
result = ' '.join(lines)
if is_webpack_noise(result):
return ""
if len(result) <= 2:
return ""
return result
def parse_type_reference(rest: str) -> str:
"""Parse the T: field to extract the type reference."""
webpack_comment = re.search(r'T:\s*[^,]+/\*\s*\.?(\w+)\s*\*/', rest)
if webpack_comment:
return webpack_comment.group(1)
type_match = re.search(r'T:\s*(\d+|[A-Za-z_][A-Za-z0-9_]*)', rest)
if type_match:
t = type_match.group(1)
if t.isdigit():
return SCALAR_TYPES.get(int(t), f"scalar_{t}")
if is_webpack_noise(t) or t.startswith('_'):
return "unknown"
return t
return "unknown"
def parse_field_list(fields_str: str, field_comments: dict[str, str]) -> list[FieldDef]:
"""Parse the fields array from newFieldList."""
fields = []
pattern = r'\{\s*no:\s*(\d+)\s*,\s*name:\s*"([^"]+)"\s*,\s*kind:\s*"([^"]+)"([^}]*)\}'
for m in re.finditer(pattern, fields_str):
no, name, kind, rest = int(m.group(1)), m.group(2), m.group(3), m.group(4)
type_ref = parse_type_reference(rest)
fields.append(FieldDef(
no=no,
name=name,
kind=kind,
type_ref=type_ref,
comment=field_comments.get(name, ""),
opt='opt: true' in rest,
repeated='repeated: true' in rest,
oneof=m2.group(1) if (m2 := re.search(r'oneof:\s*"([^"]+)"', rest)) else None,
map_key=int(m2.group(1)) if (m2 := re.search(r'mapKey:\s*(\d+)', rest)) else None,
))
return fields
def extract_field_comments(class_body: str) -> dict[str, str]:
"""Extract field comments from class property declarations."""
comments = {}
pattern = r'/\*\*([\s\S]*?)@generated from field:[^*]*\*/\s*\n?\s*(\w+)'
for m in re.finditer(pattern, class_body):
comment_text = extract_jsdoc_comment(m.group(1))
field_name_camel = m.group(2)
field_name_snake = re.sub(r'([A-Z])', r'_\1', field_name_camel).lower().lstrip('_')
if comment_text and not is_webpack_noise(comment_text):
comments[field_name_snake] = comment_text
return comments
def find_file_for_pos(file_ranges: dict[str, list[tuple[int, int]]], pos: int) -> str | None:
"""Find which file a position belongs to."""
for fp, segments in file_ranges.items():
for start, end in segments:
if start <= pos < end:
return fp
return None
def extract_messages(content: str, file_ranges: dict[str, list[tuple[int, int]]]) -> dict[str, list[MessageDef]]:
"""Extract all message definitions grouped by file."""
messages_by_file: dict[str, list[MessageDef]] = {}
seen_types: set[str] = set()
pattern = re.compile(
r'/\*\*([\s\S]*?)@generated from message ([^\s*]+)[\s\S]*?\*/'
r'[\s\S]*?class (\w+) extends [^{]+\{'
r'([\s\S]*?)'
r'static typeName\s*=\s*"([^"]+)"'
r'[\s\S]*?'
r'static fields\s*=\s*[^(]+\(\(\)\s*=>\s*\[([\s\S]*?)\]\)',
re.MULTILINE
)
for m in pattern.finditer(content):
comment_block, _, class_name, class_body, type_name, fields_str = m.groups()
pos = m.start()
if type_name in seen_types:
continue
seen_types.add(type_name)
file_path = find_file_for_pos(file_ranges, pos)
if not file_path:
continue
comment = extract_jsdoc_comment(comment_block)
field_comments = extract_field_comments(class_body)
fields = parse_field_list(fields_str, field_comments)
msg = MessageDef(type_name=type_name, fields=fields, comment=comment)
messages_by_file.setdefault(file_path, []).append(msg)
return messages_by_file
def extract_enums(content: str, file_ranges: dict[str, list[tuple[int, int]]]) -> dict[str, list[EnumDef]]:
"""Extract all enum definitions grouped by file."""
enums_by_file: dict[str, list[EnumDef]] = {}
seen_types: set[str] = set()
enum_block_pattern = re.compile(
r'/\*\*([\s\S]*?)@generated from enum ([^\s*]+)[\s\S]*?\*/'
r'[\s\S]*?'
r'setEnumType\([^,]+,\s*"([^"]+)",\s*\[([\s\S]*?)\]\)',
re.MULTILINE
)
for m in enum_block_pattern.finditer(content):
comment_block, _, type_name, values_str = m.groups()
pos = m.start()
if type_name in seen_types:
continue
seen_types.add(type_name)
file_path = find_file_for_pos(file_ranges, pos)
if not file_path:
continue
comment = extract_jsdoc_comment(comment_block)
values = []
value_pattern = r'\{\s*no:\s*(\d+)\s*,\s*name:\s*"([^"]+)"\s*\}'
for vm in re.finditer(value_pattern, values_str):
values.append(EnumValueDef(no=int(vm.group(1)), name=vm.group(2)))
enum = EnumDef(type_name=type_name, values=values, comment=comment)
enums_by_file.setdefault(file_path, []).append(enum)
return enums_by_file
def resolve_webpack_type(ref: str) -> str:
"""Resolve a webpack type reference like 'agent_service_pb/* AgentClientMessage */.KS'."""
webpack_comment = re.search(r'/\*\s*(\w+)\s*\*/', ref)
if webpack_comment:
return webpack_comment.group(1)
parts = ref.replace(',', '').strip().split('.')
return parts[-1] if parts else ref
def extract_services(content: str, file_ranges: dict[str, list[tuple[int, int]]]) -> dict[str, list[ServiceDef]]:
"""Extract all service definitions grouped by file."""
services_by_file: dict[str, list[ServiceDef]] = {}
seen_types: set[str] = set()
service_pattern = re.compile(
r'/\*\*([^*]|\*[^/])*@generated from service ([^\s*]+)([^*]|\*[^/])*\*/'
r'\s*(?:const|var)\s+\w+\s*=\s*\{'
r'[^}]*typeName:\s*"([^"]+)"'
r'[^}]*methods:\s*\{([\s\S]*?)\}\s*\}',
re.MULTILINE
)
for m in service_pattern.finditer(content):
groups = m.groups()
type_name = groups[3] # typeName
methods_str = groups[4] # methods content
pos = m.start()
if type_name in seen_types:
continue
seen_types.add(type_name)
file_path = find_file_for_pos(file_ranges, pos)
if not file_path:
continue
# Extract comment from the match text before @generated
full_match = m.group(0)
comment_end = full_match.find('@generated')
comment_text = full_match[3:comment_end] if comment_end > 0 else ""
comment = extract_jsdoc_comment(comment_text)
methods = []
method_pattern = re.compile(
r'/\*\*([\s\S]*?)@generated from rpc [^\s*]+\.(\w+)[\s\S]*?\*/'
r'\s*\w+:\s*\{'
r'[^}]*name:\s*"([^"]+)"'
r'[^}]*I:\s*([^,]+),'
r'[^}]*O:\s*([^,]+),'
r'[^}]*kind:\s*[^.]+\.(\w+)',
re.MULTILINE
)
for mm in method_pattern.finditer(methods_str):
method_comment, _, name, input_ref, output_ref, kind = mm.groups()
methods.append(MethodDef(
name=name,
input_type=resolve_webpack_type(input_ref),
output_type=resolve_webpack_type(output_ref),
kind=kind,
comment=extract_jsdoc_comment(method_comment),
))
svc = ServiceDef(type_name=type_name, methods=methods, comment=comment)
services_by_file.setdefault(file_path, []).append(svc)
return services_by_file
def find_file_ranges(content: str) -> dict[str, tuple[int, int]]:
"""Find the byte ranges for each proto file in the bundle.
A file may appear multiple times (e.g., messages in _pb.js and services in _connect.js).
We collect all ranges and merge them so all occurrences are captured.
"""
file_pattern = re.compile(
r'// @generated from file ([^\s]+) \(package ([^,]+), syntax (\w+)\)'
)
matches = list(file_pattern.finditer(content))
file_segments: dict[str, list[tuple[int, int]]] = {}
for i, m in enumerate(matches):
file_path = m.group(1)
start = m.start()
end = matches[i + 1].start() if i + 1 < len(matches) else len(content)
if file_path not in file_segments:
file_segments[file_path] = []
file_segments[file_path].append((start, end))
ranges = {}
for file_path, segments in file_segments.items():
ranges[file_path] = segments
return ranges
def field_to_proto(f: FieldDef, indent: str = " ") -> str:
"""Convert a field definition to proto3 syntax."""
lines = []
if f.comment:
lines.append(f"{indent}// {f.comment}")
if f.kind == "map":
key_type = SCALAR_TYPES.get(f.map_key, "string") if f.map_key else "string"
lines.append(f"{indent}map<{key_type}, {f.type_ref}> {f.name} = {f.no};")
else:
prefix = ""
if f.opt:
prefix = "optional "
elif f.repeated:
prefix = "repeated "
lines.append(f"{indent}{prefix}{f.type_ref} {f.name} = {f.no};")
return '\n'.join(lines)
def get_simple_name(type_name: str) -> str:
"""Get a unique message name from a fully qualified type name.
Handles nested types like 'agent.v1.Outer.Inner' by converting to 'Outer_Inner'.
"""
parts = type_name.split('.')
# Skip the package prefix (e.g., 'agent.v1')
if len(parts) > 2:
return '_'.join(parts[2:])
return parts[-1]
def message_to_proto(msg: MessageDef, indent: str = "") -> str:
"""Convert a message definition to proto3 syntax."""
lines = []
name = get_simple_name(msg.type_name)
if msg.comment:
lines.append(f"{indent}// {msg.comment}")
lines.append(f"{indent}message {name} {{")
oneof_groups: dict[str, list[FieldDef]] = {}
regular_fields: list[FieldDef] = []
for f in msg.fields:
if f.oneof:
oneof_groups.setdefault(f.oneof, []).append(f)
else:
regular_fields.append(f)
for f in regular_fields:
lines.append(field_to_proto(f, indent + " "))
for oneof_name, fields in oneof_groups.items():
lines.append(f"{indent} oneof {oneof_name} {{")
for f in fields:
if f.comment:
lines.append(f"{indent} // {f.comment}")
lines.append(f"{indent} {f.type_ref} {f.name} = {f.no};")
lines.append(f"{indent} }}")
lines.append(f"{indent}}}")
return '\n'.join(lines)
def enum_to_proto(enum: EnumDef, indent: str = "") -> str:
"""Convert an enum definition to proto3 syntax."""
lines = []
name = get_simple_name(enum.type_name)
if enum.comment:
lines.append(f"{indent}// {enum.comment}")
lines.append(f"{indent}enum {name} {{")
for v in enum.values:
if v.comment:
lines.append(f"{indent} // {v.comment}")
lines.append(f"{indent} {v.name} = {v.no};")
lines.append(f"{indent}}}")
return '\n'.join(lines)
def service_to_proto(svc: ServiceDef, indent: str = "") -> str:
"""Convert a service definition to proto3 syntax."""
lines = []
name = get_simple_name(svc.type_name)
if svc.comment:
lines.append(f"{indent}// {svc.comment}")
lines.append(f"{indent}service {name} {{")
for m in svc.methods:
if m.comment:
lines.append(f"{indent} // {m.comment}")
stream_in = "stream " if m.kind in ("ClientStreaming", "BiDiStreaming") else ""
stream_out = "stream " if m.kind in ("ServerStreaming", "BiDiStreaming") else ""
lines.append(f"{indent} rpc {m.name}({stream_in}{m.input_type}) returns ({stream_out}{m.output_type});")
lines.append(f"{indent}}}")
return '\n'.join(lines)
def generate_proto_file(proto: ProtoFile) -> str:
"""Generate complete proto file content."""
lines = [
f'syntax = "{proto.syntax}";',
'',
f'package {proto.package};',
'',
]
for enum in proto.enums:
lines.append(enum_to_proto(enum))
lines.append('')
for msg in proto.messages:
lines.append(message_to_proto(msg))
lines.append('')
for svc in proto.services:
lines.append(service_to_proto(svc))
lines.append('')
return '\n'.join(lines)
def main():
if len(sys.argv) < 3:
print("Usage: python proto-extractor.py <bundled-js-file> <output-file>")
print("")
print("Options:")
print(" --filter=<pkg> Only extract types from package (e.g., agent.v1)")
sys.exit(1)
input_file = sys.argv[1]
output_file = Path(sys.argv[2])
filter_pkg = None
for arg in sys.argv[3:]:
if arg.startswith('--filter='):
filter_pkg = arg.split('=')[1]
print(f"Reading {input_file}...", file=sys.stderr)
with open(input_file, 'r', encoding='utf-8', errors='replace') as f:
content = f.read()
print(f"File size: {len(content) / 1024 / 1024:.2f} MB", file=sys.stderr)
print("Finding file boundaries...", file=sys.stderr)
file_ranges = find_file_ranges(content)
print(f"Found {len(file_ranges)} proto files", file=sys.stderr)
print("Extracting messages...", file=sys.stderr)
messages_by_file = extract_messages(content, file_ranges)
print("Extracting enums...", file=sys.stderr)
enums_by_file = extract_enums(content, file_ranges)
print("Extracting services...", file=sys.stderr)
services_by_file = extract_services(content, file_ranges)
file_pattern = re.compile(
r'// @generated from file ([^\s]+) \(package ([^,]+), syntax (\w+)\)'
)
# Collect all messages, enums, services into one consolidated proto
all_messages: dict[str, MessageDef] = {}
all_enums: dict[str, EnumDef] = {}
all_services: dict[str, ServiceDef] = {}
seen_files: set[str] = set()
package = None
for m in file_pattern.finditer(content):
file_path, pkg, syntax = m.groups()
if filter_pkg and not pkg.startswith(filter_pkg):
continue
if file_path in seen_files:
continue
seen_files.add(file_path)
if package is None:
package = pkg
for msg in messages_by_file.get(file_path, []):
if msg.type_name not in all_messages:
all_messages[msg.type_name] = msg
for enum in enums_by_file.get(file_path, []):
if enum.type_name not in all_enums:
all_enums[enum.type_name] = enum
for svc in services_by_file.get(file_path, []):
if svc.type_name not in all_services:
all_services[svc.type_name] = svc
if package is None:
print("No matching proto files found", file=sys.stderr)
sys.exit(1)
# Create consolidated proto file
consolidated = ProtoFile(
path=str(output_file),
package=package,
syntax="proto3",
messages=list(all_messages.values()),
enums=list(all_enums.values()),
services=list(all_services.values()),
)
output_file.parent.mkdir(parents=True, exist_ok=True)
proto_content = generate_proto_file(consolidated)
output_file.write_text(proto_content)
print(f"\nTotal: {len(all_messages)} messages, {len(all_enums)} enums, {len(all_services)} services", file=sys.stderr)
print(f"Output written to: {output_file}", file=sys.stderr)
if __name__ == "__main__":
main()
+16
View File
@@ -3,6 +3,7 @@ import { homedir } from "node:os";
import { join } from "node:path";
import { supportsXhigh } from "./models";
import { type AnthropicOptions, streamAnthropic } from "./providers/anthropic";
import { type CursorOptions, streamCursor } from "./providers/cursor";
import { type GoogleOptions, streamGoogle } from "./providers/google";
import {
type GoogleGeminiCliOptions,
@@ -82,6 +83,7 @@ export function getEnvApiKey(provider: any): string | undefined {
zai: "ZAI_API_KEY",
mistral: "MISTRAL_API_KEY",
opencode: "OPENCODE_API_KEY",
cursor: "CURSOR_ACCESS_TOKEN",
};
const envVar = envMap[provider];
@@ -128,6 +130,9 @@ export function stream<TApi extends Api>(
providerOptions as GoogleGeminiCliOptions,
);
case "cursor-agent":
return streamCursor(model as Model<"cursor-agent">, context, providerOptions as CursorOptions);
default: {
// This should never be reached if all Api cases are handled
const _exhaustive: never = api;
@@ -185,6 +190,7 @@ function mapOptionsForApi<TApi extends Api>(
signal: options?.signal,
apiKey: apiKey || options?.apiKey,
sessionId: options?.sessionId,
execHandlers: options?.execHandlers,
};
// Helper to clamp xhigh to high for providers that don't support it
@@ -353,6 +359,16 @@ function mapOptionsForApi<TApi extends Api>(
} satisfies GoogleVertexOptions;
}
case "cursor-agent": {
const execHandlers = options?.cursorExecHandlers ?? options?.execHandlers;
const onToolResult = options?.cursorOnToolResult ?? execHandlers?.onToolResult;
return {
...base,
execHandlers,
onToolResult,
} satisfies CursorOptions;
}
default: {
// Exhaustiveness check
const _exhaustive: never = model.api;
+55 -1
View File
@@ -1,4 +1,22 @@
import type { AnthropicOptions } from "./providers/anthropic";
import type { CursorOptions } from "./providers/cursor";
import type {
DeleteArgs,
DeleteResult,
DiagnosticsArgs,
DiagnosticsResult,
GrepArgs,
GrepResult,
LsArgs,
LsResult,
McpResult,
ReadArgs,
ReadResult,
ShellArgs,
ShellResult,
WriteArgs,
WriteResult,
} from "./providers/cursor/gen/agent_pb";
import type { GoogleOptions } from "./providers/google";
import type { GoogleGeminiCliOptions } from "./providers/google-gemini-cli";
import type { GoogleVertexOptions } from "./providers/google-vertex";
@@ -16,7 +34,8 @@ export type Api =
| "anthropic-messages"
| "google-generative-ai"
| "google-gemini-cli"
| "google-vertex";
| "google-vertex"
| "cursor-agent";
export interface ApiOptionsMap {
"anthropic-messages": AnthropicOptions;
@@ -26,6 +45,7 @@ export interface ApiOptionsMap {
"google-generative-ai": GoogleOptions;
"google-gemini-cli": GoogleGeminiCliOptions;
"google-vertex": GoogleVertexOptions;
"cursor-agent": CursorOptions;
}
// Compile-time exhaustiveness check - this will fail if ApiOptionsMap doesn't have all KnownApi keys
@@ -49,6 +69,7 @@ export type KnownProvider =
| "openai"
| "openai-codex"
| "github-copilot"
| "cursor"
| "xai"
| "groq"
| "cerebras"
@@ -80,6 +101,8 @@ export interface StreamOptions {
* session-aware features. Ignored by providers that don't support it.
*/
sessionId?: string;
/** Cursor exec/MCP tool handlers (cursor-agent only). */
execHandlers?: CursorExecHandlers;
}
// Unified options with reasoning passed to streamSimple() and completeSimple()
@@ -87,6 +110,10 @@ export interface SimpleStreamOptions extends StreamOptions {
reasoning?: ThinkingLevel;
/** Custom token budgets for thinking levels (token-based providers only) */
thinkingBudgets?: ThinkingBudgets;
/** Cursor exec handlers for local tool execution */
cursorExecHandlers?: CursorExecHandlers;
/** Hook to handle tool results from Cursor exec */
cursorOnToolResult?: CursorToolResultHandler;
}
// Generic StreamFunction with typed options
@@ -169,6 +196,33 @@ export interface ToolResultMessage<TDetails = any> {
export type Message = UserMessage | AssistantMessage | ToolResultMessage;
export type CursorExecHandlerResult<T> = { result: T; toolResult?: ToolResultMessage } | T | ToolResultMessage;
export type CursorToolResultHandler = (
result: ToolResultMessage,
) => ToolResultMessage | undefined | Promise<ToolResultMessage | undefined>;
export interface CursorMcpCall {
name: string;
providerIdentifier: string;
toolName: string;
toolCallId: string;
args: Record<string, unknown>;
rawArgs: Record<string, Uint8Array>;
}
export interface CursorExecHandlers {
read?: (args: ReadArgs) => Promise<CursorExecHandlerResult<ReadResult>>;
ls?: (args: LsArgs) => Promise<CursorExecHandlerResult<LsResult>>;
grep?: (args: GrepArgs) => Promise<CursorExecHandlerResult<GrepResult>>;
write?: (args: WriteArgs) => Promise<CursorExecHandlerResult<WriteResult>>;
delete?: (args: DeleteArgs) => Promise<CursorExecHandlerResult<DeleteResult>>;
shell?: (args: ShellArgs) => Promise<CursorExecHandlerResult<ShellResult>>;
diagnostics?: (args: DiagnosticsArgs) => Promise<CursorExecHandlerResult<DiagnosticsResult>>;
mcp?: (call: CursorMcpCall) => Promise<CursorExecHandlerResult<McpResult>>;
onToolResult?: CursorToolResultHandler;
}
import type { TSchema } from "@sinclair/typebox";
export interface Tool<TParameters extends TSchema = TSchema> {
+157
View File
@@ -0,0 +1,157 @@
import { generatePKCE } from "./pkce";
import type { OAuthCredentials } from "./types";
const CURSOR_LOGIN_URL = "https://cursor.com/loginDeepControl";
const CURSOR_POLL_URL = "https://api2.cursor.sh/auth/poll";
const CURSOR_REFRESH_URL = "https://api2.cursor.sh/auth/exchange_user_api_key";
const POLL_MAX_ATTEMPTS = 150;
const POLL_BASE_DELAY = 1000;
const POLL_MAX_DELAY = 10000;
const POLL_BACKOFF_MULTIPLIER = 1.2;
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export interface CursorAuthParams {
verifier: string;
challenge: string;
uuid: string;
loginUrl: string;
}
export async function generateCursorAuthParams(): Promise<CursorAuthParams> {
const { verifier, challenge } = await generatePKCE();
const uuid = crypto.randomUUID();
const params = new URLSearchParams({
challenge,
uuid,
mode: "login",
redirectTarget: "cli",
});
const loginUrl = `${CURSOR_LOGIN_URL}?${params.toString()}`;
return { verifier, challenge, uuid, loginUrl };
}
export async function pollCursorAuth(
uuid: string,
verifier: string,
): Promise<{ accessToken: string; refreshToken: string }> {
let delay = POLL_BASE_DELAY;
let consecutiveErrors = 0;
for (let attempt = 0; attempt < POLL_MAX_ATTEMPTS; attempt++) {
await sleep(delay);
try {
const response = await fetch(`${CURSOR_POLL_URL}?uuid=${uuid}&verifier=${verifier}`);
if (response.status === 404) {
consecutiveErrors = 0;
delay = Math.min(delay * POLL_BACKOFF_MULTIPLIER, POLL_MAX_DELAY);
continue;
}
if (response.ok) {
const data = (await response.json()) as {
accessToken: string;
refreshToken: string;
};
return {
accessToken: data.accessToken,
refreshToken: data.refreshToken,
};
}
throw new Error(`Poll failed: ${response.status}`);
} catch (_error) {
consecutiveErrors++;
if (consecutiveErrors >= 3) {
throw new Error("Too many consecutive errors during Cursor auth polling");
}
}
}
throw new Error("Cursor authentication polling timeout");
}
export async function loginCursor(
onAuthUrl: (url: string) => void,
onPollStart?: () => void,
): Promise<OAuthCredentials> {
const { verifier, uuid, loginUrl } = await generateCursorAuthParams();
onAuthUrl(loginUrl);
onPollStart?.();
const { accessToken, refreshToken } = await pollCursorAuth(uuid, verifier);
const expiresAt = getTokenExpiry(accessToken);
return {
access: accessToken,
refresh: refreshToken,
expires: expiresAt,
};
}
export async function refreshCursorToken(apiKeyOrRefreshToken: string): Promise<OAuthCredentials> {
const response = await fetch(CURSOR_REFRESH_URL, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKeyOrRefreshToken}`,
"Content-Type": "application/json",
},
body: "{}",
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Cursor token refresh failed: ${error}`);
}
const data = (await response.json()) as {
accessToken: string;
refreshToken: string;
};
const expiresAt = getTokenExpiry(data.accessToken);
return {
access: data.accessToken,
refresh: data.refreshToken || apiKeyOrRefreshToken,
expires: expiresAt,
};
}
function getTokenExpiry(token: string): number {
try {
const [, payload] = token.split(".");
if (!payload) {
return Date.now() + 3600 * 1000;
}
const decoded = JSON.parse(atob(payload.replace(/-/g, "+").replace(/_/g, "/")));
if (decoded.exp) {
return decoded.exp * 1000 - 5 * 60 * 1000;
}
} catch {
// Ignore parsing errors
}
return Date.now() + 3600 * 1000;
}
export function isTokenExpiringSoon(token: string, thresholdSeconds = 300): boolean {
try {
const [, payload] = token.split(".");
if (!payload) return true;
const decoded = JSON.parse(atob(payload.replace(/-/g, "+").replace(/_/g, "/")));
const currentTime = Math.floor(Date.now() / 1000);
return decoded.exp - currentTime < thresholdSeconds;
} catch {
return true;
}
}
+17
View File
@@ -11,6 +11,14 @@
// Anthropic
export { loginAnthropic, refreshAnthropicToken } from "./anthropic";
// Cursor
export {
generateCursorAuthParams,
isTokenExpiringSoon as isCursorTokenExpiringSoon,
loginCursor,
pollCursorAuth,
refreshCursorToken,
} from "./cursor";
// GitHub Copilot
export {
getGitHubCopilotBaseUrl,
@@ -41,6 +49,7 @@ export * from "./types";
// ============================================================================
import { refreshAnthropicToken } from "./anthropic";
import { refreshCursorToken } from "./cursor";
import { refreshGitHubCopilotToken } from "./github-copilot";
import { refreshAntigravityToken } from "./google-antigravity";
import { refreshGoogleCloudToken } from "./google-gemini-cli";
@@ -83,6 +92,9 @@ export async function refreshOAuthToken(
case "openai-codex":
newCredentials = await refreshOpenAICodexToken(credentials.refresh);
break;
case "cursor":
newCredentials = await refreshCursorToken(credentials.refresh);
break;
default:
throw new Error(`Unknown OAuth provider: ${provider}`);
}
@@ -153,5 +165,10 @@ export function getOAuthProviders(): OAuthProviderInfo[] {
name: "Antigravity (Gemini 3, Claude, GPT-OSS)",
available: true,
},
{
id: "cursor",
name: "Cursor (Claude, GPT, etc.)",
available: true,
},
];
}
+2 -1
View File
@@ -13,7 +13,8 @@ export type OAuthProvider =
| "github-copilot"
| "google-gemini-cli"
| "google-antigravity"
| "openai-codex";
| "openai-codex"
| "cursor";
export type OAuthPrompt = {
message: string;
+7
View File
@@ -1,6 +1,13 @@
# Changelog
## [Unreleased]
### Added
- Added Cursor provider support with browser-based OAuth authentication
- Added default model configuration for Cursor provider (claude-sonnet-4-5)
- Added execution bridge for Cursor tool calls including read, ls, grep, write, delete, shell, diagnostics, and MCP operations
### Fixed
- Improved fuzzy matching accuracy for edit operations when file and target have inconsistent indentation patterns
@@ -9,6 +9,7 @@ import {
getOAuthApiKey,
loginAnthropic,
loginAntigravity,
loginCursor,
loginGeminiCli,
loginGitHubCopilot,
loginOpenAICodex,
@@ -585,6 +586,12 @@ export class AuthStorage {
onManualCodeInput: callbacks.onManualCodeInput,
});
break;
case "cursor":
credentials = await loginCursor(
(url) => callbacks.onAuth({ url }),
callbacks.onProgress ? () => callbacks.onProgress?.("Waiting for browser authentication...") : undefined,
);
break;
default:
throw new Error(`Unknown OAuth provider: ${provider}`);
}
@@ -0,0 +1,234 @@
import { randomUUID } from "node:crypto";
import { rmSync, statSync } from "node:fs";
import type {
AgentEvent,
AgentTool,
AgentToolContext,
AgentToolResult,
AgentToolUpdateCallback,
} from "@oh-my-pi/pi-agent-core";
import type { CursorExecHandlers, CursorMcpCall, ToolResultMessage } from "@oh-my-pi/pi-ai";
import { resolveToCwd } from "../tools/path-utils";
interface CursorExecBridgeOptions {
cwd: string;
tools: Map<string, AgentTool>;
getToolContext?: () => AgentToolContext | undefined;
emitEvent?: (event: AgentEvent) => void;
}
function createToolResultMessage(
toolCallId: string,
toolName: string,
result: AgentToolResult<unknown>,
isError: boolean,
): ToolResultMessage {
return {
role: "toolResult",
toolCallId,
toolName,
content: result.content,
details: result.details,
isError,
timestamp: Date.now(),
};
}
function buildToolErrorResult(message: string): AgentToolResult<unknown> {
return {
content: [{ type: "text", text: message }],
details: {},
};
}
async function executeTool(
options: CursorExecBridgeOptions,
toolName: string,
toolCallId: string,
args: Record<string, unknown>,
): Promise<ToolResultMessage> {
const tool = options.tools.get(toolName);
if (!tool) {
const result = buildToolErrorResult(`Tool "${toolName}" not available`);
return createToolResultMessage(toolCallId, toolName, result, true);
}
options.emitEvent?.({ type: "tool_execution_start", toolCallId, toolName, args });
let result: AgentToolResult<unknown>;
let isError = false;
const onUpdate: AgentToolUpdateCallback<unknown> | undefined = options.emitEvent
? (partialResult) => {
options.emitEvent?.({
type: "tool_execution_update",
toolCallId,
toolName,
args,
partialResult,
});
}
: undefined;
try {
result = await tool.execute(
toolCallId,
args as Record<string, unknown>,
undefined,
onUpdate,
options.getToolContext?.(),
);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
result = buildToolErrorResult(message);
isError = true;
}
options.emitEvent?.({ type: "tool_execution_end", toolCallId, toolName, result, isError });
return createToolResultMessage(toolCallId, toolName, result, isError);
}
async function executeDelete(options: CursorExecBridgeOptions, pathArg: string, toolCallId: string) {
const toolName = "delete";
options.emitEvent?.({ type: "tool_execution_start", toolCallId, toolName, args: { path: pathArg } });
const absolutePath = resolveToCwd(pathArg, options.cwd);
let isError = false;
let result: AgentToolResult<unknown>;
try {
const stat = statSync(absolutePath, { throwIfNoEntry: false });
if (!stat) {
throw new Error(`File not found: ${pathArg}`);
}
if (!stat.isFile()) {
throw new Error(`Path is not a file: ${pathArg}`);
}
rmSync(absolutePath);
const sizeText = stat.size ? ` (${stat.size} bytes)` : "";
const message = `Deleted ${pathArg}${sizeText}`;
result = { content: [{ type: "text", text: message }], details: {} };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
result = buildToolErrorResult(message);
isError = true;
}
options.emitEvent?.({ type: "tool_execution_end", toolCallId, toolName, result, isError });
return createToolResultMessage(toolCallId, toolName, result, isError);
}
function decodeToolCallId(toolCallId?: string): string {
return toolCallId && toolCallId.length > 0 ? toolCallId : randomUUID();
}
function decodeMcpArgs(rawArgs: Record<string, Uint8Array>): Record<string, unknown> {
const decoded: Record<string, unknown> = {};
for (const [key, value] of Object.entries(rawArgs)) {
const text = new TextDecoder().decode(value);
try {
decoded[key] = JSON.parse(text);
} catch {
decoded[key] = text;
}
}
return decoded;
}
function formatMcpToolErrorMessage(toolName: string, availableTools: string[]): string {
const list = availableTools.length > 0 ? availableTools.join(", ") : "none";
return `MCP tool "${toolName}" not found. Available tools: ${list}`;
}
export function createCursorExecHandlers(options: CursorExecBridgeOptions): CursorExecHandlers {
return {
read: async (args) => {
const toolCallId = decodeToolCallId(args.toolCallId);
const toolResultMessage = await executeTool(options, "read", toolCallId, { path: args.path });
return toolResultMessage;
},
ls: async (args) => {
const toolCallId = decodeToolCallId(args.toolCallId);
const toolResultMessage = await executeTool(options, "ls", toolCallId, { path: args.path });
return toolResultMessage;
},
grep: async (args) => {
const toolCallId = decodeToolCallId(args.toolCallId);
const toolResultMessage = await executeTool(options, "grep", toolCallId, {
pattern: args.pattern,
path: args.path || undefined,
glob: args.glob || undefined,
outputMode: args.outputMode || undefined,
context: args.context ?? args.contextBefore ?? args.contextAfter ?? undefined,
ignoreCase: args.caseInsensitive || undefined,
type: args.type || undefined,
headLimit: args.headLimit ?? undefined,
multiline: args.multiline || undefined,
});
return toolResultMessage;
},
write: async (args) => {
const toolCallId = decodeToolCallId(args.toolCallId);
const content = args.fileText ?? new TextDecoder().decode(args.fileBytes ?? new Uint8Array());
const toolResultMessage = await executeTool(options, "write", toolCallId, {
path: args.path,
content,
});
return toolResultMessage;
},
delete: async (args) => {
const toolCallId = decodeToolCallId(args.toolCallId);
const toolResultMessage = await executeDelete(options, args.path, toolCallId);
return toolResultMessage;
},
shell: async (args) => {
const toolCallId = decodeToolCallId(args.toolCallId);
const timeoutSeconds =
args.timeout && args.timeout > 0
? args.timeout > 1000
? Math.ceil(args.timeout / 1000)
: args.timeout
: undefined;
const toolResultMessage = await executeTool(options, "bash", toolCallId, {
command: args.command,
workdir: args.workingDirectory || undefined,
timeout: timeoutSeconds,
});
return toolResultMessage;
},
diagnostics: async (args) => {
const toolCallId = decodeToolCallId(args.toolCallId);
const toolResultMessage = await executeTool(options, "lsp", toolCallId, {
action: "diagnostics",
file: args.path,
});
return toolResultMessage;
},
mcp: async (call: CursorMcpCall) => {
const toolName = call.toolName || call.name;
const toolCallId = decodeToolCallId(call.toolCallId);
const tool = options.tools.get(toolName);
if (!tool) {
const availableTools = Array.from(options.tools.keys()).filter((name) => name.startsWith("mcp_"));
const message = formatMcpToolErrorMessage(toolName, availableTools);
const toolResult: ToolResultMessage = {
role: "toolResult",
toolCallId,
toolName,
content: [{ type: "text", text: message }],
details: {},
isError: true,
timestamp: Date.now(),
};
return toolResult;
}
const args = Object.keys(call.args ?? {}).length > 0 ? call.args : decodeMcpArgs(call.rawArgs ?? {});
const toolResultMessage = await executeTool(options, toolName, toolCallId, args);
return toolResultMessage;
},
};
}
@@ -19,6 +19,7 @@ export const defaultModelPerProvider: Record<KnownProvider, string> = {
"google-antigravity": "gemini-3-pro-high",
"google-vertex": "gemini-2.5-pro",
"github-copilot": "gpt-4o",
cursor: "claude-sonnet-4-5",
openrouter: "openai/gpt-5.1-codex",
xai: "grok-4-fast-non-reasoning",
groq: "openai/gpt-oss-120b",
+12 -1
View File
@@ -27,7 +27,7 @@
*/
import { join } from "node:path";
import { Agent, type AgentMessage, type AgentTool, type ThinkingLevel } from "@oh-my-pi/pi-agent-core";
import { Agent, type AgentEvent, type AgentMessage, type AgentTool, type ThinkingLevel } from "@oh-my-pi/pi-agent-core";
import type { Message, Model } from "@oh-my-pi/pi-ai";
import type { Component } from "@oh-my-pi/pi-tui";
import chalk from "chalk";
@@ -40,6 +40,7 @@ import { initializeWithSettings } from "../discovery";
import { registerAsyncCleanup } from "../modes/cleanup";
import { AgentSession } from "./agent-session";
import { AuthStorage } from "./auth-storage";
import { createCursorExecHandlers } from "./cursor/exec-bridge";
import {
type CustomCommandsLoadResult,
loadCustomCommands as loadCustomCommandsInternal,
@@ -854,6 +855,14 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
}
time("combineTools");
let cursorEventEmitter: ((event: AgentEvent) => void) | undefined;
const cursorExecHandlers = createCursorExecHandlers({
cwd,
tools: toolRegistry,
getToolContext: toolContextStore.getContext,
emitEvent: (event) => cursorEventEmitter?.(event),
});
const rebuildSystemPrompt = async (toolNames: string[], tools: Map<string, AgentTool>): Promise<string> => {
toolContextStore.setToolNames(toolNames);
const defaultPrompt = await buildSystemPromptInternal({
@@ -964,7 +973,9 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
}
return key;
},
cursorExecHandlers,
});
cursorEventEmitter = (event) => agent.emitExternalEvent(event);
time("createAgent");
// Restore messages if session has existing data