dsh-cursor-dev 0.0.0-stage → 0.11.1

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/lib/changes.js ADDED
@@ -0,0 +1,192 @@
1
+ /**
2
+ * Independent change evidence for a Cursor run.
3
+ *
4
+ * The ACP stream already reports diffs for edits Cursor performs through its own
5
+ * edit tools. This module adds a *second, independent* source of truth: a cheap
6
+ * filesystem fingerprint taken before and after the turn. That catches anything
7
+ * the stream omits — shell commands that write files, moves, deletes — so the
8
+ * parent model can verify the child's work instead of trusting its summary.
9
+ */
10
+ import { readdir, stat } from 'node:fs/promises';
11
+ import { join, relative, sep } from 'node:path';
12
+
13
+ /** Directories never worth fingerprinting in a source workspace. */
14
+ const SKIP_DIRS = new Set([
15
+ '.git', 'node_modules', '__pycache__', '.venv', 'venv', 'env',
16
+ '.mypy_cache', '.pytest_cache', '.ruff_cache', 'dist', 'build',
17
+ '.next', '.nuxt', '.turbo', '.cache', '.idea', '.vscode',
18
+ 'game', 'cache', 'saves', 'log',
19
+ ]);
20
+
21
+ /**
22
+ * Normalize a path for cross-platform comparison.
23
+ * @param path - absolute or relative path.
24
+ */
25
+ export function normalizePath(path) {
26
+ return path.split(sep).join('/');
27
+ }
28
+
29
+ /**
30
+ * Whether a directory name is excluded from fingerprinting.
31
+ * @param name - directory basename.
32
+ */
33
+ function isSkippedDir(name) {
34
+ return SKIP_DIRS.has(name) || name.startsWith('.');
35
+ }
36
+
37
+ /**
38
+ * Fingerprint a workspace tree: path -> mtime+size.
39
+ *
40
+ * Bounded on purpose: a huge tree costs a few hundred ms and the cap keeps a
41
+ * pathological workspace from stalling a tool call.
42
+ *
43
+ * @param root - absolute workspace directory.
44
+ * @param maxFiles - stop after this many files.
45
+ */
46
+ export async function fingerprint(root, maxFiles = 20_000) {
47
+ const files = new Map();
48
+ let truncated = false;
49
+
50
+ /** @param dir - directory to walk. */
51
+ async function walk(dir) {
52
+ if (truncated)
53
+ return;
54
+ let entries;
55
+ try {
56
+ entries = await readdir(dir, { withFileTypes: true });
57
+ }
58
+ catch {
59
+ return;
60
+ }
61
+ for (const entry of entries) {
62
+ if (files.size >= maxFiles) {
63
+ truncated = true;
64
+ return;
65
+ }
66
+ if (entry.isDirectory()) {
67
+ if (isSkippedDir(entry.name))
68
+ continue;
69
+ await walk(join(dir, entry.name));
70
+ continue;
71
+ }
72
+ if (!entry.isFile())
73
+ continue;
74
+ const full = join(dir, entry.name);
75
+ try {
76
+ const info = await stat(full);
77
+ files.set(normalizePath(relative(root, full)), `${info.mtimeMs}|${info.size}`);
78
+ }
79
+ catch {
80
+ // Racing deletes are not interesting to the diff.
81
+ }
82
+ }
83
+ }
84
+
85
+ await walk(root);
86
+ return { files, truncated };
87
+ }
88
+
89
+ /**
90
+ * Compare two fingerprints.
91
+ *
92
+ * @param before - fingerprint captured before the turn.
93
+ * @param after - fingerprint captured after the turn.
94
+ * @returns added / modified / removed relative paths.
95
+ */
96
+ export function compareFingerprints(before, after) {
97
+ const added = [];
98
+ const modified = [];
99
+ const removed = [];
100
+
101
+ for (const [path, stamp] of after.files) {
102
+ const previous = before.files.get(path);
103
+ if (previous === undefined)
104
+ added.push(path);
105
+ else if (previous !== stamp)
106
+ modified.push(path);
107
+ }
108
+ for (const path of before.files.keys()) {
109
+ if (!after.files.has(path))
110
+ removed.push(path);
111
+ }
112
+ added.sort();
113
+ modified.sort();
114
+ removed.sort();
115
+ return { added, modified, removed };
116
+ }
117
+
118
+ /**
119
+ * Count added/removed lines in a unified-ish diff pair.
120
+ *
121
+ * @param oldText - previous content (`null` when the file is new).
122
+ * @param newText - new content.
123
+ */
124
+ export function diffStats(oldText, newText) {
125
+ const before = oldText === null ? [] : oldText.split('\n');
126
+ const after = newText.split('\n');
127
+ let added = 0;
128
+ let removed = 0;
129
+ if (oldText === null) {
130
+ added = after.length;
131
+ return { added, removed, isNew: true };
132
+ }
133
+ // Line-set comparison: exact enough for a summary, cheap on large files.
134
+ const beforeSet = new Map();
135
+ for (const line of before)
136
+ beforeSet.set(line, (beforeSet.get(line) ?? 0) + 1);
137
+ for (const line of after) {
138
+ const count = beforeSet.get(line);
139
+ if (count === undefined || count === 0)
140
+ added += 1;
141
+ else
142
+ beforeSet.set(line, count - 1);
143
+ }
144
+ for (const count of beforeSet.values())
145
+ removed += count;
146
+ return { added, removed, isNew: before.length === 0 };
147
+ }
148
+
149
+ /**
150
+ * Merge ACP-reported diffs with the filesystem comparison into one change set.
151
+ *
152
+ * ACP diffs win when both describe the same path: they carry real text.
153
+ *
154
+ * @param diffs - `FileDiff[]` observed on the ACP stream.
155
+ * @param comparison - result of {@link compareFingerprints}.
156
+ * @param root - workspace root, used to relativize absolute ACP paths.
157
+ */
158
+ export function mergeChanges(diffs, comparison, root) {
159
+ const byPath = new Map();
160
+ const prefix = normalizePath(root.endsWith(sep) ? root : `${root}${sep}`);
161
+
162
+ for (const diff of diffs) {
163
+ const normalized = normalizePath(diff.path);
164
+ const key = normalized.startsWith(prefix) ? normalized.slice(prefix.length) : normalized;
165
+ const stats = diffStats(diff.oldText, diff.newText);
166
+ byPath.set(key, {
167
+ path: key,
168
+ absolutePath: diff.path,
169
+ operation: stats.isNew ? 'created' : 'modified',
170
+ added: stats.added,
171
+ removed: stats.removed,
172
+ source: 'acp',
173
+ diff,
174
+ });
175
+ }
176
+
177
+ for (const path of comparison.added) {
178
+ if (!byPath.has(path)) {
179
+ byPath.set(path, { path, operation: 'created', added: 0, removed: 0, source: 'fs' });
180
+ }
181
+ }
182
+ for (const path of comparison.modified) {
183
+ if (!byPath.has(path)) {
184
+ byPath.set(path, { path, operation: 'modified', added: 0, removed: 0, source: 'fs' });
185
+ }
186
+ }
187
+ for (const path of comparison.removed) {
188
+ byPath.set(path, { path, operation: 'deleted', added: 0, removed: 0, source: 'fs' });
189
+ }
190
+
191
+ return [...byPath.values()].sort((left, right) => left.path.localeCompare(right.path));
192
+ }
package/lib/cli.js ADDED
@@ -0,0 +1,237 @@
1
+ /**
2
+ * One-shot questions to the Cursor CLI.
3
+ *
4
+ * A new user hits the same two walls before this plugin can do anything: the CLI
5
+ * is not installed, or it is installed but not signed in. Both look identical
6
+ * from inside the panel (dashes, an empty model picker), so they are answered
7
+ * here explicitly — with the commands that fix them — instead of being left for
8
+ * the user to guess.
9
+ *
10
+ * This module also owns the one-shot spawn helper the model listing uses: the
11
+ * ACP session owns a long-lived child, while these queries ask a question,
12
+ * print, and exit.
13
+ *
14
+ * @module dsh-cursor-dev/cli
15
+ */
16
+
17
+ /** Largest output accepted from a one-shot CLI query. */
18
+ const MAX_OUTPUT_BYTES = 512 * 1024;
19
+
20
+ /** How long a one-shot query may take before it is killed. */
21
+ const DEFAULT_TIMEOUT_MS = 30_000;
22
+
23
+ /** How long an authentication answer is reused. */
24
+ export const AUTH_CACHE_MS = 60 * 1000;
25
+
26
+ /**
27
+ * What a user runs by hand when something is missing.
28
+ *
29
+ * Kept in one place because three surfaces quote it: the panel's setup card,
30
+ * the `cursor_code` error, and the README.
31
+ */
32
+ export const CLI_SETUP = {
33
+ install: {
34
+ win32: "irm 'https://cursor.com/install?win32=true' | iex",
35
+ unix: 'curl https://cursor.com/install -fsS | bash',
36
+ },
37
+ login: 'agent login',
38
+ verify: 'agent status --format json',
39
+ docs: 'https://cursor.com/docs/cli',
40
+ };
41
+
42
+ /** Last authentication answer, keyed by the CLI it came from. */
43
+ let cache = { at: 0, key: '', value: undefined };
44
+
45
+ /**
46
+ * Describe the resolved CLI for the panel.
47
+ *
48
+ * `found` is the field the panel branches on; the rest is what a user needs in
49
+ * order to see *which* CLI the plugin is actually talking to.
50
+ *
51
+ * @param resolved - result of `resolveAgent`, or undefined.
52
+ */
53
+ export function describeAgent(resolved) {
54
+ if (resolved === undefined || resolved === null)
55
+ return { found: false, command: '', entry: '', source: '' };
56
+ return {
57
+ found: true,
58
+ command: typeof resolved.command === 'string' ? resolved.command : '',
59
+ entry: typeof resolved.entry === 'string' ? resolved.entry : '',
60
+ source: typeof resolved.source === 'string' ? resolved.source : '',
61
+ };
62
+ }
63
+
64
+ /**
65
+ * Parse `agent status --format json`.
66
+ *
67
+ * The CLI answers with `{ status, isAuthenticated, hasAccessToken, userInfo }`.
68
+ * Anything that is not that shape is reported as "no answer" rather than being
69
+ * guessed at: a signed-out CLI prints a human sentence, and treating that as
70
+ * signed-in would send the user hunting in the wrong place.
71
+ *
72
+ * @param stdout - raw command output.
73
+ * @returns the parsed status, or undefined when the output is not that JSON.
74
+ */
75
+ export function parseAuthStatus(stdout) {
76
+ let parsed;
77
+ try {
78
+ parsed = JSON.parse(String(stdout ?? '').trim());
79
+ }
80
+ catch {
81
+ return undefined;
82
+ }
83
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
84
+ return undefined;
85
+ const user = parsed.userInfo !== null && typeof parsed.userInfo === 'object' ? parsed.userInfo : {};
86
+ return {
87
+ authenticated: parsed.isAuthenticated === true || parsed.status === 'authenticated',
88
+ status: typeof parsed.status === 'string' ? parsed.status : '',
89
+ email: typeof user.email === 'string' ? user.email : '',
90
+ userId: typeof user.userId === 'number' ? user.userId : 0,
91
+ };
92
+ }
93
+
94
+ /**
95
+ * Ask the CLI whether this machine is signed in.
96
+ *
97
+ * Never throws: the answer is exactly what the panel renders, and a throwing
98
+ * "is it set up?" check is useless to a user who is already stuck. A failed
99
+ * check keeps the CLI path and reports the reason instead.
100
+ *
101
+ * @param deps - `spawn`, resolved agent, cwd, env, plus optional `force`.
102
+ */
103
+ export async function authStatus(deps) {
104
+ const key = deps?.resolved?.command ?? '';
105
+ if (deps?.force !== true && cache.value !== undefined && cache.key === key && Date.now() - cache.at < AUTH_CACHE_MS)
106
+ return cache.value;
107
+ const value = await probeAuth(deps);
108
+ cache = { at: Date.now(), key, value };
109
+ return value;
110
+ }
111
+
112
+ /** Drop the cached authentication answer (tests, and a config change). */
113
+ export function forgetAuthStatus() {
114
+ cache = { at: 0, key: '', value: undefined };
115
+ }
116
+
117
+ /**
118
+ * Run one short-lived CLI command and collect its output.
119
+ *
120
+ * stdin is closed immediately so a CLI that waits on input cannot hang the
121
+ * panel, and the process is terminated when the deadline passes.
122
+ *
123
+ * @param deps - `spawn`, resolved agent, cwd, env (an object, or a function that
124
+ * answers the current one or a promise for it), argv `tail`,
125
+ * optional `timeoutMs`.
126
+ */
127
+ export async function runOnce(deps) {
128
+ const { spawn, resolved, tail } = deps;
129
+ if (spawn === undefined || resolved === undefined)
130
+ throw new Error('the Cursor CLI is not available');
131
+ const argv = [resolved.command, ...(resolved.entry === undefined ? [] : [resolved.entry]), ...tail];
132
+ const handle = spawn({
133
+ argv,
134
+ cwd: deps.cwd,
135
+ // A function is asked per spawn: `--list-models` is exactly the call
136
+ // whose answer depends on the proxy, so it must never read a stale one —
137
+ // and it awaits, so a listing that races the first lookup still gets it.
138
+ env: typeof deps.env === 'function' ? await deps.env() : deps.env,
139
+ stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'pipe' },
140
+ graceMs: 2000,
141
+ });
142
+ let stdout = '';
143
+ let stderr = '';
144
+ handle.stdout?.on('data', (chunk) => {
145
+ if (stdout.length < MAX_OUTPUT_BYTES)
146
+ stdout += String(chunk);
147
+ });
148
+ handle.stderr?.on('data', (chunk) => {
149
+ if (stderr.length < MAX_OUTPUT_BYTES)
150
+ stderr += String(chunk);
151
+ });
152
+ try {
153
+ handle.stdin?.end();
154
+ }
155
+ catch {
156
+ // A CLI that already exited has nothing to close.
157
+ }
158
+
159
+ const timeoutMs = deps.timeoutMs ?? DEFAULT_TIMEOUT_MS;
160
+ let timer;
161
+ const deadline = new Promise((_resolve, reject) => {
162
+ // Deliberately ref'd: this deadline is the only thing that settles the
163
+ // call when the CLI hangs, so it has to be able to fire on its own. An
164
+ // unref'd timer is silently disabled whenever the awaited work holds no
165
+ // live handle of its own — which is exactly the hung case it exists for,
166
+ // and it left the call pending until the process drained instead of
167
+ // reporting the timeout. `finally` below is what releases the handle.
168
+ timer = setTimeout(() => reject(new Error(`the CLI did not answer within ${Math.round(timeoutMs / 1000)}s`)), timeoutMs);
169
+ });
170
+ try {
171
+ await Promise.race([Promise.resolve(handle.done).catch(() => undefined), deadline]);
172
+ }
173
+ catch (error) {
174
+ try {
175
+ handle.terminate?.();
176
+ }
177
+ catch {
178
+ // Already gone.
179
+ }
180
+ throw error;
181
+ }
182
+ finally {
183
+ clearTimeout(timer);
184
+ }
185
+ return { stdout, stderr };
186
+ }
187
+
188
+ /**
189
+ * The uncached half of {@link authStatus}.
190
+ *
191
+ * @param deps - spawn/agent dependencies.
192
+ */
193
+ async function probeAuth(deps) {
194
+ const checkedAt = Date.now();
195
+ const resolved = deps?.resolved;
196
+ if (resolved === undefined)
197
+ return {
198
+ ok: false,
199
+ installed: false,
200
+ authenticated: false,
201
+ email: '',
202
+ checkedAt,
203
+ error: 'the Cursor CLI was not found',
204
+ };
205
+ try {
206
+ const result = await runOnce({ ...deps, tail: ['status', '--format', 'json'] });
207
+ const parsed = parseAuthStatus(result.stdout);
208
+ if (parsed === undefined) {
209
+ const detail = lastLine(result.stderr) || lastLine(result.stdout) || 'the CLI did not report a sign-in status';
210
+ return { ok: false, installed: true, authenticated: false, email: '', checkedAt, error: detail };
211
+ }
212
+ return {
213
+ ok: true,
214
+ installed: true,
215
+ authenticated: parsed.authenticated,
216
+ status: parsed.status,
217
+ email: parsed.email,
218
+ checkedAt,
219
+ error: null,
220
+ };
221
+ }
222
+ catch (error) {
223
+ return {
224
+ ok: false,
225
+ installed: true,
226
+ authenticated: false,
227
+ email: '',
228
+ checkedAt,
229
+ error: error instanceof Error ? error.message : String(error),
230
+ };
231
+ }
232
+ }
233
+
234
+ /** The last non-empty line of a command's output. */
235
+ function lastLine(text) {
236
+ return String(text ?? '').split(/\r?\n/).map(line => line.trim()).filter(Boolean).slice(-1)[0] ?? '';
237
+ }