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.
@@ -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
- env: options.environment ?? process.env,
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(extensionId: string, entries: SubcommandEntry[]): string {
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
- return `${lines.join("\n")}\n`;
301
- }
302
- const sorted = [...entries].sort((left, right) => (left.name < right.name ? -1 : 1));
303
- for (const entry of sorted) {
304
- const summary = entry.summary ? ` — ${entry.summary}` : "";
305
- const flag = entry.hasRuntime ? "" : " (no runtime declared)";
306
- lines.push(` ${entry.name}${summary}${flag}`);
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
- const SKILL_ID_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
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