plugnz 0.0.4 → 0.0.5

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 (46) hide show
  1. package/dist/plugnz.mjs +20000 -0
  2. package/package.json +13 -10
  3. package/bin/plgnz.mjs +0 -3
  4. package/bin/plugnz.mjs +0 -5
  5. package/src/cli.ts +0 -489
  6. package/src/compatibility.ts +0 -23
  7. package/src/consumer-profiles.ts +0 -77
  8. package/src/conversion.ts +0 -544
  9. package/src/doctor.ts +0 -277
  10. package/src/exec.ts +0 -91
  11. package/src/fingerprint.ts +0 -77
  12. package/src/globals.d.ts +0 -97
  13. package/src/hermes-identity.ts +0 -4
  14. package/src/host.ts +0 -131
  15. package/src/hosts/claude-code-writer.ts +0 -589
  16. package/src/hosts/claude-code.ts +0 -124
  17. package/src/hosts/codex-writer.ts +0 -418
  18. package/src/hosts/codex.ts +0 -183
  19. package/src/hosts/cursor-writer.ts +0 -279
  20. package/src/hosts/cursor.ts +0 -107
  21. package/src/hosts/dcode-writer.ts +0 -150
  22. package/src/hosts/dcode.ts +0 -74
  23. package/src/hosts/grok-writer.ts +0 -254
  24. package/src/hosts/grok.ts +0 -94
  25. package/src/hosts/hermes-writer.ts +0 -223
  26. package/src/hosts/hermes.ts +0 -88
  27. package/src/hosts/index.ts +0 -25
  28. package/src/hosts/kimi-writer.ts +0 -238
  29. package/src/hosts/kimi.ts +0 -140
  30. package/src/hosts/omp-writer.ts +0 -179
  31. package/src/hosts/omp.ts +0 -153
  32. package/src/hosts/opencode-writer.ts +0 -112
  33. package/src/hosts/opencode.ts +0 -38
  34. package/src/hosts/pi-writer.ts +0 -111
  35. package/src/hosts/pi.ts +0 -48
  36. package/src/hosts/writers.ts +0 -23
  37. package/src/hosts/zcode-cli-writer.ts +0 -239
  38. package/src/hosts/zcode-cli.ts +0 -195
  39. package/src/mcp-write.ts +0 -81
  40. package/src/mcp.ts +0 -161
  41. package/src/paths.ts +0 -101
  42. package/src/pin.ts +0 -135
  43. package/src/source.ts +0 -224
  44. package/src/state-write.ts +0 -25
  45. package/src/state.ts +0 -118
  46. package/src/update.ts +0 -230
