mirror-mind 0.35.0 → 0.36.1
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 +2 -0
- package/docs/releases/v0.36.0.md +222 -0
- package/docs/releases/v0.36.1.md +137 -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/loggerRuntime.ts +5 -18
- package/ts/src/conversation/runtimePolicy.ts +26 -3
- package/ts/src/conversation/sessionComposites.ts +5 -7
- package/ts/src/conversation/transcriptBackfill.ts +5 -1
- package/ts/src/db/migrations.ts +4 -7
- 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/guards/retiredSurfaces.ts +42 -5
- 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
- package/ts/src/conversation/backfill.ts +0 -271
- package/ts/src/conversation/sessionImport.ts +0 -90
|
@@ -22,8 +22,10 @@ import { join } from "node:path";
|
|
|
22
22
|
import type { Database, WritableDatabase } from "#db/database.ts";
|
|
23
23
|
import { PROGRAM } from "#util/program.ts";
|
|
24
24
|
import type { RenderedCommand } from "./catalog.ts";
|
|
25
|
+
import { readDeclaredCapabilityIds } from "./contextRuntime.ts";
|
|
25
26
|
import { extensionNotInstalled, installedExtensionDir } from "./dispatch.ts";
|
|
26
|
-
import { ExtensionError } from "./errors.ts";
|
|
27
|
+
import { ExtensionError, ExtensionValidationError } from "./errors.ts";
|
|
28
|
+
import { loadExtensionManifest, SKILL_ID_RE } from "./manifest.ts";
|
|
27
29
|
import { runMigrations } from "./migrations.ts";
|
|
28
30
|
|
|
29
31
|
/** Python `datetime.now(timezone.utc).isoformat()`: `+00:00`, microseconds. */
|
|
@@ -49,14 +51,50 @@ function label(target: BindingTarget): string {
|
|
|
49
51
|
return target.id ? `${target.kind}/${target.id}` : target.kind;
|
|
50
52
|
}
|
|
51
53
|
|
|
52
|
-
/**
|
|
54
|
+
/**
|
|
55
|
+
* `_cmd_bind` checked nothing it bound: a missing extension, an undeclared
|
|
56
|
+
* capability, and a persona that does not exist all answered `bound`, and the
|
|
57
|
+
* person learned of the typo when the context they expected never appeared.
|
|
58
|
+
* CR149 diverges on purpose: four checks, outside in, before the one insert,
|
|
59
|
+
* each refusing with one line on stdout at exit 1 -- the shape of the
|
|
60
|
+
* family's other refusals -- and writing no row.
|
|
61
|
+
*
|
|
62
|
+
* The id check comes first and is not cosmetic. `installedExtensionDir` does
|
|
63
|
+
* not normalize (Python's bytes), so `../x` would pass the existence check
|
|
64
|
+
* when `<home>/x/skill.yaml` exists, and the context runtime later builds a
|
|
65
|
+
* path from whatever this row holds.
|
|
66
|
+
*/
|
|
53
67
|
export function runBind(
|
|
54
68
|
db: WritableDatabase,
|
|
69
|
+
mirrorHome: string,
|
|
55
70
|
extensionId: string,
|
|
56
71
|
capabilityId: string,
|
|
57
72
|
target: BindingTarget,
|
|
58
73
|
deps: BindingDeps,
|
|
59
74
|
): RenderedCommand {
|
|
75
|
+
if (!SKILL_ID_RE.test(extensionId)) {
|
|
76
|
+
return out(`'${extensionId}' is not a valid extension id\n`, 1);
|
|
77
|
+
}
|
|
78
|
+
const extensionDir = installedExtensionDir(mirrorHome, extensionId);
|
|
79
|
+
if (!existsSync(extensionDir)) return extensionNotInstalled(mirrorHome, extensionId);
|
|
80
|
+
// The validator first, as `install` runs it: its message is already the
|
|
81
|
+
// user-facing one. Then the context runtime's reader for the ids.
|
|
82
|
+
try {
|
|
83
|
+
loadExtensionManifest(extensionDir);
|
|
84
|
+
} catch (error) {
|
|
85
|
+
if (!(error instanceof ExtensionValidationError)) throw error;
|
|
86
|
+
return out(`${error.message}\n`, 1);
|
|
87
|
+
}
|
|
88
|
+
const declared = readDeclaredCapabilityIds(extensionDir);
|
|
89
|
+
if (!declared.includes(capabilityId)) {
|
|
90
|
+
const list = declared.length === 0 ? "declares none" : `declared: ${declared.join(", ")}`;
|
|
91
|
+
return out(`extension/${extensionId} declares no capability '${capabilityId}' (${list})\n`, 1);
|
|
92
|
+
}
|
|
93
|
+
if (target.kind !== "global" && !targetExists(db, target.kind, target.id ?? "")) {
|
|
94
|
+
// The fix in the line, as `missing target: pass --persona <id>, ...` does.
|
|
95
|
+
const shows = target.kind === "persona" ? `${PROGRAM} list personas` : `${PROGRAM} journeys`;
|
|
96
|
+
return out(`no ${target.kind} '${target.id ?? ""}' (${shows} shows this home's)\n`, 1);
|
|
97
|
+
}
|
|
60
98
|
db.prepare(
|
|
61
99
|
"INSERT OR IGNORE INTO _ext_bindings " +
|
|
62
100
|
"(extension_id, capability_id, target_kind, target_id, created_at) " +
|
|
@@ -65,6 +103,19 @@ export function runBind(
|
|
|
65
103
|
return out(`bound ${extensionId}/${capabilityId} -> ${label(target)}\n`);
|
|
66
104
|
}
|
|
67
105
|
|
|
106
|
+
/**
|
|
107
|
+
* The rows a load is later matched against (`selectExtensionBindings`), and
|
|
108
|
+
* nothing else: `layer = 'journey'`, not `journey_path`; exact key, so
|
|
109
|
+
* `Engineer` is not `engineer`. What `bind` accepts is what a load can find.
|
|
110
|
+
*/
|
|
111
|
+
function targetExists(db: Database, layer: "persona" | "journey", key: string): boolean {
|
|
112
|
+
return (
|
|
113
|
+
db
|
|
114
|
+
.prepare("SELECT 1 AS found FROM identity WHERE layer = ? AND key = ? LIMIT 1")
|
|
115
|
+
.get(layer, key) !== undefined
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
68
119
|
/** Port of `_cmd_unbind`. `target_id IS ?` is what makes a global unbind work. */
|
|
69
120
|
export function runUnbind(
|
|
70
121
|
db: WritableDatabase,
|
|
@@ -17,8 +17,12 @@
|
|
|
17
17
|
|
|
18
18
|
import { existsSync } from "node:fs";
|
|
19
19
|
import { homedir } from "node:os";
|
|
20
|
-
import { join } from "node:path";
|
|
20
|
+
import { basename, join } from "node:path";
|
|
21
21
|
import type { WritableDatabase } from "#db/database.ts";
|
|
22
|
+
import {
|
|
23
|
+
extensionPluginStates,
|
|
24
|
+
renderExtensionPluginBlock,
|
|
25
|
+
} from "#runtime/extensionPluginWiring.ts";
|
|
22
26
|
import { PROGRAM } from "#util/program.ts";
|
|
23
27
|
import {
|
|
24
28
|
type BindingDeps,
|
|
@@ -59,6 +63,8 @@ import {
|
|
|
59
63
|
renderSubcommandListing,
|
|
60
64
|
} from "./dispatch.ts";
|
|
61
65
|
import { ExtensionValidationError } from "./errors.ts";
|
|
66
|
+
import { regenerateExtensionPlugin } from "./extensionPlugin.ts";
|
|
67
|
+
import { readExtensionSettings } from "./settings.ts";
|
|
62
68
|
|
|
63
69
|
export class UnsupportedCatalogCommandError extends Error {}
|
|
64
70
|
|
|
@@ -98,6 +104,15 @@ const EXTENSIONS_VERBS = new Set([
|
|
|
98
104
|
export interface CatalogContext {
|
|
99
105
|
/** The resolved Mirror home; `--mirror-home` has already been applied. */
|
|
100
106
|
readonly mirrorHome: string;
|
|
107
|
+
/**
|
|
108
|
+
* The environment the extensions-plugin readers take their homes and PATH
|
|
109
|
+
* from (CV21.DS5.US2): HOME, CODEX_HOME, GROK_HOME, PATH. A test passes a
|
|
110
|
+
* stand-in; the route passes the process's. Absent, the readers see no
|
|
111
|
+
* runtime and the plugin block is its first line alone.
|
|
112
|
+
*/
|
|
113
|
+
readonly env?: NodeJS.ProcessEnv;
|
|
114
|
+
/** Whether a PATH candidate is executable; absent, nothing is. */
|
|
115
|
+
readonly isExecutable?: (path: string) => boolean;
|
|
101
116
|
}
|
|
102
117
|
|
|
103
118
|
/** The writable half, for the four `ext` verbs that touch the database. */
|
|
@@ -201,6 +216,30 @@ function writeContext(context: CatalogContext, command: string): ExtWriteContext
|
|
|
201
216
|
* the recorded divergence shape -- the same exit code, one line on stderr --
|
|
202
217
|
* and, like Python, writes nothing to stdout.
|
|
203
218
|
*/
|
|
219
|
+
/**
|
|
220
|
+
* CV21.DS5.US2: after a write that changed the home's `claude` catalog, the
|
|
221
|
+
* home's extensions plugin is regenerated and the lines a person needs are
|
|
222
|
+
* appended to the report — the runtimes on the PATH that still need a step, or
|
|
223
|
+
* one line saying the plugin is wired everywhere. New lines at the end, never
|
|
224
|
+
* changed ones, so a caller parsing the report sees what it saw before.
|
|
225
|
+
*/
|
|
226
|
+
function withExtensionPluginBlock(
|
|
227
|
+
context: CatalogContext,
|
|
228
|
+
rendered: RenderedCommand,
|
|
229
|
+
removed: readonly string[] = [],
|
|
230
|
+
): RenderedCommand {
|
|
231
|
+
if (rendered.exitCode !== 0) return rendered;
|
|
232
|
+
const plugin = regenerateExtensionPlugin(context.mirrorHome);
|
|
233
|
+
if (plugin === null) return rendered;
|
|
234
|
+
const states = extensionPluginStates(
|
|
235
|
+
plugin,
|
|
236
|
+
context.env ?? {},
|
|
237
|
+
context.isExecutable ?? (() => false),
|
|
238
|
+
);
|
|
239
|
+
const block = renderExtensionPluginBlock(plugin, states, removed);
|
|
240
|
+
return { ...rendered, stdout: `${rendered.stdout}${block.join("\n")}\n` };
|
|
241
|
+
}
|
|
242
|
+
|
|
204
243
|
function failLoudly(run: () => RenderedCommand): RenderedCommand {
|
|
205
244
|
try {
|
|
206
245
|
return run();
|
|
@@ -229,15 +268,18 @@ export function runExtensionsCommand(
|
|
|
229
268
|
if (options.extensionsRoot === null) return usage("install requires --extensions-root PATH");
|
|
230
269
|
const write = writeContext(context, "install");
|
|
231
270
|
return failLoudly(() =>
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
271
|
+
withExtensionPluginBlock(
|
|
272
|
+
context,
|
|
273
|
+
renderInstallReport(
|
|
274
|
+
installExtension({
|
|
275
|
+
extensionId: options.positional[1] as string,
|
|
276
|
+
sourceRoot: options.extensionsRoot as string,
|
|
277
|
+
mirrorHome: write.mirrorHome,
|
|
278
|
+
runtime: options.runtime,
|
|
279
|
+
db: write.db,
|
|
280
|
+
deps: write.deps,
|
|
281
|
+
}),
|
|
282
|
+
),
|
|
241
283
|
),
|
|
242
284
|
);
|
|
243
285
|
}
|
|
@@ -252,15 +294,19 @@ export function runExtensionsCommand(
|
|
|
252
294
|
// `install` lets the same class escape as a traceback. Same command, two
|
|
253
295
|
// refusal shapes, measured rather than unified.
|
|
254
296
|
try {
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
297
|
+
const report = uninstallExtension({
|
|
298
|
+
extensionId: options.positional[1] as string,
|
|
299
|
+
mirrorHome: write.mirrorHome,
|
|
300
|
+
runtime: options.runtime,
|
|
301
|
+
db: write.db,
|
|
302
|
+
deps: write.deps,
|
|
303
|
+
});
|
|
304
|
+
// What the copying runtimes "still have" after the removal: the claude
|
|
305
|
+
// command names this uninstall took out of the catalog.
|
|
306
|
+
const removed = report.removed
|
|
307
|
+
.filter(([runtimeName]) => runtimeName === "claude")
|
|
308
|
+
.flatMap(([, paths]) => paths.map((path) => basename(path)));
|
|
309
|
+
return withExtensionPluginBlock(context, renderUninstallReport(report), removed);
|
|
264
310
|
} catch (error) {
|
|
265
311
|
if (!(error instanceof ExtensionValidationError)) throw error;
|
|
266
312
|
return out(`${error.message}\n`, 1);
|
|
@@ -337,7 +383,9 @@ export function runExtensionsCommand(
|
|
|
337
383
|
`${stdout}Synced ${synced.length} extension(s) to ${options.targetRoot}`,
|
|
338
384
|
...synced.map((item) => ` ${item.command_name} -> ${item.installed_skill_path}`),
|
|
339
385
|
];
|
|
340
|
-
|
|
386
|
+
// A verb that regenerates the plugin without installing (plan amendment 6):
|
|
387
|
+
// from the home's own claude catalog, when the home has one.
|
|
388
|
+
return withExtensionPluginBlock(context, out(`${lines.join("\n")}\n`));
|
|
341
389
|
}
|
|
342
390
|
|
|
343
391
|
/**
|
|
@@ -404,7 +452,7 @@ export function runExtCommand(
|
|
|
404
452
|
if ("stdout" in parsed) return parsed;
|
|
405
453
|
const target = { kind: parsed.kind, id: parsed.id };
|
|
406
454
|
return verb === "bind"
|
|
407
|
-
? runBind(context.db, extensionId, parsed.capabilityId, target, context.deps)
|
|
455
|
+
? runBind(context.db, home, extensionId, parsed.capabilityId, target, context.deps)
|
|
408
456
|
: runUnbind(context.db, extensionId, parsed.capabilityId, target);
|
|
409
457
|
}
|
|
410
458
|
|
|
@@ -444,13 +492,24 @@ function dispatchOrNotInstalled(
|
|
|
444
492
|
if (!existsSync(extensionRoot)) return extensionNotInstalled(mirrorHome, extensionId);
|
|
445
493
|
|
|
446
494
|
const entries = readSubcommandListing(extensionRoot);
|
|
447
|
-
|
|
495
|
+
// The listing says what the settings file holds (CR145) on both paths that
|
|
496
|
+
// render it -- a help spelling and an unknown subcommand -- by decision, not
|
|
497
|
+
// by accident: it is the same listing wherever it appears.
|
|
498
|
+
if (HELP_FLAGS.has(subcommand)) {
|
|
499
|
+
return out(
|
|
500
|
+
renderSubcommandListing(extensionId, entries, readExtensionSettings(mirrorHome, extensionId)),
|
|
501
|
+
);
|
|
502
|
+
}
|
|
448
503
|
|
|
449
504
|
const documented = entries.find((entry) => entry.name === subcommand);
|
|
450
505
|
if (documented === undefined) {
|
|
451
506
|
return out(
|
|
452
507
|
`unknown subcommand '${subcommand}' for extension/${extensionId}\n` +
|
|
453
|
-
renderSubcommandListing(
|
|
508
|
+
renderSubcommandListing(
|
|
509
|
+
extensionId,
|
|
510
|
+
entries,
|
|
511
|
+
readExtensionSettings(mirrorHome, extensionId),
|
|
512
|
+
),
|
|
454
513
|
1,
|
|
455
514
|
);
|
|
456
515
|
}
|
|
@@ -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",
|