@henols/vice-mcp 0.1.4

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.
@@ -0,0 +1,532 @@
1
+ #!/usr/bin/env node
2
+ // Deploys this skill's resources/ (the host-side shell launchers) into
3
+ // <repo>/tools/ the first time any skill .mjs entry point runs, so a copy of
4
+ // this skill directory alone is sufficient -- nobody has to remember to also
5
+ // copy three shell scripts from somewhere else (D-1, quick-260730-q4b).
6
+ //
7
+ // HOSTING CHOICE (D-3): this check lives in a DEDICATED module, triggered
8
+ // from repo-root.mjs, for two reasons. First, repo-root.mjs is a pure path
9
+ // resolver, and inlining filesystem-writing side effects into it would make
10
+ // every importer of a path function also a file writer. Second and decisive:
11
+ // this module needs the repo root, and repo-root.mjs is where the repo root
12
+ // is computed -- hosting this logic INSIDE repo-root.mjs and importing it
13
+ // back from there would be a module cycle. In that cycle, this module would
14
+ // evaluate while repo-root.mjs's `const HERE` is still in its temporal dead
15
+ // zone, and every entry point would die with
16
+ // "Cannot access 'HERE' before initialization".
17
+ //
18
+ // THE CYCLE IS AVOIDED STRUCTURALLY: this module takes the repo root as an
19
+ // ARGUMENT and imports NOTHING from repo-root.mjs. Do not "clean this up" by
20
+ // adding `import { repoRoot } from "./repo-root.mjs"` here -- that importable
21
+ // convenience is exactly the cycle described above.
22
+ import {
23
+ existsSync,
24
+ mkdirSync,
25
+ readdirSync,
26
+ readFileSync,
27
+ writeFileSync,
28
+ copyFileSync,
29
+ statSync,
30
+ chmodSync,
31
+ renameSync,
32
+ unlinkSync,
33
+ } from "node:fs";
34
+ import { fileURLToPath } from "node:url";
35
+ import { dirname, join, isAbsolute, resolve, sep } from "node:path";
36
+
37
+ import { hostPath, SET_ENV_HINT } from "./hostpath.ts";
38
+ import { HOST_BOUND_ARTIFACTS } from "./build.ts";
39
+
40
+ const HERE = dirname(fileURLToPath(import.meta.url));
41
+
42
+ /** true iff `value` is a well-formed, generic JSON object -- not null, not
43
+ * an array. Narrows JSON.parse()'s otherwise-`any` result before any field
44
+ * on it is touched, matching vice-broker.mts's isPlainObject() idiom
45
+ * exactly (PATTERNS.md "Narrowing unknown, not casting"). */
46
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
47
+ return typeof value === "object" && value !== null && !Array.isArray(value);
48
+ }
49
+
50
+ /** The on-disk shape of `.vice-deployed.json`, as read/written by
51
+ * readDeployManifest()/writeDeployManifest() below. */
52
+ export interface DeployManifest {
53
+ entries: string[];
54
+ }
55
+
56
+ /** Per-entry outcome of a copy attempt: which resources/ entries landed,
57
+ * were left alone (already present, or a hand-authored divergence refused
58
+ * without force -- see isGeneratedEntry() in installResources() for why a
59
+ * DIVERGED GENERATED entry lands in `installed` instead, not here), or
60
+ * failed -- returned by both installResources() and, for the prune half,
61
+ * pruneResources(). */
62
+ export interface InstallResourcesResult {
63
+ installed: string[];
64
+ skipped: string[];
65
+ diverged: string[];
66
+ failed: string[];
67
+ pruned: string[];
68
+ }
69
+
70
+ export interface PruneResourcesResult {
71
+ pruned: string[];
72
+ skipped: string[];
73
+ failed: string[];
74
+ }
75
+
76
+ /** missing | present (byte-identical to the resource) | diverged (exists,
77
+ * differs) -- statusForEntry()'s own return type, reused by resourcesStatus(). */
78
+ export type ResourceStatus = "missing" | "present" | "diverged";
79
+
80
+ /** This module directory's resources/ subdirectory -- a plain SIBLING of this
81
+ * module (scripts/ was flattened away in the .claude/mcp/vice/ move), the
82
+ * tracked source of truth every deployed tools/ file is copied from. Getting
83
+ * this hop wrong is silent and total: readdirSync() throws inside
84
+ * resourceEntries(), but ensureResourcesInstalled() catches everything by
85
+ * contract (D-3 above), so every command keeps reporting success while
86
+ * nothing is ever deployed. */
87
+ export const RESOURCES_DIR = join(HERE, "resources");
88
+
89
+ /** Where resources/ gets deployed to, for a given repo root. Always
90
+ * `<root>/tools` -- the host's existing muscle-memory location. */
91
+ export function installTargetDir(root: string): string {
92
+ return join(root, "tools");
93
+ }
94
+
95
+ /** Recursive walk of RESOURCES_DIR, returning the relative path (posix-style,
96
+ * "/"-joined) of every regular file underneath it -- a WALK, not a hardcoded
97
+ * list, so a file added under resources/lib/ later deploys with no code
98
+ * change here. */
99
+ function walk(dir: string, base = ""): string[] {
100
+ const out: string[] = [];
101
+ for (const dirent of readdirSync(dir, { withFileTypes: true })) {
102
+ const rel = base ? `${base}/${dirent.name}` : dirent.name;
103
+ const abs = join(dir, dirent.name);
104
+ if (dirent.isDirectory()) {
105
+ out.push(...walk(abs, rel));
106
+ } else if (dirent.isFile()) {
107
+ out.push(rel);
108
+ }
109
+ }
110
+ return out;
111
+ }
112
+
113
+ export function resourceEntries(): string[] {
114
+ return walk(RESOURCES_DIR);
115
+ }
116
+
117
+ /** missing | present (byte-identical to the resource) | diverged (exists,
118
+ * differs). An unreadable target is treated as "diverged" -- conservative on
119
+ * purpose, so a permissions oddity never gets silently reported as
120
+ * "present" and skipped. */
121
+ function statusForEntry(entry: string, root: string): ResourceStatus {
122
+ const src = join(RESOURCES_DIR, entry);
123
+ const target = join(installTargetDir(root), entry);
124
+ if (!existsSync(target)) return "missing";
125
+ try {
126
+ return readFileSync(src).equals(readFileSync(target)) ? "present" : "diverged";
127
+ } catch {
128
+ return "diverged";
129
+ }
130
+ }
131
+
132
+ /** Per-entry status against a given repo root, without writing anything. */
133
+ export function resourcesStatus({ root }: { root: string }): Record<string, ResourceStatus> {
134
+ const out: Record<string, ResourceStatus> = {};
135
+ for (const entry of resourceEntries()) {
136
+ out[entry] = statusForEntry(entry, root);
137
+ }
138
+ return out;
139
+ }
140
+
141
+ /** The D-4 host-launch instructions, as prose covering: the host path to
142
+ * run, that it cannot run inside the container and will refuse with exit 2,
143
+ * --check-container as the diagnostic, and that Ctrl-C stops it cleanly.
144
+ * hostPath() (devcontainer-host-path skill) translates the deployed
145
+ * launcher's container path into the HOST path a human should actually
146
+ * type -- the same cross-skill shape tools/recover.mjs already uses -- and
147
+ * degrades to the container path plus hostpath.mjs's own SET_ENV_HINT when
148
+ * translation fails (e.g. no /proc/self/mountinfo, or an unmapped mount).
149
+ *
150
+ * 01.6.2-09 (D-23, T-01.6.2-56): this used to return TWO paragraphs, one
151
+ * per script -- the broker launcher (start the on-demand broker) and the
152
+ * per-instance supervisor (a "standalone (non-MCP) recovery pipeline"). The
153
+ * supervisor paragraph is DELETED here, not repointed: the standalone
154
+ * pipeline it advertised (vice-pool.mjs/vice-session.mjs) was already
155
+ * deleted behind a zero-consumers gate in an earlier phase
156
+ * (01.6-CONTEXT.md D-02), so repointing its path would keep advertising a
157
+ * capability that no longer exists. Only ONE script survives
158
+ * (resources/vice-launcher.sh, deployed to tools/vice-launcher.sh), and its
159
+ * own broker now performs both jobs: on-demand acquisition (what the old
160
+ * broker paragraph promised) and launch/supervise/respawn-with-backoff
161
+ * (what the old supervisor paragraph promised). Exactly one resolved host
162
+ * path is returned as a result -- asserted directly in
163
+ * install-resources.test.ts. */
164
+ export function hostLaunchInstructions(root: string): string {
165
+ const target = join(installTargetDir(root), "vice-launcher.sh");
166
+ let displayPath: string;
167
+ try {
168
+ displayPath = hostPath(target, { workspaceRoot: root });
169
+ } catch {
170
+ displayPath = `${target}\n (host path could not be determined -- ${SET_ENV_HINT})`;
171
+ }
172
+ return [
173
+ `vice-mcp-selector: deployed host launcher scripts to ${installTargetDir(root)}`,
174
+ "vice-mcp-selector: for MCP-mediated access (mcp__vice__* tools), start the on-demand broker from the HOST workspace, e.g.:",
175
+ ` ${displayPath}`,
176
+ "vice-mcp-selector: the broker launches a boot-fresh instance per session on demand, supervises it, and respawns a crashed one with backoff, while keeping a warm floor of spare instances ready.",
177
+ "vice-mcp-selector: it cannot run inside the container -- the container guard refuses with exit 2.",
178
+ "vice-mcp-selector: if it refuses when it should not, run it with --check-container for the full per-signal diagnostic.",
179
+ "vice-mcp-selector: press Ctrl-C to stop it -- SIGINT/SIGTERM are handled and it shuts down cleanly.",
180
+ ].join("\n");
181
+ }
182
+
183
+ /** The manifest's basename -- a dotfile under the deployment target,
184
+ * gitignored alongside every other deployed path (see .gitignore's
185
+ * deployed-path block). It is the ONLY thing pruneResources() below is ever
186
+ * allowed to consult when deciding what to delete (T-01.6-11): never a
187
+ * directory walk of installTargetDir(), which also holds tracked reverse-
188
+ * engineering tooling sharing the same `tools/` directory. */
189
+ export const DEPLOY_MANIFEST_NAME = ".vice-deployed.json";
190
+
191
+ /** Where the manifest lives, for a given repo root -- always beneath
192
+ * installTargetDir(root), same as every other deployed entry. */
193
+ export function deployManifestPath(root: string): string {
194
+ return join(installTargetDir(root), DEPLOY_MANIFEST_NAME);
195
+ }
196
+
197
+ /** Reads and parses the deploy manifest, returning its recorded relative-path
198
+ * entries. Never throws (D-3, and this codebase's standing never-throw-on-
199
+ * untrusted-read discipline -- the manifest is untrusted input by
200
+ * construction, T-01.6-13): a missing file, an unreadable file, malformed
201
+ * JSON, a non-object top-level shape, and an `entries` field that isn't an
202
+ * array are ALL treated identically as "nothing has been recorded here yet"
203
+ * -- an empty array. Two nested try/catch layers: the outer catches a read
204
+ * failure (ENOENT, EACCES, a directory at that path, ...), the inner catches
205
+ * a JSON.parse failure -- matching readJsonMaybe()'s posture elsewhere in
206
+ * this module tree (vice-broker-client.mjs) exactly. */
207
+ export function readDeployManifest(root: string): string[] {
208
+ let raw: string;
209
+ try {
210
+ raw = readFileSync(deployManifestPath(root), "utf8");
211
+ } catch {
212
+ return [];
213
+ }
214
+ try {
215
+ const parsed: unknown = JSON.parse(raw);
216
+ if (!isPlainObject(parsed)) return [];
217
+ if (!Array.isArray(parsed.entries)) return [];
218
+ return parsed.entries as string[];
219
+ } catch {
220
+ return [];
221
+ }
222
+ }
223
+
224
+ /** Writes the deploy manifest through the same tmp-sibling, mode-restricted,
225
+ * rename sequence every other state file in this subsystem uses
226
+ * (vice-broker.mts's writeBrokerRecord(): tmp file created empty, chmod 0600
227
+ * BEFORE any content reaches it, then content written, then renamed into
228
+ * place -- so the manifest is never briefly world-readable, V4). `entries`
229
+ * is sorted before being written so the on-disk manifest is stable and
230
+ * diff-friendly across runs that deploy the same resource set in a
231
+ * different enumeration order. */
232
+ export function writeDeployManifest(root: string, entries: Iterable<string>): void {
233
+ const target = deployManifestPath(root);
234
+ mkdirSync(dirname(target), { recursive: true });
235
+ const tmpPath = `${target}.tmp-${process.pid}-${Date.now()}`;
236
+ writeFileSync(tmpPath, "");
237
+ chmodSync(tmpPath, 0o600);
238
+ writeFileSync(tmpPath, JSON.stringify({ entries: [...entries].sort() }, null, 2) + "\n");
239
+ renameSync(tmpPath, target);
240
+ }
241
+
242
+ /** True iff `entry` is a safe manifest candidate: a plain relative path (no
243
+ * leading "/" and no drive-letter-style absolute form), containing no
244
+ * parent-directory ("..") path segment, whose resolved absolute location
245
+ * sits AT OR BENEATH `targetDir` -- rejecting an absolute path, a
246
+ * `..`-escaping path, and anything else that would resolve outside the
247
+ * deployment target (T-01.6-12). Every check here runs BEFORE any
248
+ * filesystem access is attempted on `entry`. */
249
+ function isSafeManifestCandidate(entry: unknown, targetDir: string): entry is string {
250
+ if (typeof entry !== "string" || entry.length === 0) return false;
251
+ if (isAbsolute(entry)) return false;
252
+ if (entry.split(/[\\/]/).includes("..")) return false;
253
+ const resolved = resolve(targetDir, entry);
254
+ return resolved === targetDir || resolved.startsWith(targetDir.endsWith(sep) ? targetDir : targetDir + sep);
255
+ }
256
+
257
+ /**
258
+ * Removes a deployed file this installer previously placed under
259
+ * installTargetDir(root) but which no longer corresponds to a current
260
+ * resources/ entry -- the delete half installResources() alone never had
261
+ * (RESEARCH.md's Runtime State Inventory: "install-resources.mjs's current
262
+ * installResources() only ever adds/overwrites -- it has no delete/prune
263
+ * step"; a retired executable would otherwise linger on the host forever).
264
+ *
265
+ * The candidate set is EXACTLY `readDeployManifest(root)` minus the current
266
+ * `resourceEntries()` -- never a directory walk of `installTargetDir(root)`,
267
+ * which is a MIXED directory also holding tracked reverse-engineering
268
+ * tooling (d64-parse.mjs, diff-images.mjs, watch-loads.mjs,
269
+ * recovery-schema.mjs, releases.mjs and their tests). A file present in the
270
+ * target but ABSENT from the manifest is therefore left untouched no matter
271
+ * what it is: the prune can only ever reach a path it recorded having placed
272
+ * there itself (T-01.6-11).
273
+ *
274
+ * Every candidate is validated by isSafeManifestCandidate() BEFORE any
275
+ * unlink is attempted; a rejected candidate is pushed to `skipped` (nothing
276
+ * was attempted) with the reason named in the warning, never to `failed`.
277
+ * Each unlink that IS attempted is individually wrapped in its own
278
+ * try/catch: a failure is pushed to `failed` and warned through `log`,
279
+ * never thrown (D-3) -- the same per-entry posture `installResources()`
280
+ * already applies to each copy. Never removes a directory.
281
+ *
282
+ * Returns { pruned, skipped, failed }, arrays of the candidate's manifest-
283
+ * recorded relative path.
284
+ */
285
+ export function pruneResources({
286
+ root,
287
+ log = console.error,
288
+ }: {
289
+ root: string;
290
+ log?: (message: string) => void;
291
+ }): PruneResourcesResult {
292
+ const pruned: string[] = [];
293
+ const skipped: string[] = [];
294
+ const failed: string[] = [];
295
+
296
+ const manifestEntries = readDeployManifest(root);
297
+ const currentEntries = new Set(resourceEntries());
298
+ const targetDir = installTargetDir(root);
299
+
300
+ for (const entry of manifestEntries) {
301
+ if (currentEntries.has(entry)) continue; // still a current resource -- nothing to prune
302
+
303
+ if (!isSafeManifestCandidate(entry, targetDir)) {
304
+ skipped.push(entry);
305
+ log(
306
+ `warn: install-resources: prune refused manifest entry ${JSON.stringify(entry)} -- it is not a ` +
307
+ "plain relative path resolving beneath the deployment target (absolute path, parent-directory " +
308
+ "hop, or an escape outside the target). Refusing rather than acting on it (T-01.6-12)."
309
+ );
310
+ continue;
311
+ }
312
+
313
+ const resolvedTarget = resolve(targetDir, entry);
314
+ try {
315
+ unlinkSync(resolvedTarget);
316
+ pruned.push(entry);
317
+ } catch (e) {
318
+ failed.push(entry);
319
+ log(
320
+ `warn: install-resources: prune failed to remove retired entry ${entry} from ${resolvedTarget} -- ` +
321
+ `${(e as Error).message}. Continuing; a failed prune must never break the caller.`
322
+ );
323
+ }
324
+ }
325
+
326
+ return { pruned, skipped, failed };
327
+ }
328
+
329
+ /** True iff `entry` is one of the tsc-compiled, banner-carrying artifacts
330
+ * build.ts emits into resources/ -- the GENERATED half of this skill's
331
+ * resources/ directory, as distinct from the one hand-authored survivor,
332
+ * `vice-launcher.sh` (CLAUDE.md's three-tier `.claude/mcp/` rule: "resources/
333
+ * is generated, but committed ... The one exception is
334
+ * resources/vice-launcher.sh, which stays hand-authored").
335
+ *
336
+ * Derived from build.ts's own `HOST_BOUND_ARTIFACTS` rather than a
337
+ * hardcoded filename here, because that list is already the ENFORCED source
338
+ * of truth for "what tsc emits": build()'s own "build: emitted file set
339
+ * does not match HOST_BOUND_ARTIFACTS" assertion throws if the compiler's
340
+ * real output ever differs from it, and resources-sync.test.ts separately
341
+ * asserts committed resources/ matches a fresh build using this same list.
342
+ * A maintainer who adds a new compiled artifact must already extend this
343
+ * list for build() to succeed at all, so this check cannot silently drift
344
+ * out of sync with reality without also breaking the build.
345
+ *
346
+ * DISCLOSED LIMITATION (autonomy contract): this is a closed-list check,
347
+ * not a content-sniffed one -- it does not itself inspect the generated
348
+ * banner text (build.ts's GENERATED_BANNER()). A hypothetical future
349
+ * resources/ entry that is hand-authored but happens to share a relative
350
+ * path with something tsc emits would be misclassified as generated; there
351
+ * is no such collision today, and the one real hand-authored survivor
352
+ * (`vice-launcher.sh`) is a `.sh` file, so it cannot collide with the
353
+ * `.mjs`-only compiled set by construction. A more robust version would
354
+ * additionally require the source file's content to start with the
355
+ * GENERATED_BANNER() prefix; left as a follow-up rather than done here to
356
+ * avoid introducing a second, independently-driftable banner check. */
357
+ function isGeneratedEntry(entry: string): boolean {
358
+ return (HOST_BOUND_ARTIFACTS as readonly string[]).includes(entry);
359
+ }
360
+
361
+ /**
362
+ * Copies every `missing` entry, every `present`/`diverged` one when `force`
363
+ * is true, and -- new as of the 260805 stale-deploy fix -- every `diverged`
364
+ * GENERATED entry even WITHOUT force. `present` (force or not) leaves the
365
+ * target alone (D-5). Parent directories are created as needed, and each
366
+ * target's permission bits are set from its source.
367
+ *
368
+ * WHY diverged-but-generated is overwritten by default: CLAUDE.md's
369
+ * `.claude/mcp/` contract says resources/'s generated half (everything
370
+ * built by build.ts) and tools/ are "never hand-edited". If nothing may be
371
+ * hand-edited there, a `diverged` GENERATED entry cannot mean "a local edit
372
+ * worth protecting" -- staleness is the only thing divergence can mean for
373
+ * it, so refusing it (the old default) was silently no-op'ing on exactly
374
+ * the files that most needed refreshing (see
375
+ * .planning/todos/pending/2026-08-05-installresources-cannot-refresh-a-stale-deploy-without-force.md).
376
+ * The one HAND-AUTHORED entry, `vice-launcher.sh`, keeps the original
377
+ * refuse-on-divergence posture -- see isGeneratedEntry() above for how the
378
+ * two are told apart.
379
+ *
380
+ * Every copy is individually wrapped in its own try/catch: a failure is
381
+ * pushed to `failed` and warned through `log`, never thrown (D-3) -- a
382
+ * read-only filesystem must never turn a working `ping` into an error.
383
+ *
384
+ * After the copy loop, prunes any manifest-recorded entry that is no longer
385
+ * a current resource, then rewrites the manifest from the current
386
+ * `resourceEntries()` -- so the manifest always reflects "what this
387
+ * installer would deploy right now", ready for the NEXT call's prune to
388
+ * compare against. The manifest write itself is wrapped separately (never
389
+ * throws, D-3): an unwritable target that already made every copy above
390
+ * fail must not ALSO throw out of the manifest write.
391
+ *
392
+ * Returns { installed, skipped, diverged, failed, pruned }, arrays of the
393
+ * resource's relative path (or, for `pruned`, the manifest's recorded path).
394
+ * `diverged` now means "refused" (hand-authored divergence only) rather
395
+ * than "seen diverged, whether or not overwritten" -- nothing in this
396
+ * module tree ever read the old broader meaning (checked before this
397
+ * change), so this is not a breaking change to any known caller.
398
+ */
399
+ export function installResources({
400
+ root,
401
+ force = false,
402
+ log = console.error,
403
+ }: {
404
+ root: string;
405
+ force?: boolean;
406
+ log?: (message: string) => void;
407
+ }): InstallResourcesResult {
408
+ const installed: string[] = [];
409
+ const skipped: string[] = [];
410
+ const diverged: string[] = [];
411
+ const failed: string[] = [];
412
+
413
+ for (const entry of resourceEntries()) {
414
+ const src = join(RESOURCES_DIR, entry);
415
+ const target = join(installTargetDir(root), entry);
416
+ const status = statusForEntry(entry, root);
417
+
418
+ if (!force && status === "present") {
419
+ skipped.push(entry);
420
+ continue;
421
+ }
422
+ if (!force && status === "diverged" && !isGeneratedEntry(entry)) {
423
+ // Hand-authored (e.g. vice-launcher.sh): a divergence here MIGHT be a
424
+ // real local edit, so the original refuse-and-report posture stands.
425
+ diverged.push(entry);
426
+ log(
427
+ `warn: install-resources: refusing to overwrite ${entry} -- it diverges from resources/ and is NOT ` +
428
+ "a generated artifact (hand-authored; see resources/vice-launcher.sh's documented exception in " +
429
+ "CLAUDE.md's .claude/mcp/ contract). Nothing was deployed for this entry. Pass force:true to " +
430
+ "overwrite deliberately; this installer will never do so on its own for a hand-authored file."
431
+ );
432
+ continue;
433
+ }
434
+ if (!force && status === "diverged" && isGeneratedEntry(entry)) {
435
+ // Generated (tsc-compiled): divergence can only mean staleness here --
436
+ // CLAUDE.md says this half of resources/ (and all of tools/) is never
437
+ // hand-edited -- so fall through to the copy below instead of
438
+ // refusing it like the hand-authored branch above.
439
+ log(
440
+ `note: install-resources: ${entry} was diverged (stale) from resources/ -- refreshing it automatically ` +
441
+ "because it is a generated artifact, and a generated artifact can only diverge by going stale."
442
+ );
443
+ }
444
+
445
+ // Reached for status === "missing" (always copied, force or not), for
446
+ // "present"/"diverged" when force === true (the only overwrite path for
447
+ // a hand-authored file), and for a "diverged" GENERATED artifact even
448
+ // without force (staleness is the only thing divergence can mean for
449
+ // it, per the WHY note above).
450
+ try {
451
+ mkdirSync(dirname(target), { recursive: true });
452
+ copyFileSync(src, target);
453
+ chmodSync(target, statSync(src).mode & 0o777);
454
+ installed.push(entry);
455
+ } catch (e) {
456
+ failed.push(entry);
457
+ log(
458
+ `warn: install-resources: failed to deploy ${entry} to ${target} -- ${(e as Error).message}. ` +
459
+ "Continuing; a failed deployment must never break the caller."
460
+ );
461
+ }
462
+ }
463
+
464
+ // Make a refused (hand-authored) divergence impossible to skim past: this
465
+ // is the one remaining case where installResources() intentionally
466
+ // deploys nothing for an entry while reporting failed: [] -- the exact
467
+ // shape that read as silent success before this fix (installed: [],
468
+ // diverged: [N], failed: []). The per-entry warning above already names
469
+ // each one; this is the loud, count-carrying summary line.
470
+ if (diverged.length > 0) {
471
+ log(
472
+ `warn: install-resources: ${diverged.length} hand-authored entrie(s) refused (diverged, no force): ` +
473
+ `${JSON.stringify(diverged)}. Nothing was deployed for these -- resolve the divergence manually, or ` +
474
+ "pass force:true if overwriting is intentional."
475
+ );
476
+ }
477
+
478
+ const { pruned } = pruneResources({ root, log });
479
+
480
+ // Never throws (D-3): an unwritable root that already made every copy
481
+ // above fail must not ALSO throw out of the manifest write. Nothing here
482
+ // is added to `failed` -- that array's existing meaning is "a resource
483
+ // copy failed", and a manifest-write failure is a distinct, best-effort
484
+ // bookkeeping step the next call's prune degrades gracefully from (an
485
+ // unwritten manifest reads back as empty, per readDeployManifest()).
486
+ try {
487
+ writeDeployManifest(root, resourceEntries());
488
+ } catch (e) {
489
+ log(
490
+ `warn: install-resources: failed to write the deploy manifest -- ${(e as Error).message}. ` +
491
+ "Continuing; a failed manifest write must never break the caller."
492
+ );
493
+ }
494
+
495
+ // The default `log` is console.error, and NOTHING in this module writes to
496
+ // stdout (D-4): `tools --json` and `pool status` emit machine-readable
497
+ // output on stdout, and a stray banner there would corrupt it. This is the
498
+ // one place that condition matters -- only print when something actually
499
+ // changed (installed OR pruned).
500
+ if (installed.length > 0 || pruned.length > 0) {
501
+ log(hostLaunchInstructions(root));
502
+ }
503
+
504
+ return { installed, skipped, diverged, failed, pruned };
505
+ }
506
+
507
+ // Fire-once latch: set BEFORE any work is attempted, so a throw partway
508
+ // through installResources() can never cause a second attempt in the same
509
+ // process. ES-module caching already makes this redundant for the import
510
+ // path (a module body runs once per process no matter how many times it is
511
+ // imported) -- this latch is what also makes a direct, repeated call to
512
+ // ensureResourcesInstalled() itself a no-op, which module caching alone does
513
+ // not guarantee.
514
+ let _resourcesInstallAttempted = false;
515
+
516
+ /**
517
+ * The fire-once entry point, wired from the bottom of repo-root.ts's module
518
+ * body. Never throws (D-3): the whole body runs inside a try/catch that
519
+ * degrades to a stderr warning. Does nothing at all when
520
+ * VICE_SKIP_RESOURCE_INSTALL=1 (D-7's env opt-out), and does nothing on any
521
+ * call after the first in this process.
522
+ */
523
+ export function ensureResourcesInstalled({ root }: { root: string }): void {
524
+ if (_resourcesInstallAttempted) return;
525
+ _resourcesInstallAttempted = true;
526
+ if (process.env.VICE_SKIP_RESOURCE_INSTALL === "1") return;
527
+ try {
528
+ installResources({ root });
529
+ } catch (e) {
530
+ console.error(`warn: install-resources: ensureResourcesInstalled failed -- ${(e as Error).message}`);
531
+ }
532
+ }
package/package.json ADDED
@@ -0,0 +1,71 @@
1
+ {
2
+ "name": "@henols/vice-mcp",
3
+ "version": "0.1.4",
4
+ "description": "VICE emulator MCP server for C64 reverse-engineering: a stdio MCP server that proxies vice tools to a host VICE MCP server.",
5
+ "type": "module",
6
+ "bin": {
7
+ "vice-mcp": "vice-proxy.ts"
8
+ },
9
+ "main": "vice-proxy.ts",
10
+ "files": [
11
+ "vice-proxy.ts",
12
+ "vice.ts",
13
+ "vice-sync.ts",
14
+ "vice-probe.ts",
15
+ "vice-broker-client.ts",
16
+ "containerpath.ts",
17
+ "hostpath.ts",
18
+ "repo-root.ts",
19
+ "install-resources.ts",
20
+ "incident-record.ts",
21
+ "refresh-manifest.ts",
22
+ "build.ts",
23
+ "container-guard.mts",
24
+ "resources",
25
+ "tools-manifest.json",
26
+ "README.md"
27
+ ],
28
+ "engines": {
29
+ "node": ">=22.18.0"
30
+ },
31
+ "publishConfig": {
32
+ "access": "public"
33
+ },
34
+ "license": "MIT",
35
+ "author": {
36
+ "name": "Henrik Olsson",
37
+ "url": "https://github.com/henols"
38
+ },
39
+ "repository": {
40
+ "type": "git",
41
+ "url": "git+https://github.com/henols/c64-re-tools.git",
42
+ "directory": ".claude/mcp/vice"
43
+ },
44
+ "homepage": "https://github.com/henols/c64-re-tools#readme",
45
+ "bugs": {
46
+ "url": "https://github.com/henols/c64-re-tools/issues"
47
+ },
48
+ "keywords": [
49
+ "c64",
50
+ "commodore-64",
51
+ "vice",
52
+ "6502",
53
+ "6510",
54
+ "mcp",
55
+ "reverse-engineering"
56
+ ],
57
+ "scripts": {
58
+ "test": "node --test '*.test.*'",
59
+ "typecheck": "tsc --noEmit -p tsconfig.json",
60
+ "build": "node build.ts",
61
+ "smoke": "node smoke.mjs"
62
+ },
63
+ "dependencies": {
64
+ "@mastra/mcp": "1.15.0",
65
+ "@mastra/core": "1.55.0"
66
+ },
67
+ "devDependencies": {
68
+ "@types/node": "24.13.3",
69
+ "typescript": "7.0.2"
70
+ }
71
+ }