@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.
@@ -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
+ })();