@x-otto/plugin-cursor 0.1.0-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,94 @@
1
+ import { createHash, randomUUID } from 'node:crypto'
2
+ import { readFileSync } from 'node:fs'
3
+ import { release, platform, arch } from 'node:os'
4
+
5
+ export const CURSOR_AGENT_BACKEND = 'https://agentn.api5.cursor.sh'
6
+
7
+ function loadCursorVersion(): string {
8
+ const env = process.env['OTTO_CURSOR_CLIENT_VERSION'] ?? process.env['CURSOR_CLIENT_VERSION']
9
+ if (env) return env
10
+ if (platform() === 'darwin') {
11
+ try {
12
+ const productPath = '/Applications/Cursor.app/Contents/Resources/app/product.json'
13
+ const data = JSON.parse(readFileSync(productPath, 'utf8')) as { version?: string }
14
+ if (data.version) return data.version
15
+ } catch {
16
+ // fall through
17
+ }
18
+ }
19
+ return '3.10.17'
20
+ }
21
+
22
+ function normalizeOs(): string {
23
+ const map: Record<string, string> = { darwin: 'darwin', linux: 'linux', win32: 'win32' }
24
+ return map[platform()] ?? platform()
25
+ }
26
+
27
+ function normalizeArch(): string {
28
+ const value = arch().toLowerCase()
29
+ if (value === 'x64' || value === 'amd64' || value === 'x86_64') return 'x64'
30
+ if (value === 'arm64' || value === 'aarch64') return 'arm64'
31
+ return value
32
+ }
33
+
34
+ function hashed64Hex(value: string, salt = ''): string {
35
+ return createHash('sha256').update(value + salt).digest('hex')
36
+ }
37
+
38
+ function cursorChecksum(token: string, machineId: string | null): string {
39
+ const mid = machineId ?? hashed64Hex(token, 'machineId')
40
+ const timestamp = Math.floor(Date.now() / 1_000_000)
41
+ const byteArray = new Uint8Array([
42
+ (timestamp >> 40) & 255,
43
+ (timestamp >> 32) & 255,
44
+ (timestamp >> 24) & 255,
45
+ (timestamp >> 16) & 255,
46
+ (timestamp >> 8) & 255,
47
+ timestamp & 255,
48
+ ])
49
+ let t = 165
50
+ for (let i = 0; i < byteArray.length; i++) {
51
+ byteArray[i] = ((byteArray[i]! ^ t) + (i % 256)) & 255
52
+ t = byteArray[i]!
53
+ }
54
+ const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'
55
+ let encoded = ''
56
+ for (let i = 0; i < byteArray.length; i += 3) {
57
+ const a = byteArray[i]!
58
+ const b = byteArray[i + 1] ?? 0
59
+ const c = byteArray[i + 2] ?? 0
60
+ encoded += alphabet[a >> 2]
61
+ encoded += alphabet[((a & 3) << 4) | (b >> 4)]
62
+ if (i + 1 < byteArray.length) encoded += alphabet[((b & 15) << 2) | (c >> 6)]
63
+ if (i + 2 < byteArray.length) encoded += alphabet[c & 63]
64
+ }
65
+ return `${encoded}${mid}`
66
+ }
67
+
68
+ export function buildAgentHeaders(token: string, machineId: string | null): Record<string, string> {
69
+ const requestId = randomUUID()
70
+ const host = new URL(CURSOR_AGENT_BACKEND).host
71
+ return {
72
+ authorization: `Bearer ${token}`,
73
+ 'connect-protocol-version': '1',
74
+ 'user-agent': 'connect-es/1.6.1',
75
+ 'x-amzn-trace-id': `Root=${requestId}`,
76
+ 'x-client-key': hashed64Hex(token),
77
+ 'x-cursor-checksum': cursorChecksum(token, machineId),
78
+ 'x-cursor-client-version': loadCursorVersion(),
79
+ 'x-cursor-client-type': 'ide',
80
+ 'x-cursor-client-os': normalizeOs(),
81
+ 'x-cursor-client-arch': normalizeArch(),
82
+ 'x-cursor-client-os-version': release() || 'unknown',
83
+ 'x-cursor-client-device-type': 'desktop',
84
+ 'x-cursor-config-version': randomUUID(),
85
+ 'x-cursor-timezone': process.env['TZ'] || 'Asia/Shanghai',
86
+ 'x-ghost-mode': 'false',
87
+ 'x-new-onboarding-completed': 'false',
88
+ 'x-request-id': requestId,
89
+ 'x-session-id': randomUUID(),
90
+ host,
91
+ 'content-type': 'application/connect+proto',
92
+ 'connect-accept-encoding': 'gzip',
93
+ }
94
+ }
@@ -0,0 +1,46 @@
1
+ export function encodeVarint(value: number): Uint8Array {
2
+ const out: number[] = []
3
+ let v = value
4
+ while (v >= 0x80) {
5
+ out.push((v & 0x7f) | 0x80)
6
+ v >>>= 7
7
+ }
8
+ out.push(v & 0x7f)
9
+ return Uint8Array.from(out)
10
+ }
11
+
12
+ export function encodeField(fieldNum: number, wireType: number, value: string | Uint8Array | number): Uint8Array {
13
+ const tag = (fieldNum << 3) | wireType
14
+ const head = encodeVarint(tag)
15
+ if (wireType === 0) {
16
+ if (typeof value !== 'number') {
17
+ throw new TypeError('encodeField: wireType 0 (varint) requires a number value')
18
+ }
19
+ const body = encodeVarint(value)
20
+ const out = new Uint8Array(head.length + body.length)
21
+ out.set(head, 0)
22
+ out.set(body, head.length)
23
+ return out
24
+ }
25
+ if (typeof value === 'number') {
26
+ throw new TypeError('encodeField: length-delimited wire types require a string or Uint8Array value')
27
+ }
28
+ const bytes = typeof value === 'string' ? new TextEncoder().encode(value) : value
29
+ const len = encodeVarint(bytes.length)
30
+ const out = new Uint8Array(head.length + len.length + bytes.length)
31
+ out.set(head, 0)
32
+ out.set(len, head.length)
33
+ out.set(bytes, head.length + len.length)
34
+ return out
35
+ }
36
+
37
+ export function concatBytes(parts: Uint8Array[]): Uint8Array {
38
+ const total = parts.reduce((sum, p) => sum + p.length, 0)
39
+ const out = new Uint8Array(total)
40
+ let offset = 0
41
+ for (const part of parts) {
42
+ out.set(part, offset)
43
+ offset += part.length
44
+ }
45
+ return out
46
+ }
@@ -0,0 +1,144 @@
1
+ /**
2
+ * cursor-provider.ts —— Cursor Connect-RPC provider 实现(RFC-130 M131-2)。
3
+ *
4
+ * `CursorProviderStream implements ProviderStream`(`@x-otto/provider` 官方契约,RFC-140 D6:
5
+ * 插件代码式 provider 工厂现在编译期强类型返回真实 `ProviderStream`,不再是宽松
6
+ * `Record<string, unknown>` + 运行时收窄)。Cursor 后端是 Connect-RPC + Protobuf over
7
+ * HTTP/2(非 `wireApiSchema` 枚举的三种标准协议之一),协议编解码闭环在本插件目录内
8
+ * (`connect-rpc/`),不进入 `@x-otto/ai`/`@x-otto/provider`(RFC-130 D3/非目标)。
9
+ *
10
+ * 凭据解析(终局 review 修复):不再自行 `readFileSync` 解析 `~/.config/otto/auth.json`
11
+ * ——那绕过了宿主统一凭据层 `AuthStore`(无 watch/无跨进程同步/无标准 env 兜底优先级)。
12
+ * 改为宿主注入的 `resolveAuth` 闭包(`plugin.ts` 经 `ctx.resolveProviderAuth('cursor')`
13
+ * 提供,对齐 `plugin-anthropic`/`plugin-github-copilot` 的既有模式——凭据解析统一走
14
+ * `AuthStore`,OAuth 登录态与 `CURSOR_ACCESS_TOKEN` env 兜底的优先级判定完全交给宿主,
15
+ * provider 本身零凭据存储知识)。`machineId`(设备指纹,非敏感凭据,仅用于构造请求头)
16
+ * 仍直接本地读取——它不经过 `AuthStore` 的凭据轮转/持久化语义。
17
+ *
18
+ * M131-2 明确拒绝 tools(`StreamContext.tools` 非空即抛 `CURSOR_TOOLS_NOT_SUPPORTED`,
19
+ * M131-3-01 WONT_FIX——agent API 仅服务端自跑内置工具,无法客户端委托)。
20
+ */
21
+ import { ProviderError } from '@x-otto/shared'
22
+ import type {
23
+ Message,
24
+ Model,
25
+ ResolvedAuth,
26
+ StreamContext,
27
+ StreamEvent,
28
+ StreamOptions,
29
+ ProviderStream,
30
+ } from '@x-otto/provider'
31
+
32
+ import { extractAgentText } from './connect-rpc/agent-decode'
33
+ import { collectAgentResponse } from './connect-rpc/agent-stream'
34
+ import { CURSOR_TOOLS_NOT_SUPPORTED } from './auth/errors'
35
+ import { readLocalAuth } from './auth/local-credentials'
36
+
37
+ function messageText(message: Message): string {
38
+ if (message.role !== 'user') return ''
39
+ if (typeof message.content === 'string') return message.content
40
+ return message.content
41
+ .filter((b) => b.type === 'text')
42
+ .map((b) => b.text)
43
+ .join('\n')
44
+ }
45
+
46
+ function buildPrompt(context: StreamContext): string {
47
+ const userMessages = context.messages.filter((m) => m.role === 'user')
48
+ const last = userMessages[userMessages.length - 1]
49
+ return last ? messageText(last) : ''
50
+ }
51
+
52
+ function buildAssistantMessage(model: Model, text: string) {
53
+ return {
54
+ role: 'assistant' as const,
55
+ content: [{ type: 'text' as const, text }],
56
+ api: model.api,
57
+ provider: model.provider,
58
+ model: model.id,
59
+ usage: {
60
+ inputTokens: 0,
61
+ outputTokens: Math.max(1, Math.ceil(text.length / 4)),
62
+ cacheReadTokens: 0,
63
+ cacheWriteTokens: 0,
64
+ },
65
+ stopReason: 'end_turn' as const,
66
+ }
67
+ }
68
+
69
+ export type CursorAuthResolver = () => Promise<ResolvedAuth | undefined>
70
+
71
+ class CursorProviderStream implements ProviderStream {
72
+ readonly id = 'cursor'
73
+ readonly displayName = 'Cursor'
74
+
75
+ constructor(private readonly resolveAuthKey: CursorAuthResolver) {}
76
+
77
+ /** 统一鉴权解析(RFC-140 D5,`ProviderStream.resolveAuth`)——转发宿主 AuthStore 解析结果。 */
78
+ async resolveAuth(): Promise<ResolvedAuth> {
79
+ const resolved = await this.resolveAuthKey()
80
+ if (resolved) return resolved
81
+ throw new ProviderError(
82
+ 'No Cursor credentials found. Run `otto auth login cursor` or set CURSOR_ACCESS_TOKEN.',
83
+ { code: 'PROVIDER_AUTH_MISSING' },
84
+ )
85
+ }
86
+
87
+ async *converse(
88
+ model: Model,
89
+ context: StreamContext,
90
+ _options: StreamOptions,
91
+ signal: AbortSignal,
92
+ ): AsyncIterable<StreamEvent> {
93
+ if (context.tools?.length) {
94
+ throw new ProviderError(
95
+ 'Cursor provider does not support tool calling in M131-2 (see M131-3).',
96
+ { code: CURSOR_TOOLS_NOT_SUPPORTED },
97
+ )
98
+ }
99
+
100
+ const prompt = buildPrompt(context)
101
+ if (!prompt.trim()) {
102
+ throw new ProviderError('No user message to send to Cursor.', {
103
+ code: 'CURSOR_EMPTY_PROMPT',
104
+ })
105
+ }
106
+
107
+ const { token: accessToken } = await this.resolveAuth()
108
+ // machineId 是设备指纹(非敏感凭据),仅用于构造请求头校验和——读取失败(未安装
109
+ // Cursor IDE/非 macOS)不应阻断走 CURSOR_ACCESS_TOKEN env 兜底通道的用户,降级为
110
+ // null(headers.ts 的 cursorChecksum 对 null machineId 有自己的兜底派生逻辑)。
111
+ let machineId: string | null = null
112
+ try {
113
+ machineId = readLocalAuth().machineId
114
+ } catch {
115
+ // 静默降级:本地未安装 Cursor IDE 或非 macOS 平台时属预期路径。
116
+ }
117
+ const modelName = model.id.includes('/') ? model.id.split('/')[1]! : model.id
118
+ const raw = await collectAgentResponse({
119
+ token: accessToken,
120
+ machineId,
121
+ prompt,
122
+ model: modelName,
123
+ signal,
124
+ })
125
+
126
+ const text = extractAgentText(raw, prompt)
127
+ if (!text.trim()) {
128
+ throw new ProviderError('Cursor agent returned no assistant text.', {
129
+ code: 'CURSOR_EMPTY_RESPONSE',
130
+ })
131
+ }
132
+
133
+ const message = buildAssistantMessage(model, text)
134
+ yield { type: 'start', partial: message }
135
+ yield { type: 'text_start', index: 0, partial: message }
136
+ yield { type: 'text_delta', index: 0, delta: text, partial: message }
137
+ yield { type: 'text_end', index: 0, content: text, partial: message }
138
+ yield { type: 'done', reason: 'end_turn', message }
139
+ }
140
+ }
141
+
142
+ export function createCursorProvider(resolveAuthKey: CursorAuthResolver): ProviderStream {
143
+ return new CursorProviderStream(resolveAuthKey)
144
+ }