mirror-mind 0.35.0 → 0.36.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +2 -2
- package/README.md +3 -3
- package/docs/releases/index.md +1 -0
- package/docs/releases/v0.36.0.md +222 -0
- package/package.json +1 -1
- package/plugins/mirror-mind/.claude-plugin/plugin.json +1 -1
- package/plugins/mirror-mind/plugin.json +11 -1
- package/plugins/mirror-mind/rules/AGENTS.md +2 -2
- package/ts/src/conversation/runtimePolicy.ts +26 -3
- package/ts/src/extensions/bindings.ts +53 -2
- package/ts/src/extensions/catalogCommands.ts +82 -23
- package/ts/src/extensions/catalogWrites.ts +12 -0
- package/ts/src/extensions/contextRuntime.ts +36 -1
- package/ts/src/extensions/dispatch.ts +43 -10
- package/ts/src/extensions/extensionPlugin.ts +325 -0
- package/ts/src/extensions/manifest.ts +7 -1
- package/ts/src/extensions/settings.ts +147 -0
- package/ts/src/frontDoor/cli.ts +16 -1
- package/ts/src/frontDoor/extensionCatalogRoute.ts +8 -2
- package/ts/src/frontDoor/runtimeRoute.ts +11 -0
- package/ts/src/guards/claudePlugin.ts +24 -7
- package/ts/src/hooks/claude.ts +6 -0
- package/ts/src/hooks/main.ts +12 -1
- package/ts/src/runtime/brokenPipe.ts +52 -0
- package/ts/src/runtime/diagnose.ts +122 -13
- package/ts/src/runtime/extensionPluginWiring.ts +222 -0
- package/ts/src/runtime/pluginWiring.ts +168 -29
- package/ts/src/runtime/status.ts +28 -1
- package/ts/src/runtime/update.ts +25 -11
- package/ts/src/runtime/wiring.ts +135 -3
|
@@ -56,6 +56,7 @@ import { readSubcommandListing } from "./dispatch.ts";
|
|
|
56
56
|
import { ExtensionError, ExtensionValidationError } from "./errors.ts";
|
|
57
57
|
import { type ExtensionManifest, loadExtensionManifest } from "./manifest.ts";
|
|
58
58
|
import { runMigrations } from "./migrations.ts";
|
|
59
|
+
import { extensionSettingsPath } from "./settings.ts";
|
|
59
60
|
|
|
60
61
|
/** Port of `_COPY_IGNORE_PATTERNS`: never shipped inside an installed tree. */
|
|
61
62
|
const COPY_IGNORE_EXACT = new Set([
|
|
@@ -400,6 +401,13 @@ export interface UninstallReport {
|
|
|
400
401
|
bindingsRemoved: number;
|
|
401
402
|
removed: Array<readonly [string, string[]]>;
|
|
402
403
|
sourceRemoved: boolean;
|
|
404
|
+
/**
|
|
405
|
+
* The settings file (CR145) a full uninstall found and left in place, or
|
|
406
|
+
* null. Kept on purpose -- deleting configuration a person wrote is the
|
|
407
|
+
* worse surprise -- and named on purpose: a credential that outlives the
|
|
408
|
+
* extension it served must not be silent.
|
|
409
|
+
*/
|
|
410
|
+
settingsKept: string | null;
|
|
403
411
|
}
|
|
404
412
|
|
|
405
413
|
/** Port of `uninstall_extension`. */
|
|
@@ -461,6 +469,8 @@ export function uninstallExtension(options: {
|
|
|
461
469
|
}
|
|
462
470
|
rmSync(installedDir, { recursive: true, force: true });
|
|
463
471
|
}
|
|
472
|
+
const settingsPath = extensionSettingsPath(mirrorHome, extensionId);
|
|
473
|
+
const settingsKept = options.runtime === null && existsSync(settingsPath) ? settingsPath : null;
|
|
464
474
|
|
|
465
475
|
return {
|
|
466
476
|
extensionId,
|
|
@@ -468,6 +478,7 @@ export function uninstallExtension(options: {
|
|
|
468
478
|
bindingsRemoved,
|
|
469
479
|
removed,
|
|
470
480
|
sourceRemoved: options.runtime === null,
|
|
481
|
+
settingsKept,
|
|
471
482
|
};
|
|
472
483
|
}
|
|
473
484
|
|
|
@@ -641,6 +652,7 @@ export function renderUninstallReport(report: UninstallReport): RenderedCommand
|
|
|
641
652
|
` installed: ${report.installedDir}`,
|
|
642
653
|
];
|
|
643
654
|
if (report.sourceRemoved) lines.push(" source tree: removed");
|
|
655
|
+
if (report.settingsKept !== null) lines.push(` settings left in place: ${report.settingsKept}`);
|
|
644
656
|
if (report.bindingsRemoved) {
|
|
645
657
|
lines.push(` bindings: ${report.bindingsRemoved} row(s) removed`);
|
|
646
658
|
}
|
|
@@ -3,6 +3,11 @@ import { existsSync, readFileSync } from "node:fs";
|
|
|
3
3
|
import { isAbsolute, relative, resolve } from "node:path";
|
|
4
4
|
import { parse } from "yaml";
|
|
5
5
|
import type { Database } from "#db/database.ts";
|
|
6
|
+
import {
|
|
7
|
+
type ExtensionSettings,
|
|
8
|
+
readExtensionSettings,
|
|
9
|
+
settingsUnderEnvironment,
|
|
10
|
+
} from "./settings.ts";
|
|
6
11
|
|
|
7
12
|
export const MIRROR_CONTEXT_PROTOCOL = "mirror-context-v1";
|
|
8
13
|
const DEFAULT_TIMEOUT_MS = 60_000;
|
|
@@ -47,6 +52,13 @@ export type ExtensionContextDiagnosticKind =
|
|
|
47
52
|
* going dark is visible as a migration state, not as an empty context.
|
|
48
53
|
*/
|
|
49
54
|
| "no_provider_runtime"
|
|
55
|
+
/**
|
|
56
|
+
* The extension's settings file (CR145) exists and could not be read. Its
|
|
57
|
+
* own kind, not `provider_failed`: months later the Mirror Mode warning
|
|
58
|
+
* must name the cause, and "the provider crashed" and "a file you chmod'ed
|
|
59
|
+
* cannot be read" are different repairs.
|
|
60
|
+
*/
|
|
61
|
+
| "settings_unreadable"
|
|
50
62
|
| "provider_failed"
|
|
51
63
|
| "provider_timeout"
|
|
52
64
|
| "invalid_output";
|
|
@@ -181,11 +193,17 @@ export function collectExtensionContext(
|
|
|
181
193
|
diagnostics.push({ kind: "no_provider_runtime" });
|
|
182
194
|
continue;
|
|
183
195
|
}
|
|
196
|
+
const settings = readExtensionSettings(options.mirrorHome, binding.extensionId);
|
|
197
|
+
if (settings.kind === "unreadable") {
|
|
198
|
+
diagnostics.push({ kind: "settings_unreadable" });
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
184
201
|
const outcome = invokeProvider(
|
|
185
202
|
capability.providerRuntime.command,
|
|
186
203
|
extensionRoot,
|
|
187
204
|
request,
|
|
188
205
|
options,
|
|
206
|
+
settings,
|
|
189
207
|
);
|
|
190
208
|
if (outcome.diagnostic) {
|
|
191
209
|
diagnostics.push({ kind: outcome.diagnostic });
|
|
@@ -221,6 +239,20 @@ export function readProvidersWithoutRuntime(extensionRoot: string): string[] {
|
|
|
221
239
|
}
|
|
222
240
|
}
|
|
223
241
|
|
|
242
|
+
/**
|
|
243
|
+
* The capability ids the manifest declares, for `bind` to check against
|
|
244
|
+
* (CR149). Tolerant like its siblings: an unreadable manifest declares
|
|
245
|
+
* nothing, and `bind` has already run the validator, whose message is the
|
|
246
|
+
* one a person should read for that case.
|
|
247
|
+
*/
|
|
248
|
+
export function readDeclaredCapabilityIds(extensionRoot: string): string[] {
|
|
249
|
+
try {
|
|
250
|
+
return readContextManifest(extensionRoot).capabilities.map((capability) => capability.id);
|
|
251
|
+
} catch {
|
|
252
|
+
return [];
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
224
256
|
function readContextManifest(extensionRoot: string): ExtensionManifest {
|
|
225
257
|
const manifestPath = resolve(extensionRoot, "skill.yaml");
|
|
226
258
|
const raw = parse(readFileSync(manifestPath, "utf8")) as unknown;
|
|
@@ -286,12 +318,15 @@ function invokeProvider(
|
|
|
286
318
|
cwd: string,
|
|
287
319
|
request: ExtensionContextRequest,
|
|
288
320
|
options: CollectExtensionContextOptions,
|
|
321
|
+
settings: ExtensionSettings,
|
|
289
322
|
): { text?: string | null; diagnostic?: ExtensionContextDiagnosticKind } {
|
|
290
323
|
const result = spawnSync(command[0] as string, command.slice(1), {
|
|
291
324
|
cwd,
|
|
292
325
|
encoding: "utf8",
|
|
293
326
|
input: `${JSON.stringify(request)}\n`,
|
|
294
|
-
|
|
327
|
+
// The settings file under the environment (CR145): the same rule the
|
|
328
|
+
// dispatcher applies, minus the `MIRROR_*` layer a provider gets on stdin.
|
|
329
|
+
env: settingsUnderEnvironment(options.environment ?? process.env, settings),
|
|
295
330
|
timeout: options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
296
331
|
maxBuffer: options.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES,
|
|
297
332
|
windowsHide: true,
|
|
@@ -44,6 +44,12 @@ import { isAbsolute, join, relative, resolve } from "node:path";
|
|
|
44
44
|
import { parse } from "yaml";
|
|
45
45
|
import type { RenderedCommand } from "./catalog.ts";
|
|
46
46
|
import { tablePrefixFor } from "./migrations.ts";
|
|
47
|
+
import {
|
|
48
|
+
describeExtensionSettings,
|
|
49
|
+
type ExtensionSettings,
|
|
50
|
+
readExtensionSettings,
|
|
51
|
+
settingsUnderEnvironment,
|
|
52
|
+
} from "./settings.ts";
|
|
47
53
|
|
|
48
54
|
export const MIRROR_CLI_PROTOCOL = "mirror-cli-v1";
|
|
49
55
|
|
|
@@ -189,13 +195,18 @@ function commandStaysInside(extensionRoot: string, command: readonly string[]):
|
|
|
189
195
|
* one thing the legacy bridge cannot offer. Argv is appended to the declared
|
|
190
196
|
* command verbatim, so a declared command sees exactly what a legacy handler
|
|
191
197
|
* sees.
|
|
198
|
+
*
|
|
199
|
+
* Three layers, lowest first: the extension's settings file (CR145), the
|
|
200
|
+
* environment the `mirror` process had, and the five names Mirror owns. The
|
|
201
|
+
* spread order IS the precedence rule.
|
|
192
202
|
*/
|
|
193
203
|
function declaredEnvironment(
|
|
194
204
|
dispatch: ExtensionDispatch,
|
|
195
205
|
options: DispatchOptions,
|
|
206
|
+
settings: ExtensionSettings,
|
|
196
207
|
): NodeJS.ProcessEnv {
|
|
197
208
|
return {
|
|
198
|
-
...(options.environment ?? process.env),
|
|
209
|
+
...settingsUnderEnvironment(options.environment ?? process.env, settings),
|
|
199
210
|
MIRROR_HOME: dispatch.mirrorHome,
|
|
200
211
|
MIRROR_DATABASE_PATH: options.databasePath,
|
|
201
212
|
MIRROR_EXTENSION_ID: dispatch.extensionId,
|
|
@@ -292,19 +303,29 @@ export function readSubcommandListing(extensionRoot: string): SubcommandEntry[]
|
|
|
292
303
|
* this TypeScript's surface from here on -- a recorded divergence, not a
|
|
293
304
|
* parity failure. Sorted by name, as Python sorted its registry, so the bytes
|
|
294
305
|
* stay stable across manifest reordering.
|
|
306
|
+
*
|
|
307
|
+
* The last line is the extension's settings file (CR145): its absolute path,
|
|
308
|
+
* in the spelling `extension not installed:` uses, and its state. It is
|
|
309
|
+
* unindented so it reads as a footer, not as a subcommand named `settings:`.
|
|
310
|
+
* Pure: the caller read the file; this function only says what it found.
|
|
295
311
|
*/
|
|
296
|
-
export function renderSubcommandListing(
|
|
312
|
+
export function renderSubcommandListing(
|
|
313
|
+
extensionId: string,
|
|
314
|
+
entries: SubcommandEntry[],
|
|
315
|
+
settings: ExtensionSettings,
|
|
316
|
+
): string {
|
|
297
317
|
const lines = [`=== subcommands of extension/${extensionId} ===`];
|
|
298
318
|
if (entries.length === 0) {
|
|
299
319
|
lines.push(" (none declared)");
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
320
|
+
} else {
|
|
321
|
+
const sorted = [...entries].sort((left, right) => (left.name < right.name ? -1 : 1));
|
|
322
|
+
for (const entry of sorted) {
|
|
323
|
+
const summary = entry.summary ? ` — ${entry.summary}` : "";
|
|
324
|
+
const flag = entry.hasRuntime ? "" : " (no runtime declared)";
|
|
325
|
+
lines.push(` ${entry.name}${summary}${flag}`);
|
|
326
|
+
}
|
|
307
327
|
}
|
|
328
|
+
lines.push(describeExtensionSettings(settings));
|
|
308
329
|
return `${lines.join("\n")}\n`;
|
|
309
330
|
}
|
|
310
331
|
|
|
@@ -343,11 +364,23 @@ export function runExtensionSubcommand(
|
|
|
343
364
|
}
|
|
344
365
|
if (!existsSync(dispatch.extensionRoot)) return 1;
|
|
345
366
|
|
|
367
|
+
// Read after the id and installed checks, before the spawn: running without
|
|
368
|
+
// the settings the user wrote is the failure the file exists to prevent, so
|
|
369
|
+
// an unreadable file is a refusal, and a refusal starts nothing.
|
|
370
|
+
const settings = readExtensionSettings(dispatch.mirrorHome, dispatch.extensionId);
|
|
371
|
+
if (settings.kind === "unreadable") {
|
|
372
|
+
report(
|
|
373
|
+
`Mirror: extension/${dispatch.extensionId} has a settings file that cannot be read: ` +
|
|
374
|
+
`${settings.path} (${settings.reason}).`,
|
|
375
|
+
);
|
|
376
|
+
return 1;
|
|
377
|
+
}
|
|
378
|
+
|
|
346
379
|
const [executable, ...head] = dispatch.command;
|
|
347
380
|
const spawn = options.spawn ?? spawnSync;
|
|
348
381
|
const result = spawn(executable as string, [...head, ...dispatch.argv], {
|
|
349
382
|
cwd: dispatch.extensionRoot,
|
|
350
|
-
env: declaredEnvironment(dispatch, options),
|
|
383
|
+
env: declaredEnvironment(dispatch, options, settings),
|
|
351
384
|
// All three streams are the user's: a declared command may read stdin,
|
|
352
385
|
// which is precisely what the retired bridge could never allow.
|
|
353
386
|
stdio: "inherit",
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
// CV21.DS5.US2 — the per-home extensions plugin.
|
|
2
|
+
//
|
|
3
|
+
// A Mirror home's installed extensions reach Claude Code, Codex, Antigravity, and
|
|
4
|
+
// Grok Build as one generated plugin per home, in the canonical plugin's format:
|
|
5
|
+
//
|
|
6
|
+
// <home>/runtime/plugin/
|
|
7
|
+
// .agents/plugins/marketplace.json the Codex marketplace, named for the home
|
|
8
|
+
// mirror-ext-<user>/
|
|
9
|
+
// .claude-plugin/plugin.json name, version, description — no hooks, no server
|
|
10
|
+
// plugin.json the root marker Grok and agy read (DS10.US1)
|
|
11
|
+
// skills/<dir>/SKILL.md each installed extension's `claude` skill
|
|
12
|
+
//
|
|
13
|
+
// DS5.TS1 measured the three shapes DS12.TS5 left open and chose this one: a plugin is
|
|
14
|
+
// listed and found on all four runtimes, needs no manifest rewritten (it carries the
|
|
15
|
+
// `claude` skill the manifests already declare), and installs beside another home's.
|
|
16
|
+
// Its price is that Codex, agy, and Grok copy a plugin, so a changed extension is live
|
|
17
|
+
// only after a reinstall — which is why the version matters: Codex keys its cache by
|
|
18
|
+
// it and `runtime diagnose` compares it.
|
|
19
|
+
//
|
|
20
|
+
// The generator is a pure function from the home's `claude` catalog — the same reader
|
|
21
|
+
// `expose-claude` projects from, so the plugin carries what `expose-claude` exposes —
|
|
22
|
+
// to a sorted file map. The version is `0.1.<generation>`, advanced by one when the
|
|
23
|
+
// tree differs from the one on disk and held when it does not: an identical
|
|
24
|
+
// regeneration must not tell every runtime it is stale. The writer replaces the tree
|
|
25
|
+
// atomically (write `plugin.next`, rename over `plugin`), so Claude Code, which reads
|
|
26
|
+
// the directory in place through a link, never sees a half tree. The tree holds only
|
|
27
|
+
// regular files the generator wrote.
|
|
28
|
+
|
|
29
|
+
import {
|
|
30
|
+
type Dirent,
|
|
31
|
+
existsSync,
|
|
32
|
+
mkdirSync,
|
|
33
|
+
readdirSync,
|
|
34
|
+
readFileSync,
|
|
35
|
+
renameSync,
|
|
36
|
+
rmSync,
|
|
37
|
+
writeFileSync,
|
|
38
|
+
} from "node:fs";
|
|
39
|
+
import { basename, dirname, join } from "node:path";
|
|
40
|
+
import { pythonJsonDumpsIndentedOrdered } from "#util/pyGenerators.ts";
|
|
41
|
+
import { discoverExtensions, extensionsRootForHome, runtimeSkillsRootForHome } from "./catalog.ts";
|
|
42
|
+
import { filesystemSkillDirName } from "./catalogWrites.ts";
|
|
43
|
+
|
|
44
|
+
export const EXTENSION_PLUGIN_SEGMENTS: readonly string[] = ["runtime", "plugin"];
|
|
45
|
+
const MARKETPLACE_RELATIVE = ".agents/plugins/marketplace.json";
|
|
46
|
+
const VERSION_PREFIX = "0.1.";
|
|
47
|
+
|
|
48
|
+
/** The plugin's name, which is also its Codex marketplace's: one per home. */
|
|
49
|
+
export function extensionPluginName(user: string): string {
|
|
50
|
+
return `mirror-ext-${user}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The marketplace root: what `codex plugin marketplace add` takes. */
|
|
54
|
+
export function extensionPluginRoot(mirrorHome: string): string {
|
|
55
|
+
return join(mirrorHome, ...EXTENSION_PLUGIN_SEGMENTS);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The plugin directory: what Claude Code links, and agy and Grok install. */
|
|
59
|
+
export function extensionPluginDirectory(mirrorHome: string): string {
|
|
60
|
+
return join(extensionPluginRoot(mirrorHome), extensionPluginName(basename(mirrorHome)));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Relative path → file content, sorted by path. */
|
|
64
|
+
export type PluginFileMap = Readonly<Record<string, string>>;
|
|
65
|
+
|
|
66
|
+
export interface PluginEntry {
|
|
67
|
+
/** The `claude` block's `command_name`, e.g. `ext:review-copy`. */
|
|
68
|
+
readonly commandName: string;
|
|
69
|
+
/** The installed skill file's bytes, copied verbatim: frontmatter is the author's. */
|
|
70
|
+
readonly content: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface PluginPlan {
|
|
74
|
+
readonly name: string;
|
|
75
|
+
readonly version: string;
|
|
76
|
+
readonly generation: number;
|
|
77
|
+
readonly skills: number;
|
|
78
|
+
/** True when the tree differs from `previous` (or there was none). */
|
|
79
|
+
readonly changed: boolean;
|
|
80
|
+
readonly files: PluginFileMap;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface PreviousPlugin {
|
|
84
|
+
readonly generation: number;
|
|
85
|
+
readonly files: PluginFileMap;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function render(user: string, entries: readonly PluginEntry[], generation: number): PluginFileMap {
|
|
89
|
+
const name = extensionPluginName(user);
|
|
90
|
+
const version = `${VERSION_PREFIX}${generation}`;
|
|
91
|
+
const sorted = [...entries].sort((a, b) =>
|
|
92
|
+
a.commandName < b.commandName ? -1 : a.commandName > b.commandName ? 1 : 0,
|
|
93
|
+
);
|
|
94
|
+
const skills = sorted.length;
|
|
95
|
+
const manifest = {
|
|
96
|
+
name,
|
|
97
|
+
version,
|
|
98
|
+
description: `Extensions of the Mirror home ${user}: ${skills} skill${skills === 1 ? "" : "s"}`,
|
|
99
|
+
};
|
|
100
|
+
const marketplace = {
|
|
101
|
+
name,
|
|
102
|
+
owner: { name: "Mirror Mind" },
|
|
103
|
+
plugins: [
|
|
104
|
+
{
|
|
105
|
+
name,
|
|
106
|
+
source: { source: "local", path: `./${name}` },
|
|
107
|
+
description: manifest.description,
|
|
108
|
+
},
|
|
109
|
+
],
|
|
110
|
+
};
|
|
111
|
+
const files: Record<string, string> = {
|
|
112
|
+
[MARKETPLACE_RELATIVE]: `${pythonJsonDumpsIndentedOrdered(marketplace)}\n`,
|
|
113
|
+
[`${name}/.claude-plugin/plugin.json`]: `${pythonJsonDumpsIndentedOrdered(manifest)}\n`,
|
|
114
|
+
[`${name}/plugin.json`]: `${pythonJsonDumpsIndentedOrdered(manifest)}\n`,
|
|
115
|
+
};
|
|
116
|
+
for (const entry of sorted) {
|
|
117
|
+
files[`${name}/skills/${filesystemSkillDirName(entry.commandName)}/SKILL.md`] = entry.content;
|
|
118
|
+
}
|
|
119
|
+
return Object.fromEntries(Object.entries(files).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function sameFiles(a: PluginFileMap, b: PluginFileMap): boolean {
|
|
123
|
+
const keysA = Object.keys(a);
|
|
124
|
+
const keysB = Object.keys(b);
|
|
125
|
+
if (keysA.length !== keysB.length) return false;
|
|
126
|
+
return keysA.every((key) => key in b && a[key] === b[key]);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The plan: the tree at the previous generation, compared to the previous tree;
|
|
131
|
+
* when it differs, the tree at the next generation. The version appears in both
|
|
132
|
+
* manifests, so a changed tree is re-rendered after the bump.
|
|
133
|
+
*/
|
|
134
|
+
export function planExtensionPlugin(input: {
|
|
135
|
+
user: string;
|
|
136
|
+
entries: readonly PluginEntry[];
|
|
137
|
+
previous: PreviousPlugin | null;
|
|
138
|
+
}): PluginPlan {
|
|
139
|
+
const name = extensionPluginName(input.user);
|
|
140
|
+
const skills = input.entries.length;
|
|
141
|
+
if (input.previous === null) {
|
|
142
|
+
const files = render(input.user, input.entries, 1);
|
|
143
|
+
return { name, version: `${VERSION_PREFIX}1`, generation: 1, skills, changed: true, files };
|
|
144
|
+
}
|
|
145
|
+
const held = render(input.user, input.entries, input.previous.generation);
|
|
146
|
+
if (sameFiles(held, input.previous.files)) {
|
|
147
|
+
return {
|
|
148
|
+
name,
|
|
149
|
+
version: `${VERSION_PREFIX}${input.previous.generation}`,
|
|
150
|
+
generation: input.previous.generation,
|
|
151
|
+
skills,
|
|
152
|
+
changed: false,
|
|
153
|
+
files: held,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
const generation = input.previous.generation + 1;
|
|
157
|
+
return {
|
|
158
|
+
name,
|
|
159
|
+
version: `${VERSION_PREFIX}${generation}`,
|
|
160
|
+
generation,
|
|
161
|
+
skills,
|
|
162
|
+
changed: true,
|
|
163
|
+
files: render(input.user, input.entries, generation),
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** The tree on disk as a file map with its generation, or null when there is none. */
|
|
168
|
+
export function readExtensionPlugin(mirrorHome: string): PreviousPlugin | null {
|
|
169
|
+
const root = extensionPluginRoot(mirrorHome);
|
|
170
|
+
const manifestPath = join(extensionPluginDirectory(mirrorHome), ".claude-plugin", "plugin.json");
|
|
171
|
+
if (!existsSync(manifestPath)) return null;
|
|
172
|
+
let generation: number;
|
|
173
|
+
try {
|
|
174
|
+
const parsed: unknown = JSON.parse(readFileSync(manifestPath, "utf8"));
|
|
175
|
+
const version =
|
|
176
|
+
typeof parsed === "object" && parsed !== null
|
|
177
|
+
? (parsed as Record<string, unknown>).version
|
|
178
|
+
: undefined;
|
|
179
|
+
const match = typeof version === "string" ? /^0\.1\.(\d+)$/.exec(version) : null;
|
|
180
|
+
if (match === null) return null;
|
|
181
|
+
generation = Number(match[1]);
|
|
182
|
+
} catch {
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
const files: Record<string, string> = {};
|
|
186
|
+
for (const path of walk(root)) {
|
|
187
|
+
files[path.slice(root.length + 1)] = readFileSync(path, "utf8");
|
|
188
|
+
}
|
|
189
|
+
return {
|
|
190
|
+
generation,
|
|
191
|
+
files: Object.fromEntries(
|
|
192
|
+
Object.entries(files).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)),
|
|
193
|
+
),
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function walk(root: string): string[] {
|
|
198
|
+
const found: string[] = [];
|
|
199
|
+
let entries: Dirent[];
|
|
200
|
+
try {
|
|
201
|
+
entries = readdirSync(root, { withFileTypes: true });
|
|
202
|
+
} catch {
|
|
203
|
+
return found;
|
|
204
|
+
}
|
|
205
|
+
for (const entry of entries) {
|
|
206
|
+
const path = join(root, entry.name);
|
|
207
|
+
if (entry.isDirectory()) found.push(...walk(path));
|
|
208
|
+
else if (entry.isFile()) found.push(path);
|
|
209
|
+
}
|
|
210
|
+
return found.sort();
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Replace the tree atomically: the map is written to `plugin.next`, then renamed over
|
|
215
|
+
* `plugin`. The path a runtime links or installed from is unchanged; a removed skill's
|
|
216
|
+
* directory is gone because the whole tree is new. Mode 0700, like the rest of the home.
|
|
217
|
+
*/
|
|
218
|
+
export function writeExtensionPlugin(mirrorHome: string, files: PluginFileMap): void {
|
|
219
|
+
const root = extensionPluginRoot(mirrorHome);
|
|
220
|
+
const next = `${root}.next`;
|
|
221
|
+
const old = `${root}.old`;
|
|
222
|
+
rmSync(next, { recursive: true, force: true });
|
|
223
|
+
mkdirSync(next, { recursive: true, mode: 0o700 });
|
|
224
|
+
for (const [relative, content] of Object.entries(files)) {
|
|
225
|
+
const path = join(next, relative);
|
|
226
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
227
|
+
writeFileSync(path, content, { encoding: "utf8", mode: 0o600 });
|
|
228
|
+
}
|
|
229
|
+
rmSync(old, { recursive: true, force: true });
|
|
230
|
+
if (existsSync(root)) renameSync(root, old);
|
|
231
|
+
renameSync(next, root);
|
|
232
|
+
rmSync(old, { recursive: true, force: true });
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export interface ExtensionPluginReport {
|
|
236
|
+
readonly name: string;
|
|
237
|
+
readonly directory: string;
|
|
238
|
+
readonly marketplaceRoot: string;
|
|
239
|
+
readonly version: string;
|
|
240
|
+
readonly skills: number;
|
|
241
|
+
readonly changed: boolean;
|
|
242
|
+
/** Installed extensions that declare no `claude` block, and so are not carried. */
|
|
243
|
+
readonly notCarried: readonly string[];
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** The home's `claude` catalog entries that still have a skill file, as plugin entries. */
|
|
247
|
+
export function claudeCatalogEntries(mirrorHome: string): PluginEntry[] | null {
|
|
248
|
+
const catalogPath = join(runtimeSkillsRootForHome(mirrorHome, "claude"), "extensions.json");
|
|
249
|
+
if (!existsSync(catalogPath)) return null;
|
|
250
|
+
let items: unknown[];
|
|
251
|
+
try {
|
|
252
|
+
const parsed: unknown = JSON.parse(readFileSync(catalogPath, "utf8"));
|
|
253
|
+
const list =
|
|
254
|
+
typeof parsed === "object" && parsed !== null
|
|
255
|
+
? (parsed as Record<string, unknown>).extensions
|
|
256
|
+
: undefined;
|
|
257
|
+
items = Array.isArray(list) ? list : [];
|
|
258
|
+
} catch {
|
|
259
|
+
items = [];
|
|
260
|
+
}
|
|
261
|
+
const entries: PluginEntry[] = [];
|
|
262
|
+
for (const item of items) {
|
|
263
|
+
if (typeof item !== "object" || item === null || Array.isArray(item)) continue;
|
|
264
|
+
const record = item as Record<string, unknown>;
|
|
265
|
+
const commandName = record.command_name;
|
|
266
|
+
const skillPath = record.installed_skill_path;
|
|
267
|
+
if (typeof commandName !== "string" || typeof skillPath !== "string") continue;
|
|
268
|
+
if (!existsSync(skillPath)) continue;
|
|
269
|
+
entries.push({ commandName, content: readFileSync(skillPath, "utf8") });
|
|
270
|
+
}
|
|
271
|
+
return entries;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Regenerate the home's plugin from its `claude` catalog. A home with no catalog has
|
|
276
|
+
* never installed an extension that declares `claude`, and gets no plugin.
|
|
277
|
+
*/
|
|
278
|
+
export function regenerateExtensionPlugin(mirrorHome: string): ExtensionPluginReport | null {
|
|
279
|
+
const entries = claudeCatalogEntries(mirrorHome);
|
|
280
|
+
if (entries === null) return null;
|
|
281
|
+
const carried = new Set(readCatalogIds(mirrorHome) ?? []);
|
|
282
|
+
const notCarried = discoverExtensions(extensionsRootForHome(mirrorHome))
|
|
283
|
+
.manifests.map((manifest) => manifest.id)
|
|
284
|
+
.filter((id) => !carried.has(id))
|
|
285
|
+
.sort();
|
|
286
|
+
const plan = planExtensionPlugin({
|
|
287
|
+
user: basename(mirrorHome),
|
|
288
|
+
entries,
|
|
289
|
+
previous: readExtensionPlugin(mirrorHome),
|
|
290
|
+
});
|
|
291
|
+
if (plan.changed || readExtensionPlugin(mirrorHome) === null)
|
|
292
|
+
writeExtensionPlugin(mirrorHome, plan.files);
|
|
293
|
+
return {
|
|
294
|
+
name: plan.name,
|
|
295
|
+
directory: extensionPluginDirectory(mirrorHome),
|
|
296
|
+
marketplaceRoot: extensionPluginRoot(mirrorHome),
|
|
297
|
+
version: plan.version,
|
|
298
|
+
skills: plan.skills,
|
|
299
|
+
changed: plan.changed,
|
|
300
|
+
notCarried,
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** The extension ids the home's `claude` catalog names, or null without a catalog. */
|
|
305
|
+
function readCatalogIds(mirrorHome: string): string[] | null {
|
|
306
|
+
const catalogPath = join(runtimeSkillsRootForHome(mirrorHome, "claude"), "extensions.json");
|
|
307
|
+
if (!existsSync(catalogPath)) return null;
|
|
308
|
+
try {
|
|
309
|
+
const parsed: unknown = JSON.parse(readFileSync(catalogPath, "utf8"));
|
|
310
|
+
const list =
|
|
311
|
+
typeof parsed === "object" && parsed !== null
|
|
312
|
+
? (parsed as Record<string, unknown>).extensions
|
|
313
|
+
: undefined;
|
|
314
|
+
if (!Array.isArray(list)) return [];
|
|
315
|
+
return list
|
|
316
|
+
.map((item) =>
|
|
317
|
+
typeof item === "object" && item !== null && !Array.isArray(item)
|
|
318
|
+
? (item as Record<string, unknown>).id
|
|
319
|
+
: undefined,
|
|
320
|
+
)
|
|
321
|
+
.filter((id): id is string => typeof id === "string");
|
|
322
|
+
} catch {
|
|
323
|
+
return [];
|
|
324
|
+
}
|
|
325
|
+
}
|
|
@@ -31,7 +31,13 @@ import { tablePrefixFor } from "./migrations.ts";
|
|
|
31
31
|
|
|
32
32
|
const ALLOWED_KINDS = new Set(["prompt-skill", "command-skill"]);
|
|
33
33
|
const RUNTIME_NAME_RE = /^[a-z][a-z0-9_-]*$/;
|
|
34
|
-
|
|
34
|
+
/**
|
|
35
|
+
* What an extension id may look like. `install` is the authority on ids, so
|
|
36
|
+
* this is THE grammar; the settings reader (CR145) builds a path from an id
|
|
37
|
+
* only after it passes here. Two older copies of the rule still live in
|
|
38
|
+
* `contextRuntime.ts` and `dispatch.ts` -- CR147 reconciles them.
|
|
39
|
+
*/
|
|
40
|
+
export const SKILL_ID_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
35
41
|
const REQUIRED_FIELDS = ["id", "name", "category", "kind", "summary", "runtimes"] as const;
|
|
36
42
|
const PATH_LIKE_SUFFIXES = new Set([".js", ".mjs", ".cjs", ".py"]);
|
|
37
43
|
|