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,124 +0,0 @@
1
- /**
2
- * claude-code host reader.
3
- *
4
- * Reader only — the writer (add/pin/remove) lives in
5
- * src/hosts/claude-code-writer.ts so loading a reader never evaluates writer
6
- * code (AGENTS.md: doctor is read-only by construction).
7
- *
8
- * Real store layout (measured 2026-09-16, evidence in docs/hosts/claude-code.md):
9
- * ~/.claude/plugins/installed_plugins.json — {version:2, plugins:{"<name>@<marketplace>":
10
- * [{scope, installPath, version, gitCommitSha, …}]}
11
- * ~/.claude/plugins/cache/<marketplace>/<name>/<version-or-sha>/ — install dirs
12
- * ~/.claude.json — user-level top-level `mcpServers`
13
- *
14
- * Plugin MCP is declared in `.mcp.json` at the plugin root (the plugins CLI
15
- * also writes a spec `mcp.json`; both are read, identical entries deduped —
16
- * spec §7.2.1 fixes the spec path as `mcp.json`).
17
- */
18
- import { existsSync } from 'node:fs';
19
- import { join } from 'node:path';
20
- import type { HostReader, InstalledPlugin, McpServerEntry } from '../host';
21
- import { claudeCodeRoot, homeRoot } from '../paths';
22
- import { collectPluginServers, collectUserServers, readJson, type PluginMcpCandidate } from '../mcp';
23
-
24
- /** Where a plugin copy declares MCP servers (spec `mcp.json`, plus the `npx plugins` `.mcp.json` twin). */
25
- export function mcpCandidates(): PluginMcpCandidate[] {
26
- return [
27
- { kind: 'spec', file: '.mcp.json' },
28
- { kind: 'spec', file: 'mcp.json' },
29
- ];
30
- }
31
-
32
- interface InstallRecordShape {
33
- scope?: string;
34
- installPath?: string;
35
- version?: string;
36
- gitCommitSha?: string;
37
- }
38
-
39
- export function pluginsDir(): string {
40
- return join(claudeCodeRoot(), 'plugins');
41
- }
42
-
43
- /** An explicit Claude Code binary permits first install before its config root exists. */
44
- declare const Bun: { spawnSync(command: string[], options: { stdout: 'pipe'; stderr: 'pipe'; timeout: number }): { exitCode: number | null; stdout: Uint8Array } };
45
-
46
- export function hasCurrentClaudeCodeBinary(env: Record<string, string | undefined> = process.env): boolean {
47
- const binary = env['OPEN_PLUGIN_CLAUDE_CODE_BIN'];
48
- if (!binary || !existsSync(binary)) return false;
49
- try {
50
- const result = Bun.spawnSync([binary, '--version'], { stdout: 'pipe', stderr: 'pipe', timeout: 10_000 });
51
- const stdout = [...result.stdout].map((byte) => String.fromCharCode(byte)).join('');
52
- return result.exitCode === 0 && /^\d+\.\d+\.\d+ \(Claude Code\)\s*$/.test(stdout);
53
- } catch { return false; }
54
- }
55
-
56
- function userConfigFile(): string {
57
- // The user-level config is `~/.claude.json` — a sibling of `~/.claude`, so
58
- // it follows OPEN_PLUGIN_HOME, not the per-host root override.
59
- return join(homeRoot(), '.claude.json');
60
- }
61
-
62
- export const claudeCode: HostReader = {
63
- id: 'claude-code',
64
- gui: false,
65
-
66
- detect(): boolean {
67
- return existsSync(claudeCodeRoot()) || hasCurrentClaudeCodeBinary();
68
- },
69
-
70
- stores(): string[] {
71
- return [pluginsDir()];
72
- },
73
-
74
- listInstalled(): InstalledPlugin[] {
75
- const file = join(pluginsDir(), 'installed_plugins.json');
76
- const root = readJson(file);
77
- if (root === null) return [];
78
- const plugins = root['plugins'];
79
- if (typeof plugins !== 'object' || plugins === null || Array.isArray(plugins)) return [];
80
- const settings = readJson(join(claudeCodeRoot(), 'settings.json'));
81
- const enabled = settings?.['enabledPlugins'];
82
- const enabledPlugins = typeof enabled === 'object' && enabled !== null && !Array.isArray(enabled)
83
- ? enabled as Record<string, unknown> : {};
84
- const out: InstalledPlugin[] = [];
85
- for (const [id, value] of Object.entries(plugins as Record<string, unknown>)) {
86
- const at = id.indexOf('@');
87
- const name = at === -1 ? id : id.slice(0, at);
88
- const marketplace = at === -1 ? undefined : id.slice(at + 1);
89
- if (!Array.isArray(value)) continue;
90
- for (const raw of value) {
91
- if (typeof raw !== 'object' || raw === null) continue;
92
- const rec = raw as InstallRecordShape;
93
- const version = typeof rec.version === 'string' ? rec.version : undefined;
94
- const recordedPath = typeof rec.installPath === 'string' ? rec.installPath : undefined;
95
- // Real records store absolute installPaths; fall back to the computed
96
- // cache slot so relocated/fixture stores still resolve.
97
- const fallback =
98
- marketplace !== undefined && version !== undefined
99
- ? join(pluginsDir(), 'cache', marketplace, name, version)
100
- : undefined;
101
- const path = recordedPath !== undefined && existsSync(recordedPath) ? recordedPath : fallback;
102
- const sha = typeof rec.gitCommitSha === 'string' ? rec.gitCommitSha : undefined;
103
- // Native user-scope installs are disabled unless explicitly enabled in settings.
104
- const plugin: InstalledPlugin = { id, name, enabled: enabledPlugins[id] === true };
105
- if (marketplace !== undefined) plugin.marketplace = marketplace;
106
- if (path !== undefined) plugin.path = path;
107
- if (version !== undefined) plugin.version = version;
108
- if (sha !== undefined) plugin.sha = sha;
109
- out.push(plugin);
110
- }
111
- }
112
- return out;
113
- },
114
-
115
- mcpEntries(): McpServerEntry[] {
116
- const entries: McpServerEntry[] = [];
117
- entries.push(...collectUserServers(userConfigFile(), claudeCodeRoot()));
118
- for (const plugin of this.listInstalled()) {
119
- if (plugin.path === undefined || !existsSync(plugin.path)) continue;
120
- entries.push(...collectPluginServers(plugin.id, plugin.path, mcpCandidates()));
121
- }
122
- return entries;
123
- },
124
- };
@@ -1,418 +0,0 @@
1
- /**
2
- * codex host writer — add/pin/remove for the store documented in
3
- * docs/hosts/codex.md.
4
- *
5
- * A sibling of the reader module so doctor's import graph never loads writer
6
- * code (AGENTS.md: doctor is read-only by construction;
7
- * test/doctor-imports.test.ts pins it).
8
- */
9
- import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
10
- import { dirname, join } from 'node:path';
11
- import { tmpdir } from 'node:os';
12
- import { parse as parseToml } from 'smol-toml';
13
- import type { AddOptions, HostWriter, InstalledPlugin, PinOptions, PinOutcome } from '../host';
14
- import { readPluginManifest, type PluginSource, type ResolvedSource } from '../source';
15
- import { codexHome } from '../paths';
16
- import { codex, configFile, mcpCandidates } from './codex';
17
- import { pinPluginMcpFiles } from '../mcp-write';
18
- import { projectPluginForCodex } from '../conversion';
19
-
20
- const OWNERSHIP = '.plgnz-install.json';
21
-
22
- export const codexWriter: HostWriter = {
23
- ...codex,
24
- supportsAdoption: true,
25
- async add(plugin: PluginSource, resolved: ResolvedSource, opts?: AddOptions): Promise<void | 'unchanged'> {
26
- const marketplace = plugin.marketplace || 'local';
27
- const id = `${plugin.name}@${marketplace}`;
28
- const version = pluginVersion(plugin.dir);
29
- if (!validVersionSegment(version)) throw new Error(`unsafe Codex plugin version: ${version}`);
30
- const slot = join(codexHome(), 'plugins', 'cache', marketplace, plugin.name);
31
- const targetDir = join(slot, version);
32
- const configPath = configFile();
33
- assertManagedConfigPath(codexHome(), configPath);
34
-
35
- if (opts?.dryRun) {
36
- const stage = mkdtempSync(join(tmpdir(), 'plgnz-codex-dry-run-'));
37
- try {
38
- projectPluginForCodex(plugin.dir, stage);
39
- ensureNativeManifest(stage, plugin.name, version);
40
- validateStage(stage);
41
- writeFileSync(join(stage, OWNERSHIP), JSON.stringify({ source: resolved.sourceUri, pluginId: id, fingerprint: plugin.contentFingerprint ?? '' }));
42
- assertManagedPath(codexHome(), slot);
43
- if (existsSync(slot)) {
44
- assertNoSymlinks(slot);
45
- assertNoWinningForeignVersion(slot, version, id, resolved.sourceUri);
46
- const marker = readOwnership(targetDir);
47
- if (marker !== null && (marker.source !== resolved.sourceUri || marker.pluginId !== id)) throw new Error(`codex cache slot ${targetDir} has a different owned source identity; refusing to replace it`);
48
- if (existsSync(targetDir) && marker === null && !sameTree(stage, targetDir)) {
49
- if (!opts.adoptExisting) throw new Error(`codex cache slot ${targetDir} is unowned and differs from the staged representation; pass --adopt-existing to take ownership explicitly`);
50
- validateCachedIdentity(targetDir, plugin.name, version);
51
- }
52
- }
53
- }
54
- finally { rmSync(stage, { recursive: true, force: true }); }
55
- console.log(`[codex] would activate directory: ${targetDir}`);
56
- console.log(`[codex] would update config: ${configPath}`);
57
- return;
58
- }
59
- assertManagedPath(codexHome(), slot);
60
- mkdirSync(slot, { recursive: true });
61
- assertNoSymlinks(slot);
62
- assertNoWinningForeignVersion(slot, version, id, resolved.sourceUri);
63
- const marker = readOwnership(targetDir);
64
- if (marker !== null && (marker.source !== resolved.sourceUri || marker.pluginId !== id)) throw new Error(`codex cache slot ${targetDir} has a different owned source identity; refusing to replace it`);
65
- const stage = mkdtempSync(join(slot, '.plgnz-stage-'));
66
- try {
67
- projectPluginForCodex(plugin.dir, stage);
68
- ensureNativeManifest(stage, plugin.name, version);
69
- validateStage(stage);
70
- writeFileSync(join(stage, OWNERSHIP), JSON.stringify({ source: resolved.sourceUri, pluginId: id, fingerprint: plugin.contentFingerprint ?? '' }));
71
- const identicalUnowned = existsSync(targetDir) && marker === null && sameTree(stage, targetDir);
72
- if (existsSync(targetDir) && marker === null && !identicalUnowned) {
73
- if (!opts?.adoptExisting) throw new Error(`codex cache slot ${targetDir} is unowned and differs from the staged representation; pass --adopt-existing to take ownership explicitly`);
74
- validateCachedIdentity(targetDir, plugin.name, version);
75
- }
76
- const unchanged = marker !== null && marker.fingerprint === plugin.contentFingerprint && sameTree(stage, targetDir);
77
- if (unchanged) {
78
- const alreadyEnabled = isEnabled(configPath, id);
79
- try { if (!alreadyEnabled) enable(configPath, id); }
80
- finally { rmSync(stage, { recursive: true, force: true }); }
81
- return alreadyEnabled ? 'unchanged' : undefined;
82
- }
83
- const configBefore = readConfigSnapshot(configPath);
84
- const activation = activate(stage, targetDir, slot);
85
- try {
86
- enable(configPath, id);
87
- } catch (error) {
88
- try { restoreConfig(configPath, configBefore); }
89
- finally { activation.rollback(); }
90
- throw error;
91
- }
92
- // The new slot and config are now the active install. Backup disposal and
93
- // old-version cleanup may fail, but must never roll back by deleting the
94
- // new active slot after its backup has been partially or fully removed.
95
- activation.commit();
96
- cleanupOwnedVersions(slot, targetDir, id, resolved.sourceUri);
97
- return;
98
- } catch (error) {
99
- rmSync(stage, { recursive: true, force: true });
100
- throw error;
101
- }
102
-
103
-
104
- },
105
- async pin(plugin: InstalledPlugin, opts?: PinOptions): Promise<PinOutcome> {
106
- if (plugin.path === undefined || !existsSync(plugin.path)) return { changes: [], refusals: [] };
107
- return pinPluginMcpFiles(plugin.path, mcpCandidates(), opts);
108
- },
109
- async remove(id: string): Promise<void> {
110
- const configPath = configFile();
111
- assertManagedConfigPath(codexHome(), configPath);
112
- const at = id.indexOf('@');
113
- const owned: string[] = [];
114
- if (at !== -1) {
115
- const slot = join(codexHome(), 'plugins', 'cache', id.slice(at + 1), id.slice(0, at));
116
- if (existsSync(slot)) {
117
- assertNoSymlinks(slot);
118
- for (const version of readdirSync(slot)) {
119
- const candidate = join(slot, version);
120
- if (!statSync(candidate).isDirectory()) continue;
121
- if (readOwnership(candidate)?.pluginId === id) owned.push(candidate);
122
- }
123
- }
124
- }
125
- if (existsSync(configPath)) {
126
- const toml = readFileSync(configPath, 'utf8');
127
- const header = `[plugins."${id}"]`;
128
- const idx = toml.indexOf(header);
129
- if (idx !== -1) {
130
- const nextTable = toml.indexOf('\n[', idx + header.length);
131
- const end = nextTable === -1 ? toml.length : nextTable;
132
- const block = toml.slice(idx, end);
133
- const withoutEnabled = block.replace(/^enabled\s*=\s*(true|false)\s*\n?/mu, '');
134
- const remaining = withoutEnabled.slice(header.length).trim();
135
- const replacement = remaining === '' ? '' : `${withoutEnabled.trimEnd()}\nenabled = false\n`;
136
- writeFileSync(configPath, toml.slice(0, idx) + replacement + toml.slice(end));
137
- }
138
- }
139
- for (const candidate of owned) rmSync(candidate, { recursive: true, force: true });
140
- }
141
- };
142
-
143
- function enable(configPath: string, id: string): void {
144
- let toml = existsSync(configPath) ? readFileSync(configPath, 'utf8') : '';
145
- parseConfigToml(toml, configPath);
146
-
147
- let newToml = toml;
148
- const header = `[plugins."${id}"]`;
149
- const idx = toml.indexOf(header);
150
- if (idx !== -1) {
151
- const nextTable = toml.indexOf('\n[', idx + header.length);
152
- const blockEnd = nextTable !== -1 ? nextTable : toml.length;
153
- const block = toml.slice(idx, blockEnd);
154
- const enabled = /^([ \t]*enabled[ \t]*=[ \t]*)(true|false)([ \t]*(?:#.*)?)$/gmu;
155
- const matches = [...block.matchAll(enabled)];
156
- if (matches.length > 1) throw new Error(`duplicate enabled keys in Codex plugin config: ${id}`);
157
- if (matches[0]?.[2] === 'false') {
158
- const newBlock = block.replace(enabled, '$1true$3');
159
- newToml = toml.slice(0, idx) + newBlock + toml.slice(blockEnd);
160
- } else if (matches.length === 0) {
161
- newToml = toml.slice(0, idx + header.length) + '\nenabled = true' + toml.slice(idx + header.length);
162
- }
163
- } else {
164
- if (!newToml.endsWith('\n') && newToml.length > 0) newToml += '\n';
165
- newToml += `[plugins."${id}"]\nenabled = true\n`;
166
- }
167
-
168
- mkdirSync(dirname(configPath), { recursive: true });
169
- parseConfigToml(newToml, configPath);
170
- writeFileSync(configPath, newToml);
171
- }
172
-
173
- function parseConfigToml(text: string, path: string): void {
174
- if (text.trim() === '') return;
175
- try { parseToml(text); }
176
- catch (error) { throw new Error(`invalid Codex config TOML: ${path} (${(error as Error).message})`); }
177
- }
178
-
179
- function isEnabled(configPath: string, id: string): boolean {
180
- if (!existsSync(configPath)) return false;
181
- const toml = readFileSync(configPath, 'utf8');
182
- const header = `[plugins."${id}"]`;
183
- const idx = toml.indexOf(header);
184
- if (idx === -1) return false;
185
- const nextTable = toml.indexOf('\n[', idx + header.length);
186
- const block = toml.slice(idx, nextTable === -1 ? toml.length : nextTable);
187
- return !/^enabled\s*=\s*false\s*$/mu.test(block);
188
- }
189
-
190
- type ConfigSnapshot = { existed: false } | { existed: true; bytes: Uint8Array };
191
-
192
- function readConfigSnapshot(path: string): ConfigSnapshot {
193
- if (!existsSync(path)) return { existed: false };
194
- const read = readFileSync as unknown as (file: string) => Uint8Array;
195
- return { existed: true, bytes: read(path) };
196
- }
197
-
198
- function restoreConfig(path: string, snapshot: ConfigSnapshot): void {
199
- if (!snapshot.existed) {
200
- rmSync(path, { force: true });
201
- return;
202
- }
203
- const write = writeFileSync as unknown as (file: string, bytes: Uint8Array) => void;
204
- write(path, snapshot.bytes);
205
- }
206
-
207
- function pluginVersion(dir: string): string {
208
- return readPluginManifest(dir)?.version ?? 'local';
209
- }
210
-
211
- function validVersionSegment(value: string): boolean {
212
- return value === 'local' || /^(\d+)\.(\d+)\.(\d+)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/u.test(value);
213
- }
214
-
215
- function readOwnership(dir: string): { source: string; pluginId: string; fingerprint: string } | null {
216
- const file = join(dir, OWNERSHIP);
217
- if (!existsSync(file)) return null;
218
- try {
219
- const value: unknown = JSON.parse(readFileSync(file, 'utf8'));
220
- if (typeof value !== 'object' || value === null) throw new Error('marker must be an object');
221
- const record = value as Record<string, unknown>;
222
- if (typeof record['source'] !== 'string' || typeof record['pluginId'] !== 'string' || typeof record['fingerprint'] !== 'string') throw new Error('marker fields are invalid');
223
- return { source: record['source'], pluginId: record['pluginId'], fingerprint: record['fingerprint'] };
224
- } catch (error) {
225
- throw new Error(`invalid plgnz ownership marker: ${file} (${(error as Error).message})`);
226
- }
227
- }
228
-
229
- function validateStage(stage: string): void {
230
- if (readPluginManifest(stage) === undefined) throw new Error('Codex stage has no Agent Plugins manifest');
231
- const skills = join(stage, 'skills');
232
- if (existsSync(skills) && !statSync(skills).isDirectory()) throw new Error('Codex stage skills path is not a directory');
233
- }
234
-
235
- function validateCachedIdentity(target: string, expectedName: string, expectedVersion: string): void {
236
- const candidates = [join(target, '.codex-plugin', 'plugin.json'), join(target, 'plugin.json'), join(target, '.plugin', 'plugin.json')];
237
- const manifest = candidates.find(existsSync);
238
- if (manifest === undefined) throw new Error(`unowned Codex cache slot has no manifest: ${target}`);
239
- const value: unknown = JSON.parse(readFileSync(manifest, 'utf8'));
240
- if (typeof value !== 'object' || value === null || Array.isArray(value)) throw new Error(`invalid cached Codex manifest: ${manifest}`);
241
- const record = value as Record<string, unknown>;
242
- if (record['name'] === expectedName && record['version'] === expectedVersion) return;
243
- if (manifest === candidates[0] && record['name'] === expectedName && nativeBuildVariant(expectedVersion, record['version'])) {
244
- const canonical = candidates.slice(1).find(existsSync);
245
- if (canonical !== undefined) {
246
- const canonicalValue: unknown = JSON.parse(readFileSync(canonical, 'utf8'));
247
- if (typeof canonicalValue === 'object' && canonicalValue !== null && !Array.isArray(canonicalValue)) {
248
- const identity = canonicalValue as Record<string, unknown>;
249
- if (identity['name'] === expectedName && identity['version'] === expectedVersion) return;
250
- }
251
- }
252
- }
253
- throw new Error(`cached Codex manifest identity does not match ${expectedName}@${expectedVersion}`);
254
- }
255
-
256
- function nativeBuildVariant(expected: string, actual: unknown): boolean {
257
- if (typeof actual !== 'string' || !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/u.test(expected)) return false;
258
- const base = expected.split('+')[0]!;
259
- return actual.startsWith(`${base}+`) && /^(?:[0-9A-Za-z-]+)(?:\.[0-9A-Za-z-]+)*$/u.test(actual.slice(base.length + 1));
260
- }
261
-
262
- /** Codex's native loader requires its own manifest path; project only safe, relevant fields. */
263
- function ensureNativeManifest(stage: string, fallbackName: string, fallbackVersion: string): void {
264
- const native = join(stage, '.codex-plugin', 'plugin.json');
265
- const sourceManifest = readPluginManifest(stage);
266
- if (sourceManifest === undefined) throw new Error('Codex stage has no Agent Plugins manifest');
267
- const name = sourceManifest.name ?? fallbackName;
268
- const version = sourceManifest.version ?? fallbackVersion;
269
- const description = sourceManifest.description ?? `Plugin ${name}`;
270
- let overlay: Record<string, unknown> = {};
271
- if (existsSync(native)) {
272
- const value: unknown = JSON.parse(readFileSync(native, 'utf8'));
273
- if (typeof value !== 'object' || value === null || Array.isArray(value)) throw new Error('Codex native plugin manifest must be an object');
274
- overlay = value as Record<string, unknown>;
275
- if (overlay['name'] !== undefined && overlay['name'] !== name) throw new Error(`Codex native plugin name conflicts with Agent Plugins manifest: ${String(overlay['name'])}`);
276
- if (overlay['version'] !== undefined && overlay['version'] !== version) throw new Error(`Codex native plugin version conflicts with Agent Plugins manifest: ${String(overlay['version'])}`);
277
- if (overlay['skills'] !== undefined && overlay['skills'] !== './skills/' && overlay['skills'] !== './skills') {
278
- throw new Error(`unsupported Codex native skills pointer: ${String(overlay['skills'])}`);
279
- }
280
- }
281
- mkdirSync(dirname(native), { recursive: true });
282
- writeFileSync(native, JSON.stringify({ ...overlay, name, version, description, skills: overlay['skills'] ?? './skills/' }));
283
- }
284
-
285
- function assertManagedPath(root: string, target: string): void {
286
- if (target !== root && !target.startsWith(`${root}/`)) throw new Error(`Codex managed path escapes its home: ${target}`);
287
- const suffix = target === root ? '' : target.slice(root.length + 1);
288
- let current = root;
289
- for (const part of ['', ...suffix.split('/').filter(Boolean)]) {
290
- if (part !== '') current = join(current, part);
291
- if (!existsSync(current)) continue;
292
- const stat = lstatSync(current);
293
- if (stat.isSymbolicLink()) throw new Error(`Codex managed path component is a symlink: ${current}`);
294
- if (!stat.isDirectory()) throw new Error(`Codex managed path component is not a directory: ${current}`);
295
- }
296
- }
297
-
298
- function assertManagedConfigPath(root: string, path: string): void {
299
- assertManagedPath(root, dirname(path));
300
- if (!existsSync(path)) return;
301
- const stat = lstatSync(path);
302
- if (stat.isSymbolicLink()) throw new Error(`Codex managed config is a symlink: ${path}`);
303
- if (!stat.isFile()) throw new Error(`Codex managed config is not a file: ${path}`);
304
- }
305
-
306
- function activate(stage: string, target: string, slot: string): { commit(): void; rollback(): void } {
307
- if (!existsSync(target)) {
308
- renameSync(stage, target);
309
- return { commit: () => {}, rollback: () => rmSync(target, { recursive: true, force: true }) };
310
- }
311
- const backup = mkdtempSync(join(slot, '.plgnz-backup-'));
312
- const previous = join(backup, 'previous');
313
- renameSync(target, previous);
314
- try { renameSync(stage, target); }
315
- catch (error) {
316
- renameSync(previous, target);
317
- rmSync(backup, { recursive: true, force: true });
318
- throw error;
319
- }
320
- return {
321
- commit: () => rmSync(backup, { recursive: true, force: true }),
322
- rollback: () => {
323
- rmSync(target, { recursive: true, force: true });
324
- renameSync(previous, target);
325
- rmSync(backup, { recursive: true, force: true });
326
- },
327
- };
328
- }
329
-
330
- function sameTree(left: string, right: string): boolean {
331
- const listing = (dir: string): string[] => {
332
- const out: string[] = [];
333
- const walk = (current: string, prefix: string): void => {
334
- for (const entry of readdirSync(current).sort()) {
335
- if (entry === OWNERSHIP) continue;
336
- const path = join(current, entry);
337
- const relative = prefix === '' ? entry : `${prefix}/${entry}`;
338
- const stat = statSync(path);
339
- if (stat.isDirectory()) walk(path, relative);
340
- else if (stat.isFile()) out.push(`${relative}:${bytesKey(path)}`);
341
- }
342
- };
343
- walk(dir, '');
344
- return out;
345
- };
346
- return JSON.stringify(listing(left)) === JSON.stringify(listing(right));
347
- }
348
-
349
- function bytesKey(path: string): string {
350
- const read = readFileSync as unknown as (file: string) => Uint8Array;
351
- return Array.from(read(path)).join(',');
352
- }
353
-
354
- function assertNoWinningForeignVersion(slot: string, requested: string, id: string, source: string): void {
355
- for (const version of readdirSync(slot)) {
356
- const candidate = join(slot, version);
357
- if (!statSync(candidate).isDirectory() || version === requested) continue;
358
- const marker = readOwnership(candidate);
359
- if (wins(version, requested) && (marker?.pluginId !== id || marker.source !== source)) {
360
- throw new Error(`foreign Codex cache version ${version} would remain active over ${requested}`);
361
- }
362
- }
363
- }
364
-
365
- function cleanupOwnedVersions(slot: string, active: string, id: string, source: string): void {
366
- for (const version of readdirSync(slot)) {
367
- const candidate = join(slot, version);
368
- if (candidate === active || !statSync(candidate).isDirectory()) continue;
369
- const marker = readOwnership(candidate);
370
- if (marker?.pluginId === id && marker.source === source) rmSync(candidate, { recursive: true, force: true });
371
- }
372
- }
373
-
374
- function assertNoSymlinks(dir: string): void {
375
- const walk = (current: string): void => {
376
- for (const entry of readdirSync(current)) {
377
- const path = join(current, entry);
378
- const stat = lstatSync(path);
379
- if (stat.isSymbolicLink()) throw new Error(`Codex managed slot contains symlink: ${path}`);
380
- if (stat.isDirectory()) walk(path);
381
- }
382
- };
383
- walk(dir);
384
- }
385
-
386
- function wins(left: string, right: string): boolean {
387
- if (left === 'local') return right !== 'local';
388
- if (right === 'local') return false;
389
- const parse = (value: string): { numbers: number[]; prerelease?: string } | null => {
390
- const match = value.match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/u);
391
- return match === null ? null : { numbers: [Number(match[1]), Number(match[2]), Number(match[3])], ...(match[4] === undefined ? {} : { prerelease: match[4] }) };
392
- };
393
- const l = parse(left); const r = parse(right);
394
- if (l !== null && r !== null) {
395
- for (let i = 0; i < 3; i += 1) if (l.numbers[i] !== r.numbers[i]) return (l.numbers[i] ?? 0) > (r.numbers[i] ?? 0);
396
- if (l.prerelease === undefined && r.prerelease !== undefined) return true;
397
- if (l.prerelease !== undefined && r.prerelease === undefined) return false;
398
- if (l.prerelease !== undefined && r.prerelease !== undefined) return comparePrerelease(l.prerelease, r.prerelease) > 0;
399
- return false;
400
- }
401
- return left > right;
402
- }
403
-
404
- function comparePrerelease(left: string, right: string): number {
405
- const l = left.split('.'); const r = right.split('.');
406
- for (let index = 0; index < Math.max(l.length, r.length); index += 1) {
407
- const a = l[index]; const b = r[index];
408
- if (a === undefined) return -1;
409
- if (b === undefined) return 1;
410
- if (a === b) continue;
411
- const an = /^\d+$/u.test(a); const bn = /^\d+$/u.test(b);
412
- if (an && bn) return Number(a) - Number(b);
413
- if (an) return -1;
414
- if (bn) return 1;
415
- return a.localeCompare(b);
416
- }
417
- return 0;
418
- }