grok-bot-cli 0.3.0 → 0.4.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.
Files changed (38) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/README.md +54 -22
  3. package/dist/.agents/plugins/marketplace.json +1 -0
  4. package/dist/.claude-plugin/marketplace.json +1 -0
  5. package/dist/.claude-plugin/plugin.json +1 -0
  6. package/dist/.codex-plugin/mcp.json +1 -0
  7. package/dist/.codex-plugin/plugin.json +1 -0
  8. package/dist/.cursor-plugin/marketplace.json +1 -0
  9. package/dist/.cursor-plugin/mcp.json +1 -0
  10. package/dist/.cursor-plugin/plugin.json +1 -0
  11. package/dist/.mcp.json +1 -0
  12. package/dist/INSTALL.md +269 -0
  13. package/dist/LICENSE +21 -0
  14. package/dist/README.md +173 -0
  15. package/dist/agent-bundle.compile-evidence.json +1 -0
  16. package/dist/agent-bundle.manifest.json +1 -0
  17. package/dist/agent-bundle.package-compile-evidence.json +1 -0
  18. package/dist/bin/gbot-flight.mjs +29162 -0
  19. package/dist/bin/gbot-install.js +135699 -0
  20. package/dist/bin/gbot.mjs +87242 -0
  21. package/dist/install.mjs +1322 -0
  22. package/dist/mcp/mcp-grok-bot-b8c2461e-flight.mjs +26413 -0
  23. package/dist/mcp/mcp-grok-bot-b8c2461e.mjs +107760 -0
  24. package/dist/mcp.json +1 -0
  25. package/dist/package.json +60 -0
  26. package/dist/plugin.json +1 -0
  27. package/dist/skills/talk-to-grok-bot/SKILL.md +37 -0
  28. package/package.json +30 -10
  29. package/src/app-session.js +0 -249
  30. package/src/cli.js +0 -548
  31. package/src/codex-bridge.js +0 -480
  32. package/src/commands.js +0 -52
  33. package/src/gateway.js +0 -361
  34. package/src/headers.js +0 -53
  35. package/src/history.js +0 -83
  36. package/src/store.js +0 -358
  37. package/src/transcript.js +0 -50
  38. package/src/url-policy.js +0 -161