@@ -1,183 +0,0 @@
1
- /**
2
- * codex host reader.
3
- *
4
- * Reader only — the writer (add/pin/remove) lives in
5
- * src/hosts/codex-writer.ts so loading a reader never evaluates writer code
6
- * (AGENTS.md: doctor is read-only by construction).
7
- *
8
- * Real store layout (measured 2026-09-16, evidence in docs/hosts/codex.md):
9
- * ~/.codex/config.toml — [plugins."<name>@<marketplace>"] enabled = bool;
10
- * user-level [mcp_servers.<name>] (command/args
11
- * = stdio, url = HTTP)
12
- * ~/.codex/plugins/cache/<marketplace>/<name>/<version>/ — install dirs
13
- *
14
- * Plugin MCP is declared via `.codex-plugin/plugin.json`'s `mcpServers`
15
- * member, which measured as a pointer string (`"./.mcp.json"`, never inline,
16
- * across 13 manifests) resolved against the plugin root; the plugins CLI also
17
- * writes spec `.mcp.json`/`mcp.json` copies — all are read, identical entries
18
- * deduped.
19
- *
20
- * Shadow semantics (measured): a user-level [mcp_servers.X] silently wins over
21
- * a plugin-provided server of the same name — see doctor check (2).
22
- */
23
- import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
24
- import { basename, join } from 'node:path';
25
- import { parse as parseToml } from 'smol-toml';
26
- import type { HostReader, InstalledPlugin, McpServerEntry } from '../host';
27
- import { codexHome } from '../paths';
28
- import { collectPluginServers, type PluginMcpCandidate, type RawServerDef } from '../mcp';
29
-
30
- export function configFile(): string {
31
- return join(codexHome(), 'config.toml');
32
- }
33
-
34
- /**
35
- * Where a plugin declares MCP servers. `.codex-plugin/plugin.json` measured as
36
- * a *pointer* (`"mcpServers": "./.mcp.json"`), never inline, so pinning it is a
37
- * no-op — the pointed file is a spec candidate here and is pinned directly.
38
- */
39
- export function mcpCandidates(): PluginMcpCandidate[] {
40
- return [
41
- { kind: 'inline', manifest: join('.codex-plugin', 'plugin.json') },
42
- { kind: 'spec', file: '.mcp.json' },
43
- { kind: 'spec', file: 'mcp.json' },
44
- ];
45
- }
46
-
47
- function parseConfig(): Record<string, unknown> | null {
48
- try {
49
- const parsed: unknown = parseToml(readFileSync(configFile(), 'utf8'));
50
- return typeof parsed === 'object' && parsed !== null ? (parsed as Record<string, unknown>) : null;
51
- } catch {
52
- return null;
53
- }
54
- }
55
-
56
- function table(value: unknown): Record<string, unknown> | null {
57
- return typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : null;
58
- }
59
-
60
- /** Pick the install dir using Codex's `active_plugin_version`: `local` wins, then semver, then lexical fallback. */
61
- function cacheDirFor(marketplace: string, name: string): string | undefined {
62
- const slot = join(codexHome(), 'plugins', 'cache', marketplace, name);
63
- if (!existsSync(slot)) return undefined;
64
- let best: string | undefined;
65
- for (const entry of readdirSync(slot)) {
66
- const candidate = join(slot, entry);
67
- try {
68
- if (!statSync(candidate).isDirectory()) continue;
69
- } catch {
70
- continue;
71
- }
72
- if (best === undefined || comparePluginVersion(entry, best) > 0) best = entry;
73
- }
74
- return best === undefined ? undefined : join(slot, best);
75
- }
76
-
77
- function comparePluginVersion(left: string, right: string): number {
78
- if (left === 'local') return right === 'local' ? 0 : 1;
79
- if (right === 'local') return -1;
80
- const parse = (value: string): { numbers: number[]; prerelease?: string } | null => {
81
- const match = value.match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/u);
82
- return match === null ? null : { numbers: [Number(match[1]), Number(match[2]), Number(match[3])], ...(match[4] === undefined ? {} : { prerelease: match[4] }) };
83
- };
84
- const l = parse(left);
85
- const r = parse(right);
86
- if (l !== null && r !== null) {
87
- for (let index = 0; index < 3; index += 1) if (l.numbers[index] !== r.numbers[index]) return (l.numbers[index] ?? 0) - (r.numbers[index] ?? 0);
88
- if (l.prerelease === undefined && r.prerelease !== undefined) return 1;
89
- if (l.prerelease !== undefined && r.prerelease === undefined) return -1;
90
- if (l.prerelease !== undefined && r.prerelease !== undefined) return comparePrerelease(l.prerelease, r.prerelease);
91
- return 0;
92
- }
93
- return left.localeCompare(right);
94
- }
95
-
96
- function comparePrerelease(left: string, right: string): number {
97
- const l = left.split('.'); const r = right.split('.');
98
- for (let index = 0; index < Math.max(l.length, r.length); index += 1) {
99
- const a = l[index]; const b = r[index];
100
- if (a === undefined) return -1;
101
- if (b === undefined) return 1;
102
- if (a === b) continue;
103
- const an = /^\d+$/u.test(a); const bn = /^\d+$/u.test(b);
104
- if (an && bn) return Number(a) - Number(b);
105
- if (an) return -1;
106
- if (bn) return 1;
107
- return a.localeCompare(b);
108
- }
109
- return 0;
110
- }
111
-
112
- export const codex: HostReader = {
113
- id: 'codex',
114
- gui: false,
115
-
116
- detect(): boolean {
117
- return existsSync(codexHome());
118
- },
119
-
120
- stores(): string[] {
121
- return [join(codexHome(), 'plugins', 'cache')];
122
- },
123
-
124
- listInstalled(): InstalledPlugin[] {
125
- const config = parseConfig();
126
- if (config === null) return [];
127
- const plugins = table(config['plugins']);
128
- if (plugins === null) return [];
129
- const out: InstalledPlugin[] = [];
130
- for (const id of Object.keys(plugins)) {
131
- const def = table(plugins[id]);
132
- const enabled = !(def !== null && def['enabled'] === false);
133
- const at = id.indexOf('@');
134
- const name = at === -1 ? id : id.slice(0, at);
135
- const marketplace = at === -1 ? undefined : id.slice(at + 1);
136
- const path = enabled && marketplace !== undefined ? cacheDirFor(marketplace, name) : undefined;
137
- const plugin: InstalledPlugin = { id, name, enabled };
138
- if (marketplace !== undefined) plugin.marketplace = marketplace;
139
- if (path !== undefined) {
140
- plugin.path = path;
141
- const version = basename(path);
142
- if (version.length > 0) plugin.version = version;
143
- }
144
- out.push(plugin);
145
- }
146
- return out;
147
- },
148
-
149
- mcpEntries(): McpServerEntry[] {
150
- const config = parseConfig();
151
- if (config === null) return [];
152
- const entries: McpServerEntry[] = [];
153
- const userServers = table(config['mcp_servers']);
154
- if (userServers !== null) {
155
- for (const [name, value] of Object.entries(userServers)) {
156
- const def = table(value);
157
- if (def === null) continue;
158
- const defRecord = def as RawServerDef;
159
- const transport =
160
- typeof defRecord['url'] === 'string' ? 'http' : typeof defRecord['command'] === 'string' ? 'stdio' : null;
161
- if (transport === null) continue;
162
- const entry: McpServerEntry = {
163
- name,
164
- transport,
165
- origin: 'user',
166
- file: configFile(),
167
- baseDir: codexHome(),
168
- };
169
- const command = defRecord['command'];
170
- if (typeof command === 'string') entry.command = command;
171
- const rawArgs = defRecord['args'];
172
- if (Array.isArray(rawArgs)) entry.args = rawArgs.filter((a): a is string => typeof a === 'string');
173
- if (defRecord['enabled'] === false) entry.enabled = false;
174
- entries.push(entry);
175
- }
176
- }
177
- for (const plugin of this.listInstalled()) {
178
- if (plugin.path === undefined || !existsSync(plugin.path)) continue;
179
- entries.push(...collectPluginServers(plugin.id, plugin.path, mcpCandidates()));
180
- }
181
- return entries;
182
- },
183
- };
@@ -1,279 +0,0 @@
1
- /** Cursor's native local-plugin lifecycle writer. */
2
- import { cpSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
3
- import { join, resolve } from 'node:path';
4
- import { tmpdir } from 'node:os';
5
- import type { AddOptions, HostWriter, InstalledPlugin, PinOptions, PinOutcome } from '../host';
6
- import { cursorRoot } from '../paths';
7
- import type { PluginSource, ResolvedSource } from '../source';
8
- import { cursor, localDir, mcpCandidates } from './cursor';
9
- import { pinPluginMcpFiles } from '../mcp-write';
10
- import { discoverCommands } from '../conversion';
11
-
12
- const OWNERSHIP = '.plgnz-install.json';
13
- type Ownership = { source: string; pluginId: string; fingerprint: string };
14
-
15
- export const cursorWriter: HostWriter = {
16
- ...cursor,
17
- supportsAdoption: true,
18
- async add(plugin: PluginSource, resolved: ResolvedSource, opts?: AddOptions): Promise<void | 'unchanged'> {
19
- const id = plugin.name;
20
- const ownershipId = plugin.marketplace === undefined ? id : `${id}@${plugin.marketplace}`;
21
- assertSafeIdentity(id);
22
- const root = localDir();
23
- const target = join(root, id);
24
- assertManagedDirectory(root, target);
25
-
26
- if (!opts?.dryRun) mkdirSync(root, { recursive: true });
27
- const stage = mkdtempSync(join(opts?.dryRun ? tmpdir() : root, '.plgnz-cursor-stage-'));
28
- try {
29
- stagePlugin(plugin.dir, stage, id);
30
- // Preserve an unresolved bare command for the existing read-only `pin`
31
- // diagnosis path; activation must not rewrite it into a guessed command.
32
- await pinPluginMcpFiles(stage, mcpCandidates(), { dryRun: false });
33
- writeOwnership(stage, { source: resolved.sourceUri, pluginId: ownershipId, fingerprint: plugin.contentFingerprint ?? '' });
34
-
35
- const marker = readOwnership(target);
36
- if (marker !== null && (marker.source !== resolved.sourceUri || !matchesOwnership(marker.pluginId, id, ownershipId))) {
37
- throw new Error(`Cursor local plugin ${id} belongs to another source; refusing to replace it`);
38
- }
39
- const identicalUnowned = existsSync(target) && marker === null && sameTree(stage, target);
40
- if (existsSync(target) && marker === null && !identicalUnowned) {
41
- if (!opts?.adoptExisting) throw new Error(`Cursor local plugin ${id} is unowned and differs from the staged representation; pass --adopt-existing to take ownership explicitly`);
42
- validateExistingIdentity(target, id, sourceVersion(plugin.dir));
43
- }
44
-
45
- // A bare legacy marker has no collection identity. Re-write it on the
46
- // next successful add so reader metadata and the durable state agree.
47
- const unchanged = marker !== null && marker.pluginId === ownershipId && marker.fingerprint === (plugin.contentFingerprint ?? '') && sameTree(stage, target);
48
- if (opts?.dryRun) {
49
- console.log(`[cursor] would activate directory: ${target}`);
50
- return unchanged ? 'unchanged' : undefined;
51
- }
52
- if (unchanged) return 'unchanged';
53
- activate(stage, target, root).commit();
54
- return;
55
- } finally {
56
- rmSync(stage, { recursive: true, force: true });
57
- }
58
- },
59
- async pin(plugin: InstalledPlugin, opts?: PinOptions): Promise<PinOutcome> {
60
- if (plugin.path === undefined || !existsSync(plugin.path)) return { changes: [], refusals: [] };
61
- return pinPluginMcpFiles(plugin.path, mcpCandidates(), opts);
62
- },
63
- async remove(id: string): Promise<void> {
64
- assertSafeIdentity(id);
65
- const root = localDir();
66
- const target = join(root, id);
67
- assertManagedDirectory(root, target);
68
- const marker = readOwnership(target);
69
- if (marker === null || !ownsNativeDirectory(marker.pluginId, id)) return;
70
- rmSync(target, { recursive: true, force: true });
71
- },
72
- };
73
-
74
- function stagePlugin(source: string, stage: string, expectedName: string): void {
75
- assertNoSymlinks(source);
76
- cpSync(source, stage, { recursive: true });
77
- projectCommandsToSkills(stage);
78
- ensureCursorManifest(stage, expectedName);
79
- validateStage(stage, expectedName);
80
- }
81
-
82
- /**
83
- * Cursor folded commands into skills: every skill is `/`-invocable and
84
- * Cursor 2.4's /migrate-to-skills preserves command invocation behavior
85
- * (Cursor docs → Skills). Command sources therefore project into the
86
- * plugin's skills tree. A command that is not user-invocable cannot be
87
- * represented — every Cursor skill is /-invocable — and is refused rather
88
- * than silently weakened.
89
- */
90
- function projectCommandsToSkills(stage: string): void {
91
- if (!hasCommands(stage)) return;
92
- const commands = discoverCommands(stage);
93
- const seen = new Set<string>();
94
- for (const command of commands) {
95
- if (command.userInvocable === false) throw new Error(`Cursor skill projection cannot represent user-invocable: false: ${command.source}`);
96
- if (seen.has(command.name)) throw new Error(`Cursor command projection collision: ${command.name}`);
97
- seen.add(command.name);
98
- const target = join(stage, 'skills', command.name, 'SKILL.md');
99
- if (existsSync(target)) throw new Error(`Cursor command collides with an existing skill: ${command.name}`);
100
- mkdirSync(join(stage, 'skills', command.name), { recursive: true });
101
- const body = command.body.endsWith('\n') ? command.body : `${command.body}\n`;
102
- writeFileSync(target, `---\nname: ${JSON.stringify(command.name)}\ndescription: ${JSON.stringify(command.description)}\n---\n${body}`);
103
- }
104
- if (commands.length > 0) {
105
- rmSync(join(stage, 'commands'), { recursive: true, force: true });
106
- rmSync(join(stage, '.claude', 'commands'), { recursive: true, force: true });
107
- }
108
- }
109
-
110
- function hasCommands(root: string): boolean {
111
- return existsSync(join(root, 'commands')) || existsSync(join(root, '.claude', 'commands'));
112
- }
113
-
114
- function ensureCursorManifest(stage: string, expectedName: string): void {
115
- const cursorManifest = join(stage, '.cursor-plugin', 'plugin.json');
116
- if (existsSync(cursorManifest)) return;
117
- const source = manifestCandidates(stage).find(existsSync);
118
- if (source === undefined) throw new Error('Cursor stage has no Agent Plugins manifest');
119
- mkdirSync(join(stage, '.cursor-plugin'), { recursive: true });
120
- cpSync(source, cursorManifest);
121
- if (parseManifest(cursorManifest).name !== expectedName) throw new Error(`Cursor manifest identity does not match ${expectedName}`);
122
- }
123
-
124
- function validateStage(stage: string, expectedName: string): void {
125
- assertNoSymlinks(stage);
126
- const native = parseManifest(join(stage, '.cursor-plugin', 'plugin.json'));
127
- if (native.name !== expectedName) throw new Error(`Cursor manifest identity does not match ${expectedName}`);
128
- const canonicalPath = manifestCandidates(stage).find(existsSync);
129
- if (canonicalPath !== undefined) {
130
- const canonical = parseManifest(canonicalPath);
131
- if (native.name !== canonical.name || (canonical.version !== undefined && native.version !== canonical.version)) {
132
- throw new Error('Cursor native manifest identity does not match the canonical manifest');
133
- }
134
- }
135
- const skills = join(stage, 'skills');
136
- if (existsSync(skills) && !statSync(skills).isDirectory()) throw new Error('Cursor stage skills path is not a directory');
137
- }
138
-
139
- function manifestCandidates(root: string): string[] {
140
- return [join(root, '.claude-plugin', 'plugin.json'), join(root, 'plugin.json'), join(root, '.plugin', 'plugin.json')];
141
- }
142
-
143
- function sourceVersion(root: string): string | undefined {
144
- const manifest = [join(root, '.cursor-plugin', 'plugin.json'), ...manifestCandidates(root)].find(existsSync);
145
- return manifest === undefined ? undefined : parseManifest(manifest).version;
146
- }
147
-
148
- function parseManifest(path: string): { name: string; version?: string } {
149
- let value: unknown;
150
- try { value = JSON.parse(readFileSync(path, 'utf8')); }
151
- catch (error) { throw new Error(`invalid Cursor plugin manifest: ${path} (${(error as Error).message})`); }
152
- if (typeof value !== 'object' || value === null || Array.isArray(value)) throw new Error(`invalid Cursor plugin manifest: ${path}`);
153
- const record = value as Record<string, unknown>;
154
- if (typeof record['name'] !== 'string' || !validIdentity(record['name'])) throw new Error(`invalid Cursor plugin manifest identity: ${path}`);
155
- if (record['version'] !== undefined && typeof record['version'] !== 'string') throw new Error(`invalid Cursor plugin manifest version: ${path}`);
156
- return { name: record['name'], ...(typeof record['version'] === 'string' ? { version: record['version'] } : {}) };
157
- }
158
-
159
- function validateExistingIdentity(target: string, expectedName: string, expectedVersion: string | undefined): void {
160
- const manifest = join(target, '.cursor-plugin', 'plugin.json');
161
- if (!existsSync(manifest)) throw new Error(`unowned Cursor local plugin has no native manifest: ${target}`);
162
- const parsed = parseManifest(manifest);
163
- if (parsed.name !== expectedName || parsed.version !== expectedVersion) throw new Error(`unowned Cursor local plugin identity does not match ${expectedName}`);
164
- }
165
-
166
- function readOwnership(target: string): Ownership | null {
167
- const marker = join(target, OWNERSHIP);
168
- if (!existsSync(marker)) return null;
169
- try {
170
- const value: unknown = JSON.parse(readFileSync(marker, 'utf8'));
171
- if (typeof value !== 'object' || value === null || Array.isArray(value)) throw new Error('marker must be an object');
172
- const record = value as Record<string, unknown>;
173
- if (typeof record['source'] !== 'string' || typeof record['pluginId'] !== 'string' || typeof record['fingerprint'] !== 'string') throw new Error('marker fields are invalid');
174
- return { source: record['source'], pluginId: record['pluginId'], fingerprint: record['fingerprint'] };
175
- } catch (error) {
176
- throw new Error(`invalid plgnz ownership marker: ${marker} (${(error as Error).message})`);
177
- }
178
- }
179
-
180
- function writeOwnership(stage: string, ownership: Ownership): void {
181
- writeFileSync(join(stage, OWNERSHIP), JSON.stringify(ownership));
182
- }
183
-
184
- /** A legacy bare marker remains owned only for this exact native directory. */
185
- function matchesOwnership(markerId: string, nativeId: string, ownershipId: string): boolean {
186
- return markerId === nativeId || markerId === ownershipId;
187
- }
188
-
189
- function ownsNativeDirectory(markerId: string, nativeId: string): boolean {
190
- if (markerId === nativeId) return true;
191
- const prefix = `${nativeId}@`;
192
- return markerId.startsWith(prefix) && validIdentity(markerId.slice(prefix.length));
193
- }
194
-
195
- function activate(stage: string, target: string, root: string): { commit(): void } {
196
- if (!existsSync(target)) {
197
- renameSync(stage, target);
198
- return { commit: () => {} };
199
- }
200
- const backupRoot = mkdtempSync(join(root, '.plgnz-cursor-backup-'));
201
- const backup = join(backupRoot, 'previous');
202
- renameSync(target, backup);
203
- try { renameSync(stage, target); }
204
- catch (error) {
205
- renameSync(backup, target);
206
- rmSync(backupRoot, { recursive: true, force: true });
207
- throw error;
208
- }
209
- return { commit: () => rmSync(backupRoot, { recursive: true, force: true }) };
210
- }
211
-
212
- function sameTree(left: string, right: string): boolean {
213
- if (!existsSync(right)) return false;
214
- const entries = (root: string): string[] => {
215
- const out: string[] = [];
216
- const walk = (dir: string, prefix: string): void => {
217
- for (const name of readdirSync(dir).sort()) {
218
- if (name === OWNERSHIP) continue;
219
- const path = join(dir, name);
220
- const relative = prefix === '' ? name : `${prefix}/${name}`;
221
- const stat = lstatSync(path);
222
- if (stat.isSymbolicLink()) throw new Error(`Cursor managed plugin contains symlink: ${path}`);
223
- if (stat.isDirectory()) walk(path, relative);
224
- else if (stat.isFile()) out.push(`${relative}:${hashBytes(path)}`);
225
- else throw new Error(`Cursor managed plugin contains unsupported file: ${path}`);
226
- }
227
- };
228
- walk(root, '');
229
- return out;
230
- };
231
- return JSON.stringify(entries(left)) === JSON.stringify(entries(right));
232
- }
233
-
234
- function hashBytes(path: string): string {
235
- const read = readFileSync as unknown as (file: string) => Uint8Array;
236
- return Array.from(read(path)).join(',');
237
- }
238
-
239
- function assertManagedDirectory(root: string, target: string): void {
240
- const resolvedRoot = resolve(cursorRoot());
241
- const resolvedTarget = resolve(target);
242
- if (resolvedTarget !== resolvedRoot && !resolvedTarget.startsWith(`${resolvedRoot}/`)) throw new Error(`Cursor managed path escapes its store: ${target}`);
243
- let current = resolvedRoot;
244
- for (const part of resolvedTarget.slice(resolvedRoot.length).split('/').filter(Boolean)) {
245
- if (existsSync(current)) assertDirectoryNotLink(current);
246
- current = join(current, part);
247
- }
248
- if (existsSync(current)) assertDirectoryNotLink(current);
249
- }
250
-
251
- function assertDirectoryNotLink(path: string): void {
252
- const stat = lstatSync(path);
253
- if (stat.isSymbolicLink()) throw new Error(`Cursor managed path component is a symlink: ${path}`);
254
- if (!stat.isDirectory()) throw new Error(`Cursor managed path component is not a directory: ${path}`);
255
- }
256
-
257
- function assertNoSymlinks(root: string): void {
258
- const walk = (dir: string): void => {
259
- const stat = lstatSync(dir);
260
- if (stat.isSymbolicLink()) throw new Error(`Cursor plugin source contains symlink: ${dir}`);
261
- if (!stat.isDirectory()) throw new Error(`Cursor plugin source is not a directory: ${dir}`);
262
- for (const name of readdirSync(dir)) {
263
- const path = join(dir, name);
264
- const entry = lstatSync(path);
265
- if (entry.isSymbolicLink()) throw new Error(`Cursor plugin source contains symlink: ${path}`);
266
- if (entry.isDirectory()) walk(path);
267
- else if (!entry.isFile()) throw new Error(`Cursor plugin source contains unsupported file: ${path}`);
268
- }
269
- };
270
- walk(root);
271
- }
272
-
273
- function assertSafeIdentity(id: string): void {
274
- if (!validIdentity(id)) throw new Error(`unsafe Cursor plugin identity: ${id}`);
275
- }
276
-
277
- function validIdentity(id: string): boolean {
278
- return /^[a-z0-9][a-z0-9._-]*$/iu.test(id);
279
- }
@@ -1,107 +0,0 @@
1
- /**
2
- * cursor host reader.
3
- *
4
- * Reader only — the writer (add/pin/remove) lives in
5
- * src/hosts/cursor-writer.ts so loading a reader never evaluates writer code
6
- * (AGENTS.md: doctor is read-only by construction).
7
- *
8
- * Real store layout (measured 2026-09-16, evidence in docs/hosts/cursor.md):
9
- * ~/.cursor/mcp.json — user-level {mcpServers:{…}}
10
- * ~/.cursor/plugins/local/<name>/ — dereferenced plugin copies (the route
11
- * `npx plugins --target cursor` does NOT take;
12
- * house evidence: install rejected)
13
- * ~/.cursor/plugins/{cache,marketplaces}/ — cursor-native dirs, observed but not read in v0
14
- *
15
- * Plugin MCP is declared in `.mcp.json` (and a spec `mcp.json` copy) at the
16
- * plugin root. Cursor is a GUI host: `launchctl getenv PATH` is unset, so bare
17
- * commands cannot be assumed to resolve (spec §7.2.1) — doctor flags them and
18
- * `pin` rewrites them to absolute paths; `add` pins a fresh copy the same way.
19
- */
20
- import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
21
- import { join } from 'node:path';
22
- import type { HostReader, InstalledPlugin, McpServerEntry } from '../host';
23
- import { cursorRoot } from '../paths';
24
- import { collectPluginServers, collectUserServers, readJson, type PluginMcpCandidate } from '../mcp';
25
-
26
- export function localDir(): string {
27
- return join(cursorRoot(), 'plugins', 'local');
28
- }
29
-
30
- /** Where a plugin copy may declare MCP servers, in cursor's priority order. */
31
- export function mcpCandidates(): PluginMcpCandidate[] {
32
- return [
33
- { kind: 'spec', file: '.mcp.json' },
34
- { kind: 'spec', file: 'mcp.json' },
35
- ];
36
- }
37
-
38
- function userConfigFile(): string {
39
- return join(cursorRoot(), 'mcp.json');
40
- }
41
-
42
- export const cursor: HostReader = {
43
- id: 'cursor',
44
- gui: true,
45
-
46
- detect(): boolean {
47
- return existsSync(cursorRoot());
48
- },
49
-
50
- stores(): string[] {
51
- return [localDir()];
52
- },
53
-
54
- listInstalled(): InstalledPlugin[] {
55
- if (!existsSync(localDir())) return [];
56
- const out: InstalledPlugin[] = [];
57
- for (const entry of readdirSync(localDir())) {
58
- const dir = join(localDir(), entry);
59
- try {
60
- if (!statSync(dir).isDirectory()) continue;
61
- } catch {
62
- continue;
63
- }
64
- const manifest = join(dir, '.cursor-plugin', 'plugin.json');
65
- if (!existsSync(manifest)) continue;
66
- const plugin: InstalledPlugin = { id: entry, name: entry, path: dir };
67
- const marketplace = ownershipMarketplace(dir, entry);
68
- if (marketplace !== undefined) plugin.marketplace = marketplace;
69
- const parsed = readJson(manifest);
70
- const version = parsed !== null ? parsed['version'] : undefined;
71
- if (typeof version === 'string') plugin.version = version;
72
- out.push(plugin);
73
- }
74
- return out;
75
- },
76
-
77
- mcpEntries(): McpServerEntry[] {
78
- const entries: McpServerEntry[] = [];
79
- entries.push(...collectUserServers(userConfigFile(), cursorRoot()));
80
- for (const plugin of this.listInstalled()) {
81
- if (plugin.path === undefined || !existsSync(plugin.path)) continue;
82
- entries.push(...collectPluginServers(plugin.id, plugin.path, mcpCandidates()));
83
- }
84
- return entries;
85
- },
86
- };
87
-
88
- /** Recover a collection identity only from a valid plgnz ownership marker. */
89
- function ownershipMarketplace(dir: string, name: string): string | undefined {
90
- const marker = join(dir, '.plgnz-install.json');
91
- if (!existsSync(marker)) return undefined;
92
- try {
93
- const parsed: unknown = JSON.parse(readFileSync(marker, 'utf8'));
94
- if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return undefined;
95
- const ownership = parsed as Record<string, unknown>;
96
- if (typeof ownership['source'] !== 'string' || typeof ownership['fingerprint'] !== 'string' || typeof ownership['pluginId'] !== 'string') return undefined;
97
- const prefix = `${name}@`;
98
- const marketplace = ownership['pluginId'].startsWith(prefix) ? ownership['pluginId'].slice(prefix.length) : undefined;
99
- return marketplace !== undefined && validIdentity(marketplace) ? marketplace : undefined;
100
- } catch {
101
- return undefined;
102
- }
103
- }
104
-
105
- function validIdentity(value: string): boolean {
106
- return /^[a-z0-9][a-z0-9._-]*$/iu.test(value);
107
- }