plugnz 0.0.4 → 0.0.6

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,589 +0,0 @@
1
- /** Claude Code's native cache/registry writer. Kept separate from the read-only reader. */
2
- import { cpSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
3
- import { dirname, join, relative, resolve } from 'node:path';
4
- import { tmpdir } from 'node:os';
5
- import type { AddOptions, HostWriter, InstalledPlugin, PinOptions, PinOutcome } from '../host';
6
- import type { PluginSource, ResolvedSource } from '../source';
7
- import { tomlCommandMarkdown } from '../conversion';
8
- import { claudeCode, mcpCandidates, pluginsDir } from './claude-code';
9
- import { pinPluginMcpFiles } from '../mcp-write';
10
-
11
- const OWNERSHIP = '.plgnz-install.json';
12
- declare const Bun: { CryptoHasher: new (algorithm: 'sha256') => { update(input: string | Uint8Array): void; digest(encoding: 'hex'): string } };
13
- type Ownership = { source: string; pluginId: string; fingerprint: string; adopted?: true };
14
- type Registry = { version: number; plugins: Record<string, unknown> };
15
-
16
- export const claudeCodeWriter: HostWriter = {
17
- ...claudeCode,
18
- supportsAdoption: true,
19
- async add(plugin: PluginSource, resolved: ResolvedSource, opts?: AddOptions): Promise<void | 'unchanged'> {
20
- const marketplace = plugin.marketplace || 'local';
21
- const id = `${plugin.name}@${marketplace}`;
22
- const version = resolved.sha;
23
- const slot = join(pluginsDir(), 'cache', marketplace, plugin.name);
24
- const registryFile = join(pluginsDir(), 'installed_plugins.json');
25
- const settingsFile = join(pluginsDir(), '..', 'settings.json');
26
- const marketplacesFile = join(pluginsDir(), 'known_marketplaces.json');
27
- const wrapper = !existsSync(join(resolved.sourceUri, '.claude-plugin', 'marketplace.json')) ? join(pluginsDir(), 'marketplaces', `.plgnz-${marketplace}`) : undefined;
28
- for (const path of [slot, registryFile, settingsFile, marketplacesFile]) assertManagedPath(path);
29
- const registry = readRegistry(registryFile);
30
- const settings = readSettings(settingsFile);
31
- const marketplaces = readMarketplaces(marketplacesFile);
32
- const alreadyOwned = hasAdoptedRegistryInstall(registry, id, resolved.sourceUri, slot);
33
- const legacy = opts?.adoptExisting && !alreadyOwned ? validateLegacyUserInstall(registry, id, slot, plugin) : undefined;
34
- const owned = ownedRegistryTarget(registry, id, resolved.sourceUri, slot, version);
35
- const matchedCatalogMember = legacy === undefined && !alreadyOwned && !hasUserRegistryInstall(registry, id) &&
36
- matchesNativeCatalogMember(marketplaces, marketplace, plugin, resolved);
37
- const preserveNativeMarketplace = legacy !== undefined || alreadyOwned || matchedCatalogMember;
38
- const target = owned ??
39
- (legacy !== undefined && existsSync(join(slot, version)) && readOwnership(join(slot, version)) === null
40
- ? join(slot, `${version}.plgnz`)
41
- : join(slot, version));
42
- assertManagedPath(target);
43
- if (existsSync(target)) assertNoSymlinks(target);
44
- const existing = readOwnership(target);
45
- assertTargetIsReplaceable(target, existing, id, resolved.sourceUri);
46
- assertNoForeignRegistryEntry(registry, id, target, resolved.sourceUri, legacy);
47
- if (!preserveNativeMarketplace && wrapper !== undefined) assertManagedPath(wrapper);
48
- if (!preserveNativeMarketplace) validateMarketplaceRegistration(marketplaces, marketplace, resolved.sourceUri, plugin);
49
- if (opts?.dryRun) {
50
- const stage = mkdtempSync(join(tmpdir(), 'plgnz-claude-dry-run-'));
51
- try { stagePlugin(plugin.dir, stage, plugin.name, version); }
52
- finally { rmSync(stage, { recursive: true, force: true }); }
53
- console.log(`[claude-code] would activate directory: ${target}`);
54
- console.log(`[claude-code] would update registry: ${registryFile}`);
55
- return;
56
- }
57
- mkdirSync(slot, { recursive: true });
58
- assertManagedPath(slot);
59
- const stage = mkdtempSync(join(slot, '.plgnz-stage-'));
60
- const ownedWrapper = preserveNativeMarketplace ? undefined : wrapper;
61
- const wrapperSnapshot = ownedWrapper === undefined ? undefined : snapshotDirectory(ownedWrapper);
62
- let durable = false;
63
- try {
64
- stagePlugin(plugin.dir, stage, plugin.name, version);
65
- const nextMarketplaces = preserveNativeMarketplace ? undefined : marketplacesWithLocalSource(marketplaces, marketplace, resolved.sourceUri, plugin);
66
- writeFileSync(join(stage, OWNERSHIP), JSON.stringify({ source: resolved.sourceUri, pluginId: id, fingerprint: plugin.contentFingerprint ?? '', ...(preserveNativeMarketplace ? { adopted: true } : {}) }));
67
- const unchanged = existing !== null && existing.fingerprint === (plugin.contentFingerprint ?? '') && sameTree(stage, target);
68
- if (unchanged) {
69
- try {
70
- writeRegistry(registryFile, registryWithEntry(registry, id, target, version, resolved));
71
- writeSettings(settingsFile, settingsWithEnabled(settings, id, true));
72
- if (nextMarketplaces !== undefined) writeMarketplaces(marketplacesFile, nextMarketplaces);
73
- durable = true;
74
- } catch (error) {
75
- restoreMetadata(registryFile, registry, settingsFile, settings, nextMarketplaces === undefined ? undefined : marketplacesFile, nextMarketplaces === undefined ? undefined : marketplaces);
76
- throw error;
77
- }
78
- return 'unchanged';
79
- }
80
- let activation: { commit(): void; rollback(): void } | undefined;
81
- try {
82
- activation = activate(stage, target, slot);
83
- writeRegistry(registryFile, registryWithEntry(registry, id, target, version, resolved));
84
- writeSettings(settingsFile, settingsWithEnabled(settings, id, true));
85
- if (nextMarketplaces !== undefined) writeMarketplaces(marketplacesFile, nextMarketplaces);
86
- durable = true;
87
- } catch (error) {
88
- activation?.rollback();
89
- restoreMetadata(registryFile, registry, settingsFile, settings, nextMarketplaces === undefined ? undefined : marketplacesFile, nextMarketplaces === undefined ? undefined : marketplaces);
90
- throw error;
91
- }
92
- // The registry is durable before old cache content is discarded. A cleanup
93
- // failure must never roll back the now-active target.
94
- activation.commit();
95
- cleanupPriorOwnedPaths(registry, id, target, resolved.sourceUri);
96
- } catch (error) {
97
- // Once metadata points at the new cache, cleanup errors must preserve the
98
- // durable activation rather than resurrecting its old wrapper.
99
- if (!durable) restoreDirectory(ownedWrapper, wrapperSnapshot);
100
- rmSync(stage, { recursive: true, force: true });
101
- throw error;
102
- }
103
- finally {
104
- rmSync(stage, { recursive: true, force: true });
105
- if (wrapperSnapshot !== undefined) rmSync(wrapperSnapshot, { recursive: true, force: true });
106
- }
107
- },
108
- async pin(plugin: InstalledPlugin, opts?: PinOptions): Promise<PinOutcome> {
109
- if (plugin.path === undefined || !existsSync(plugin.path)) return { changes: [], refusals: [] };
110
- return pinPluginMcpFiles(plugin.path, mcpCandidates(), opts);
111
- },
112
- async remove(id: string): Promise<void> {
113
- const registryFile = join(pluginsDir(), 'installed_plugins.json');
114
- const settingsFile = join(pluginsDir(), '..', 'settings.json');
115
- for (const path of [registryFile, settingsFile]) assertManagedPath(path);
116
- const settings = readSettings(settingsFile);
117
- if (!existsSync(registryFile)) {
118
- const enabled = settings['enabledPlugins'];
119
- if (typeof enabled === 'object' && enabled !== null && !Array.isArray(enabled) && (enabled as Record<string, unknown>)[id] === true) writeSettings(settingsFile, settingsWithEnabled(settings, id, false));
120
- return;
121
- }
122
- const registry = readRegistry(registryFile);
123
- const row = registry.plugins[id];
124
- if (!Array.isArray(row)) return;
125
- const ownedRows = row.filter(record => typeof record === 'object' && record !== null && (record as Record<string, unknown>)['scope'] === 'user');
126
- const ownedPaths = ownedRows.flatMap(record => {
127
- if (typeof record !== 'object' || record === null) return [];
128
- const path = (record as Record<string, unknown>)['installPath'];
129
- return typeof path === 'string' && readOwnership(path)?.pluginId === id ? [path] : [];
130
- });
131
- if (ownedRows.length === 0 || ownedPaths.length !== ownedRows.length) throw new Error(`claude-code user registry entry ${id} is not wholly plgnz-owned; refusing to remove it`);
132
- const backups: Array<{ commit(): void; rollback(): void }> = [];
133
- try {
134
- for (const path of ownedPaths) backups.push(moveAside(path));
135
- const next: Registry = { ...registry, plugins: { ...registry.plugins } };
136
- const retained = row.filter(record => !ownedRows.includes(record));
137
- if (retained.length === 0) delete next.plugins[id]; else next.plugins[id] = retained;
138
- writeRegistry(registryFile, next);
139
- writeSettings(settingsFile, settingsWithEnabled(settings, id, false));
140
- } catch (error) {
141
- for (const backup of backups.reverse()) backup.rollback();
142
- restoreMetadata(registryFile, registry, settingsFile, settings);
143
- throw error;
144
- }
145
- // Do not attempt rollback after any deletion cleanup has begun: the
146
- // registry removal is already durable and the active state is correct.
147
- for (const backup of backups) backup.commit();
148
- const marketplacesFile = join(pluginsDir(), 'known_marketplaces.json');
149
- const marketplaces = readMarketplaces(marketplacesFile);
150
- const at = id.indexOf('@'); const market = at === -1 ? undefined : id.slice(at + 1);
151
- const entry = market ? marketplaces[market] as Record<string, unknown> | undefined : undefined;
152
- const path = entry?.['installLocation'];
153
- if (typeof path === 'string') {
154
- const plugin = id.slice(0, id.indexOf('@'));
155
- const copy = join(path, 'plugins', plugin);
156
- if (readOwnership(copy)?.pluginId === id) {
157
- rmSync(copy, { recursive: true, force: true });
158
- const manifest = join(path, '.claude-plugin', 'marketplace.json');
159
- if (existsSync(manifest)) {
160
- const document: unknown = JSON.parse(readFileSync(manifest, 'utf8'));
161
- if (typeof document === 'object' && document !== null && !Array.isArray(document)) {
162
- const current = document as Record<string, unknown>;
163
- const entries = Array.isArray(current['plugins']) ? current['plugins'] : [];
164
- writeFileSync(manifest, JSON.stringify({ ...current, plugins: entries.filter(value => !(typeof value === 'object' && value !== null && (value as Record<string, unknown>)['name'] === plugin)) }, null, 2));
165
- }
166
- }
167
- }
168
- if (readOwnership(path)?.pluginId === `marketplace@${market}` && readdirSync(join(path, 'plugins')).length === 0) { rmSync(path, { recursive: true, force: true }); const next = { ...marketplaces }; delete next[market as string]; writeMarketplaces(marketplacesFile, next); }
169
- }
170
- },
171
- };
172
-
173
- function stagePlugin(source: string, stage: string, fallbackName: string, fallbackVersion: string): void {
174
- cpSync(source, stage, { recursive: true });
175
- assertNoSymlinks(stage);
176
- const root = join(stage, 'plugin.json');
177
- const canonical = join(stage, '.plugin', 'plugin.json');
178
- const manifest = existsSync(canonical) ? canonical : existsSync(root) ? root : undefined;
179
- if (manifest === undefined) throw new Error('Claude Code stage has no Agent Plugins manifest');
180
- const canonicalManifest = parseManifest(manifest, 'canonical');
181
- if (canonicalManifest.name !== fallbackName) throw new Error(`Claude Code canonical manifest name ${canonicalManifest.name} does not match selected plugin ${fallbackName}`);
182
- const native = join(stage, '.claude-plugin', 'plugin.json');
183
- if (!existsSync(native)) {
184
- mkdirSync(dirname(native), { recursive: true });
185
- writeFileSync(native, JSON.stringify({ name: canonicalManifest.name, version: canonicalManifest.version ?? fallbackVersion, description: canonicalManifest.description, skills: './skills/' }));
186
- }
187
- projectRootCommandsToNative(stage);
188
- projectNativeCommands(stage, native);
189
- const nativeManifest = parseManifest(native, 'Claude Code');
190
- if (nativeManifest.name !== canonicalManifest.name || (canonicalManifest.version !== undefined && nativeManifest.version !== canonicalManifest.version)) throw new Error('Claude Code native manifest identity does not match the canonical manifest');
191
- for (const path of [join(stage, 'skills'), join(stage, 'commands'), join(stage, '.claude', 'commands')]) {
192
- if (existsSync(path) && !statSync(path).isDirectory()) throw new Error(`Claude Code stage native content path is not a directory: ${path}`);
193
- }
194
- if (fallbackName.length === 0) throw new Error('Claude Code plugin name is empty');
195
- }
196
-
197
- /** Root command sources map into Claude Code's native discovery path when
198
- * the package has no explicit .claude/commands tree (spec §2): Markdown
199
- * commands pass through and TOML prompt commands convert there. The
200
- * package's own command bytes are never modified — Claude Code installs are
201
- * byte-preserving. */
202
- function projectRootCommandsToNative(stage: string): void {
203
- const nativeCommands = join(stage, '.claude', 'commands');
204
- if (existsSync(nativeCommands)) return;
205
- const rootCommands = join(stage, 'commands');
206
- if (!existsSync(rootCommands)) return;
207
- const entries = readdirSync(rootCommands).sort();
208
- const markdown = entries.filter((file) => file.endsWith('.md'));
209
- const toml = entries.filter((file) => file.endsWith('.toml'));
210
- if (markdown.length === 0 && toml.length === 0) return;
211
- mkdirSync(nativeCommands, { recursive: true });
212
- for (const file of markdown) cpSync(join(rootCommands, file), join(nativeCommands, file));
213
- if (markdown.length > 0) return;
214
- for (const file of toml) {
215
- const projected = tomlCommandMarkdown(join(rootCommands, file));
216
- writeFileSync(join(nativeCommands, `${projected.name}.md`), projected.text);
217
- }
218
- }
219
-
220
- /** Claude Code only discovers plugin commands from an explicit manifest list. */
221
- function projectNativeCommands(stage: string, native: string): void {
222
- const commandsRoot = join(stage, '.claude', 'commands');
223
- if (!existsSync(commandsRoot)) return;
224
- const commands = readdirSync(commandsRoot).filter(file => file.endsWith('.md')).sort().map(file => `./.claude/commands/${file}`);
225
- if (commands.length === 0) return;
226
- const parsed: unknown = JSON.parse(readFileSync(native, 'utf8'));
227
- if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) throw new Error('Invalid Claude Code manifest');
228
- const manifest = parsed as Record<string, unknown>;
229
- if (manifest['commands'] !== undefined) {
230
- const listed = manifest['commands'];
231
- if (!Array.isArray(listed) || !commands.every(command => listed.includes(command))) throw new Error('Claude Code manifest does not enumerate every native Markdown command');
232
- return;
233
- }
234
- manifest['commands'] = commands;
235
- writeFileSync(native, JSON.stringify(manifest, null, 2));
236
- }
237
-
238
- function parseManifest(file: string, kind: string): { name: string; version?: string; description?: string } {
239
- let value: unknown;
240
- try { value = JSON.parse(readFileSync(file, 'utf8')); }
241
- catch (error) { throw new Error(`Invalid ${kind} manifest: ${error instanceof Error ? error.message : String(error)}`); }
242
- if (typeof value !== 'object' || value === null || Array.isArray(value)) throw new Error(`Invalid ${kind} manifest`);
243
- const manifest = value as Record<string, unknown>;
244
- if (typeof manifest['name'] !== 'string' || manifest['name'] === '') throw new Error(`Invalid ${kind} manifest identity`);
245
- if (manifest['version'] !== undefined && (typeof manifest['version'] !== 'string' || manifest['version'] === '')) throw new Error(`Invalid ${kind} manifest identity`);
246
- return { name: manifest['name'], ...(typeof manifest['version'] === 'string' ? { version: manifest['version'] } : {}), ...(typeof manifest['description'] === 'string' ? { description: manifest['description'] } : {}) };
247
- }
248
-
249
- function readRegistry(file: string): Registry {
250
- if (!existsSync(file)) return { version: 2, plugins: {} };
251
- let value: unknown;
252
- try { value = JSON.parse(readFileSync(file, 'utf8')); }
253
- catch (error) { throw new Error(`Invalid Claude Code plugin registry: ${file} (${error instanceof Error ? error.message : String(error)})`); }
254
- if (typeof value !== 'object' || value === null || Array.isArray(value)) throw new Error(`Invalid Claude Code plugin registry: ${file}`);
255
- const root = value as Record<string, unknown>;
256
- if (typeof root['plugins'] !== 'object' || root['plugins'] === null || Array.isArray(root['plugins'])) throw new Error(`Invalid Claude Code plugin registry: ${file}`);
257
- return { version: typeof root['version'] === 'number' ? root['version'] : 2, plugins: root['plugins'] as Record<string, unknown> };
258
- }
259
-
260
- function readSettings(file: string): Record<string, unknown> {
261
- if (!existsSync(file)) return {};
262
- let value: unknown;
263
- try { value = JSON.parse(readFileSync(file, 'utf8')); }
264
- catch (error) { throw new Error(`Invalid Claude Code settings: ${file} (${error instanceof Error ? error.message : String(error)})`); }
265
- if (typeof value !== 'object' || value === null || Array.isArray(value)) throw new Error(`Invalid Claude Code settings: ${file}`);
266
- const settings = value as Record<string, unknown>;
267
- if (settings['enabledPlugins'] !== undefined && (typeof settings['enabledPlugins'] !== 'object' || settings['enabledPlugins'] === null || Array.isArray(settings['enabledPlugins']))) throw new Error(`Invalid Claude Code enabledPlugins setting: ${file}`);
268
- return settings;
269
- }
270
-
271
- function readMarketplaces(file: string): Record<string, unknown> {
272
- if (!existsSync(file)) return {};
273
- let value: unknown;
274
- try { value = JSON.parse(readFileSync(file, 'utf8')); }
275
- catch (error) { throw new Error(`Invalid Claude Code marketplace registry: ${file} (${error instanceof Error ? error.message : String(error)})`); }
276
- if (typeof value !== 'object' || value === null || Array.isArray(value)) throw new Error(`Invalid Claude Code marketplace registry: ${file}`);
277
- return value as Record<string, unknown>;
278
- }
279
-
280
- function marketplacesWithLocalSource(marketplaces: Record<string, unknown>, name: string, source: string, plugin: PluginSource): Record<string, unknown> {
281
- const manifest = join(source, '.claude-plugin', 'marketplace.json');
282
- const catalog = validateMarketplaceRegistration(marketplaces, name, source, plugin);
283
- if (!existsSync(manifest)) {
284
- const marker = join(catalog, OWNERSHIP);
285
- const copy = join(catalog, 'plugins', plugin.name);
286
- rmSync(copy, { recursive: true, force: true });
287
- cpSync(plugin.dir, copy, { recursive: true });
288
- writeFileSync(join(copy, OWNERSHIP), JSON.stringify({ source, pluginId: `${plugin.name}@${name}`, fingerprint: plugin.contentFingerprint ?? '' }));
289
- mkdirSync(join(catalog, '.claude-plugin'), { recursive: true });
290
- const current = existsSync(join(catalog, '.claude-plugin', 'marketplace.json')) ? JSON.parse(readFileSync(join(catalog, '.claude-plugin', 'marketplace.json'), 'utf8')) as { plugins?: unknown[] } : {};
291
- const plugins = Array.isArray(current.plugins) ? current.plugins.filter(entry => !(typeof entry === 'object' && entry !== null && (entry as Record<string, unknown>)['name'] === plugin.name)) : [];
292
- plugins.push({ name: plugin.name, source: `./plugins/${plugin.name}` });
293
- writeFileSync(join(catalog, '.claude-plugin', 'marketplace.json'), JSON.stringify({ name, owner: { name: 'plgnz' }, plugins }));
294
- writeFileSync(marker, JSON.stringify({ source: 'plgnz-wrapper', pluginId: `marketplace@${name}`, fingerprint: '' }));
295
- }
296
- let document: unknown;
297
- try { document = JSON.parse(readFileSync(join(catalog, '.claude-plugin', 'marketplace.json'), 'utf8')); }
298
- catch (error) { throw new Error(`Invalid Claude Code marketplace manifest: ${error instanceof Error ? error.message : String(error)}`); }
299
- if (typeof document !== 'object' || document === null || (document as Record<string, unknown>)['name'] !== name) throw new Error(`Claude Code marketplace manifest identity does not match ${name}`);
300
- return { ...marketplaces, [name]: { source: { source: 'directory', path: catalog }, installLocation: catalog, lastUpdated: new Date().toISOString() } };
301
- }
302
-
303
- /** Read-only preflight shared by real activation and dry-run. */
304
- function validateMarketplaceRegistration(marketplaces: Record<string, unknown>, name: string, source: string, plugin: PluginSource): string {
305
- if (!source.startsWith('/')) throw new Error(`Claude Code marketplace registration for remote source ${name} is unverified`);
306
- const manifest = join(source, '.claude-plugin', 'marketplace.json');
307
- const catalog = existsSync(manifest) ? source : join(pluginsDir(), 'marketplaces', `.plgnz-${name}`);
308
- if (!existsSync(manifest) && existsSync(catalog)) {
309
- assertNoSymlinks(catalog);
310
- const wrapper = readOwnership(catalog);
311
- if (wrapper?.pluginId !== `marketplace@${name}` || wrapper.source !== 'plgnz-wrapper') throw new Error(`Claude Code marketplace wrapper ${catalog} is foreign; refusing to replace it`);
312
- const copy = join(catalog, 'plugins', plugin.name);
313
- if (existsSync(copy) && readOwnership(copy)?.source !== source) throw new Error(`Claude Code marketplace wrapper plugin ${copy} is foreign; refusing to replace it`);
314
- }
315
- if (existsSync(join(catalog, '.claude-plugin', 'marketplace.json'))) {
316
- let document: unknown;
317
- try { document = JSON.parse(readFileSync(join(catalog, '.claude-plugin', 'marketplace.json'), 'utf8')); }
318
- catch (error) { throw new Error(`Invalid Claude Code marketplace manifest: ${error instanceof Error ? error.message : String(error)}`); }
319
- if (typeof document !== 'object' || document === null || (document as Record<string, unknown>)['name'] !== name) throw new Error(`Claude Code marketplace manifest identity does not match ${name}`);
320
- }
321
- const existing = marketplaces[name];
322
- if (typeof existing === 'object' && existing !== null) {
323
- const previous = ((existing as Record<string, unknown>)['source'] as Record<string, unknown> | undefined)?.['path'];
324
- if (typeof previous === 'string' && previous !== catalog) throw new Error(`Claude Code marketplace ${name} is registered from a different source; refusing to replace it`);
325
- }
326
- return catalog;
327
- }
328
-
329
- /** Restore durable metadata without letting a restoration failure strand a cache swap. */
330
- function restoreMetadata(registryFile: string, registry: Registry, settingsFile: string, settings: Record<string, unknown>, marketplacesFile?: string, marketplaces?: Record<string, unknown>): void {
331
- try { writeRegistry(registryFile, registry); } catch { /* the failed atomic write left this file untouched */ }
332
- try { writeSettings(settingsFile, settings); } catch { /* preserve the original operation error */ }
333
- if (marketplacesFile !== undefined && marketplaces !== undefined) {
334
- try { writeMarketplaces(marketplacesFile, marketplaces); } catch { /* preserve the original operation error */ }
335
- }
336
- }
337
-
338
- /** Snapshot an owned wrapper so wrapper, cache, and registries share one rollback boundary. */
339
- function snapshotDirectory(directory: string): string | undefined {
340
- if (!existsSync(directory)) return undefined;
341
- assertNoSymlinks(directory);
342
- const snapshot = mkdtempSync(join(tmpdir(), 'plgnz-claude-wrapper-'));
343
- cpSync(directory, join(snapshot, 'previous'), { recursive: true });
344
- return snapshot;
345
- }
346
-
347
- function restoreDirectory(directory: string | undefined, snapshot: string | undefined): void {
348
- if (directory === undefined) return;
349
- rmSync(directory, { recursive: true, force: true });
350
- const previous = snapshot === undefined ? undefined : join(snapshot, 'previous');
351
- if (previous !== undefined && existsSync(previous)) {
352
- mkdirSync(dirname(directory), { recursive: true });
353
- renameSync(previous, directory);
354
- }
355
- }
356
-
357
- function settingsWithEnabled(settings: Record<string, unknown>, id: string, enabled: boolean): Record<string, unknown> {
358
- const prior = settings['enabledPlugins'];
359
- const plugins = typeof prior === 'object' && prior !== null && !Array.isArray(prior) ? prior as Record<string, unknown> : {};
360
- return { ...settings, enabledPlugins: { ...plugins, [id]: enabled } };
361
- }
362
-
363
- function registryWithEntry(registry: Registry, id: string, installPath: string, version: string, resolved: ResolvedSource): Registry {
364
- const now = new Date().toISOString();
365
- const prior = Array.isArray(registry.plugins[id]) ? registry.plugins[id] : [];
366
- const user = prior.find(value => typeof value === 'object' && value !== null && (value as Record<string, unknown>)['scope'] === 'user');
367
- const entry: Record<string, unknown> = typeof user === 'object' && user !== null ? { ...(user as Record<string, unknown>) } : { scope: 'user', installedAt: now };
368
- entry['installPath'] = installPath; entry['version'] = version; entry['lastUpdated'] = now;
369
- if (resolved.isGit) entry['gitCommitSha'] = resolved.sha; else delete entry['gitCommitSha'];
370
- let replaced = false;
371
- const rows = prior.map(value => {
372
- if (!replaced && typeof value === 'object' && value !== null && (value as Record<string, unknown>)['scope'] === 'user') { replaced = true; return entry; }
373
- return value;
374
- });
375
- if (!replaced) rows.push(entry);
376
- return { ...registry, plugins: { ...registry.plugins, [id]: rows } };
377
- }
378
-
379
- function writeRegistry(file: string, registry: Registry): void {
380
- writeJsonAtomically(file, registry);
381
- }
382
-
383
- function writeSettings(file: string, settings: Record<string, unknown>): void {
384
- writeJsonAtomically(file, settings);
385
- }
386
-
387
- function writeMarketplaces(file: string, marketplaces: Record<string, unknown>): void {
388
- writeJsonAtomically(file, marketplaces);
389
- }
390
-
391
- function writeJsonAtomically(file: string, value: unknown): void {
392
- assertManagedPath(file);
393
- mkdirSync(dirname(file), { recursive: true });
394
- const temp = `${file}.plgnz-${Date.now()}`;
395
- assertManagedPath(temp);
396
- try { writeFileSync(temp, `${JSON.stringify(value, null, 2)}\n`); renameSync(temp, file); }
397
- finally { rmSync(temp, { force: true }); }
398
- }
399
-
400
- function assertNoForeignRegistryEntry(registry: Registry, id: string, target: string, source: string, adoptedLegacy?: string): void {
401
- const rows = registry.plugins[id]; if (!Array.isArray(rows)) return;
402
- for (const row of rows) {
403
- if (typeof row !== 'object' || row === null || (row as Record<string, unknown>)['scope'] !== 'user') continue;
404
- const path = (row as Record<string, unknown>)['installPath'];
405
- if (typeof path !== 'string' || path === target || !existsSync(path)) continue;
406
- if (path === adoptedLegacy) continue;
407
- const ownership = readOwnership(path);
408
- if (ownership?.pluginId !== id || ownership.source !== source) throw new Error(`claude-code user install ${id} points at ${path}; refusing to replace it`);
409
- }
410
- }
411
-
412
- /** A legacy native row may be superseded only through explicit adoption. The old cache is never marked or removed. */
413
- function validateLegacyUserInstall(registry: Registry, id: string, slot: string, plugin: PluginSource): string | undefined {
414
- const rows = Array.isArray(registry.plugins[id]) ? registry.plugins[id] : [];
415
- const users = rows.filter((row): row is Record<string, unknown> => typeof row === 'object' && row !== null && (row as Record<string, unknown>)['scope'] === 'user');
416
- if (users.length === 0) return undefined;
417
- if (users.length !== 1) throw new Error(`claude-code user install ${id} is ambiguous; refusing adoption`);
418
- const row = users[0]!;
419
- const path = row['installPath'];
420
- if (typeof path !== 'string' || dirname(path) !== slot || !existsSync(path)) throw new Error(`claude-code user install ${id} is outside its native cache slot; refusing adoption`);
421
- assertNoSymlinks(path);
422
- if (readOwnership(path) !== null) throw new Error(`claude-code user install ${id} is already owned; refusing legacy adoption`);
423
- const manifest = parseManifest(join(path, '.claude-plugin', 'plugin.json'), 'existing native');
424
- const selected = parseManifest(join(plugin.dir, 'plugin.json'), 'selected canonical');
425
- if (manifest.name !== plugin.name || manifest.version === undefined || manifest.version !== selected.version || row['version'] !== manifest.version) {
426
- throw new Error(`claude-code user install ${id} native identity does not match the selected plugin; refusing adoption`);
427
- }
428
- return path;
429
- }
430
-
431
- /** Once a marker-owned adoption is active, updates keep using its registry-selected sibling slot. */
432
- function ownedRegistryTarget(registry: Registry, id: string, source: string, slot: string, version: string): string | undefined {
433
- const rows = Array.isArray(registry.plugins[id]) ? registry.plugins[id] : [];
434
- const users = rows.filter((row): row is Record<string, unknown> => typeof row === 'object' && row !== null && (row as Record<string, unknown>)['scope'] === 'user');
435
- if (users.length !== 1) return undefined;
436
- const path = users[0]!['installPath'];
437
- if (typeof path !== 'string' || dirname(path) !== slot || path !== join(slot, `${version}.plgnz`) || !existsSync(path)) return undefined;
438
- const ownership = readOwnership(path);
439
- return ownership?.pluginId === id && ownership.source === source ? path : undefined;
440
- }
441
-
442
- /** A later source SHA gets a new cache slot but retains the native marketplace established before adoption. */
443
- function hasAdoptedRegistryInstall(registry: Registry, id: string, source: string, slot: string): boolean {
444
- const rows = Array.isArray(registry.plugins[id]) ? registry.plugins[id] : [];
445
- const users = rows.filter((row): row is Record<string, unknown> => typeof row === 'object' && row !== null && (row as Record<string, unknown>)['scope'] === 'user');
446
- if (users.length !== 1) return false;
447
- const path = users[0]!['installPath'];
448
- if (typeof path !== 'string' || dirname(path) !== slot || !existsSync(path)) return false;
449
- const ownership = readOwnership(path);
450
- return ownership?.pluginId === id && ownership.source === source && ownership.adopted === true;
451
- }
452
-
453
- function hasUserRegistryInstall(registry: Registry, id: string): boolean {
454
- const rows = registry.plugins[id];
455
- return Array.isArray(rows) && rows.some(row => typeof row === 'object' && row !== null && (row as Record<string, unknown>)['scope'] === 'user');
456
- }
457
-
458
- /** A new member may keep a native catalog only if that catalog delivers identical staged bytes. */
459
- function matchesNativeCatalogMember(marketplaces: Record<string, unknown>, name: string, plugin: PluginSource, resolved: ResolvedSource): boolean {
460
- const registration = marketplaces[name];
461
- if (typeof registration !== 'object' || registration === null || Array.isArray(registration)) return false;
462
- const record = registration as Record<string, unknown>;
463
- const source = record['source'];
464
- if (typeof source !== 'object' || source === null || Array.isArray(source)) return false;
465
- const sourceRecord = source as Record<string, unknown>;
466
- const catalog = sourceRecord['path'];
467
- if (sourceRecord['source'] !== 'directory' || typeof catalog !== 'string' || catalog !== record['installLocation'] || !catalog.startsWith('/') || catalog === resolved.sourceUri) return false;
468
- if (!containedPath(resolved.sourceUri, plugin.dir)) return false;
469
- const manifest = join(catalog, '.claude-plugin', 'marketplace.json');
470
- if (!existsSync(manifest)) return false;
471
- assertCatalogPath(catalog, manifest);
472
- let document: unknown;
473
- try { document = JSON.parse(readFileSync(manifest, 'utf8')); } catch { return false; }
474
- if (typeof document !== 'object' || document === null || Array.isArray(document)) return false;
475
- const catalogManifest = document as Record<string, unknown>;
476
- if (catalogManifest['name'] !== name || !Array.isArray(catalogManifest['plugins'])) return false;
477
- const selected = catalogManifest['plugins'].filter(item => typeof item === 'object' && item !== null && (item as Record<string, unknown>)['name'] === plugin.name);
478
- if (selected.length !== 1) return false;
479
- const memberSource = (selected[0] as Record<string, unknown>)['source'];
480
- if (typeof memberSource !== 'string' || !memberSource.startsWith('./')) return false;
481
- const nativeDir = resolve(catalog, memberSource);
482
- if (!containedPath(catalog, nativeDir) || nativeDir === resolve(catalog)) return false;
483
- assertCatalogPath(catalog, nativeDir);
484
- if (!existsSync(nativeDir) || !lstatSync(nativeDir).isDirectory()) return false;
485
- const stages = mkdtempSync(join(tmpdir(), 'plgnz-claude-catalog-proof-'));
486
- try {
487
- const incoming = join(stages, 'incoming');
488
- const native = join(stages, 'native');
489
- stagePlugin(plugin.dir, incoming, plugin.name, resolved.sha);
490
- stagePlugin(nativeDir, native, plugin.name, resolved.sha);
491
- return sameTree(incoming, native);
492
- } finally { rmSync(stages, { recursive: true, force: true }); }
493
- }
494
-
495
- function containedPath(root: string, path: string): boolean {
496
- if (!root.startsWith('/') || !path.startsWith('/')) return false;
497
- const suffix = relative(resolve(root), resolve(path));
498
- return suffix !== '..' && !suffix.startsWith('../') && !suffix.startsWith('..\\');
499
- }
500
-
501
- function assertCatalogPath(root: string, path: string): void {
502
- if (!containedPath(root, path)) throw new Error(`Claude Code native catalog member escapes catalog: ${path}`);
503
- const target = resolve(path);
504
- let current = resolve(root);
505
- for (const part of ['', ...relative(current, target).split('/').filter(Boolean)]) {
506
- if (part !== '') current = join(current, part);
507
- const stat = lstatSync(current);
508
- if (stat.isSymbolicLink()) throw new Error(`Claude Code native catalog path contains symlink: ${current}`);
509
- if (current !== target && !stat.isDirectory()) throw new Error(`Claude Code native catalog path is not a directory: ${current}`);
510
- }
511
- }
512
-
513
- function assertTargetIsReplaceable(target: string, ownership: Ownership | null, id: string, source: string): void {
514
- if (existsSync(target) && ownership === null) throw new Error(`claude-code cache slot ${target} is unowned; refusing to replace it`);
515
- if (ownership !== null && (ownership.pluginId !== id || ownership.source !== source)) throw new Error(`claude-code cache slot ${target} has a different owned source identity; refusing to replace it`);
516
- }
517
-
518
- /** Check only path components this operation will read or write, including dangling links. */
519
- function assertManagedPath(path: string): void {
520
- const root = resolve(join(pluginsDir(), '..'));
521
- const target = resolve(path);
522
- const suffix = relative(root, target);
523
- if (suffix === '..' || suffix.startsWith('../') || suffix.startsWith('..\\')) throw new Error(`Claude Code managed path escapes its root: ${path}`);
524
- let current = root;
525
- for (const part of ['', ...suffix.split('/').filter(Boolean)]) {
526
- if (part !== '') current = join(current, part);
527
- let stat: ReturnType<typeof lstatSync>;
528
- try { stat = lstatSync(current); }
529
- catch (error) { if ((error as { code?: string }).code === 'ENOENT') return; throw error; }
530
- if (stat.isSymbolicLink()) throw new Error(`Claude Code managed path component is a symlink: ${current}`);
531
- if (current !== target && !stat.isDirectory()) throw new Error(`Claude Code managed path component is not a directory: ${current}`);
532
- }
533
- }
534
-
535
- function cleanupPriorOwnedPaths(registry: Registry, id: string, active: string, source: string): void {
536
- const rows = registry.plugins[id];
537
- if (!Array.isArray(rows)) return;
538
- for (const row of rows) {
539
- if (typeof row !== 'object' || row === null) continue;
540
- const path = (row as Record<string, unknown>)['installPath'];
541
- if (typeof path !== 'string' || path === active || !existsSync(path)) continue;
542
- const ownership = readOwnership(path);
543
- if (ownership?.pluginId === id && ownership.source === source) rmSync(path, { recursive: true, force: true });
544
- }
545
- }
546
-
547
- function readOwnership(dir: string): Ownership | null {
548
- const file = join(dir, OWNERSHIP); if (!existsSync(file)) return null;
549
- try {
550
- const parsed: unknown = JSON.parse(readFileSync(file, 'utf8'));
551
- if (typeof parsed !== 'object' || parsed === null) return null;
552
- const value = parsed as Record<string, unknown>;
553
- return typeof value['source'] === 'string' && typeof value['pluginId'] === 'string' && typeof value['fingerprint'] === 'string' && (value['adopted'] === undefined || value['adopted'] === true) ? { source: value['source'], pluginId: value['pluginId'], fingerprint: value['fingerprint'], ...(value['adopted'] === true ? { adopted: true } : {}) } : null;
554
- } catch { return null; }
555
- }
556
-
557
- function activate(stage: string, target: string, slot: string): { commit(): void; rollback(): void } {
558
- if (!existsSync(target)) { renameSync(stage, target); return { commit: () => {}, rollback: () => rmSync(target, { recursive: true, force: true }) }; }
559
- const backup = moveAside(target, slot);
560
- try { renameSync(stage, target); } catch (error) { backup.rollback(); throw error; }
561
- return { commit: backup.commit, rollback: () => { rmSync(target, { recursive: true, force: true }); backup.rollback(); } };
562
- }
563
-
564
- function moveAside(path: string, parent = dirname(path)): { commit(): void; rollback(): void } {
565
- const backupRoot = mkdtempSync(join(parent, '.plgnz-backup-')); const backup = join(backupRoot, 'previous'); renameSync(path, backup);
566
- return { commit: () => rmSync(backupRoot, { recursive: true, force: true }), rollback: () => { if (existsSync(backup)) renameSync(backup, path); rmSync(backupRoot, { recursive: true, force: true }); } };
567
- }
568
-
569
- function sameTree(left: string, right: string): boolean {
570
- if (!existsSync(right)) return false;
571
- const listing = (root: string): string[] => {
572
- const out: string[] = []; const visit = (dir: string, prefix: string): void => {
573
- for (const entry of readdirSync(dir).sort()) { if (entry === OWNERSHIP) continue; const path = join(dir, entry); const relative = prefix === '' ? entry : `${prefix}/${entry}`; if (statSync(path).isDirectory()) visit(path, relative); else if (statSync(path).isFile()) out.push(`${relative}:${bytesKey(path)}`); }
574
- }; visit(root, ''); return out;
575
- };
576
- return JSON.stringify(listing(left)) === JSON.stringify(listing(right));
577
- }
578
-
579
- function bytesKey(path: string): string {
580
- const readBytes = readFileSync as unknown as (file: string) => Uint8Array;
581
- const hash = new Bun.CryptoHasher('sha256');
582
- hash.update(readBytes(path));
583
- return hash.digest('hex');
584
- }
585
-
586
- function assertNoSymlinks(dir: string): void {
587
- if (lstatSync(dir).isSymbolicLink()) throw new Error(`Claude Code cache contains symlink: ${dir}`);
588
- for (const entry of readdirSync(dir)) { const path = join(dir, entry); const stat = lstatSync(path); if (stat.isSymbolicLink()) throw new Error(`Claude Code cache contains symlink: ${path}`); if (stat.isDirectory()) assertNoSymlinks(path); }
589
- }