@@ -0,0 +1,1322 @@
1
+ #!/usr/bin/env node
2
+ import { execFile } from 'node:child_process';
3
+ import { createHash, randomUUID } from 'node:crypto';
4
+ import { cp, lstat, mkdir, mkdtemp, open, readFile, readdir, realpath, rename, rm, rmdir, writeFile } from 'node:fs/promises';
5
+ import { homedir } from 'node:os';
6
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+
9
+ const pluginName = "gbot";
10
+ const pluginVersion = "0.4.0";
11
+ const receiptFile = ".agent-bundle-install.json";
12
+ const receiptFormat = "agent-bundle-install-receipt/2";
13
+ const legacyReceiptFormat = "agent-bundle-install-receipt/1";
14
+ const preservedEntries = ["state"];
15
+ // Runtime roots match case-insensitively: on case-insensitive filesystems State/ is state/.
16
+ const isPreservedRoot = (name) => preservedEntries.includes(String(name).toLowerCase());
17
+ const markerFiles = ["INSTALL.md","install.mjs"];
18
+ const source = resolve(fileURLToPath(new URL('.', import.meta.url)));
19
+ const artifactManifest = await (async () => {
20
+ try {
21
+ const value = JSON.parse(await readFile(join(source, 'agent-bundle.manifest.json'), 'utf8'));
22
+ if (value === null || typeof value !== 'object' || Array.isArray(value) || !Array.isArray(value.files) || !Array.isArray(value.projections)) {
23
+ throw new Error('agent-bundle.manifest.json has no files or projections array.');
24
+ }
25
+ return value;
26
+ } catch (error) { if (error?.code === 'ENOENT') return undefined; throw error; }
27
+ })();
28
+ const installProjection = artifactManifest?.projections.find((projection) => projection?.builtInHost === 'cursor') ??
29
+ artifactManifest?.projections.find((projection) => projection?.builtInHost === 'portable');
30
+ const declaredDocument = (name) => typeof installProjection?.documents?.[name] === 'string' ? installProjection.documents[name] : undefined;
31
+ const cursorPluginDocument = declaredDocument('plugin') ?? '.cursor-plugin/plugin.json';
32
+ const cursorRoot = join(homedir(), '.cursor');
33
+ const installRoot = join(cursorRoot, 'plugins', 'local');
34
+ const destination = join(installRoot, pluginName);
35
+ const marketplaceRoot = join(cursorRoot, 'agent-bundle', 'marketplaces');
36
+ const marketplaceRepo = join(marketplaceRoot, pluginName);
37
+ const marketplacePlugin = join(marketplaceRepo, 'plugins', pluginName);
38
+ // Agent Plugins 1.0.0 §9.1 PLUGIN_DATA for the Cursor copy: a writable, install-independent directory the installer creates.
39
+ const pluginData = join(cursorRoot, 'agent-bundle', 'plugin-data', pluginName);
40
+ const receiptsRoot = join(cursorRoot, 'agent-bundle', 'receipts');
41
+ const marketplaceReceipt = join(receiptsRoot, `${pluginName}.marketplace.json`);
42
+ const usage = 'Usage: node install.mjs [--mode local|marketplace] [--replace|--force] [--help]\n node install.mjs --uninstall [--mode local|marketplace] [--keep-data | --purge-data --confirm-purge] [--force] [--plan]';
43
+
44
+ let replace = false;
45
+ let force = false;
46
+ let uninstall = false;
47
+ let plan = false;
48
+ let keepData = false;
49
+ let purgeData = false;
50
+ let confirmPurge = false;
51
+ let mode = 'local';
52
+ const argv = process.argv.slice(2);
53
+ for (let index = 0; index < argv.length; index += 1) {
54
+ const argument = argv[index];
55
+ if (argument === '--replace' || argument === '--force') { replace = true; force = argument === '--force'; continue; }
56
+ if (argument === '--uninstall') { uninstall = true; continue; }
57
+ if (argument === '--plan') { plan = true; continue; }
58
+ if (argument === '--keep-data') { keepData = true; continue; }
59
+ if (argument === '--purge-data') { purgeData = true; continue; }
60
+ if (argument === '--confirm-purge') { confirmPurge = true; continue; }
61
+ if (argument === '--mode') {
62
+ mode = argv[index + 1];
63
+ if (mode !== 'local' && mode !== 'marketplace') { console.error(`Install mode must be local or marketplace.\n${usage}`); process.exit(2); }
64
+ index += 1;
65
+ continue;
66
+ }
67
+ if (argument === '--help' || argument === '-h') { console.log(usage); process.exit(0); }
68
+ console.error(`Unknown installer argument ${JSON.stringify(argument)}.\n${usage}`);
69
+ process.exit(2);
70
+ }
71
+ if (!uninstall && (plan || keepData || purgeData || confirmPurge)) {
72
+ console.error(`--plan, --keep-data, --purge-data, and --confirm-purge apply to --uninstall only.\n${usage}`);
73
+ process.exit(2);
74
+ }
75
+ if (uninstall && purgeData && keepData) { console.error(`--keep-data and --purge-data are mutually exclusive.\n${usage}`); process.exit(2); }
76
+ if (uninstall && purgeData && !confirmPurge) {
77
+ console.error(`--purge-data deletes the plugin's durable runtime state (state kernel, notices journal) and requires --confirm-purge; omit both flags to keep the data.\n${usage}`);
78
+ process.exit(2);
79
+ }
80
+
81
+ const exists = async (path) => {
82
+ try { await lstat(path); return true; }
83
+ catch (error) { if (error?.code === 'ENOENT') return false; throw error; }
84
+ };
85
+ const unsupported = (relative) => new Error(`Refusing unsupported filesystem entry ${JSON.stringify(relative || '.')}.`);
86
+ const toPosix = (path) => path.replaceAll('\\', '/');
87
+ const short = (hash) => hash.slice(0, 12);
88
+ const sortNames = (names) => [...names].sort((left, right) => left.localeCompare(right));
89
+ const compareTreePaths = (left, right) => {
90
+ const leftSegments = left.split('/');
91
+ const rightSegments = right.split('/');
92
+ for (let index = 0; index < Math.min(leftSegments.length, rightSegments.length); index += 1) {
93
+ const compared = leftSegments[index].localeCompare(rightSegments[index]);
94
+ if (compared !== 0) return compared;
95
+ }
96
+ return leftSegments.length - rightSegments.length;
97
+ };
98
+ // Every ancestor directory of the given POSIX-relative files, deduplicated and sorted.
99
+ const directoriesOf = (files) => {
100
+ const directories = new Set();
101
+ for (const file of files) {
102
+ let directory = dirname(file);
103
+ while (directory !== '.' && directory !== '') { directories.add(directory); directory = dirname(directory); }
104
+ }
105
+ return sortNames(directories);
106
+ };
107
+
108
+ // path\0mode\0bytes\0 per file; mode is x for an executable and - otherwise.
109
+ const hashEntry = (hash, relative, metadata, bytes) => {
110
+ hash.update(toPosix(relative));
111
+ hash.update('\0');
112
+ hash.update((metadata.mode & 0o111) === 0 ? '-' : 'x');
113
+ hash.update('\0');
114
+ hash.update(bytes);
115
+ hash.update('\0');
116
+ };
117
+
118
+ // Deterministic tree walk: symlinks and special files refused, the root receipt skipped. `transform`
119
+ // maps a file's bytes to what the Cursor copy will hold (the Agent Plugins mcp.json expansion below), so
120
+ // the artifact hash describes the installed form and reruns compare like for like.
121
+ const inventory = async (root, transform) => {
122
+ const rootMetadata = await lstat(root);
123
+ if (rootMetadata.isSymbolicLink() || !rootMetadata.isDirectory()) throw unsupported('.');
124
+ const hash = createHash('sha256');
125
+ const files = [];
126
+ const visit = async (relative) => {
127
+ const absolute = join(root, relative);
128
+ const metadata = await lstat(absolute);
129
+ if (metadata.isSymbolicLink() || (!metadata.isDirectory() && !metadata.isFile())) throw unsupported(relative);
130
+ if (metadata.isDirectory()) {
131
+ for (const entry of (await readdir(absolute)).sort((left, right) => left.localeCompare(right))) await visit(join(relative, entry));
132
+ return;
133
+ }
134
+ // Every inventoried path must round-trip through a receipt unchanged (no backslash in a POSIX
135
+ // name, nothing the receipt reader rejects); refuse the rest up front.
136
+ const posixPath = toPosix(relative);
137
+ if ((sep === '/' && relative.includes('\\')) || !safeRelative(posixPath)) throw unsupported(sep === '/' ? relative : posixPath);
138
+ files.push(posixPath);
139
+ const bytes = await readFile(absolute);
140
+ hashEntry(hash, relative, metadata, transform === undefined ? bytes : transform(posixPath, bytes));
141
+ };
142
+ if (artifactManifest !== undefined) {
143
+ const selected = new Set(['agent-bundle.manifest.json', ...artifactManifest.files.map((file) => file.path)]);
144
+ for (const file of ['.env', '.env.local']) if (await exists(join(root, file))) selected.add(file);
145
+ for (const file of [...selected].sort(compareTreePaths)) await visit(file);
146
+ return { files, hash: hash.digest('hex') };
147
+ }
148
+ for (const entry of (await readdir(root)).sort((left, right) => left.localeCompare(right))) {
149
+ if (entry === receiptFile) {
150
+ // The receipt is deletion authority: skipped from the hash, must be a regular file.
151
+ if (!(await lstat(join(root, entry))).isFile()) throw unsupported(entry);
152
+ continue;
153
+ }
154
+ // Runtime-owned roots (state/) are never plugin content: not hashed, not installed, not owned.
155
+ if (isPreservedRoot(entry)) continue;
156
+ await visit(entry);
157
+ }
158
+ return { files, hash: hash.digest('hex') };
159
+ };
160
+
161
+ const treeHash = async (root) => (await inventory(root)).hash;
162
+
163
+ // An existing entry is one of ours when its exact name is owned, or when the filesystem proves it a case
164
+ // alias of an owned name (both spellings realpath to the same on-disk path). Inode equality is not used.
165
+ const isOwnedEntry = async (root, owned, relative) => {
166
+ if (owned.has(relative)) return true;
167
+ const alias = relative.toLowerCase();
168
+ let canonical;
169
+ for (const candidate of owned) {
170
+ if (candidate.toLowerCase() !== alias) continue;
171
+ try {
172
+ canonical ??= await realpath(join(root, relative));
173
+ if ((await realpath(join(root, candidate))) === canonical) return true;
174
+ } catch (error) { if (error?.code !== 'ENOENT') throw error; }
175
+ }
176
+ return false;
177
+ };
178
+
179
+ // Every directory on the way to a listed path must be a real directory (no symlinked ancestors);
180
+ // an owned regular file that a rebuild turns into a directory leaves as stale first, so it is tolerated.
181
+ const assertRealAncestors = async (root, files, ownedFiles = new Set()) => {
182
+ const checked = new Set();
183
+ for (const file of files) {
184
+ let directory = dirname(file);
185
+ while (directory !== '.' && directory !== '') {
186
+ if (!checked.has(directory)) {
187
+ checked.add(directory);
188
+ try {
189
+ const metadata = await lstat(join(root, directory));
190
+ if (metadata.isSymbolicLink()) throw unsupported(directory);
191
+ if (!metadata.isDirectory() && !(metadata.isFile() && await isOwnedEntry(root, ownedFiles, directory))) {
192
+ throw unsupported(directory);
193
+ }
194
+ } catch (error) { if (error?.code !== 'ENOENT') throw error; }
195
+ }
196
+ directory = dirname(directory);
197
+ }
198
+ }
199
+ };
200
+
201
+ // Hashes exactly the owned files; a missing owned file simply changes the digest.
202
+ const hashOwned = async (root, files) => {
203
+ await assertRealAncestors(root, files);
204
+ const hash = createHash('sha256');
205
+ for (const relative of files) {
206
+ const absolute = join(root, relative);
207
+ let metadata;
208
+ try { metadata = await lstat(absolute); } catch (error) { if (error?.code === 'ENOENT') continue; throw error; }
209
+ if (metadata.isSymbolicLink() || !metadata.isFile()) throw unsupported(relative);
210
+ hashEntry(hash, relative, metadata, await readFile(absolute));
211
+ }
212
+ return hash.digest('hex');
213
+ };
214
+
215
+ // Receipt paths drive deletions: POSIX-relative only, no backslashes, no empty/./.. segments, and no
216
+ // segment Windows would normalise onto another entry (reserved characters, colons, trailing dot/space)
217
+ // or resolve as a DOS device (NUL, CON.txt, COM1, LPT1.json, ...).
218
+ const windowsDeviceName = /^(?:con|prn|aux|nul|com[0-9¹²³]|lpt[0-9¹²³])(?:\.|$)/iu;
219
+ // The receipt name is reserved as a top-level entry in every spelling, file or directory (a case alias
220
+ // resolves to the receipt itself on case-insensitive filesystems).
221
+ const safeRelative = (value) => typeof value === 'string' && value.length > 0 &&
222
+ value.split('/')[0].toLowerCase() !== receiptFile.toLowerCase() &&
223
+ !value.includes('\\') && !value.startsWith('/') && !isPreservedRoot(value.split('/')[0]) &&
224
+ value.split('/').every((segment) => segment !== '' && segment !== '.' && segment !== '..' &&
225
+ !/[<>:"|?*]/u.test(segment) && !windowsDeviceName.test(segment) &&
226
+ [...segment].every((character) => character.charCodeAt(0) >= 0x20) &&
227
+ !segment.endsWith('.') && !segment.endsWith(' '));
228
+
229
+ const registrationKinds = ["amp-project-plugin","amp-system-plugin","claude-marketplace","claude-plugin","codex-marketplace","codex-plugin","cursor-local-plugin","cursor-marketplace-staging"];
230
+ const isScope = (value) => value === 'local' || value === 'project' || value === 'user';
231
+ const isRegistration = (value) => value !== null && typeof value === 'object' && !Array.isArray(value) &&
232
+ registrationKinds.includes(value.kind) &&
233
+ (value.commit === undefined || typeof value.commit === 'string') && (value.id === undefined || typeof value.id === 'string') &&
234
+ (value.name === undefined || typeof value.name === 'string') && (value.scope === undefined || isScope(value.scope));
235
+ const isStateOwnership = (value) => value !== null && typeof value === 'object' && !Array.isArray(value) && (
236
+ value.kind === 'derived' ||
237
+ (value.kind === 'marker' && typeof value.marker === 'string' && isAbsolute(value.marker)) ||
238
+ (value.kind === 'unowned' && ['foreign-marker', 'pre-existing', 'unproven'].includes(value.reason)));
239
+ const isReceiptState = (value) => value !== null && typeof value === 'object' && !Array.isArray(value) &&
240
+ value.owner !== null && typeof value.owner === 'object' && !Array.isArray(value.owner) &&
241
+ typeof value.owner.id === 'string' && value.owner.id.length > 0 && typeof value.owner.host === 'string' &&
242
+ ['host-cli', 'local', 'marketplace'].includes(value.owner.mode) && typeof value.owner.plugin === 'string' && isScope(value.owner.scope) &&
243
+ Array.isArray(value.roots) && value.roots.every((root) => root !== null && typeof root === 'object' && !Array.isArray(root) &&
244
+ typeof root.root === 'string' && isAbsolute(root.root) && typeof root.canonicalRoot === 'string' && isAbsolute(root.canonicalRoot) &&
245
+ ['declared', 'derived'].includes(root.source) && Array.isArray(root.servers) && root.servers.every((server) => typeof server === 'string') &&
246
+ isStateOwnership(root.ownership));
247
+ // Same shape check as the core reader: a receipt missing any field reads as absent. A format/1 receipt (#420)
248
+ // is read with its lifecycle fields synthesized (local mode, user scope, one cursor-local-plugin registration,
249
+ // no host directories) and `migratedFrom` set; the next replacement rewrites it as the current format.
250
+ const readReceiptFile = async (path) => {
251
+ let value;
252
+ try {
253
+ if (!(await lstat(path)).isFile()) throw unsupported(basename(path));
254
+ value = JSON.parse(await readFile(path, 'utf8'));
255
+ } catch (error) { if (error?.code === 'ENOENT' || error instanceof SyntaxError) return undefined; throw error; }
256
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) return undefined;
257
+ if ((value.format !== receiptFormat && value.format !== legacyReceiptFormat) ||
258
+ typeof value.plugin !== 'string' || typeof value.version !== 'string' ||
259
+ typeof value.host !== 'string' || typeof value.contentHash !== 'string' || typeof value.installedAt !== 'string' ||
260
+ !Array.isArray(value.files) || !value.files.every(safeRelative) ||
261
+ !Array.isArray(value.directories) || !value.directories.every(safeRelative) ||
262
+ (value.state !== undefined && !isReceiptState(value.state)) ||
263
+ (value.stateRoot !== undefined && (value.stateRoot === null || typeof value.stateRoot !== 'object' || Array.isArray(value.stateRoot) || typeof value.stateRoot.root !== 'string' || !['derived', 'native'].includes(value.stateRoot.source))) ||
264
+ (value.webDataRoot !== undefined && typeof value.webDataRoot !== 'string')) return undefined;
265
+ if (value.format === legacyReceiptFormat) {
266
+ return { ...value, format: receiptFormat, hostDirectories: [], migratedFrom: legacyReceiptFormat, mode: 'local',
267
+ registrations: [{ kind: 'cursor-local-plugin' }], scope: 'user', updatedAt: value.installedAt };
268
+ }
269
+ if (!['host-cli', 'local', 'marketplace'].includes(value.mode) || !isScope(value.scope) || typeof value.updatedAt !== 'string' ||
270
+ !Array.isArray(value.hostDirectories) || !value.hostDirectories.every(safeRelative) ||
271
+ !Array.isArray(value.registrations) || !value.registrations.every(isRegistration)) return undefined;
272
+ if (value.state !== undefined && (value.state.owner.host !== value.host || value.state.owner.mode !== value.mode ||
273
+ value.state.owner.plugin !== value.plugin || value.state.owner.scope !== value.scope ||
274
+ value.state.owner.projectRoot !== value.projectRoot)) { value = { ...value }; delete value.state; }
275
+ return value;
276
+ };
277
+ const readReceipt = (root) => readReceiptFile(join(root, receiptFile));
278
+ // Atomic receipt write: an exclusively created random sibling (never follows a link) renamed into place.
279
+ const writeReceiptFile = async (path, text) => {
280
+ const temporary = `${path}.${randomUUID()}.tmp`;
281
+ const handle = await open(temporary, 'wx');
282
+ try {
283
+ await handle.writeFile(text, 'utf8');
284
+ await handle.close();
285
+ await rename(temporary, path);
286
+ } finally {
287
+ await rm(temporary, { force: true });
288
+ }
289
+ };
290
+ const rmdirIfEmpty = async (path) => {
291
+ try { await rmdir(path); return true; }
292
+ catch (error) { if (['ENOTEMPTY', 'ENOENT', 'EEXIST', 'ENOTDIR'].includes(error?.code)) return false; throw error; }
293
+ };
294
+
295
+ const readManifest = async (root) => {
296
+ for (const manifest of declaredDocument('plugin') === undefined ? ['.cursor-plugin/plugin.json', 'plugin.json'] : [declaredDocument('plugin')]) {
297
+ try {
298
+ const value = JSON.parse(await readFile(join(root, manifest), 'utf8'));
299
+ if (value !== null && typeof value === 'object' && typeof value.name === 'string') {
300
+ return { name: value.name, ...(typeof value.version === 'string' ? { version: value.version } : {}) };
301
+ }
302
+ } catch (error) { if (error?.code !== 'ENOENT' && !(error instanceof SyntaxError)) throw error; }
303
+ }
304
+ return undefined;
305
+ };
306
+
307
+ const hasMarkers = async (root) => {
308
+ for (const marker of markerFiles) if (!(await exists(join(root, marker)))) return false;
309
+ return true;
310
+ };
311
+
312
+ // Agent Plugins packs (root plugin.json with an agent-plugins.org $schema, no .cursor-plugin/plugin.json).
313
+ // Observed on Cursor 3.18.25 (docs/audits/2026-09-03-agent-plugins-cursor-ide-proof.md): the loader spawns their
314
+ // stdio servers without expanding ${PLUGIN_ROOT}/${PLUGIN_DATA} in args, env values or cwd, without providing
315
+ // the reserved PLUGIN_ROOT/PLUGIN_DATA variables (spec 9.1), with an omitted cwd defaulting to the home directory
316
+ // and with ./ commands resolved against the workspace folder (spec 7.2.1). The installer therefore expands those
317
+ // forms itself in the Cursor copy of mcp.json and records the expansion in the receipt (provenance: derived).
318
+ const agentPluginsSchemaPrefix = 'https://agent-plugins.org/schemas/';
319
+ const isAgentPluginsPack = async (root) => {
320
+ if (artifactManifest !== undefined) {
321
+ const plugin = declaredDocument('plugin');
322
+ if (plugin === undefined) throw new Error('agent-bundle.manifest.json has no Cursor-compatible plugin document.');
323
+ try {
324
+ const manifest = JSON.parse(await readFile(join(root, plugin), 'utf8'));
325
+ return manifest !== null && typeof manifest === 'object' && typeof manifest.$schema === 'string' && manifest.$schema.startsWith(agentPluginsSchemaPrefix);
326
+ } catch (error) { if (error?.code === 'ENOENT' || error?.code === 'EISDIR') return false; throw error; }
327
+ }
328
+ if (await exists(join(root, '.cursor-plugin', 'plugin.json'))) return false;
329
+ try {
330
+ const manifest = JSON.parse(await readFile(join(root, 'plugin.json'), 'utf8'));
331
+ return manifest !== null && typeof manifest === 'object' && typeof manifest.$schema === 'string' && manifest.$schema.startsWith(agentPluginsSchemaPrefix);
332
+ } catch (error) { if (error?.code === 'ENOENT' || error?.code === 'EISDIR' || error instanceof SyntaxError) return false; throw error; }
333
+ };
334
+ const isRecord = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
335
+ const expandPlaceholders = (value) => value.replaceAll('${PLUGIN_ROOT}', destination).replaceAll('${PLUGIN_DATA}', pluginData);
336
+ // A plugin-relative ./ path resolves against the plugin root (spec 7.2.1); anything else is expanded in place.
337
+ const expandPath = (value) => value.startsWith('./') ? join(destination, value.slice(2)) : expandPlaceholders(value);
338
+ // Every stdio server rewritten into the form Cursor launches; undefined when the document has none to expand
339
+ // (skills-only or remote-only packs stay byte-identical to the bundle). Doctor recomputes this exact output
340
+ // from the receipt (install/cursor-agent-plugins-launch.ts expandAgentPluginsMcpForCursor); keep the two in step.
341
+ const expandAgentPluginsMcp = (text) => {
342
+ let document;
343
+ try { document = JSON.parse(text); } catch { return undefined; }
344
+ if (!isRecord(document) || !isRecord(document.mcpServers)) return undefined;
345
+ let expanded = false;
346
+ const servers = {};
347
+ for (const [name, server] of Object.entries(document.mcpServers)) {
348
+ if (!isRecord(server) || server.type !== 'stdio' || typeof server.command !== 'string') { servers[name] = server; continue; }
349
+ expanded = true;
350
+ const env = {};
351
+ for (const [key, value] of Object.entries(isRecord(server.env) ? server.env : {})) env[key] = typeof value === 'string' ? expandPlaceholders(value) : value;
352
+ env.PLUGIN_ROOT = destination;
353
+ env.PLUGIN_DATA = pluginData;
354
+ servers[name] = {
355
+ ...server,
356
+ command: expandPath(server.command),
357
+ ...(Array.isArray(server.args) ? { args: server.args.map((argument) => typeof argument === 'string' ? expandPlaceholders(argument) : argument) } : {}),
358
+ cwd: typeof server.cwd === 'string' ? expandPath(server.cwd) : destination,
359
+ env,
360
+ };
361
+ }
362
+ if (!expanded) return undefined;
363
+ return `${JSON.stringify({ ...document, mcpServers: servers }, null, 2)}\n`;
364
+ };
365
+ const agentPluginsPack = await isAgentPluginsPack(source);
366
+ const expansion = await (async () => {
367
+ if (!agentPluginsPack) return undefined;
368
+ let text;
369
+ const mcpDocument = declaredDocument('mcp') ?? 'mcp.json';
370
+ try { text = await readFile(join(source, mcpDocument), 'utf8'); }
371
+ catch (error) { if (error?.code === 'ENOENT' || error?.code === 'EISDIR') return undefined; throw error; }
372
+ const expanded = expandAgentPluginsMcp(text);
373
+ return expanded === undefined ? undefined : { documents: { [mcpDocument]: text }, expanded, mcpDocument, pluginData, pluginRoot: destination };
374
+ })();
375
+ // The Cursor copy differs from the bundle in exactly that one file; the artifact inventory hashes the expanded
376
+ // form so receipts, no-op reruns and replacement compare the bundle with what the copy must hold.
377
+ const cursorTransform = expansion === undefined ? undefined : (relativePath, bytes) => relativePath === expansion.mcpDocument ? Buffer.from(expansion.expanded, "utf8") : bytes;
378
+
379
+ // directories: the ones the installer created (all of them on a fresh install); only those are ever pruned.
380
+ // hostDirectories: the ones created under ~/.cursor on the way to the plugin root; installedAt carries over from a
381
+ // replaced receipt; mode/registrations describe the delivery so --uninstall reverses exactly that.
382
+ const receiptFor = (tree, options = {}) => {
383
+ const now = new Date().toISOString();
384
+ const mode = options.mode ?? 'local';
385
+ return JSON.stringify({
386
+ contentHash: tree.hash,
387
+ // The pre-expansion document bytes and the values substituted, so Doctor validates the Agent Plugins
388
+ // contract against what the bundle shipped and proves the expansion against what Cursor spawns. Only a
389
+ // local copy that owns files carries it: marketplace staging holds no mcp.json, and a remnant receipt carries
390
+ // it only when --uninstall --keep-data preserved a written PLUGIN_DATA directory (options.cursorExpansion).
391
+ ...(options.cursorExpansion !== undefined ? { cursorExpansion: options.cursorExpansion } : expansion === undefined || mode !== 'local' || tree.files.length === 0 ? {} : { cursorExpansion: { documents: expansion.documents, pluginData: expansion.pluginData, pluginRoot: expansion.pluginRoot } }),
392
+ directories: options.directories ?? directoriesOf(tree.files),
393
+ files: tree.files,
394
+ format: receiptFormat,
395
+ host: 'cursor',
396
+ hostDirectories: sortNames(options.hostDirectories ?? []),
397
+ installedAt: options.installedAt ?? now,
398
+ mode,
399
+ plugin: pluginName,
400
+ registrations: options.registrations ?? [{ kind: 'cursor-local-plugin' }],
401
+ scope: 'user',
402
+ ...(options.state === undefined ? {} : { state: options.state }),
403
+ ...(options.stateRoot === undefined ? {} : { stateRoot: options.stateRoot }),
404
+ updatedAt: now,
405
+ version: pluginVersion,
406
+ ...(options.webDataRoot === undefined ? {} : { webDataRoot: options.webDataRoot }),
407
+ }, null, 2) + '\n';
408
+ };
409
+
410
+ const stateMarkerFile = ".agent-bundle-state-owner.json";
411
+ const expandStatePath = (value, root) => value.replaceAll('${CURSOR_PLUGIN_ROOT}', root).replaceAll('${PLUGIN_ROOT}', root);
412
+ const canonicalPath = async (path) => {
413
+ try { return await realpath(path); }
414
+ catch (error) { if (error?.code !== 'ENOENT') throw error; const parent = dirname(path); return parent === path ? resolve(path) : join(await canonicalPath(parent), basename(path)); }
415
+ };
416
+ const stateLocations = async () => {
417
+ const canonical = await realpath(destination);
418
+ let servers = [];
419
+ for (const manifest of declaredDocument('mcp') === undefined ? ['.cursor-plugin/mcp.json', 'mcp.json'] : [declaredDocument('mcp')]) {
420
+ let document;
421
+ try { document = JSON.parse(await readFile(join(canonical, manifest), 'utf8')); }
422
+ catch (error) { if (error?.code === 'ENOENT' || error instanceof SyntaxError) continue; throw error; }
423
+ if (document?.mcpServers !== null && typeof document?.mcpServers === 'object' && !Array.isArray(document.mcpServers)) {
424
+ servers = Object.entries(document.mcpServers).filter(([, server]) => server !== null && typeof server === 'object' && !Array.isArray(server)).sort(([left], [right]) => left.localeCompare(right));
425
+ break;
426
+ }
427
+ }
428
+ const xdg = process.env.XDG_STATE_HOME ?? '';
429
+ const stateHome = isAbsolute(xdg) ? join(xdg, 'agent-bundle') : join(homedir(), '.agent-bundle', 'state');
430
+ const digest = createHash('sha256').update(canonical).digest('hex').slice(0, 16);
431
+ const name = /^[a-zA-Z0-9](?:[a-zA-Z0-9._-]*[a-zA-Z0-9])?$/u.test(basename(canonical)) ? basename(canonical) : 'plugin';
432
+ const derived = join(stateHome, `${name}-${digest}`);
433
+ if (servers.length === 0) {
434
+ const inherited = process.env.AGENT_BUNDLE_STATE_ROOT;
435
+ if (typeof inherited !== 'string' || inherited.trim() === '') return [{ root: derived, server: 'default', source: 'derived' }];
436
+ const expanded = expandStatePath(inherited, canonical);
437
+ if (/\$\{[^}]*\}/u.test(expanded)) return [{ root: derived, server: 'default', source: 'derived' }];
438
+ return isAbsolute(expanded) ? [{ root: resolve(expanded), server: 'default', source: 'declared' }] : [];
439
+ }
440
+ const locations = [];
441
+ for (const [server, definition] of servers) {
442
+ const value = definition?.env?.AGENT_BUNDLE_STATE_ROOT ?? process.env.AGENT_BUNDLE_STATE_ROOT;
443
+ if (typeof value !== 'string' || value.trim() === '') { locations.push({ root: derived, server, source: 'derived' }); continue; }
444
+ const expanded = expandStatePath(value, canonical);
445
+ if (/\$\{[^}]*\}/u.test(expanded)) { locations.push({ root: derived, server, source: 'derived' }); continue; }
446
+ if (isAbsolute(expanded)) { locations.push({ root: resolve(expanded), server, source: 'declared' }); continue; }
447
+ if (typeof definition?.cwd !== 'string') continue;
448
+ const expandedCwd = expandStatePath(definition.cwd, canonical);
449
+ if (/\$\{[^}]*\}/u.test(expandedCwd)) continue;
450
+ const cwd = isAbsolute(expandedCwd) ? resolve(expandedCwd) : resolve(canonical, expandedCwd);
451
+ locations.push({ root: resolve(cwd, expanded), server, source: 'declared' });
452
+ }
453
+ return locations;
454
+ };
455
+ const attachStateOwnership = async (previousState) => {
456
+ const receipt = await readReceipt(destination);
457
+ if (receipt === undefined) throw new Error(`Installed receipt is missing at ${destination}.`);
458
+ const owner = previousState?.owner ?? { host: 'cursor', id: randomUUID(), mode: 'local', plugin: pluginName, scope: 'user' };
459
+ const grouped = new Map();
460
+ for (const location of await stateLocations()) {
461
+ const current = grouped.get(location.root);
462
+ if (current === undefined) grouped.set(location.root, { ...location, servers: [location.server] });
463
+ else current.servers.push(location.server);
464
+ }
465
+ const roots = [];
466
+ const created = [];
467
+ const markerOwns = async (marker) => {
468
+ let document;
469
+ try { document = JSON.parse(await readFile(marker, 'utf8')); }
470
+ catch (error) { if (error?.code === 'ENOENT' || error instanceof SyntaxError) return false; throw error; }
471
+ const actual = document?.owner;
472
+ return document?.format === 1 && actual?.id === owner.id && actual?.host === owner.host && actual?.mode === owner.mode &&
473
+ actual?.plugin === owner.plugin && actual?.scope === owner.scope && actual?.projectRoot === owner.projectRoot;
474
+ };
475
+ const rollbackCreated = async () => {
476
+ for (const root of [...created].reverse()) {
477
+ await rm(join(root, stateMarkerFile), { force: true });
478
+ try { await rmdir(root); } catch (error) { if (!['ENOENT', 'ENOTEMPTY'].includes(error?.code)) throw error; }
479
+ }
480
+ };
481
+ try {
482
+ for (const location of grouped.values()) {
483
+ if (location.source === 'derived') { roots.push({ canonicalRoot: await canonicalPath(location.root), ownership: { kind: 'derived' }, root: location.root, servers: location.servers, source: 'derived' }); continue; }
484
+ try {
485
+ const marker = join(location.root, stateMarkerFile);
486
+ let existed = true;
487
+ try { await lstat(location.root); } catch (error) { if (error?.code !== 'ENOENT') throw error; existed = false; }
488
+ let ownership;
489
+ if (!existed) {
490
+ await mkdir(dirname(location.root), { recursive: true });
491
+ try {
492
+ await mkdir(location.root);
493
+ created.push(location.root);
494
+ const handle = await open(marker, 'wx');
495
+ try { await handle.writeFile(`${JSON.stringify({ format: 1, owner }, null, 2)}\n`, 'utf8'); } finally { await handle.close(); }
496
+ ownership = { kind: 'marker', marker };
497
+ } catch (error) {
498
+ if (error?.code !== 'EEXIST') {
499
+ if (created.at(-1) === location.root) {
500
+ created.pop();
501
+ try { await rmdir(location.root); } catch (rollbackError) { if (!['ENOENT', 'ENOTEMPTY'].includes(rollbackError?.code)) throw rollbackError; }
502
+ }
503
+ throw error;
504
+ }
505
+ if (created.at(-1) === location.root) created.pop();
506
+ ownership = await markerOwns(marker) ? { kind: 'marker', marker } : { kind: 'unowned', reason: 'foreign-marker' };
507
+ }
508
+ } else if (await markerOwns(marker)) ownership = { kind: 'marker', marker };
509
+ else {
510
+ let markerExists = true;
511
+ try { await lstat(marker); } catch (error) { if (error?.code !== 'ENOENT') throw error; markerExists = false; }
512
+ ownership = { kind: 'unowned', reason: markerExists ? 'foreign-marker' : 'pre-existing' };
513
+ }
514
+ roots.push({ canonicalRoot: await canonicalPath(location.root), ownership, root: location.root, servers: location.servers, source: 'declared' });
515
+ } catch (error) {
516
+ if (!['EACCES', 'ENOTDIR', 'EPERM', 'EROFS'].includes(error?.code)) throw error;
517
+ if (created.at(-1) === location.root) {
518
+ created.pop();
519
+ await rm(join(location.root, stateMarkerFile), { force: true });
520
+ try { await rmdir(location.root); } catch (rollbackError) { if (!['ENOENT', 'ENOTEMPTY'].includes(rollbackError?.code)) throw rollbackError; }
521
+ }
522
+ roots.push({ canonicalRoot: resolve(location.root), ownership: { kind: 'unowned', reason: 'unproven' }, root: location.root, servers: location.servers, source: 'declared' });
523
+ }
524
+ }
525
+ } catch (error) { await rollbackCreated(); throw error; }
526
+ try {
527
+ await writeReceiptFile(join(destination, receiptFile), `${JSON.stringify({ ...receipt, state: { owner, roots }, updatedAt: new Date().toISOString() }, null, 2)}\n`);
528
+ } catch (error) {
529
+ await rollbackCreated();
530
+ throw error;
531
+ }
532
+ };
533
+
534
+ // Staged sibling copy on the destination filesystem so every later rename is atomic.
535
+ const stage = async (tree, receiptOptions = {}) => {
536
+ const parent = await mkdtemp(join(installRoot, `.${basename(destination)}.stage-`));
537
+ const root = join(parent, 'bundle');
538
+ try {
539
+ // Exactly the inventoried content is copied: runtime roots, a stray receipt, and empty directories
540
+ // (no plugin content: not hashed, not installed, not owned) never are.
541
+ const content = new Set([...tree.files, ...directoriesOf(tree.files)]);
542
+ const filter = (path) => {
543
+ const relativePath = relative(source, path);
544
+ return relativePath === '' || content.has(toPosix(relativePath));
545
+ };
546
+ await cp(source, root, { errorOnExist: true, filter, force: false, recursive: true, verbatimSymlinks: true });
547
+ if (expansion !== undefined) await writeFile(join(root, expansion.mcpDocument), expansion.expanded, 'utf8');
548
+ const staged = await inventory(root);
549
+ await writeFile(join(root, receiptFile), receiptFor(staged, receiptOptions), 'utf8');
550
+ return { inventory: staged, parent, root };
551
+ } catch (error) {
552
+ await rm(parent, { force: true, recursive: true });
553
+ throw error;
554
+ }
555
+ };
556
+
557
+ // Prunes the now-empty ancestors of removed files, deepest first, but only installer-created ones:
558
+ // a pre-existing directory that merely became an ancestor of an owned file is not ours to delete.
559
+ const pruneEmptyDirectories = async (removed, ownedDirectories) => {
560
+ const pruned = new Set();
561
+ const candidates = directoriesOf(removed).filter((directory) => ownedDirectories.has(directory));
562
+ for (const directory of candidates.sort((left, right) => right.length - left.length)) {
563
+ try { await rmdir(join(destination, directory)); pruned.add(directory); }
564
+ catch (error) { if (!['ENOTEMPTY', 'ENOENT', 'EEXIST'].includes(error?.code)) throw error; }
565
+ }
566
+ return pruned;
567
+ };
568
+ // Creates missing ancestors one level at a time and records the ones this run created.
569
+ const ensureAncestors = async (file, created) => {
570
+ const ancestors = [];
571
+ let directory = dirname(file);
572
+ while (directory !== '.' && directory !== '') { ancestors.unshift(directory); directory = dirname(directory); }
573
+ for (const ancestor of ancestors) {
574
+ if (created.has(ancestor)) continue;
575
+ try { await mkdir(join(destination, ancestor)); created.add(ancestor); }
576
+ catch (error) { if (error?.code !== 'EEXIST') throw error; }
577
+ }
578
+ };
579
+
580
+ // Unowned entries under root that survive the uninstall, POSIX-relative: files that are neither owned nor runtime state
581
+ // (symlinks listed, never followed) plus unowned directories holding nothing retained (`name/`), which the prune never touches.
582
+ const listRetained = async (root, owned, ownedDirectories) => {
583
+ const retained = [];
584
+ const visit = async (relativePath) => {
585
+ let entries;
586
+ try { entries = (await readdir(join(root, relativePath))).sort((left, right) => left.localeCompare(right)); }
587
+ catch (error) { if (error?.code === 'ENOENT') return 0; throw error; }
588
+ let kept = 0;
589
+ for (const name of entries) {
590
+ const child = relativePath === '' ? name : `${relativePath}/${name}`;
591
+ if (relativePath === '' && (name === receiptFile || isPreservedRoot(name))) continue;
592
+ const metadata = await lstat(join(root, child));
593
+ if (metadata.isDirectory() && !metadata.isSymbolicLink()) {
594
+ const below = await visit(child);
595
+ if (below === 0 && !ownedDirectories.has(child)) retained.push(`${child}/`);
596
+ kept += below === 0 && !ownedDirectories.has(child) ? 1 : below;
597
+ continue;
598
+ }
599
+ if (!owned.has(child)) { retained.push(child); kept += 1; }
600
+ }
601
+ return kept;
602
+ };
603
+ await visit('');
604
+ return retained;
605
+ };
606
+ // HEAD of a staged repository from its ref text (no git needed): the commit Cursor imports.
607
+ const readHead = async (repoRoot) => {
608
+ try {
609
+ const head = (await readFile(join(repoRoot, '.git', 'HEAD'), 'utf8')).trim();
610
+ if (!head.startsWith('ref: ')) return /^[0-9a-f]{40}$|^[0-9a-f]{64}$/u.test(head) ? head : undefined;
611
+ const ref = head.slice('ref: '.length);
612
+ let resolved;
613
+ try { resolved = (await readFile(join(repoRoot, '.git', ref), 'utf8')).trim(); }
614
+ catch { resolved = (await readFile(join(repoRoot, '.git', 'packed-refs'), 'utf8')).split('\n').find((line) => line.endsWith(` ${ref}`))?.split(' ')[0]; }
615
+ return resolved !== undefined && /^[0-9a-f]{40}$|^[0-9a-f]{64}$/u.test(resolved) ? resolved : undefined;
616
+ } catch { return undefined; }
617
+ };
618
+ const printPaths = (label, paths) => {
619
+ console.log(`${label} ${paths.length} ${paths.length === 1 ? "entry" : "entries"}:`);
620
+ for (const path of paths) console.log(` ${path}`);
621
+ };
622
+ const runtimeStateRoots = async () => {
623
+ const canonical = await realpath(destination);
624
+ const inherited = process.env.AGENT_BUNDLE_STATE_ROOT ?? '';
625
+ const inheritedStateRoot = inherited.trim() === '' || /\$\{[^}]*\}/u.test(inherited)
626
+ ? undefined
627
+ : isAbsolute(inherited) ? resolve(inherited) : resolve(canonical, inherited);
628
+ let declared;
629
+ for (const manifest of declaredDocument('mcp') === undefined ? ['.cursor-plugin/mcp.json', 'mcp.json'] : [declaredDocument('mcp')]) {
630
+ let document;
631
+ try { document = JSON.parse(await readFile(join(canonical, manifest), 'utf8')); }
632
+ catch (error) { if (error?.code === 'ENOENT' || error instanceof SyntaxError) continue; throw error; }
633
+ const servers = document !== null && typeof document === 'object' && !Array.isArray(document) ? document.mcpServers : undefined;
634
+ if (servers === null || typeof servers !== 'object' || Array.isArray(servers)) continue;
635
+ for (const server of Object.values(servers)) {
636
+ const env = server !== null && typeof server === 'object' && !Array.isArray(server) ? server.env : undefined;
637
+ const value = env !== null && typeof env === 'object' && !Array.isArray(env) ? env.AGENT_BUNDLE_STATE_ROOT : undefined;
638
+ if (typeof value !== 'string' || value.trim() === '') continue;
639
+ const expanded = value.replaceAll('${CURSOR_PLUGIN_ROOT}', canonical).replaceAll('${PLUGIN_ROOT}', canonical);
640
+ if (!/\$\{[^}]*\}/u.test(expanded)) declared = isAbsolute(expanded) ? resolve(expanded) : resolve(canonical, expanded);
641
+ if (declared !== undefined) break;
642
+ }
643
+ if (declared !== undefined) break;
644
+ }
645
+ const xdg = process.env.XDG_STATE_HOME ?? '';
646
+ const stateHome = isAbsolute(xdg) ? join(xdg, 'agent-bundle') : join(homedir(), '.agent-bundle', 'state');
647
+ const digest = createHash('sha256').update(canonical).digest('hex').slice(0, 16);
648
+ const name = /^[a-zA-Z0-9](?:[a-zA-Z0-9._-]*[a-zA-Z0-9])?$/u.test(basename(canonical)) ? basename(canonical) : 'plugin';
649
+ const segment = `${name}-${digest}`;
650
+ const explicitStateRoot = declared ?? inheritedStateRoot;
651
+ const webCanonical = resolve(destination);
652
+ const webDigest = createHash('sha256').update(webCanonical).digest('hex').slice(0, 16);
653
+ const webName = /^[a-zA-Z0-9](?:[a-zA-Z0-9._-]*[a-zA-Z0-9])?$/u.test(basename(webCanonical)) ? basename(webCanonical) : 'plugin';
654
+ return [explicitStateRoot ?? join(stateHome, segment), join(canonical, 'state'), join(homedir(), '.agent-bundle', 'web-data', `${webName}-${webDigest}`), explicitStateRoot === undefined ? 'derived' : 'native'];
655
+ };
656
+
657
+ if (uninstall && mode === 'local') {
658
+ const notInstalled = () => { console.log(`Not installed ${pluginName}@${pluginVersion} for cursor (local mode) at ${destination}`); process.exit(0); };
659
+ if (!(await exists(cursorRoot)) || !(await exists(destination))) notInstalled();
660
+ const destinationMetadata = await lstat(destination);
661
+ if (destinationMetadata.isSymbolicLink() || !destinationMetadata.isDirectory()) throw unsupported('.');
662
+ const receipt = await readReceipt(destination);
663
+ let owned;
664
+ let ownedDirectories;
665
+ let hostDirectories;
666
+ let receiptStatus;
667
+ if (receipt === undefined) {
668
+ // No receipt: only a legacy layout (emitted install surface + manifest naming this plugin) may go, under --force.
669
+ const manifest = await readManifest(destination);
670
+ const legacy = manifest?.name === pluginName && await hasMarkers(destination);
671
+ if (!legacy) {
672
+ throw new Error(`Refusing to uninstall foreign directory ${destination}: it carries no install receipt and is not a ` +
673
+ `recognizable agent-bundle install of ${pluginName}${manifest === undefined ? " (no loader manifest)" : ` (manifest names ${JSON.stringify(manifest.name)})`}. ` +
674
+ 'Remove it manually if it is stale; --force does not apply to foreign directories.');
675
+ }
676
+ if (!force) {
677
+ throw new Error(`Refusing to uninstall ${destination} without an install receipt: this copy predates install receipts, so ownership ` +
678
+ 'cannot be proven. Re-run with --force to remove its inventoried plugin files (runtime state under state/ is kept unless ' +
679
+ '--purge-data --confirm-purge is passed), or reinstall with --replace first to adopt it.');
680
+ }
681
+ const tree = await inventory(destination);
682
+ owned = tree.files;
683
+ ownedDirectories = directoriesOf(tree.files);
684
+ hostDirectories = [];
685
+ receiptStatus = 'forced-legacy';
686
+ } else {
687
+ if (receipt.plugin !== pluginName) {
688
+ throw new Error(`Refusing to uninstall ${destination}: its install receipt names plugin ${JSON.stringify(receipt.plugin)}, not ` +
689
+ `${JSON.stringify(pluginName)}. Uninstall that plugin from its own bundle instead; --force does not apply.`);
690
+ }
691
+ const installedHash = await hashOwned(destination, receipt.files);
692
+ receiptStatus = receipt.migratedFrom === undefined ? 'consumed' : 'migrated';
693
+ if (installedHash !== receipt.contentHash) {
694
+ if (!force) {
695
+ throw new Error(`Refusing to uninstall ${destination}: the owned files hash ${short(installedHash)} but the receipt recorded ` +
696
+ `${short(receipt.contentHash)}, so the installed copy was modified after installation. Re-run with --force to remove the ` +
697
+ 'receipt-owned files anyway (unowned entries are never removed).');
698
+ }
699
+ receiptStatus = 'forced-mismatch';
700
+ }
701
+ owned = receipt.files;
702
+ ownedDirectories = receipt.directories;
703
+ hostDirectories = receipt.hostDirectories;
704
+ }
705
+ // A symlinked ancestor would let a leaf-only delete reach outside the plugin root: refused before any change.
706
+ await assertRealAncestors(destination, owned);
707
+ const files = [];
708
+ for (const file of owned) {
709
+ const path = join(destination, file);
710
+ let metadata;
711
+ try { metadata = await lstat(path); } catch (error) { if (error?.code === 'ENOENT') continue; throw error; }
712
+ if (metadata.isSymbolicLink() || !metadata.isFile()) throw unsupported(file);
713
+ files.push(path);
714
+ }
715
+ if (await exists(join(destination, receiptFile))) files.push(join(destination, receiptFile));
716
+ const [resolvedStateDirectory, stateDirectory, resolvedWebDataDirectory, resolvedStateSource] = await runtimeStateRoots();
717
+ const retainedState = [];
718
+ const ownedStatePaths = [];
719
+ const emptyOwnedStateFiles = [];
720
+ const emptyOwnedStateRoots = [];
721
+ if (receipt?.state !== undefined) {
722
+ for (const root of receipt.state.roots) {
723
+ let metadata;
724
+ try { metadata = await lstat(root.root); } catch (error) { if (error?.code === 'ENOENT') continue; throw error; }
725
+ if (root.ownership.kind === 'unowned') { retainedState.push({ path: root.root, reason: root.ownership.reason }); continue; }
726
+ if (!metadata.isDirectory() || metadata.isSymbolicLink()) { retainedState.push({ path: root.root, reason: 'unsupported-entry' }); continue; }
727
+ if (await realpath(root.root) !== root.canonicalRoot) { retainedState.push({ path: root.root, reason: 'canonical-path-changed' }); continue; }
728
+ if (root.ownership.kind === 'marker') {
729
+ let marker;
730
+ try { marker = JSON.parse(await readFile(root.ownership.marker, 'utf8')); } catch { marker = undefined; }
731
+ const owner = marker?.owner;
732
+ const expected = receipt.state.owner;
733
+ if (root.ownership.marker !== join(root.root, stateMarkerFile) || marker?.format !== 1 || owner?.id !== expected.id ||
734
+ owner?.host !== expected.host || owner?.mode !== expected.mode || owner?.plugin !== expected.plugin ||
735
+ owner?.scope !== expected.scope || owner?.projectRoot !== expected.projectRoot) { retainedState.push({ path: root.root, reason: 'marker-mismatch' }); continue; }
736
+ }
737
+ const entries = await readdir(root.root);
738
+ if (entries.length === 0 || (root.ownership.kind === 'marker' && entries.length === 1 && entries[0] === stateMarkerFile)) {
739
+ emptyOwnedStateRoots.push(root.root);
740
+ if (root.ownership.kind === 'marker') emptyOwnedStateFiles.push(root.ownership.marker);
741
+ continue;
742
+ }
743
+ ownedStatePaths.push(root.root);
744
+ }
745
+ } else {
746
+ const fallbackStateDirectory = receipt?.stateRoot?.root ?? resolvedStateDirectory;
747
+ const fallbackStateSource = receipt?.stateRoot?.source ?? resolvedStateSource;
748
+ let metadata;
749
+ try { metadata = await lstat(fallbackStateDirectory); } catch (error) { if (error?.code !== 'ENOENT') throw error; }
750
+ if (metadata?.isDirectory()) {
751
+ if (receipt?.stateRoot?.root === fallbackStateDirectory && fallbackStateSource === 'derived') ownedStatePaths.push(fallbackStateDirectory);
752
+ else retainedState.push({ path: fallbackStateDirectory, reason: 'unproven' });
753
+ }
754
+ }
755
+ const webDataDirectory = receipt?.webDataRoot ?? resolvedWebDataDirectory;
756
+ const externalDataPaths = [...ownedStatePaths];
757
+ for (const path of [webDataDirectory]) {
758
+ if (path === stateDirectory) continue;
759
+ let metadata;
760
+ try { metadata = await lstat(path); } catch (error) { if (error?.code === 'ENOENT') continue; throw error; }
761
+ if (metadata.isSymbolicLink() || !metadata.isDirectory()) throw unsupported(path);
762
+ externalDataPaths.push(path);
763
+ }
764
+ let stateMetadata;
765
+ try { stateMetadata = await lstat(stateDirectory); } catch (error) { if (error?.code !== 'ENOENT') throw error; }
766
+ if (stateMetadata !== undefined && (stateMetadata.isSymbolicLink() || !stateMetadata.isDirectory())) throw unsupported('state');
767
+ // A state/ holding nothing is not durable state: pruned like an installer-created directory instead of kept as a remnant.
768
+ const emptyState = stateMetadata !== undefined && (await readdir(stateDirectory)).length === 0 ? stateDirectory : undefined;
769
+ const dataPaths = [...externalDataPaths, ...(stateMetadata === undefined || emptyState !== undefined ? [] : [stateDirectory])];
770
+ const dataKinds = [...externalDataPaths.map((path) => ownedStatePaths.includes(path) ? `owned framework state root ${path}` : `web-data directory ${path}`), ...(stateMetadata === undefined || emptyState !== undefined ? [] : ['legacy state/ (state kernel, notices journal)'])];
771
+ // The receipt's cursorExpansion records the PLUGIN_DATA directory this installer created for the copy (spec 9.1). Only
772
+ // the directory at this home's own plugin-data location is receipt-owned; a written one is durable state (kept or
773
+ // purged like state/), an empty one is an installer-created directory that is pruned, a recorded path elsewhere is left alone.
774
+ const recordedPluginData = receipt?.cursorExpansion?.pluginData;
775
+ const pluginDataRecorded = recordedPluginData === pluginData;
776
+ let emptyPluginData;
777
+ let foreignNote = "";
778
+ if (recordedPluginData !== undefined && !pluginDataRecorded) {
779
+ foreignNote = ` The receipt records PLUGIN_DATA at ${recordedPluginData}, outside this home's agent-bundle/plugin-data; it is not touched.`;
780
+ } else if (pluginDataRecorded) {
781
+ // Reached only through real directories: a symlinked agent-bundle or plugin-data ancestor would let a recursive purge
782
+ // of the leaf follow it outside the Cursor home, so any link on the way is refused before anything is read or removed.
783
+ let pluginDataMetadata;
784
+ for (const directory of [join(cursorRoot, 'agent-bundle'), join(cursorRoot, 'agent-bundle', 'plugin-data'), pluginData]) {
785
+ pluginDataMetadata = undefined;
786
+ try { pluginDataMetadata = await lstat(directory); } catch (error) { if (error?.code !== 'ENOENT') throw error; }
787
+ if (pluginDataMetadata === undefined) break;
788
+ if (pluginDataMetadata.isSymbolicLink() || !pluginDataMetadata.isDirectory()) throw unsupported(relative(cursorRoot, directory));
789
+ }
790
+ if (pluginDataMetadata !== undefined) {
791
+ if ((await readdir(pluginData)).length === 0) emptyPluginData = pluginData;
792
+ else { dataPaths.push(pluginData); dataKinds.push(`the PLUGIN_DATA directory ${pluginData}`); }
793
+ }
794
+ }
795
+ const retainedStateNote = retainedState.length === 0 ? '' : ` Retained ${retainedState.map((entry) => `${entry.path} (${entry.reason})`).join(', ')} because the receipt does not prove exclusive ownership.`;
796
+ const dataOutcome = dataPaths.length === 0 ? retainedState.length === 0 ? 'absent' : 'kept' : purgeData ? 'purged' : 'kept';
797
+ const dataDetail = dataPaths.length === 0 && retainedState.length === 0
798
+ ? `No durable runtime state exists (${emptyState === undefined ? 'no state/ under the installed plugin root' : 'state/ under the installed plugin root is empty and is pruned'}${emptyPluginData === undefined ? '' : `; the installer-created PLUGIN_DATA directory ${emptyPluginData} is empty and is pruned`}).${foreignNote}`
799
+ : purgeData
800
+ ? `${dataPaths.length === 0 ? 'No owned durable runtime state is removed.' : `Durable runtime state — ${dataKinds.join(' and ')} — is removed (--purge-data --confirm-purge).`}${retainedStateNote}${foreignNote}`
801
+ : `Durable runtime state${dataKinds.length === 0 ? '' : ` — ${dataKinds.join(' and ')}`} — is kept; pass --purge-data --confirm-purge to remove owned roots.${retainedStateNote}${foreignNote}`;
802
+ // External state kept by --keep-data needs the remnant receipt and recorded ownership so a later purge
803
+ // removes the same root even though no plugin content remains.
804
+ const keepRoot = !purgeData && [...dataPaths, ...retainedState.map((entry) => entry.path)].some((path) => path !== stateDirectory);
805
+ const directories = [
806
+ ...ownedDirectories.map((directory) => join(destination, directory)),
807
+ ...(keepRoot ? [] : [destination]),
808
+ ...hostDirectories.map((directory) => join(cursorRoot, directory)),
809
+ ...(emptyPluginData === undefined ? [] : [emptyPluginData]),
810
+ ...(emptyState === undefined ? [] : [emptyState]),
811
+ ...(pluginDataRecorded ? [join(cursorRoot, 'agent-bundle', 'plugin-data'), join(cursorRoot, 'agent-bundle')] : []),
812
+ ...emptyOwnedStateRoots,
813
+ ].sort((left, right) => right.length - left.length || left.localeCompare(right));
814
+ files.push(...emptyOwnedStateFiles);
815
+ const ownedSet = new Set(owned);
816
+ const ownedDirectorySet = new Set(ownedDirectories);
817
+ const remnantOnly = receipt !== undefined && receipt.files.length === 0 && receipt.registrations.length === 0;
818
+ const purging = purgeData && dataPaths.length > 0;
819
+ // A keep-data rerun over a remnant whose preserved data (or retained unowned entries) are still there is the documented
820
+ // no-op. Once state/ and the PLUGIN_DATA directory are gone or emptied by hand the remnant guards nothing, and the rerun
821
+ // consumes it (receipt, empty plugin root, the host and plugin-data directories it recorded) like an explicit purge would.
822
+ const remnantGuards = dataPaths.length > 0 || retainedState.length > 0 || (await listRetained(destination, ownedSet, ownedDirectorySet)).length > 0;
823
+ if (remnantOnly && !purgeData && remnantGuards && files.length === 1 && files[0] === join(destination, receiptFile)) {
824
+ // A rerun over what an earlier --keep-data uninstall left behind, still keeping the data: nothing to remove, so the
825
+ // remnant receipt stays and the run is the documented no-op.
826
+ console.log(`Not installed ${pluginName}@${pluginVersion} for cursor (local mode) at ${destination}`);
827
+ console.log(`Receipt: remnant (${join(destination, receiptFile)}) — only preserved runtime state remains from an earlier --uninstall --keep-data; pass --purge-data --confirm-purge to remove it.`);
828
+ process.exit(0);
829
+ }
830
+ const purgedDirectories = purging ? dataPaths : [];
831
+ const summary = (verb) => {
832
+ console.log(`${verb} ${pluginName}@${pluginVersion} for cursor (local mode) at ${destination}${force ? ' [--force]' : ''}`);
833
+ console.log(`Receipt: ${receiptStatus} (${join(destination, receiptFile)})`);
834
+ console.log(remnantOnly
835
+ ? 'Registration cursor-local-plugin: already-absent — Only preserved runtime state remained from an earlier --uninstall --keep-data; no plugin content was registered.'
836
+ : `Registration cursor-local-plugin: ${verb === 'Would uninstall' ? 'planned' : 'removed'} — Cursor loads plugins/local/<name> directly; removing the directory unregisters the plugin at the next window reload.`);
837
+ };
838
+ if (plan) {
839
+ summary('Would uninstall');
840
+ printPaths('Would remove file', files);
841
+ // Exactly the directories the run would prune: deepest first, once everything they hold is itself removed.
842
+ const gone = new Set([...files, ...purgedDirectories]);
843
+ const prunable = [];
844
+ for (const directory of directories) {
845
+ let entries;
846
+ try { entries = await readdir(directory); } catch (error) { if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') continue; throw error; }
847
+ if (entries.every((entry) => gone.has(join(directory, entry)))) { gone.add(directory); prunable.push(directory); }
848
+ }
849
+ printPaths('Would remove directory', [...purgedDirectories, ...prunable]);
850
+ console.log(`Data (${purgeData ? 'purge' : 'keep'}): ${dataOutcome} — ${dataDetail}`);
851
+ for (const path of dataPaths) console.log(` ${path}`);
852
+ for (const entry of retainedState) console.log(` retained ${entry.path}: ${entry.reason}`);
853
+ const retained = await listRetained(destination, ownedSet, ownedDirectorySet);
854
+ if (retained.length > 0) printPaths(`Retained unowned under ${destination}:`, retained);
855
+ if (!prunable.includes(destination)) console.log(`Remnant receipt (would be written): ${join(destination, receiptFile)} — owns no files; keeps the created host directories receipt-owned for a later purge.`);
856
+ process.exit(0);
857
+ }
858
+ for (const path of files) await rm(path, { force: true });
859
+ for (const path of purgedDirectories) await rm(path, { force: true, recursive: true });
860
+ const pruned = [];
861
+ for (const directory of directories) if (await rmdirIfEmpty(directory)) pruned.push(directory);
862
+ summary('Uninstalled');
863
+ printPaths('Removed file', files);
864
+ printPaths('Removed directory', [...purgedDirectories, ...pruned]);
865
+ console.log(`Data (${purgeData ? 'purge' : 'keep'}): ${dataOutcome} — ${dataDetail}`);
866
+ for (const path of dataPaths) console.log(` ${path}`);
867
+ const retained = await exists(destination) ? await listRetained(destination, ownedSet, ownedDirectorySet) : [];
868
+ if (retained.length > 0) printPaths(`Retained unowned under ${destination}:`, retained);
869
+ if (await exists(destination)) {
870
+ // The plugin root survives (retained runtime state or unowned entries): a remnant receipt owning no files keeps the
871
+ // created host directories receipt-owned for a later purge and lets Doctor explain the directory; a reinstall fills it in.
872
+ await writeReceiptFile(join(destination, receiptFile), receiptFor({ files: [], hash: createHash('sha256').digest('hex') }, {
873
+ // A kept PLUGIN_DATA directory stays receipt-owned through the remnant's expansion record.
874
+ ...(keepRoot && receipt?.cursorExpansion !== undefined ? { cursorExpansion: receipt.cursorExpansion } : {}),
875
+ directories: [], hostDirectories, installedAt: receipt?.installedAt, registrations: [],
876
+ ...(receipt?.state === undefined ? {} : { state: receipt.state }),
877
+ ...(keepRoot ? { webDataRoot: webDataDirectory } : {}),
878
+ }));
879
+ console.log(`Remnant receipt: ${join(destination, receiptFile)} — owns no files; keeps the created host directories receipt-owned for a later purge.`);
880
+ }
881
+ process.exit(0);
882
+ }
883
+
884
+ if (uninstall && mode === 'marketplace') {
885
+ const receipt = await exists(cursorRoot) ? await readReceiptFile(marketplaceReceipt) : undefined;
886
+ const repoExists = await exists(cursorRoot) && await exists(marketplaceRepo);
887
+ if (repoExists) {
888
+ const repoMetadata = await lstat(marketplaceRepo);
889
+ if (repoMetadata.isSymbolicLink() || !repoMetadata.isDirectory()) throw unsupported(relative(cursorRoot, marketplaceRepo));
890
+ }
891
+ if (!repoExists && receipt === undefined) {
892
+ console.log(`Not installed ${pluginName}@${pluginVersion} for cursor (marketplace mode) at ${marketplaceRepo}`);
893
+ process.exit(0);
894
+ }
895
+ let receiptStatus = receipt === undefined ? 'forced-missing' : receipt.migratedFrom === undefined ? 'consumed' : 'migrated';
896
+ const recorded = receipt?.registrations.find((registration) => registration.kind === 'cursor-marketplace-staging');
897
+ if (repoExists) {
898
+ if (receipt === undefined) {
899
+ if (!force) {
900
+ throw new Error(`Refusing to remove staged Cursor marketplace ${marketplaceRepo} without an install receipt at ${marketplaceReceipt}. ` +
901
+ 'Re-run with --force to remove it after verifying it is this plugin\'s staging, or rerun `node install.mjs --mode marketplace` to record a receipt first.');
902
+ }
903
+ let stagedName;
904
+ try { stagedName = JSON.parse(await readFile(join(marketplaceRepo, '.cursor-plugin', 'marketplace.json'), 'utf8'))?.name; }
905
+ catch (error) { if (error?.code !== 'ENOENT' && !(error instanceof SyntaxError)) throw error; }
906
+ const stagedPlugin = await readManifest(marketplacePlugin);
907
+ if (stagedName !== `${pluginName}-marketplace` || stagedPlugin?.name !== pluginName) {
908
+ throw new Error(`Refusing to remove ${marketplaceRepo}: it is not a staged Agent Bundle marketplace for ${pluginName} ` +
909
+ `(expected .cursor-plugin/marketplace.json naming ${pluginName}-marketplace and plugins/${pluginName}). --force does not apply.`);
910
+ }
911
+ } else if (receipt.plugin !== pluginName) {
912
+ throw new Error(`Refusing to remove ${marketplaceRepo}: the receipt at ${marketplaceReceipt} names plugin ${JSON.stringify(receipt.plugin)}.`);
913
+ } else if (recorded?.commit !== undefined) {
914
+ const head = await readHead(marketplaceRepo);
915
+ if (head !== recorded.commit) {
916
+ if (!force) {
917
+ throw new Error(`Refusing to remove ${marketplaceRepo}: its HEAD is ${head ?? 'unresolvable'} but the receipt recorded commit ` +
918
+ `${recorded.commit}, so the staged repository changed after staging. Re-run with --force to remove it anyway.`);
919
+ }
920
+ receiptStatus = 'forced-mismatch';
921
+ } else {
922
+ // HEAD matching the receipt proves the commit, not the working tree: entries added since staging are not
923
+ // receipt-owned and the removal below is recursive. Unverifiable (no git, status failing) is dirt too.
924
+ const dirt = await new Promise((resolvePromise) => {
925
+ execFile('git', ['--no-optional-locks', 'status', '--porcelain', '--untracked-files=all', '--ignored=matching'], { cwd: marketplaceRepo }, (error, stdout, stderr) => {
926
+ if (error?.code === 'ENOENT') { resolvePromise('git is not available on PATH, so the staged working tree cannot be verified against the receipted commit.'); return; }
927
+ if (error) { resolvePromise(`\`git status\` failed in the staged repository (${(stderr || error.message).trim()}), so its working tree cannot be verified against the receipted commit.`); return; }
928
+ const entries = stdout.split('\n').map((line) => line.trimEnd()).filter((line) => line !== '');
929
+ if (entries.length === 0) { resolvePromise(undefined); return; }
930
+ const shown = entries.slice(0, 5).map((line) => JSON.stringify(line.slice(3))).join(', ');
931
+ resolvePromise(`its working tree differs from the receipted commit (${entries.length} uncommitted, untracked, or ignored ${entries.length === 1 ? 'entry' : 'entries'}: ${shown}${entries.length > 5 ? ', …' : ''}) that the receipt does not own.`);
932
+ });
933
+ });
934
+ if (dirt !== undefined) {
935
+ if (!force) {
936
+ throw new Error(`Refusing to remove ${marketplaceRepo}: ${dirt} Move those entries out (or commit them and rerun ` +
937
+ '`node install.mjs --mode marketplace`), or re-run with --force to remove them anyway.');
938
+ }
939
+ receiptStatus = 'forced-mismatch';
940
+ }
941
+ }
942
+ }
943
+ }
944
+ // A completed copy from this staging under Cursor's cache means Cursor imported it; that copy is Cursor-owned.
945
+ const cacheSegment = (value) => value.replaceAll(/[^A-Za-z0-9._-]/gu, '-');
946
+ // Whether the staged repository still exists or not: the receipt's commit is what identifies the imported copy.
947
+ let imported = false;
948
+ if (recorded?.commit !== undefined) {
949
+ imported = await exists(join(cursorRoot, 'plugins', 'cache', cacheSegment(`${pluginName}-marketplace`), cacheSegment(pluginName), cacheSegment(recorded.commit), '.cache-complete'));
950
+ }
951
+ const files = await exists(marketplaceReceipt) ? [marketplaceReceipt] : [];
952
+ const directories = repoExists ? [marketplaceRepo] : [];
953
+ const summary = (verb) => {
954
+ console.log(`${verb} ${pluginName}@${pluginVersion} for cursor (marketplace mode) at ${marketplaceRepo}${force ? ' [--force]' : ''}`);
955
+ console.log(`Receipt: ${receiptStatus} (${marketplaceReceipt})`);
956
+ console.log(`Registration cursor-marketplace-staging ${pluginName}-marketplace: ${repoExists ? (verb === 'Would uninstall' ? 'planned' : 'removed') : 'already-absent'}`);
957
+ if (imported) {
958
+ console.log(`Registration cursor-marketplace-staging ${pluginName}-marketplace: manual — Cursor imported this marketplace; its installed-plugin registry is server-assigned and exposes no non-interactive removal verb.`);
959
+ }
960
+ };
961
+ const finish = (verb, removedDirectories) => {
962
+ printPaths(verb === 'Would uninstall' ? 'Would remove file' : 'Removed file', files);
963
+ printPaths(verb === 'Would uninstall' ? 'Would remove directory' : 'Removed directory', removedDirectories);
964
+ console.log(`Data (${purgeData ? 'purge' : 'keep'}): unavailable — A staged marketplace repository holds no runtime state; a copy Cursor imported from it is Cursor-owned and is not touched.`);
965
+ if (imported) {
966
+ console.log('Next steps:');
967
+ console.log(` 1. Open Cursor, then Customize -> Plugins, and uninstall "${pluginName}" (marketplace ${pluginName}-marketplace) there.`);
968
+ }
969
+ };
970
+ if (plan) {
971
+ // The plan names exactly what the run would prune: each parent below is removed only once every entry in it is gone.
972
+ const gone = new Set([...files, ...directories]);
973
+ const prunable = [];
974
+ for (const directory of [receiptsRoot, marketplaceRoot, join(cursorRoot, 'agent-bundle')]) {
975
+ let entries;
976
+ try { entries = await readdir(directory); } catch (error) { if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') continue; throw error; }
977
+ if (entries.every((entry) => gone.has(join(directory, entry)))) { gone.add(directory); prunable.push(directory); }
978
+ }
979
+ summary('Would uninstall');
980
+ finish('Would uninstall', [...directories, ...prunable]);
981
+ process.exit(0);
982
+ }
983
+ if (repoExists) await rm(marketplaceRepo, { force: true, recursive: true });
984
+ for (const path of files) await rm(path, { force: true });
985
+ const pruned = [...directories];
986
+ for (const directory of [receiptsRoot, marketplaceRoot, join(cursorRoot, 'agent-bundle')]) if (await rmdirIfEmpty(directory)) pruned.push(directory);
987
+ summary('Uninstalled');
988
+ finish('Uninstalled', pruned);
989
+ process.exit(0);
990
+ }
991
+
992
+ if (!(await exists(cursorRoot)) || !(await lstat(cursorRoot)).isDirectory()) {
993
+ throw new Error(`Cursor is not installed in ${cursorRoot}.`);
994
+ }
995
+
996
+ const git = (cwd, args) => new Promise((resolvePromise, reject) => {
997
+ execFile('git', args, { cwd }, (error, stdout, stderr) => {
998
+ if (error?.code === 'ENOENT') {
999
+ reject(new Error('git is required for --mode marketplace; install git or use the default local mode.'));
1000
+ return;
1001
+ }
1002
+ if (error) { reject(new Error(`git ${args[0]} failed: ${(stderr || stdout || error.message).trim()}`)); return; }
1003
+ resolvePromise(stdout.trim());
1004
+ });
1005
+ });
1006
+
1007
+ const printMarketplaceSteps = (state, commit) => {
1008
+ console.log(`${state} ${pluginName}@${pluginVersion} for cursor (marketplace mode) at ${marketplaceRepo}`);
1009
+ console.log(`Marketplace: ${pluginName}-marketplace${commit ? ` @ ${commit}` : ''}`);
1010
+ console.log('Next steps:');
1011
+ console.log(` 1. Open Cursor, then Customize -> Plugins -> "Add Plugins from Local Repository" and select ${marketplaceRepo}.`);
1012
+ console.log(` 2. Choose "${pluginName}" in the imported marketplace and select Install (user scope).`);
1013
+ console.log(' 3. Verify with `agent-bundle doctor --host cursor`: the plugin must appear under ~/.cursor/plugins/cache once Cursor has installed it.');
1014
+ };
1015
+
1016
+ const marketplaceManifestPath = join(marketplaceRepo, '.cursor-plugin', 'marketplace.json');
1017
+ // The staged repository is a committed Git tree, so its receipt lives in the store beside it; the recorded
1018
+ // commit lets --uninstall prove the repository is still the one staging wrote.
1019
+ const writeMarketplaceReceipt = async (commit) => {
1020
+ const previous = await readReceiptFile(marketplaceReceipt);
1021
+ const tree = await inventory(source);
1022
+ if (previous !== undefined && previous.contentHash === tree.hash && previous.registrations[0]?.commit === commit) return;
1023
+ await mkdir(receiptsRoot, { recursive: true });
1024
+ // The committed repository is removed wholesale after a HEAD check; the receipt owns no individual files.
1025
+ await writeReceiptFile(marketplaceReceipt, receiptFor({ files: [], hash: tree.hash }, {
1026
+ directories: [],
1027
+ hostDirectories: [],
1028
+ installedAt: previous?.installedAt,
1029
+ mode: 'marketplace',
1030
+ registrations: [{ ...(commit ? { commit } : {}), kind: 'cursor-marketplace-staging', name: `${pluginName}-marketplace` }],
1031
+ }));
1032
+ };
1033
+
1034
+ if (mode === 'marketplace' && (cursorPluginDocument !== '.cursor-plugin/plugin.json' || !(await exists(join(source, cursorPluginDocument))))) {
1035
+ throw new Error('--mode marketplace requires a Cursor Plugin (.cursor-plugin/plugin.json); Cursor marketplaces resolve plugins/<name>/.cursor-plugin/plugin.json. This bundle is an Agent Plugins (root plugin.json) pack: use the default local mode.');
1036
+ }
1037
+ // Owner and description come from the emitted .cursor-plugin/plugin.json, exactly as `agent-bundle install
1038
+ // cursor --mode marketplace` derives them, so both entry points stage byte-identical marketplace manifests.
1039
+ const marketplaceManifestFor = async () => {
1040
+ const manifest = JSON.parse(await readFile(join(source, cursorPluginDocument), 'utf8'));
1041
+ const owner = typeof manifest?.author?.name === 'string' ? manifest.author.name : pluginName;
1042
+ const description = typeof manifest?.description === 'string' ? { description: manifest.description } : {};
1043
+ // Entry fields are limited to the pinned Cursor marketplace schema (name/source/description).
1044
+ return `${JSON.stringify({
1045
+ metadata: { description: `Agent Bundle local marketplace for ${pluginName}@${pluginVersion}.` },
1046
+ name: `${pluginName}-marketplace`,
1047
+ owner: { name: owner },
1048
+ plugins: [{ ...description, name: pluginName, source: `plugins/${pluginName}` }],
1049
+ }, null, 2)}\n`;
1050
+ };
1051
+ const blobIndex = async (root, relative = '') => {
1052
+ const index = new Map();
1053
+ for (const name of await readdir(join(root, relative))) {
1054
+ if (relative === '' && name === '.git') continue;
1055
+ const child = relative === '' ? name : `${relative}/${name}`;
1056
+ const metadata = await lstat(join(root, child));
1057
+ if (metadata.isDirectory()) {
1058
+ for (const [path, id] of await blobIndex(root, child)) index.set(path, id);
1059
+ } else if (metadata.isFile()) {
1060
+ const bytes = await readFile(join(root, child));
1061
+ index.set(child, createHash('sha1').update(`blob ${bytes.length}\0`).update(bytes).digest('hex'));
1062
+ }
1063
+ }
1064
+ return index;
1065
+ };
1066
+ const findNestedGit = async (root, relative = '') => {
1067
+ for (const name of (await readdir(join(root, relative))).sort()) {
1068
+ const child = relative === '' ? name : join(relative, name);
1069
+ if (name === '.git') return child;
1070
+ const metadata = await lstat(join(root, child));
1071
+ if (!metadata.isSymbolicLink() && metadata.isDirectory()) {
1072
+ const nested = await findNestedGit(root, child);
1073
+ if (nested !== undefined) return nested;
1074
+ }
1075
+ }
1076
+ return undefined;
1077
+ };
1078
+ if (mode === 'marketplace') {
1079
+ const marketplaceManifest = await marketplaceManifestFor();
1080
+ const nestedGit = await findNestedGit(source);
1081
+ if (nestedGit !== undefined) {
1082
+ throw new Error(`--mode marketplace refuses bundle-internal Git metadata at ${JSON.stringify(nestedGit)}: git would record it as an empty gitlink and Cursor would import a plugin without files. Stage from a built bundle directory without .git, or use the default local mode.`);
1083
+ }
1084
+ await mkdir(marketplaceRoot, { recursive: true });
1085
+ if (await exists(marketplaceRepo)) {
1086
+ let stagedVersion;
1087
+ try { stagedVersion = JSON.parse(await readFile(join(marketplacePlugin, cursorPluginDocument), "utf8")).version; }
1088
+ catch (error) { if (error?.code !== 'ENOENT') throw error; }
1089
+ if (stagedVersion !== undefined && stagedVersion !== pluginVersion) {
1090
+ throw new Error(`Refusing version collision at ${marketplaceRepo}: found ${stagedVersion}, requested ${pluginVersion}.`);
1091
+ }
1092
+ if (await exists(marketplacePlugin) && await exists(join(marketplaceRepo, '.git')) && await treeHash(source) === await treeHash(marketplacePlugin)) {
1093
+ let stagedManifest;
1094
+ try { stagedManifest = await readFile(marketplaceManifestPath, 'utf8'); }
1095
+ catch (error) { if (error?.code !== 'ENOENT') throw error; }
1096
+ if (stagedManifest !== marketplaceManifest) {
1097
+ throw new Error(`Refusing content collision at ${marketplaceRepo}: .cursor-plugin/marketplace.json differs from the generated marketplace manifest; remove the staged repository and rerun.`);
1098
+ }
1099
+ if (await git(marketplaceRepo, ['status', '--porcelain', '--untracked-files=all', '--ignored=matching']) !== '') {
1100
+ throw new Error(`Refusing content collision at ${marketplaceRepo}: the working tree differs from the committed HEAD Cursor would import; remove the staged repository and rerun.`);
1101
+ }
1102
+ const commit = await git(marketplaceRepo, ['rev-parse', 'HEAD']);
1103
+ await writeMarketplaceReceipt(commit);
1104
+ printMarketplaceSteps('Already staged', commit);
1105
+ process.exit(0);
1106
+ }
1107
+ throw new Error(`Refusing content collision at ${marketplaceRepo}.`);
1108
+ }
1109
+ const stageParent = await mkdtemp(join(marketplaceRoot, `.${pluginName}.stage-`));
1110
+ const stage = join(stageParent, 'repo');
1111
+ try {
1112
+ await mkdir(join(stage, '.cursor-plugin'), { recursive: true });
1113
+ await cp(source, join(stage, 'plugins', pluginName), { errorOnExist: true, force: false, recursive: true, verbatimSymlinks: true });
1114
+ await writeFile(join(stage, '.cursor-plugin', 'marketplace.json'), marketplaceManifest);
1115
+ await treeHash(join(stage, 'plugins', pluginName));
1116
+ await git(stage, ['init', '-q', '--object-format=sha1']);
1117
+ // Attributes (text/eol/filter/ident) would rewrite bytes in the index while leaving the tree clean; disable them.
1118
+ await mkdir(join(stage, '.git', 'info'), { recursive: true });
1119
+ await writeFile(join(stage, '.git', 'info', 'attributes'), '* -text -eol -filter -ident -working-tree-encoding -export-ignore -export-subst\n');
1120
+ await git(stage, ['-c', 'core.autocrlf=false', '-c', 'core.safecrlf=false', 'add', '--all', '--force']);
1121
+ await git(stage, ['-c', 'user.name=agent-bundle', '-c', 'user.email=agent-bundle@localhost', '-c', 'commit.gpgsign=false', 'commit', '-q', '-m', `${pluginName}@${pluginVersion}`]);
1122
+ // Prove the commit holds the staged bytes: every blob id must equal sha1("blob <len>\0" + bytes).
1123
+ const expected = await blobIndex(stage);
1124
+ const committed = new Map();
1125
+ for (const entry of (await git(stage, ['ls-tree', '-r', '-z', 'HEAD'])).split('\0')) {
1126
+ if (entry === '') continue;
1127
+ const tab = entry.indexOf('\t');
1128
+ committed.set(entry.slice(tab + 1), entry.slice(0, tab).split(' ')[2]);
1129
+ }
1130
+ const drifted = [...expected].filter(([path, id]) => committed.get(path) !== id).map(([path]) => path);
1131
+ const extra = [...committed.keys()].filter((path) => !expected.has(path));
1132
+ if (drifted.length > 0 || extra.length > 0) {
1133
+ throw new Error(`Cursor marketplace staging failed: the committed tree differs from the staged bundle bytes (${[...drifted, ...extra].map((path) => JSON.stringify(path)).join(', ')}); a Git attribute or filter transformed them.`);
1134
+ }
1135
+ const commit = await git(stage, ['rev-parse', 'HEAD']);
1136
+ await rename(stage, marketplaceRepo);
1137
+ await writeMarketplaceReceipt(commit);
1138
+ printMarketplaceSteps('Staged', commit);
1139
+ } finally {
1140
+ await rm(stageParent, { force: true, recursive: true });
1141
+ }
1142
+ process.exit(0);
1143
+ }
1144
+
1145
+ // The artifact is inventoried (and any unsupported entry refused) before anything is created in the home.
1146
+ const artifact = await inventory(source, cursorTransform);
1147
+ // The receipt records which host directories this run creates on the way to the plugin root (a fresh Cursor
1148
+ // home has no plugins/local), so --uninstall can prune exactly those and no more.
1149
+ const createdHostDirectories = [];
1150
+ for (const relativePath of ['plugins', 'plugins/local']) {
1151
+ if (!(await exists(join(cursorRoot, relativePath)))) createdHostDirectories.push(relativePath);
1152
+ }
1153
+ await mkdir(installRoot, { recursive: true });
1154
+ // Spec 9.1: the data directory exists before any plugin subprocess is launched.
1155
+ if (expansion !== undefined) await mkdir(pluginData, { recursive: true });
1156
+ const reportExpansion = () => {
1157
+ if (expansion === undefined) return;
1158
+ console.log(`Expanded Agent Plugins placeholders for Cursor in ${expansion.mcpDocument}: PLUGIN_ROOT=${destination} PLUGIN_DATA=${pluginData} (Cursor does not expand them; recorded in ${receiptFile})`);
1159
+ };
1160
+
1161
+ if (!(await exists(destination))) {
1162
+ const staged = await stage(artifact, { hostDirectories: createdHostDirectories });
1163
+ try {
1164
+ await rename(staged.root, destination);
1165
+ await attachStateOwnership();
1166
+ console.log(`Installed ${pluginName}@${pluginVersion} at ${destination} (content ${short(artifact.hash)})`);
1167
+ reportExpansion();
1168
+ } finally {
1169
+ await rm(staged.parent, { force: true, recursive: true });
1170
+ }
1171
+ process.exit(0);
1172
+ }
1173
+ if (source === destination) {
1174
+ console.log(`Already installed ${pluginName}@${pluginVersion} at ${destination} (content ${short(artifact.hash)})`);
1175
+ process.exit(0);
1176
+ }
1177
+
1178
+ const destinationMetadata = await lstat(destination);
1179
+ if (destinationMetadata.isSymbolicLink() || !destinationMetadata.isDirectory()) throw unsupported('.');
1180
+ const receipt = await readReceipt(destination);
1181
+ const manifest = await readManifest(destination);
1182
+ let ownership;
1183
+ let installedHash;
1184
+ // --uninstall --keep-data leaves a shell holding only state/: a reinstall fills it back in around the preserved
1185
+ // durable state instead of refusing it as foreign (nothing in it is anyone's plugin content).
1186
+ const remnantEntries = (await readdir(destination)).filter((name) => name !== receiptFile);
1187
+ const stateOnlyRemnant = receipt === undefined
1188
+ ? remnantEntries.length > 0 && remnantEntries.every(isPreservedRoot)
1189
+ : receipt.plugin === pluginName && receipt.files.length === 0 && receipt.registrations.length === 0; // remnant receipt from --uninstall --keep-data
1190
+ if (receipt !== undefined && receipt.plugin === pluginName) {
1191
+ ownership = 'receipt';
1192
+ installedHash = await hashOwned(destination, receipt.files);
1193
+ } else {
1194
+ installedHash = (await inventory(destination)).hash;
1195
+ ownership = stateOnlyRemnant || (receipt === undefined && manifest?.name === pluginName && await hasMarkers(destination)) ? 'legacy' : 'foreign';
1196
+ }
1197
+ const installedVersion = manifest?.version ?? (ownership === 'receipt' ? receipt.version : undefined);
1198
+ const installedName = manifest?.name ?? (ownership === 'receipt' ? receipt.plugin : pluginName);
1199
+ const sameContent = installedHash === artifact.hash;
1200
+ const verdict = installedVersion === undefined
1201
+ ? (sameContent ? 'same content, installed version unknown' : 'different content, installed version unknown')
1202
+ : installedVersion === pluginVersion
1203
+ ? (sameContent ? 'same content' : 'same version, different content')
1204
+ : (sameContent ? 'same content, different version' : 'different version');
1205
+ const detail = `installed ${installedName}@${installedVersion ?? 'unknown version'} content ${short(installedHash)} ` +
1206
+ `vs artifact ${pluginName}@${pluginVersion} content ${short(artifact.hash)} (${verdict})`;
1207
+ // Foreign ownership wins over byte equality: a directory that is not ours is never current.
1208
+ if (ownership === 'foreign') {
1209
+ throw new Error(`Refusing foreign install at ${destination}: ${detail}; the directory is not an agent-bundle install of ` +
1210
+ `${pluginName}, so --replace does not apply. Remove it manually if it is stale.`);
1211
+ }
1212
+ // A receipt-managed copy is current only when its recorded inventory matches the artifact too.
1213
+ const inventoryMatches = ownership !== 'receipt' ||
1214
+ (receipt.files.length === artifact.files.length && receipt.files.every((file, index) => file === artifact.files[index]));
1215
+ if (installedHash === artifact.hash && inventoryMatches) {
1216
+ if (ownership === 'legacy' && replace) {
1217
+ // Byte-identical pre-receipt copy: adoption only writes the receipt (adoption created no directories),
1218
+ // through an exclusively created random sibling so no existing file or link is followed or overwritten.
1219
+ await writeReceiptFile(join(destination, receiptFile), receiptFor(artifact, { directories: [], hostDirectories: [] }));
1220
+ await attachStateOwnership();
1221
+ console.log(`Adopted ${pluginName}@${pluginVersion} at ${destination} (content ${short(artifact.hash)})`);
1222
+ reportExpansion();
1223
+ process.exit(0);
1224
+ }
1225
+ if (ownership === 'receipt' && receipt.migratedFrom !== undefined) {
1226
+ // An identical receipt-managed copy whose receipt predates the current format is upgraded in place:
1227
+ // lifecycle fields exactly as the reader synthesized them, and nothing else changes.
1228
+ await writeReceiptFile(join(destination, receiptFile), receiptFor(artifact, {
1229
+ directories: receipt.directories, hostDirectories: receipt.hostDirectories, installedAt: receipt.installedAt,
1230
+ }));
1231
+ }
1232
+ if (ownership === 'receipt' && receipt.state === undefined) await attachStateOwnership();
1233
+ console.log(`Already installed ${pluginName}@${pluginVersion} at ${destination} (content ${short(artifact.hash)})`);
1234
+ process.exit(0);
1235
+ }
1236
+ if (installedVersion !== undefined && installedVersion !== pluginVersion && !replace) {
1237
+ throw new Error(`Refusing version collision at ${destination}: ${detail}. Re-run with --replace to replace this agent-bundle install.`);
1238
+ }
1239
+ if (ownership === 'legacy' && !replace && !stateOnlyRemnant) {
1240
+ throw new Error(`Refusing content collision at ${destination}: ${detail}; this copy predates install receipts. ` +
1241
+ 'Re-run with --replace once to adopt it; later same-version rebuilds replace automatically.');
1242
+ }
1243
+
1244
+ // Owned-files-only replacement: stale owned files leave first, staged files rename over their
1245
+ // predecessors, and the receipt lands last as the commit marker. Unowned entries (runtime state) stay.
1246
+ const staged = await stage(artifact);
1247
+ try {
1248
+ // A legacy copy has no inventory: only files the new artifact also ships count as owned; everything
1249
+ // else (operator files, stale artifact files, runtime state) stays in place and remains unowned.
1250
+ const incoming = new Set(staged.inventory.files);
1251
+ let owned;
1252
+ if (ownership === 'receipt') {
1253
+ owned = receipt.files;
1254
+ } else {
1255
+ owned = [];
1256
+ for (const file of (await inventory(destination)).files) {
1257
+ if (await isOwnedEntry(destination, incoming, file)) owned.push(file);
1258
+ }
1259
+ }
1260
+ const ownedSet = new Set(owned);
1261
+ const ownedDirectories = new Set(ownership === 'receipt' ? receipt.directories : []);
1262
+ await assertRealAncestors(destination, owned);
1263
+ await assertRealAncestors(destination, staged.inventory.files, ownedSet);
1264
+ // An existing directory at an incoming file path is fine only when it is wholly owned: it and every
1265
+ // directory beneath were created by the installer, at least one file, every file owned, and no empty
1266
+ // directory anywhere beneath (no evidence of ownership).
1267
+ const isWhollyOwnedDirectory = async (relativePath) => {
1268
+ let files = 0;
1269
+ const visit = async (directory) => {
1270
+ if (!(await isOwnedEntry(destination, ownedDirectories, directory))) return false;
1271
+ const entries = (await readdir(join(destination, directory))).sort((left, right) => left.localeCompare(right));
1272
+ if (entries.length === 0) return false;
1273
+ for (const name of entries) {
1274
+ const relative = `${directory}/${name}`;
1275
+ const metadata = await lstat(join(destination, relative));
1276
+ if (metadata.isSymbolicLink()) throw unsupported(relative);
1277
+ if (metadata.isDirectory()) { if (!(await visit(relative))) return false; continue; }
1278
+ if (!metadata.isFile() || !(await isOwnedEntry(destination, ownedSet, relative))) return false;
1279
+ files += 1;
1280
+ }
1281
+ return true;
1282
+ };
1283
+ return (await visit(relativePath)) && files > 0;
1284
+ };
1285
+ const collisions = [];
1286
+ for (const file of staged.inventory.files) {
1287
+ if (ownedSet.has(file)) continue;
1288
+ const target = join(destination, file);
1289
+ let metadata;
1290
+ try { metadata = await lstat(target); } catch (error) { if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') continue; throw error; }
1291
+ const tolerated = metadata.isDirectory() && !metadata.isSymbolicLink()
1292
+ ? await isWhollyOwnedDirectory(file)
1293
+ : metadata.isFile() && await isOwnedEntry(destination, ownedSet, file);
1294
+ if (!tolerated) collisions.push(file);
1295
+ }
1296
+ if (collisions.length > 0) {
1297
+ throw new Error(`Refusing to overwrite unowned files at ${destination}: ${collisions.join(', ')}. Move them aside manually before replacing.`);
1298
+ }
1299
+ const stale = owned.filter((file) => !incoming.has(file));
1300
+ for (const file of stale) await rm(join(destination, file), { force: true });
1301
+ const pruned = await pruneEmptyDirectories(stale, ownedDirectories);
1302
+ const created = new Set();
1303
+ for (const file of staged.inventory.files) {
1304
+ await ensureAncestors(file, created);
1305
+ await rename(join(staged.root, file), join(destination, file));
1306
+ }
1307
+ // The receipt owns what the installer owns now: surviving directories it created before plus the
1308
+ // ones this replacement created; the first install time and the host directories it created carry
1309
+ // over. Finalised in the private staging copy, then committed by rename.
1310
+ const directories = sortNames(new Set([...[...ownedDirectories].filter((directory) => !pruned.has(directory)), ...created]));
1311
+ const previous = ownership === 'receipt' ? receipt : undefined;
1312
+ await writeFile(join(staged.root, receiptFile), receiptFor(staged.inventory, {
1313
+ directories, hostDirectories: previous?.hostDirectories ?? [], installedAt: previous?.installedAt,
1314
+ }), 'utf8');
1315
+ await rename(join(staged.root, receiptFile), join(destination, receiptFile));
1316
+ await attachStateOwnership(previous?.state);
1317
+ if (stateOnlyRemnant) console.log(`Installed ${pluginName}@${pluginVersion} at ${destination} (content ${short(artifact.hash)})`);
1318
+ else console.log(`Replaced ${pluginName}@${pluginVersion} at ${destination} (content ${short(installedHash)} -> ${short(artifact.hash)})`);
1319
+ reportExpansion();
1320
+ } finally {
1321
+ await rm(staged.parent, { force: true, recursive: true });
1322
+ }