@ahmednawaz/crank 0.1.2
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/.env.example +53 -0
- package/README.md +266 -0
- package/bin/crank.js +737 -0
- package/bookmarklet/crank-bookmarklet.js +2295 -0
- package/bookmarklet/crank-ui-helpers.js +355 -0
- package/bookmarklet/install-snippet.txt +5 -0
- package/bookmarklet/text-source.js +239 -0
- package/companion/agent-queue.js +485 -0
- package/companion/agent-runner.js +334 -0
- package/companion/bin/crank.js +69 -0
- package/companion/dev-loader.js +39 -0
- package/companion/glean-client.js +991 -0
- package/companion/package.json +18 -0
- package/companion/persist-apply.js +189 -0
- package/companion/selection-store.js +175 -0
- package/companion/server.js +1147 -0
- package/companion/text-dispatch.js +419 -0
- package/companion/vizpatch-bridge.js +49 -0
- package/lib/copy-labels.js +86 -0
- package/lib/detect.js +88 -0
- package/lib/env.js +23 -0
- package/lib/init.js +435 -0
- package/lib/load-team-env.js +43 -0
- package/lib/resolve-project.js +203 -0
- package/lib/team-auth.js +82 -0
- package/lib/urls.js +164 -0
- package/mcp-server/index.js +174 -0
- package/mcp-server/package.json +16 -0
- package/mcp-server/tools.js +480 -0
- package/package.json +57 -0
- package/panel/demo.html +62 -0
- package/skill/SKILL.md +60 -0
- package/skills/README.md +16 -0
- package/skills/copy-guidance.md +25 -0
- package/team.env +4 -0
- package/vite.js +75 -0
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Optional headless Cursor agent runner (secondary UX).
|
|
5
|
+
* Primary path is Retune-style MCP + skill pull. This only runs when the user
|
|
6
|
+
* explicitly clicks "Run fully automatic" and CURSOR_API_KEY is set.
|
|
7
|
+
*
|
|
8
|
+
* Runtime:
|
|
9
|
+
* - local (default): @cursor/sdk Agent.prompt with local.cwd = project root
|
|
10
|
+
* - cloud: @cursor/sdk cloud agent against CURSOR_AGENT_REPO_URL (GitHub)
|
|
11
|
+
* Falls back to Cursor CLI `agent -p` when the SDK package is unavailable.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const { spawn } = require('child_process');
|
|
15
|
+
const crypto = require('crypto');
|
|
16
|
+
const path = require('path');
|
|
17
|
+
const fs = require('fs');
|
|
18
|
+
|
|
19
|
+
/** @type {Map<string, object>} */
|
|
20
|
+
const jobs = new Map();
|
|
21
|
+
|
|
22
|
+
function apiKeyConfigured() {
|
|
23
|
+
return Boolean(process.env.CURSOR_API_KEY && String(process.env.CURSOR_API_KEY).trim());
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function resolveRuntime() {
|
|
27
|
+
const raw = String(process.env.CURSOR_AGENT_RUNTIME || 'local')
|
|
28
|
+
.trim()
|
|
29
|
+
.toLowerCase();
|
|
30
|
+
return raw === 'cloud' ? 'cloud' : 'local';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function resolveCloudRepoUrl() {
|
|
34
|
+
const fromEnv = String(process.env.CURSOR_AGENT_REPO_URL || '').trim();
|
|
35
|
+
if (fromEnv) return fromEnv;
|
|
36
|
+
try {
|
|
37
|
+
const { execSync } = require('child_process');
|
|
38
|
+
const url = execSync('git remote get-url origin', {
|
|
39
|
+
cwd: process.env.CRANK_PROJECT_ROOT || process.cwd(),
|
|
40
|
+
encoding: 'utf8',
|
|
41
|
+
stdio: ['ignore', 'pipe', 'ignore']
|
|
42
|
+
}).trim();
|
|
43
|
+
if (/^https?:\/\//i.test(url)) return url;
|
|
44
|
+
// git@github.com:org/repo.git → https://github.com/org/repo
|
|
45
|
+
const ssh = url.match(/^git@([^:]+):(.+?)(?:\.git)?$/i);
|
|
46
|
+
if (ssh) return `https://${ssh[1]}/${ssh[2].replace(/\.git$/i, '')}`;
|
|
47
|
+
} catch {
|
|
48
|
+
/* no remote */
|
|
49
|
+
}
|
|
50
|
+
return '';
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function publicJob(job) {
|
|
54
|
+
if (!job) return null;
|
|
55
|
+
return {
|
|
56
|
+
id: job.id,
|
|
57
|
+
status: job.status,
|
|
58
|
+
createdAt: job.createdAt,
|
|
59
|
+
startedAt: job.startedAt || null,
|
|
60
|
+
finishedAt: job.finishedAt || null,
|
|
61
|
+
taskId: job.taskId || null,
|
|
62
|
+
variantLabel: job.variantLabel || null,
|
|
63
|
+
mode: job.mode || null,
|
|
64
|
+
runtime: job.runtime || null,
|
|
65
|
+
error: job.error || null,
|
|
66
|
+
logSnippet: job.logSnippet || ''
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function appendLog(job, chunk) {
|
|
71
|
+
const text = String(chunk || '');
|
|
72
|
+
if (!text) return;
|
|
73
|
+
job.logSnippet = String(job.logSnippet || '')
|
|
74
|
+
.concat(text)
|
|
75
|
+
.slice(-4000);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function buildAutoPrompt(task, projectRoot) {
|
|
79
|
+
const prompt = task?.prompt || '';
|
|
80
|
+
return [
|
|
81
|
+
'You are applying a pending Crank copy change automatically.',
|
|
82
|
+
`Project root: ${projectRoot}`,
|
|
83
|
+
'',
|
|
84
|
+
'Use Crank MCP tools if available:',
|
|
85
|
+
'- crank_get_pending_changes or get_pending_agent_prompt',
|
|
86
|
+
'- apply_copy_variant with persist when appropriate',
|
|
87
|
+
'- crank_clear_changes or ack_pending_agent_prompt when done',
|
|
88
|
+
'',
|
|
89
|
+
'Apply the mapped text changes to source files under the project root.',
|
|
90
|
+
'Prefer hardcoded JSX/HTML literals. Do not invent i18n keys.',
|
|
91
|
+
'',
|
|
92
|
+
'--- PENDING TASK ---',
|
|
93
|
+
prompt,
|
|
94
|
+
'--- END ---'
|
|
95
|
+
].join('\n');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function classifySdkError(err) {
|
|
99
|
+
const name = err && err.name ? String(err.name) : '';
|
|
100
|
+
const message = err && err.message ? String(err.message) : String(err);
|
|
101
|
+
const code = err && err.code ? String(err.code) : '';
|
|
102
|
+
return {
|
|
103
|
+
name: name || 'Error',
|
|
104
|
+
message,
|
|
105
|
+
code,
|
|
106
|
+
isRetryable: Boolean(err && err.isRetryable)
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function trySdkPrompt(prompt, projectRoot, job) {
|
|
111
|
+
let Agent;
|
|
112
|
+
let AuthenticationError;
|
|
113
|
+
let ConfigurationError;
|
|
114
|
+
let IntegrationNotConnectedError;
|
|
115
|
+
try {
|
|
116
|
+
({
|
|
117
|
+
Agent,
|
|
118
|
+
AuthenticationError,
|
|
119
|
+
ConfigurationError,
|
|
120
|
+
IntegrationNotConnectedError
|
|
121
|
+
} = require('@cursor/sdk'));
|
|
122
|
+
} catch {
|
|
123
|
+
return { used: false };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const runtime = resolveRuntime();
|
|
127
|
+
job.runtime = runtime;
|
|
128
|
+
job.mode = runtime === 'cloud' ? 'sdk-cloud' : 'sdk-local';
|
|
129
|
+
|
|
130
|
+
const modelId = process.env.CURSOR_AGENT_MODEL || 'composer-2.5';
|
|
131
|
+
/** @type {Record<string, unknown>} */
|
|
132
|
+
const options = {
|
|
133
|
+
apiKey: process.env.CURSOR_API_KEY,
|
|
134
|
+
model: { id: modelId }
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
if (runtime === 'cloud') {
|
|
138
|
+
const repoUrl = resolveCloudRepoUrl();
|
|
139
|
+
if (!repoUrl) {
|
|
140
|
+
return {
|
|
141
|
+
used: true,
|
|
142
|
+
ok: false,
|
|
143
|
+
error:
|
|
144
|
+
'Cloud runtime needs CURSOR_AGENT_REPO_URL (or git remote origin). Connect GitHub in Cursor and set the HTTPS repo URL.'
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
const startingRef =
|
|
148
|
+
process.env.CURSOR_AGENT_STARTING_REF ||
|
|
149
|
+
process.env.CURSOR_AGENT_BRANCH ||
|
|
150
|
+
'main';
|
|
151
|
+
options.cloud = {
|
|
152
|
+
repos: [{ url: repoUrl, startingRef }],
|
|
153
|
+
autoCreatePR: String(process.env.CURSOR_AGENT_AUTO_PR || '')
|
|
154
|
+
.toLowerCase() === 'true',
|
|
155
|
+
skipReviewerRequest: true
|
|
156
|
+
};
|
|
157
|
+
appendLog(
|
|
158
|
+
job,
|
|
159
|
+
`Starting @cursor/sdk cloud Agent.prompt against ${repoUrl} @ ${startingRef}…\n`
|
|
160
|
+
);
|
|
161
|
+
} else {
|
|
162
|
+
options.local = { cwd: projectRoot };
|
|
163
|
+
appendLog(job, 'Starting @cursor/sdk local Agent.prompt…\n');
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
try {
|
|
167
|
+
const result = await Agent.prompt(prompt, options);
|
|
168
|
+
appendLog(job, `SDK status: ${result?.status || 'unknown'}\n`);
|
|
169
|
+
if (result?.result) appendLog(job, String(result.result).slice(0, 2000));
|
|
170
|
+
if (result?.status === 'error') {
|
|
171
|
+
return {
|
|
172
|
+
used: true,
|
|
173
|
+
ok: false,
|
|
174
|
+
error: 'Cursor SDK agent finished with status error'
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
return { used: true, ok: true, result };
|
|
178
|
+
} catch (err) {
|
|
179
|
+
const info = classifySdkError(err);
|
|
180
|
+
let hint = info.message;
|
|
181
|
+
if (AuthenticationError && err instanceof AuthenticationError) {
|
|
182
|
+
hint = `AuthenticationError: ${info.message} (use a user or service-account key — Team Admin keys are not supported)`;
|
|
183
|
+
} else if (ConfigurationError && err instanceof ConfigurationError) {
|
|
184
|
+
hint = `ConfigurationError: ${info.message}`;
|
|
185
|
+
} else if (
|
|
186
|
+
IntegrationNotConnectedError &&
|
|
187
|
+
err instanceof IntegrationNotConnectedError
|
|
188
|
+
) {
|
|
189
|
+
hint = `IntegrationNotConnectedError: ${info.message} (connect GitHub in Cursor Dashboard → Integrations)`;
|
|
190
|
+
} else if (info.name && info.name !== 'Error') {
|
|
191
|
+
hint = `${info.name}: ${info.message}`;
|
|
192
|
+
}
|
|
193
|
+
appendLog(job, `\n${hint}\n`);
|
|
194
|
+
return {
|
|
195
|
+
used: true,
|
|
196
|
+
ok: false,
|
|
197
|
+
error: hint
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function runCliAgent(prompt, projectRoot, job) {
|
|
203
|
+
return new Promise((resolve) => {
|
|
204
|
+
job.mode = 'cli';
|
|
205
|
+
job.runtime = 'local';
|
|
206
|
+
const agentBin = process.env.CURSOR_AGENT_BIN || 'agent';
|
|
207
|
+
const args = [
|
|
208
|
+
'-p',
|
|
209
|
+
'--force',
|
|
210
|
+
'--trust',
|
|
211
|
+
'--workspace',
|
|
212
|
+
projectRoot,
|
|
213
|
+
prompt
|
|
214
|
+
];
|
|
215
|
+
appendLog(job, `Starting CLI: ${agentBin} -p …\n`);
|
|
216
|
+
const child = spawn(agentBin, args, {
|
|
217
|
+
cwd: projectRoot,
|
|
218
|
+
env: {
|
|
219
|
+
...process.env,
|
|
220
|
+
CURSOR_API_KEY: process.env.CURSOR_API_KEY
|
|
221
|
+
},
|
|
222
|
+
stdio: ['ignore', 'pipe', 'pipe']
|
|
223
|
+
});
|
|
224
|
+
job.pid = child.pid;
|
|
225
|
+
child.stdout.on('data', (buf) => appendLog(job, buf.toString('utf8')));
|
|
226
|
+
child.stderr.on('data', (buf) => appendLog(job, buf.toString('utf8')));
|
|
227
|
+
child.on('error', (err) => {
|
|
228
|
+
resolve({
|
|
229
|
+
ok: false,
|
|
230
|
+
error:
|
|
231
|
+
err && err.code === 'ENOENT'
|
|
232
|
+
? 'Cursor CLI `agent` not found on PATH'
|
|
233
|
+
: err.message || String(err)
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
child.on('close', (code) => {
|
|
237
|
+
if (code === 0) {
|
|
238
|
+
resolve({ ok: true });
|
|
239
|
+
} else {
|
|
240
|
+
resolve({
|
|
241
|
+
ok: false,
|
|
242
|
+
error: `Cursor CLI agent exited with code ${code}`
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Start a background headless agent for a queued task.
|
|
251
|
+
* @param {{ task: object, projectRoot: string, onUpdate?: Function }} opts
|
|
252
|
+
*/
|
|
253
|
+
function startAgentRun({ task, projectRoot, onUpdate }) {
|
|
254
|
+
if (!apiKeyConfigured()) {
|
|
255
|
+
const err = new Error(
|
|
256
|
+
'Set CURSOR_API_KEY to enable one-click Agent apply'
|
|
257
|
+
);
|
|
258
|
+
err.code = 'MISSING_API_KEY';
|
|
259
|
+
throw err;
|
|
260
|
+
}
|
|
261
|
+
if (!task || !task.prompt) {
|
|
262
|
+
throw new Error('No pending Crank task to run');
|
|
263
|
+
}
|
|
264
|
+
const root = path.resolve(projectRoot || process.cwd());
|
|
265
|
+
if (!fs.existsSync(root)) {
|
|
266
|
+
throw new Error(`Project root not found: ${root}`);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const job = {
|
|
270
|
+
id: crypto.randomBytes(6).toString('hex'),
|
|
271
|
+
status: 'queued',
|
|
272
|
+
createdAt: new Date().toISOString(),
|
|
273
|
+
taskId: task.id,
|
|
274
|
+
variantLabel: task.variantLabel || task.variantId,
|
|
275
|
+
logSnippet: '',
|
|
276
|
+
projectRoot: root,
|
|
277
|
+
runtime: resolveRuntime()
|
|
278
|
+
};
|
|
279
|
+
jobs.set(job.id, job);
|
|
280
|
+
if (typeof onUpdate === 'function') onUpdate(publicJob(job));
|
|
281
|
+
|
|
282
|
+
setImmediate(async () => {
|
|
283
|
+
job.status = 'running';
|
|
284
|
+
job.startedAt = new Date().toISOString();
|
|
285
|
+
if (typeof onUpdate === 'function') onUpdate(publicJob(job));
|
|
286
|
+
const prompt = buildAutoPrompt(task, root);
|
|
287
|
+
try {
|
|
288
|
+
const sdk = await trySdkPrompt(prompt, root, job);
|
|
289
|
+
let result;
|
|
290
|
+
if (sdk.used) {
|
|
291
|
+
result = sdk;
|
|
292
|
+
} else {
|
|
293
|
+
result = await runCliAgent(prompt, root, job);
|
|
294
|
+
}
|
|
295
|
+
if (result.ok) {
|
|
296
|
+
job.status = 'done';
|
|
297
|
+
} else {
|
|
298
|
+
job.status = 'failed';
|
|
299
|
+
job.error = result.error || 'Agent run failed';
|
|
300
|
+
}
|
|
301
|
+
} catch (err) {
|
|
302
|
+
job.status = 'failed';
|
|
303
|
+
job.error = err && err.message ? err.message : String(err);
|
|
304
|
+
appendLog(job, `\n${job.error}\n`);
|
|
305
|
+
}
|
|
306
|
+
job.finishedAt = new Date().toISOString();
|
|
307
|
+
if (typeof onUpdate === 'function') onUpdate(publicJob(job));
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
return publicJob(job);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function getAgentRun(id) {
|
|
314
|
+
return publicJob(jobs.get(id) || null);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function getLatestAgentRun() {
|
|
318
|
+
let latest = null;
|
|
319
|
+
for (const job of jobs.values()) {
|
|
320
|
+
if (!latest || String(job.createdAt) > String(latest.createdAt)) {
|
|
321
|
+
latest = job;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
return publicJob(latest);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
module.exports = {
|
|
328
|
+
apiKeyConfigured,
|
|
329
|
+
resolveRuntime,
|
|
330
|
+
resolveCloudRepoUrl,
|
|
331
|
+
startAgentRun,
|
|
332
|
+
getAgentRun,
|
|
333
|
+
getLatestAgentRun
|
|
334
|
+
};
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Crank companion CLI — thin launcher with auto-restart.
|
|
6
|
+
* Prefer the package root CLI for durable daemon mode:
|
|
7
|
+
* npx crank start --daemon --no-open
|
|
8
|
+
*
|
|
9
|
+
* Usage: crank-companion [project-root]
|
|
10
|
+
*/
|
|
11
|
+
const path = require('path');
|
|
12
|
+
const { spawn } = require('child_process');
|
|
13
|
+
|
|
14
|
+
const projectRoot = process.argv[2]
|
|
15
|
+
? path.resolve(process.argv[2])
|
|
16
|
+
: process.cwd();
|
|
17
|
+
|
|
18
|
+
process.env.CRANK_PROJECT_ROOT = projectRoot;
|
|
19
|
+
|
|
20
|
+
const serverPath = path.join(__dirname, '..', 'server.js');
|
|
21
|
+
let child = null;
|
|
22
|
+
let shuttingDown = false;
|
|
23
|
+
let delayMs = 400;
|
|
24
|
+
|
|
25
|
+
function spawnOnce() {
|
|
26
|
+
if (shuttingDown) return;
|
|
27
|
+
child = spawn(process.execPath, [serverPath], {
|
|
28
|
+
stdio: 'inherit',
|
|
29
|
+
env: process.env
|
|
30
|
+
});
|
|
31
|
+
child.on('exit', (code, signal) => {
|
|
32
|
+
child = null;
|
|
33
|
+
if (shuttingDown) {
|
|
34
|
+
if (signal) {
|
|
35
|
+
process.kill(process.pid, signal);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
process.exit(code == null ? 0 : code);
|
|
39
|
+
}
|
|
40
|
+
// Exit 0 = often EADDRINUSE / already up — don't tight-loop.
|
|
41
|
+
const wait = code === 0 && !signal ? 5000 : delayMs;
|
|
42
|
+
console.error(
|
|
43
|
+
`[Crank] Companion exited (code=${code} signal=${signal || ''}); restarting in ${wait}ms…`
|
|
44
|
+
);
|
|
45
|
+
setTimeout(() => {
|
|
46
|
+
delayMs = Math.min(delayMs * 2, 8000);
|
|
47
|
+
spawnOnce();
|
|
48
|
+
}, wait);
|
|
49
|
+
});
|
|
50
|
+
child.on('spawn', () => {
|
|
51
|
+
delayMs = 400;
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function shutdown(signal) {
|
|
56
|
+
shuttingDown = true;
|
|
57
|
+
if (child) {
|
|
58
|
+
try {
|
|
59
|
+
child.kill(signal);
|
|
60
|
+
} catch {
|
|
61
|
+
// ignore
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
setTimeout(() => process.exit(0), 200);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
68
|
+
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
69
|
+
spawnOnce();
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Injected by Vite plugin (or bookmarklet fallback).
|
|
5
|
+
* Loads the Crank panel from the local companion.
|
|
6
|
+
*/
|
|
7
|
+
(function () {
|
|
8
|
+
if (window.__CRANK_LOADER__) {
|
|
9
|
+
return;
|
|
10
|
+
}
|
|
11
|
+
window.__CRANK_LOADER__ = true;
|
|
12
|
+
|
|
13
|
+
var params = new URLSearchParams(window.location.search);
|
|
14
|
+
var autoload =
|
|
15
|
+
params.get('crank') === '1' ||
|
|
16
|
+
params.get('crank_autoload') === '1' ||
|
|
17
|
+
localStorage.getItem('crank:autoload') === '1' ||
|
|
18
|
+
true; // always on in dev when this script is injected
|
|
19
|
+
|
|
20
|
+
if (!autoload) {
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
var script = document.createElement('script');
|
|
25
|
+
var base = script.src || '';
|
|
26
|
+
// Prefer same companion host this loader was served from.
|
|
27
|
+
var companion =
|
|
28
|
+
(document.currentScript && document.currentScript.src) ||
|
|
29
|
+
'http://127.0.0.1:3344/dev-loader.js';
|
|
30
|
+
var origin;
|
|
31
|
+
try {
|
|
32
|
+
origin = new URL(companion).origin;
|
|
33
|
+
} catch (e) {
|
|
34
|
+
origin = 'http://127.0.0.1:3344';
|
|
35
|
+
}
|
|
36
|
+
script.src = origin + '/bookmarklet.js?t=' + Date.now();
|
|
37
|
+
script.async = true;
|
|
38
|
+
document.documentElement.appendChild(script);
|
|
39
|
+
})();
|