@rama_nigg/open-cursor 2.2.1 → 2.3.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.
- package/package.json +9 -3
- package/src/acp/metrics.ts +83 -0
- package/src/acp/sessions.ts +107 -0
- package/src/acp/tools.ts +209 -0
- package/src/auth.ts +269 -0
- package/src/cli/discover.ts +53 -0
- package/src/cli/model-discovery.ts +50 -0
- package/src/cli/opencode-cursor.ts +620 -0
- package/src/client/simple.ts +277 -0
- package/src/commands/status.ts +39 -0
- package/src/index.ts +40 -0
- package/src/models/config.ts +64 -0
- package/src/models/discovery.ts +132 -0
- package/src/models/index.ts +3 -0
- package/src/models/types.ts +11 -0
- package/src/plugin-entry.ts +28 -0
- package/src/plugin-toggle.ts +67 -0
- package/src/plugin.ts +1918 -0
- package/src/provider/boundary.ts +161 -0
- package/src/provider/runtime-interception.ts +721 -0
- package/src/provider/tool-loop-guard.ts +644 -0
- package/src/provider/tool-schema-compat.ts +516 -0
- package/src/provider.ts +268 -0
- package/src/proxy/formatter.ts +42 -0
- package/src/proxy/handler.ts +29 -0
- package/src/proxy/prompt-builder.ts +171 -0
- package/src/proxy/server.ts +207 -0
- package/src/proxy/tool-loop.ts +317 -0
- package/src/proxy/types.ts +13 -0
- package/src/streaming/ai-sdk-parts.ts +105 -0
- package/src/streaming/delta-tracker.ts +33 -0
- package/src/streaming/line-buffer.ts +44 -0
- package/src/streaming/openai-sse.ts +114 -0
- package/src/streaming/parser.ts +22 -0
- package/src/streaming/types.ts +152 -0
- package/src/tools/core/executor.ts +25 -0
- package/src/tools/core/registry.ts +27 -0
- package/src/tools/core/types.ts +31 -0
- package/src/tools/defaults.ts +673 -0
- package/src/tools/discovery.ts +140 -0
- package/src/tools/executors/cli.ts +58 -0
- package/src/tools/executors/local.ts +25 -0
- package/src/tools/executors/mcp.ts +39 -0
- package/src/tools/executors/sdk.ts +39 -0
- package/src/tools/index.ts +8 -0
- package/src/tools/registry.ts +34 -0
- package/src/tools/router.ts +123 -0
- package/src/tools/schema.ts +58 -0
- package/src/tools/skills/loader.ts +61 -0
- package/src/tools/skills/resolver.ts +21 -0
- package/src/tools/types.ts +29 -0
- package/src/types.ts +8 -0
- package/src/utils/errors.ts +131 -0
- package/src/utils/logger.ts +146 -0
- package/src/utils/perf.ts +44 -0
package/package.json
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rama_nigg/open-cursor",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.3.0",
|
|
4
4
|
"description": "No prompt limits. No broken streams. Full thinking + tool support. Your Cursor subscription, properly integrated.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
7
|
+
"module": "src/plugin-entry.ts",
|
|
7
8
|
"scripts": {
|
|
8
9
|
"build": "bun build ./src/index.ts ./src/plugin-entry.ts ./src/cli/discover.ts ./src/cli/opencode-cursor.ts --outdir ./dist --target node",
|
|
9
10
|
"dev": "bun build ./src/index.ts ./src/plugin-entry.ts ./src/cli/discover.ts ./src/cli/opencode-cursor.ts --outdir ./dist --target node --watch",
|
|
@@ -20,10 +21,15 @@
|
|
|
20
21
|
"cursor-discover": "dist/cli/discover.js"
|
|
21
22
|
},
|
|
22
23
|
"exports": {
|
|
23
|
-
".":
|
|
24
|
+
".": {
|
|
25
|
+
"bun": "./src/plugin-entry.ts",
|
|
26
|
+
"import": "./dist/index.js",
|
|
27
|
+
"default": "./dist/index.js"
|
|
28
|
+
}
|
|
24
29
|
},
|
|
25
30
|
"files": [
|
|
26
|
-
"dist"
|
|
31
|
+
"dist",
|
|
32
|
+
"src"
|
|
27
33
|
],
|
|
28
34
|
"dependencies": {
|
|
29
35
|
"ai": "^6.0.55",
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
export interface SessionMetrics {
|
|
2
|
+
sessionId: string;
|
|
3
|
+
model: string;
|
|
4
|
+
promptTokens: number;
|
|
5
|
+
toolCalls: number;
|
|
6
|
+
duration: number;
|
|
7
|
+
timestamp: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface AggregateMetrics {
|
|
11
|
+
totalPrompts: number;
|
|
12
|
+
totalToolCalls: number;
|
|
13
|
+
totalDuration: number;
|
|
14
|
+
avgDuration: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class MetricsTracker {
|
|
18
|
+
private sessions: Map<string, SessionMetrics> = new Map();
|
|
19
|
+
|
|
20
|
+
recordPrompt(sessionId: string, model: string, tokens: number): void {
|
|
21
|
+
const existing = this.sessions.get(sessionId);
|
|
22
|
+
if (existing) {
|
|
23
|
+
existing.promptTokens = tokens;
|
|
24
|
+
existing.model = model;
|
|
25
|
+
} else {
|
|
26
|
+
this.sessions.set(sessionId, {
|
|
27
|
+
sessionId,
|
|
28
|
+
model,
|
|
29
|
+
promptTokens: tokens,
|
|
30
|
+
toolCalls: 0,
|
|
31
|
+
duration: 0,
|
|
32
|
+
timestamp: Date.now()
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
recordToolCall(sessionId: string, toolName: string, duration: number): void {
|
|
38
|
+
const existing = this.sessions.get(sessionId);
|
|
39
|
+
if (existing) {
|
|
40
|
+
existing.toolCalls++;
|
|
41
|
+
existing.duration += duration;
|
|
42
|
+
}
|
|
43
|
+
// If no session exists, silently ignore (matches test expectations)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
getSessionMetrics(sessionId: string): SessionMetrics | undefined {
|
|
47
|
+
return this.sessions.get(sessionId);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
getAggregateMetrics(hours: number): AggregateMetrics {
|
|
51
|
+
const cutoff = Date.now() - (hours * 60 * 60 * 1000);
|
|
52
|
+
let totalPrompts = 0;
|
|
53
|
+
let totalToolCalls = 0;
|
|
54
|
+
let totalDuration = 0;
|
|
55
|
+
|
|
56
|
+
for (const metrics of this.sessions.values()) {
|
|
57
|
+
if (metrics.timestamp >= cutoff) {
|
|
58
|
+
totalPrompts++;
|
|
59
|
+
totalToolCalls += metrics.toolCalls;
|
|
60
|
+
totalDuration += metrics.duration;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
totalPrompts,
|
|
66
|
+
totalToolCalls,
|
|
67
|
+
totalDuration,
|
|
68
|
+
avgDuration: totalPrompts > 0 ? Math.round(totalDuration / totalPrompts) : 0
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
clearMetrics(sessionId?: string): void {
|
|
73
|
+
if (sessionId) {
|
|
74
|
+
this.sessions.delete(sessionId);
|
|
75
|
+
} else {
|
|
76
|
+
this.sessions.clear();
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
clearAll(): void {
|
|
81
|
+
this.sessions.clear();
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from "fs/promises";
|
|
2
|
+
import { dirname, join } from "path";
|
|
3
|
+
|
|
4
|
+
export interface Session {
|
|
5
|
+
id: string;
|
|
6
|
+
cwd: string;
|
|
7
|
+
modeId?: string;
|
|
8
|
+
cancelled?: boolean;
|
|
9
|
+
resumeId?: string;
|
|
10
|
+
createdAt: number;
|
|
11
|
+
updatedAt: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface SessionCreateOptions {
|
|
15
|
+
cwd?: string;
|
|
16
|
+
modeId?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export class SessionManager {
|
|
20
|
+
private sessions: Map<string, Session> = new Map();
|
|
21
|
+
private storagePath: string;
|
|
22
|
+
|
|
23
|
+
constructor(storagePath?: string) {
|
|
24
|
+
this.storagePath = storagePath || join(process.cwd(), ".opencode", "sessions.json");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async initialize(): Promise<void> {
|
|
28
|
+
// Load sessions from disk if storage file exists
|
|
29
|
+
try {
|
|
30
|
+
const data = await readFile(this.storagePath, "utf-8");
|
|
31
|
+
const sessions = JSON.parse(data) as Record<string, Session>;
|
|
32
|
+
this.sessions = new Map(Object.entries(sessions));
|
|
33
|
+
} catch {
|
|
34
|
+
// File doesn't exist or is invalid, start fresh
|
|
35
|
+
this.sessions.clear();
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
private async persist(): Promise<void> {
|
|
40
|
+
// Save sessions to disk
|
|
41
|
+
const dir = dirname(this.storagePath);
|
|
42
|
+
await mkdir(dir, { recursive: true });
|
|
43
|
+
const data = JSON.stringify(Object.fromEntries(this.sessions), null, 2);
|
|
44
|
+
await writeFile(this.storagePath, data, "utf-8");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async createSession(options: SessionCreateOptions): Promise<Session> {
|
|
48
|
+
const id = `session-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
|
49
|
+
const session: Session = {
|
|
50
|
+
id,
|
|
51
|
+
cwd: options.cwd || process.cwd(),
|
|
52
|
+
modeId: options.modeId,
|
|
53
|
+
cancelled: false,
|
|
54
|
+
createdAt: Date.now(),
|
|
55
|
+
updatedAt: Date.now()
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
this.sessions.set(id, session);
|
|
59
|
+
await this.persist();
|
|
60
|
+
return session;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async getSession(id: string): Promise<Session | null> {
|
|
64
|
+
return this.sessions.get(id) || null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async updateSession(id: string, updates: Partial<Session>): Promise<void> {
|
|
68
|
+
const session = this.sessions.get(id);
|
|
69
|
+
if (session) {
|
|
70
|
+
Object.assign(session, updates, { updatedAt: Date.now() });
|
|
71
|
+
await this.persist();
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async deleteSession(id: string): Promise<void> {
|
|
76
|
+
this.sessions.delete(id);
|
|
77
|
+
await this.persist();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
isCancelled(id: string): boolean {
|
|
81
|
+
const session = this.sessions.get(id);
|
|
82
|
+
return session?.cancelled || false;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
markCancelled(id: string): void {
|
|
86
|
+
const session = this.sessions.get(id);
|
|
87
|
+
if (session) {
|
|
88
|
+
session.cancelled = true;
|
|
89
|
+
session.updatedAt = Date.now();
|
|
90
|
+
this.persist().catch(() => {});
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
canResume(id: string): boolean {
|
|
95
|
+
const session = this.sessions.get(id);
|
|
96
|
+
return !!session?.resumeId;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
setResumeId(id: string, resumeId: string): void {
|
|
100
|
+
const session = this.sessions.get(id);
|
|
101
|
+
if (session) {
|
|
102
|
+
session.resumeId = resumeId;
|
|
103
|
+
session.updatedAt = Date.now();
|
|
104
|
+
this.persist().catch(() => {});
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
package/src/acp/tools.ts
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
export interface ToolUpdate {
|
|
2
|
+
sessionId: string;
|
|
3
|
+
toolCallId: string;
|
|
4
|
+
title?: string;
|
|
5
|
+
kind?: 'read' | 'write' | 'edit' | 'search' | 'execute' | 'other';
|
|
6
|
+
status?: 'pending' | 'in_progress' | 'completed' | 'failed';
|
|
7
|
+
locations?: Array<{ path: string; line?: number }>;
|
|
8
|
+
content?: Array<{ type: string; [key: string]: unknown }>;
|
|
9
|
+
rawOutput?: string;
|
|
10
|
+
startTime?: number;
|
|
11
|
+
endTime?: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface CursorEvent {
|
|
15
|
+
type: string;
|
|
16
|
+
call_id?: string;
|
|
17
|
+
tool_call_id?: string;
|
|
18
|
+
subtype?: string;
|
|
19
|
+
tool_call?: {
|
|
20
|
+
[key: string]: {
|
|
21
|
+
args?: Record<string, unknown>;
|
|
22
|
+
result?: Record<string, unknown>;
|
|
23
|
+
};
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export class ToolMapper {
|
|
28
|
+
async mapCursorEventToAcp(event: CursorEvent, sessionId: string): Promise<ToolUpdate[]> {
|
|
29
|
+
if (event.type !== 'tool_call') {
|
|
30
|
+
return [];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const updates: ToolUpdate[] = [];
|
|
34
|
+
const toolCallId = event.call_id || event.tool_call_id || 'unknown';
|
|
35
|
+
const subtype = event.subtype || 'started';
|
|
36
|
+
|
|
37
|
+
// Completed/failed events return 1 update with results
|
|
38
|
+
if (subtype === 'completed' || subtype === 'failed') {
|
|
39
|
+
const result = this.extractResult(event.tool_call || {});
|
|
40
|
+
const locations = result.locations?.length ? result.locations : this.extractLocations(event.tool_call || {});
|
|
41
|
+
|
|
42
|
+
updates.push({
|
|
43
|
+
sessionId,
|
|
44
|
+
toolCallId,
|
|
45
|
+
title: this.buildToolTitle(event.tool_call || {}),
|
|
46
|
+
kind: this.inferToolType(event.tool_call || {}),
|
|
47
|
+
status: result.error ? 'failed' : 'completed',
|
|
48
|
+
content: result.content,
|
|
49
|
+
locations,
|
|
50
|
+
rawOutput: result.rawOutput,
|
|
51
|
+
endTime: Date.now()
|
|
52
|
+
});
|
|
53
|
+
} else {
|
|
54
|
+
// Started events return 2 updates: pending and in_progress
|
|
55
|
+
updates.push({
|
|
56
|
+
sessionId,
|
|
57
|
+
toolCallId,
|
|
58
|
+
title: this.buildToolTitle(event.tool_call || {}),
|
|
59
|
+
kind: this.inferToolType(event.tool_call || {}),
|
|
60
|
+
status: 'pending',
|
|
61
|
+
locations: this.extractLocations(event.tool_call || {}),
|
|
62
|
+
startTime: Date.now()
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
updates.push({
|
|
66
|
+
sessionId,
|
|
67
|
+
toolCallId,
|
|
68
|
+
status: 'in_progress'
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return updates;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
private inferToolType(toolCall: Record<string, unknown>): ToolUpdate['kind'] {
|
|
76
|
+
const keys = Object.keys(toolCall);
|
|
77
|
+
for (const key of keys) {
|
|
78
|
+
if (key.includes('read')) return 'read';
|
|
79
|
+
if (key.includes('write')) return 'edit';
|
|
80
|
+
if (key.includes('grep') || key.includes('glob')) return 'search';
|
|
81
|
+
if (key.includes('bash') || key.includes('shell')) return 'execute';
|
|
82
|
+
}
|
|
83
|
+
return 'other';
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
private buildToolTitle(toolCall: Record<string, unknown>): string {
|
|
87
|
+
const keys = Object.keys(toolCall);
|
|
88
|
+
for (const key of keys) {
|
|
89
|
+
const tool = toolCall[key] as { args?: Record<string, unknown> } | undefined;
|
|
90
|
+
const args = tool?.args || {};
|
|
91
|
+
|
|
92
|
+
if (key.includes('read') && args.path) return `Read ${args.path}`;
|
|
93
|
+
if (key.includes('write') && args.path) return `Write ${args.path}`;
|
|
94
|
+
if (key.includes('grep')) {
|
|
95
|
+
const pattern = args.pattern || 'pattern';
|
|
96
|
+
const path = args.path;
|
|
97
|
+
return path ? `Search ${path} for ${pattern}` : `Search for ${pattern}`;
|
|
98
|
+
}
|
|
99
|
+
if (key.includes('glob') && args.pattern) return `Glob ${args.pattern}`;
|
|
100
|
+
if ((key.includes('bash') || key.includes('shell')) && (args.command || args.cmd)) {
|
|
101
|
+
return `\`${args.command || args.cmd}\``;
|
|
102
|
+
}
|
|
103
|
+
if ((key.includes('bash') || key.includes('shell')) && args.commands && Array.isArray(args.commands)) {
|
|
104
|
+
return `\`${args.commands.join(' && ')}\``;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return 'other';
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
private extractLocations(toolCall: Record<string, unknown>): ToolUpdate['locations'] {
|
|
111
|
+
const keys = Object.keys(toolCall);
|
|
112
|
+
for (const key of keys) {
|
|
113
|
+
const tool = toolCall[key] as { args?: Record<string, unknown> } | undefined;
|
|
114
|
+
const args = tool?.args || {};
|
|
115
|
+
|
|
116
|
+
if (args.path) {
|
|
117
|
+
if (typeof args.path === 'string') {
|
|
118
|
+
return [{ path: args.path, line: args.line as number | undefined }];
|
|
119
|
+
}
|
|
120
|
+
if (Array.isArray(args.path)) {
|
|
121
|
+
return args.path.map((p: string | { path: string; line?: number }) =>
|
|
122
|
+
typeof p === 'string' ? { path: p } : { path: p.path, line: p.line }
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (args.paths && Array.isArray(args.paths)) {
|
|
128
|
+
return args.paths.map((p: string | { path: string; line?: number }) =>
|
|
129
|
+
typeof p === 'string' ? { path: p } : { path: p.path, line: p.line }
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return undefined;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
private extractResult(toolCall: Record<string, unknown>): {
|
|
137
|
+
error?: string;
|
|
138
|
+
content?: ToolUpdate['content'];
|
|
139
|
+
locations?: ToolUpdate['locations'];
|
|
140
|
+
rawOutput?: string;
|
|
141
|
+
} {
|
|
142
|
+
const keys = Object.keys(toolCall);
|
|
143
|
+
for (const key of keys) {
|
|
144
|
+
const tool = toolCall[key] as {
|
|
145
|
+
result?: Record<string, unknown>;
|
|
146
|
+
args?: Record<string, unknown>;
|
|
147
|
+
} | undefined;
|
|
148
|
+
const result = tool?.result || {};
|
|
149
|
+
|
|
150
|
+
if (result.error) {
|
|
151
|
+
return { error: result.error as string };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const locations: ToolUpdate['locations'] = [];
|
|
155
|
+
if (result.matches && Array.isArray(result.matches)) {
|
|
156
|
+
locations.push(...result.matches.map((m: { path: string; line?: number }) => ({
|
|
157
|
+
path: m.path,
|
|
158
|
+
line: m.line
|
|
159
|
+
})));
|
|
160
|
+
}
|
|
161
|
+
if (result.files && Array.isArray(result.files)) {
|
|
162
|
+
locations.push(...result.files.map((f: string) => ({ path: f })));
|
|
163
|
+
}
|
|
164
|
+
if (result.path) {
|
|
165
|
+
locations.push({ path: result.path as string, line: result.line as number | undefined });
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const content: ToolUpdate['content'] = [];
|
|
169
|
+
|
|
170
|
+
// Handle write operations with diff generation
|
|
171
|
+
if (key.includes('write')) {
|
|
172
|
+
const oldText = result.oldText ?? null;
|
|
173
|
+
const newText = result.newText as string | undefined;
|
|
174
|
+
const path = (tool?.args?.path as string) || (result.path as string);
|
|
175
|
+
if (newText !== undefined || oldText !== undefined) {
|
|
176
|
+
content.push({
|
|
177
|
+
type: 'diff',
|
|
178
|
+
path,
|
|
179
|
+
oldText,
|
|
180
|
+
newText
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (result.content) {
|
|
186
|
+
content.push({
|
|
187
|
+
type: 'content',
|
|
188
|
+
content: { text: result.content as string }
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (result.output !== undefined || result.exitCode !== undefined) {
|
|
193
|
+
content.push({
|
|
194
|
+
type: 'content',
|
|
195
|
+
content: {
|
|
196
|
+
text: `Exit code: ${result.exitCode ?? 0}\n${result.output || '(no output)'}`
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return {
|
|
202
|
+
content: content.length > 0 ? content : undefined,
|
|
203
|
+
locations: locations.length > 0 ? locations : undefined,
|
|
204
|
+
rawOutput: JSON.stringify(result)
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
return {};
|
|
208
|
+
}
|
|
209
|
+
}
|
package/src/auth.ts
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
// src/auth.ts
|
|
2
|
+
|
|
3
|
+
import { spawn } from "child_process";
|
|
4
|
+
import { existsSync } from "fs";
|
|
5
|
+
import { homedir, platform } from "os";
|
|
6
|
+
import { join } from "path";
|
|
7
|
+
import { createLogger } from "./utils/logger";
|
|
8
|
+
import { stripAnsi } from "./utils/errors";
|
|
9
|
+
|
|
10
|
+
const log = createLogger("auth");
|
|
11
|
+
|
|
12
|
+
// Polling configuration for auth file detection
|
|
13
|
+
const AUTH_POLL_INTERVAL = 2000; // Check every 2 seconds
|
|
14
|
+
const AUTH_POLL_TIMEOUT = 5 * 60 * 1000; // 5 minutes total timeout
|
|
15
|
+
const URL_EXTRACTION_TIMEOUT = 10000; // Wait up to 10 seconds for URL
|
|
16
|
+
|
|
17
|
+
export interface AuthResult {
|
|
18
|
+
type: "success" | "failed";
|
|
19
|
+
provider?: string;
|
|
20
|
+
key?: string;
|
|
21
|
+
error?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function getHomeDir(): string {
|
|
25
|
+
const override = process.env.CURSOR_ACP_HOME_DIR;
|
|
26
|
+
if (override && override.length > 0) {
|
|
27
|
+
return override;
|
|
28
|
+
}
|
|
29
|
+
return homedir();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function pollForAuthFile(
|
|
33
|
+
timeoutMs: number = AUTH_POLL_TIMEOUT,
|
|
34
|
+
intervalMs: number = AUTH_POLL_INTERVAL
|
|
35
|
+
): Promise<boolean> {
|
|
36
|
+
const startTime = Date.now();
|
|
37
|
+
const possiblePaths = getPossibleAuthPaths();
|
|
38
|
+
|
|
39
|
+
return new Promise((resolve) => {
|
|
40
|
+
const check = () => {
|
|
41
|
+
const elapsed = Date.now() - startTime;
|
|
42
|
+
|
|
43
|
+
for (const authPath of possiblePaths) {
|
|
44
|
+
if (existsSync(authPath)) {
|
|
45
|
+
log.debug("Auth file detected", { path: authPath });
|
|
46
|
+
resolve(true);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
log.debug("Polling for auth file", {
|
|
52
|
+
checkedPaths: possiblePaths,
|
|
53
|
+
elapsed: `${elapsed}ms`,
|
|
54
|
+
timeout: `${timeoutMs}ms`,
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
if (elapsed >= timeoutMs) {
|
|
58
|
+
log.debug("Auth file polling timed out");
|
|
59
|
+
resolve(false);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
setTimeout(check, intervalMs);
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
check();
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function startCursorOAuth(): Promise<{
|
|
71
|
+
url: string;
|
|
72
|
+
instructions: string;
|
|
73
|
+
callback: () => Promise<AuthResult>;
|
|
74
|
+
}> {
|
|
75
|
+
return new Promise((resolve, reject) => {
|
|
76
|
+
log.info("Starting cursor-cli login process");
|
|
77
|
+
|
|
78
|
+
const proc = spawn("cursor-agent", ["login"], {
|
|
79
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
let stdout = "";
|
|
83
|
+
let stderr = "";
|
|
84
|
+
let urlExtracted = false;
|
|
85
|
+
|
|
86
|
+
proc.stdout.on("data", (data) => {
|
|
87
|
+
stdout += data.toString();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
proc.stderr.on("data", (data) => {
|
|
91
|
+
stderr += data.toString();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const extractUrl = () => {
|
|
95
|
+
// Step 1: Strip ANSI codes
|
|
96
|
+
let cleanOutput = stripAnsi(stdout);
|
|
97
|
+
// Step 2: Remove ALL whitespace (newlines, spaces, tabs)
|
|
98
|
+
// The URL is split across lines with continuation spaces
|
|
99
|
+
cleanOutput = cleanOutput.replace(/\s/g, "");
|
|
100
|
+
// Step 3: Now extract the continuous URL
|
|
101
|
+
const urlMatch = cleanOutput.match(/https:\/\/cursor\.com\/loginDeepControl[^\s]*/);
|
|
102
|
+
if (urlMatch) {
|
|
103
|
+
return urlMatch[0];
|
|
104
|
+
}
|
|
105
|
+
return null;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
// Try to extract URL with polling instead of fixed timeout
|
|
109
|
+
const tryExtractUrl = () => {
|
|
110
|
+
const url = extractUrl();
|
|
111
|
+
|
|
112
|
+
if (url && !urlExtracted) {
|
|
113
|
+
urlExtracted = true;
|
|
114
|
+
log.debug("Captured stdout", { length: stdout.length });
|
|
115
|
+
log.debug("Extracted URL", { url: url.substring(0, 50) + "..." });
|
|
116
|
+
log.info("Got login URL, waiting for browser auth");
|
|
117
|
+
|
|
118
|
+
resolve({
|
|
119
|
+
url,
|
|
120
|
+
instructions: "Click 'Continue with Cursor' in your browser to authenticate",
|
|
121
|
+
callback: async () => {
|
|
122
|
+
// Wait for process to complete
|
|
123
|
+
return new Promise((resolve) => {
|
|
124
|
+
let resolved = false;
|
|
125
|
+
|
|
126
|
+
const resolveOnce = (result: AuthResult) => {
|
|
127
|
+
if (!resolved) {
|
|
128
|
+
resolved = true;
|
|
129
|
+
resolve(result);
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
proc.on("close", async (code) => {
|
|
134
|
+
log.debug("Login process closed", { code });
|
|
135
|
+
|
|
136
|
+
// If process exited successfully, poll for auth file
|
|
137
|
+
if (code === 0) {
|
|
138
|
+
log.info("Process exited successfully, polling for auth file...");
|
|
139
|
+
const isAuthenticated = await pollForAuthFile();
|
|
140
|
+
|
|
141
|
+
if (isAuthenticated) {
|
|
142
|
+
log.info("Authentication successful");
|
|
143
|
+
resolveOnce({
|
|
144
|
+
type: "success",
|
|
145
|
+
provider: "cursor-acp",
|
|
146
|
+
key: "cursor-auth",
|
|
147
|
+
});
|
|
148
|
+
} else {
|
|
149
|
+
log.warn("Auth file not found after polling");
|
|
150
|
+
resolveOnce({
|
|
151
|
+
type: "failed",
|
|
152
|
+
error: "Authentication was not completed. Please try again.",
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
} else {
|
|
156
|
+
log.warn("Login process failed", { code });
|
|
157
|
+
resolveOnce({
|
|
158
|
+
type: "failed",
|
|
159
|
+
error: stderr ? stripAnsi(stderr) : `Authentication failed with code ${code}`,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
// Timeout after 5 minutes
|
|
165
|
+
setTimeout(() => {
|
|
166
|
+
log.warn("Authentication timed out after 5 minutes");
|
|
167
|
+
proc.kill();
|
|
168
|
+
resolveOnce({
|
|
169
|
+
type: "failed",
|
|
170
|
+
error: "Authentication timed out. Please try again.",
|
|
171
|
+
});
|
|
172
|
+
}, AUTH_POLL_TIMEOUT);
|
|
173
|
+
});
|
|
174
|
+
},
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
// Poll for URL extraction with timeout
|
|
180
|
+
const urlPollStart = Date.now();
|
|
181
|
+
const pollForUrl = () => {
|
|
182
|
+
if (urlExtracted) return;
|
|
183
|
+
|
|
184
|
+
const elapsed = Date.now() - urlPollStart;
|
|
185
|
+
if (elapsed >= URL_EXTRACTION_TIMEOUT) {
|
|
186
|
+
proc.kill();
|
|
187
|
+
const errorMsg = stderr ? stripAnsi(stderr) : "No login URL received within timeout";
|
|
188
|
+
log.error("Failed to extract login URL", { error: errorMsg, elapsed: `${elapsed}ms` });
|
|
189
|
+
reject(new Error(`Failed to get login URL: ${errorMsg}`));
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
tryExtractUrl();
|
|
194
|
+
|
|
195
|
+
if (!urlExtracted) {
|
|
196
|
+
setTimeout(pollForUrl, 100); // Check every 100ms
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
// Start polling for URL
|
|
201
|
+
pollForUrl();
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function verifyCursorAuth(): boolean {
|
|
206
|
+
const possiblePaths = getPossibleAuthPaths();
|
|
207
|
+
|
|
208
|
+
for (const authPath of possiblePaths) {
|
|
209
|
+
if (existsSync(authPath)) {
|
|
210
|
+
log.debug("Auth file found", { path: authPath });
|
|
211
|
+
return true;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
log.debug("No auth file found", { checkedPaths: possiblePaths });
|
|
216
|
+
return false;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Returns all possible auth file paths in priority order.
|
|
221
|
+
* Checks both auth.json (legacy) and cli-config.json (current cursor-agent format).
|
|
222
|
+
* - macOS: ~/.cursor/ (primary), ~/.config/cursor/ (fallback)
|
|
223
|
+
* - Linux: ~/.config/cursor/ (XDG), XDG_CONFIG_HOME/cursor/, ~/.cursor/
|
|
224
|
+
*/
|
|
225
|
+
export function getPossibleAuthPaths(): string[] {
|
|
226
|
+
const home = getHomeDir();
|
|
227
|
+
const paths: string[] = [];
|
|
228
|
+
const isDarwin = platform() === "darwin";
|
|
229
|
+
|
|
230
|
+
const authFiles = ["cli-config.json", "auth.json"];
|
|
231
|
+
|
|
232
|
+
if (isDarwin) {
|
|
233
|
+
for (const file of authFiles) {
|
|
234
|
+
paths.push(join(home, ".cursor", file));
|
|
235
|
+
}
|
|
236
|
+
for (const file of authFiles) {
|
|
237
|
+
paths.push(join(home, ".config", "cursor", file));
|
|
238
|
+
}
|
|
239
|
+
} else {
|
|
240
|
+
for (const file of authFiles) {
|
|
241
|
+
paths.push(join(home, ".config", "cursor", file));
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const xdgConfig = process.env.XDG_CONFIG_HOME;
|
|
245
|
+
if (xdgConfig && xdgConfig !== join(home, ".config")) {
|
|
246
|
+
for (const file of authFiles) {
|
|
247
|
+
paths.push(join(xdgConfig, "cursor", file));
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
for (const file of authFiles) {
|
|
252
|
+
paths.push(join(home, ".cursor", file));
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return paths;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export function getAuthFilePath(): string {
|
|
260
|
+
const possiblePaths = getPossibleAuthPaths();
|
|
261
|
+
|
|
262
|
+
for (const authPath of possiblePaths) {
|
|
263
|
+
if (existsSync(authPath)) {
|
|
264
|
+
return authPath;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return possiblePaths[0];
|
|
269
|
+
}
|