c64bridge 0.7.4 → 0.8.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.
Files changed (109) hide show
  1. package/.github/prompts/README.md +14 -4
  2. package/.github/prompts/assembly-routine-session.prompt.md +9 -9
  3. package/.github/prompts/basic-program-session.prompt.md +11 -9
  4. package/.github/prompts/drive-management-session.prompt.md +9 -9
  5. package/.github/prompts/hello-world-session.prompt.md +17 -0
  6. package/.github/prompts/petscii-art-session.prompt.md +9 -9
  7. package/.github/prompts/play-classic-game.prompt.md +9 -9
  8. package/.github/prompts/pr-converge.prompt.md +13 -0
  9. package/.github/prompts/sid-music-session.prompt.md +9 -9
  10. package/AGENTS.md +59 -60
  11. package/README.md +550 -191
  12. package/data/assembly/assembly-spec.md +290 -1
  13. package/data/context/bootstrap.md +26 -2
  14. package/data/context/fast-paths.md +44 -0
  15. package/data/graphics/petscii-style-guide.md +6 -2
  16. package/data/graphics/sprite-charset-best-practices.md +5 -5
  17. package/dist/.tsbuildinfo +1 -1
  18. package/dist/c64Client.js +990 -26
  19. package/dist/config.js +5 -1
  20. package/dist/device.js +680 -57
  21. package/dist/diagnostics.js +371 -0
  22. package/dist/mcp/metadata.js +39 -0
  23. package/dist/mcp-server.js +170 -20
  24. package/dist/platform.js +10 -2
  25. package/dist/prompts/registry.js +166 -309
  26. package/dist/rag/init.js +37 -1
  27. package/dist/rag/knowledgeIndex.js +18 -6
  28. package/dist/rag/rateLimiter.js +2 -2
  29. package/dist/streamCapture.js +135 -0
  30. package/dist/tools/audio.js +3 -1
  31. package/dist/tools/debug.js +470 -0
  32. package/dist/tools/developer.js +10 -4
  33. package/dist/tools/graphics.js +183 -17
  34. package/dist/tools/machineControl.js +11 -5
  35. package/dist/tools/memory.js +19 -10
  36. package/dist/tools/meta/audio.js +240 -1
  37. package/dist/tools/meta/config.js +20 -28
  38. package/dist/tools/meta/configInventory.js +104 -0
  39. package/dist/tools/meta/diagnostics.js +45 -2
  40. package/dist/tools/meta/graphics.js +14 -6
  41. package/dist/tools/meta/index.js +1 -0
  42. package/dist/tools/meta/memory.js +20 -6
  43. package/dist/tools/meta/program.js +473 -0
  44. package/dist/tools/pollValidator.js +4 -2
  45. package/dist/tools/programRunners.js +8 -4
  46. package/dist/tools/registry/config.js +17 -0
  47. package/dist/tools/registry/disk.js +7 -0
  48. package/dist/tools/registry/drive.js +3 -0
  49. package/dist/tools/registry/graphics.js +111 -27
  50. package/dist/tools/registry/index.js +6 -0
  51. package/dist/tools/registry/memory.js +1 -0
  52. package/dist/tools/registry/platform.js +121 -0
  53. package/dist/tools/registry/program.js +26 -8
  54. package/dist/tools/registry/sound.js +96 -0
  55. package/dist/tools/registry/system.js +15 -2
  56. package/dist/tools/storage.js +1 -0
  57. package/dist/tools/streaming.js +1 -0
  58. package/dist/tools/types.js +38 -4
  59. package/dist/tools/vice.js +160 -0
  60. package/dist/vicBitmap.js +317 -0
  61. package/dist/vice/mockServer.js +439 -0
  62. package/dist/vice/process.js +310 -0
  63. package/dist/vice/readiness.js +18 -2
  64. package/dist/vice/viceClient.js +382 -22
  65. package/doc/developer.md +71 -25
  66. package/doc/img/backend-switch/hello-c64u.png +0 -0
  67. package/doc/img/backend-switch/hello-vice-and-c64u.png +0 -0
  68. package/doc/img/backend-switch/hello-vice.png +0 -0
  69. package/doc/img/backend-switch/metadata.json +50 -0
  70. package/doc/plans/vice-backend-switch/plan.md +486 -0
  71. package/doc/plans/vice-backend-switch-2/plan.md +48 -0
  72. package/doc/plans/vice-support/notes.md +1 -0
  73. package/doc/plans/vice-support/plan.md +5 -0
  74. package/doc/plans/vice-support/rollout.md +23 -17
  75. package/doc/reviews/vice-support-review.md +185 -0
  76. package/doc/troubleshooting-mcp.md +35 -6
  77. package/doc/vice/support-matrix.md +54 -0
  78. package/mcp/prompts.json +292 -0
  79. package/mcp/protocol-examples.json +46 -0
  80. package/mcp/resources.json +940 -0
  81. package/mcp/schemas/c64_config.schema.json +350 -0
  82. package/mcp/schemas/c64_debug.schema.json +380 -0
  83. package/mcp/schemas/c64_disk.schema.json +278 -0
  84. package/mcp/schemas/c64_drive.schema.json +120 -0
  85. package/mcp/schemas/c64_extract.schema.json +302 -0
  86. package/mcp/schemas/c64_graphics.schema.json +333 -0
  87. package/mcp/schemas/c64_memory.schema.json +155 -0
  88. package/mcp/schemas/c64_printer.schema.json +218 -0
  89. package/mcp/schemas/c64_program.schema.json +391 -0
  90. package/mcp/schemas/c64_rag.schema.json +67 -0
  91. package/mcp/schemas/c64_select_backend.schema.json +32 -0
  92. package/mcp/schemas/c64_sound.schema.json +644 -0
  93. package/mcp/schemas/c64_stream.schema.json +61 -0
  94. package/mcp/schemas/c64_system.schema.json +227 -0
  95. package/mcp/schemas/c64_vice.schema.json +63 -0
  96. package/mcp/server.json +15 -0
  97. package/mcp/tools.json +4517 -0
  98. package/mcp.json +90 -15
  99. package/package.json +11 -4
  100. package/scripts/capture-backend-switch-screenshots.mjs +276 -0
  101. package/scripts/generate-mcp-interface.ts +289 -0
  102. package/scripts/invoke-bun.mjs +67 -21
  103. package/scripts/mockC64Server.mjs +162 -6
  104. package/scripts/prepare-release.mjs +59 -2
  105. package/scripts/run-coverage.mjs +284 -0
  106. package/scripts/run-tests.mjs +95 -39
  107. package/scripts/run-tests.ts +378 -73
  108. package/scripts/update-readme.ts +147 -13
  109. package/server.json +94 -0
@@ -3,84 +3,389 @@ import path from "node:path";
3
3
  import fs from "node:fs";
4
4
  import os from "node:os";
5
5
  import process from "node:process";
6
+ import { spawn } from "node:child_process";
6
7
  import { fileURLToPath } from "node:url";
7
8
 
8
9
  const DEFAULT_TARGET = "mock";
10
+ const DEFAULT_PLATFORM = "c64u";
11
+ const DEFAULT_BUN_FILE_LIMIT = 4;
12
+ const DEFAULT_BUN_BATCH_SIZE = 12;
13
+ const ISOLATED_BUN_TEST_FILES = new Set([
14
+ "test/audioRuntime.test.mjs",
15
+ ]);
9
16
  const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
10
17
  const defaultEmbeddingsDir = path.join(repoRoot, "artifacts", "test-embeddings");
18
+ const defaultTestFiles = listRepoTestFiles(path.join(repoRoot, "test"));
11
19
 
12
- let target = DEFAULT_TARGET;
13
- let explicitBaseUrl: string | null = null;
14
- let runCoverage = false;
15
- const passthrough: string[] = [];
16
-
17
- for (const arg of process.argv.slice(2)) {
18
- if (arg === "--mock") {
19
- target = "mock";
20
- continue;
21
- }
22
- if (arg === "--real") {
23
- target = "real";
24
- continue;
25
- }
26
- if (arg.startsWith("--target=")) {
27
- target = arg.split("=", 2)[1] ?? DEFAULT_TARGET;
28
- continue;
29
- }
30
- if (arg.startsWith("--base-url=")) {
31
- explicitBaseUrl = arg.split("=", 2)[1] ?? null;
32
- continue;
33
- }
34
- if (arg === "--coverage") {
35
- runCoverage = true;
36
- continue;
37
- }
38
- passthrough.push(arg);
39
- }
40
-
41
- if (!fs.existsSync(defaultEmbeddingsDir)) {
42
- fs.mkdirSync(defaultEmbeddingsDir, { recursive: true });
43
- }
44
-
45
- const env: Record<string, string> = { ...process.env } as Record<string, string>;
46
- env.C64_TEST_TARGET = target;
47
- if (!env.RAG_EMBEDDINGS_DIR) {
48
- env.RAG_EMBEDDINGS_DIR = defaultEmbeddingsDir;
49
- }
50
- if (explicitBaseUrl) {
51
- env.C64_TEST_BASE_URL = explicitBaseUrl;
52
- }
53
- if (target === "real" && !env.C64_TEST_BASE_URL) {
54
- env.C64_TEST_BASE_URL = resolveBaseUrlFromConfig() ?? "http://c64u";
55
- }
56
-
57
- const bunExecutable = process.execPath;
58
- const cmd = [
59
- bunExecutable,
60
- "test",
61
- ...(runCoverage
62
- ? [
63
- "--coverage",
64
- // Ensure LCOV is emitted for Codecov
65
- "--coverage-reporter=lcov",
66
- // Use supported console reporter in Bun
67
- "--coverage-reporter=text",
68
- ]
69
- : []),
70
- ...passthrough,
71
- ];
72
- const child = Bun.spawn({
73
- cmd,
74
- cwd: repoRoot,
75
- env,
76
- stdout: "inherit",
77
- stderr: "inherit",
78
- });
79
-
80
- const exitCode = await child.exited;
81
- process.exit(typeof exitCode === "number" ? exitCode : 1);
82
-
83
- function resolveBaseUrlFromConfig(): string | null {
20
+ function resolveNodeExecutable(): string {
21
+ const candidates = [
22
+ process.env.C64BRIDGE_TEST_NODE_BIN,
23
+ process.env.C64BRIDGE_NODE_BIN,
24
+ process.env.NODE_BINARY,
25
+ process.env.NODE_EXEC_PATH,
26
+ process.env.npm_node_execpath,
27
+ ];
28
+
29
+ for (const candidate of candidates) {
30
+ if (candidate && typeof candidate === "string" && candidate.trim()) {
31
+ return candidate.trim();
32
+ }
33
+ }
34
+
35
+ return "node";
36
+ }
37
+
38
+ export type RunTestsArgs = {
39
+ target: string;
40
+ platform: "c64u" | "vice";
41
+ explicitBaseUrl: string | null;
42
+ runCoverage: boolean;
43
+ passthrough: string[];
44
+ };
45
+
46
+ export function parseRunTestsArgs(args: string[]): RunTestsArgs {
47
+ let target = DEFAULT_TARGET;
48
+ let platform: "c64u" | "vice" = DEFAULT_PLATFORM;
49
+ let explicitBaseUrl: string | null = null;
50
+ let runCoverage = false;
51
+ const passthrough: string[] = [];
52
+
53
+ for (let index = 0; index < args.length; index += 1) {
54
+ const arg = args[index];
55
+ if (!arg || !arg.trim()) {
56
+ continue;
57
+ }
58
+ if (arg === "--mock") {
59
+ target = "mock";
60
+ continue;
61
+ }
62
+ if (arg === "--real") {
63
+ target = "device";
64
+ continue;
65
+ }
66
+ if (arg === "--platform" && index + 1 < args.length) {
67
+ platform = normalizePlatform(args[index + 1]);
68
+ index += 1;
69
+ continue;
70
+ }
71
+ if (arg.startsWith("--platform=")) {
72
+ platform = normalizePlatform(arg.split("=", 2)[1] ?? DEFAULT_PLATFORM);
73
+ continue;
74
+ }
75
+ if (arg.startsWith("--target=")) {
76
+ target = arg.split("=", 2)[1] ?? DEFAULT_TARGET;
77
+ continue;
78
+ }
79
+ if (arg === "--target" && index + 1 < args.length) {
80
+ target = args[index + 1] ?? DEFAULT_TARGET;
81
+ index += 1;
82
+ continue;
83
+ }
84
+ if (arg.startsWith("--base-url=")) {
85
+ explicitBaseUrl = arg.split("=", 2)[1] ?? null;
86
+ continue;
87
+ }
88
+ if (arg === "--coverage") {
89
+ runCoverage = true;
90
+ continue;
91
+ }
92
+ passthrough.push(arg);
93
+ }
94
+
95
+ return { target, platform, explicitBaseUrl, runCoverage, passthrough };
96
+ }
97
+
98
+ export function shouldUseNodeFallback(runCoverage: boolean, passthrough: string[], env: NodeJS.ProcessEnv = process.env): boolean {
99
+ const requestedRunner = String(env.C64BRIDGE_TEST_RUNNER ?? "").trim().toLowerCase();
100
+ if (requestedRunner === "bun") {
101
+ return false;
102
+ }
103
+ if (requestedRunner === "node") {
104
+ return true;
105
+ }
106
+ if (runCoverage) {
107
+ return false;
108
+ }
109
+
110
+ const explicitFiles = passthrough.filter(looksLikeTestFileArg);
111
+ if (passthrough.length === 0) {
112
+ return true;
113
+ }
114
+
115
+ const maxBunFiles = resolveBunFileLimit(env.C64BRIDGE_BUN_FILE_LIMIT);
116
+ return explicitFiles.length > maxBunFiles;
117
+ }
118
+
119
+ export function buildBunTestBatches(passthrough: string[], env: NodeJS.ProcessEnv = process.env): string[][] {
120
+ const explicitFiles = passthrough.filter(looksLikeTestFileArg);
121
+ if (passthrough.length === 0) {
122
+ const sharedFiles = defaultTestFiles.filter((file) => !ISOLATED_BUN_TEST_FILES.has(file));
123
+ return [
124
+ ...chunkFiles(sharedFiles, resolveBunBatchSize(env.C64BRIDGE_BUN_BATCH_SIZE)),
125
+ ...[...ISOLATED_BUN_TEST_FILES].filter((file) => defaultTestFiles.includes(file)).map((file) => [file]),
126
+ ];
127
+ }
128
+ if (explicitFiles.length === 0) {
129
+ return [passthrough];
130
+ }
131
+
132
+ const batchSize = resolveBunBatchSize(env.C64BRIDGE_BUN_BATCH_SIZE);
133
+ const sharedArgs = passthrough.filter((arg) => !looksLikeTestFileArg(arg));
134
+ const sharedFiles = explicitFiles.filter((file) => !ISOLATED_BUN_TEST_FILES.has(file));
135
+ const isolatedFiles = explicitFiles.filter((file) => ISOLATED_BUN_TEST_FILES.has(file));
136
+ return [
137
+ ...chunkFiles(sharedFiles, batchSize).map((files) => [...files, ...sharedArgs]),
138
+ ...isolatedFiles.map((file) => [file, ...sharedArgs]),
139
+ ];
140
+ }
141
+
142
+ async function runNodeFallback(target: string, explicitBaseUrl: string | null, passthrough: string[], env: Record<string, string>, runCoverage: boolean): Promise<number> {
143
+ console.warn("[run-tests] Using Node runner for this test set to avoid Bun memory growth on broad suites");
144
+ if (runCoverage) {
145
+ console.warn("[run-tests] Coverage reporting is unavailable in Node fallback mode");
146
+ }
147
+ const nodeExecutable = resolveNodeExecutable();
148
+ const nodeScript = path.join(repoRoot, "scripts", "run-tests.mjs");
149
+ const fallbackArgs = [] as string[];
150
+ if (target !== DEFAULT_TARGET) {
151
+ fallbackArgs.push(`--target=${target}`);
152
+ }
153
+ if (explicitBaseUrl) {
154
+ fallbackArgs.push(`--base-url=${explicitBaseUrl}`);
155
+ }
156
+ fallbackArgs.push(...passthrough);
157
+ return await new Promise<number>((resolve) => {
158
+ const childProcess = spawn(nodeExecutable, [nodeScript, ...fallbackArgs], {
159
+ cwd: repoRoot,
160
+ env,
161
+ stdio: "inherit",
162
+ }) as unknown as {
163
+ on(event: "error", listener: (error: Error) => void): void;
164
+ on(event: "exit", listener: (code: number | null) => void): void;
165
+ };
166
+ childProcess.on("error", (error) => {
167
+ console.error("[run-tests] Failed to launch Node fallback:", error);
168
+ resolve(1);
169
+ });
170
+ childProcess.on("exit", (code) => {
171
+ resolve(typeof code === "number" ? code : 1);
172
+ });
173
+ });
174
+ }
175
+
176
+ async function runExternalCommand(command: string, args: string[], env: Record<string, string>): Promise<number> {
177
+ return await new Promise<number>((resolve) => {
178
+ const childProcess = spawn(command, args, {
179
+ cwd: repoRoot,
180
+ env,
181
+ stdio: "inherit",
182
+ }) as unknown as {
183
+ on(event: "error", listener: (error: Error) => void): void;
184
+ on(event: "exit", listener: (code: number | null) => void): void;
185
+ };
186
+ childProcess.on("error", (error) => {
187
+ console.error("[run-tests] Failed to launch test runner:", error);
188
+ resolve(1);
189
+ });
190
+ childProcess.on("exit", (code) => {
191
+ resolve(typeof code === "number" ? code : 1);
192
+ });
193
+ });
194
+ }
195
+
196
+ async function main(): Promise<number> {
197
+ const { target, platform, explicitBaseUrl, runCoverage, passthrough } = parseRunTestsArgs(process.argv.slice(2));
198
+
199
+ if (!fs.existsSync(defaultEmbeddingsDir)) {
200
+ fs.mkdirSync(defaultEmbeddingsDir, { recursive: true });
201
+ }
202
+
203
+ const env: Record<string, string> = { ...process.env } as Record<string, string>;
204
+ const normalizedTarget = normalizeTarget(target);
205
+ env.C64_MODE = platform;
206
+ env.C64_TEST_TARGET = normalizedTarget === "device" ? "real" : "mock";
207
+ if (platform === "vice") {
208
+ env.VICE_TEST_TARGET = normalizedTarget === "device" ? "vice" : "mock";
209
+ } else {
210
+ delete env.VICE_TEST_TARGET;
211
+ }
212
+ if (!env.RAG_EMBEDDINGS_DIR) {
213
+ env.RAG_EMBEDDINGS_DIR = defaultEmbeddingsDir;
214
+ }
215
+ if (explicitBaseUrl) {
216
+ env.C64_TEST_BASE_URL = explicitBaseUrl;
217
+ }
218
+ if (platform === "c64u" && normalizedTarget === "device" && !env.C64_TEST_BASE_URL) {
219
+ env.C64_TEST_BASE_URL = resolveBaseUrlFromConfig(env) ?? "http://c64u";
220
+ }
221
+
222
+ printMatrixHeading({
223
+ platform,
224
+ target: normalizedTarget,
225
+ coverage: runCoverage,
226
+ baseUrl: env.C64_TEST_BASE_URL ?? null,
227
+ passthrough,
228
+ });
229
+
230
+ const bunRuntime = (globalThis as { Bun?: unknown }).Bun;
231
+ if (!bunRuntime) {
232
+ console.warn("[run-tests] Bun runtime not detected; falling back to Node runner");
233
+ return await runNodeFallback(target, explicitBaseUrl, passthrough, env, runCoverage);
234
+ }
235
+
236
+ if (!runCoverage && passthrough.length === 0) {
237
+ return await runBunBatches(env, buildBunTestBatches([], env), {
238
+ coverage: false,
239
+ labelPrefix: "default-suite",
240
+ });
241
+ }
242
+
243
+ if (shouldUseNodeFallback(runCoverage, passthrough, env)) {
244
+ if (!runCoverage) {
245
+ return await runBunBatches(env, buildBunTestBatches(passthrough, env), {
246
+ coverage: false,
247
+ labelPrefix: "sharded-suite",
248
+ });
249
+ }
250
+ return await runNodeFallback(target, explicitBaseUrl, passthrough, env, runCoverage);
251
+ }
252
+
253
+ const bunArgs = [
254
+ "test",
255
+ ...(runCoverage
256
+ ? [
257
+ "--coverage",
258
+ "--coverage-reporter=lcov",
259
+ "--coverage-reporter=text",
260
+ ]
261
+ : []),
262
+ ...(passthrough.length > 0 ? passthrough : defaultTestFiles),
263
+ ];
264
+ return await runExternalCommand(process.execPath, bunArgs, env);
265
+ }
266
+
267
+ if (import.meta.main) {
268
+ process.exit(await main());
269
+ }
270
+
271
+ type MatrixHeadingOptions = {
272
+ platform: "c64u" | "vice";
273
+ target: "mock" | "device";
274
+ coverage: boolean;
275
+ baseUrl: string | null;
276
+ passthrough: string[];
277
+ };
278
+
279
+ function printMatrixHeading(options: MatrixHeadingOptions): void {
280
+ const { platform, target, coverage, baseUrl, passthrough } = options;
281
+ const useColor = Boolean(process.stdout.isTTY) && process.env.NO_COLOR !== "1";
282
+ const color = (code: string): string => (useColor ? `\x1b[${code}m` : "");
283
+ const reset = useColor ? "\x1b[0m" : "";
284
+ const bold = useColor ? "\x1b[1m" : "";
285
+
286
+ const platformColor = platform === "vice" ? color("34") : color("36");
287
+ const targetColor = target === "device" ? color("33") : color("32");
288
+ const coverageColor = coverage ? color("31") : color("90");
289
+ const labelColor = color("90");
290
+
291
+ const header = `${bold}${color("97")}=== test-matrix run ===${reset}`;
292
+ const platformLine = `${labelColor}platform:${reset} ${platformColor}${platform}${reset}`;
293
+ const targetLine = `${labelColor}target:${reset} ${targetColor}${target}${reset}`;
294
+ const coverageLine = `${labelColor}coverage:${reset} ${coverageColor}${coverage ? "enabled" : "disabled"}${reset}`;
295
+
296
+ console.log("\n" + header);
297
+ console.log(`${platformLine} ${targetLine} ${coverageLine}`);
298
+ if (baseUrl) {
299
+ console.log(`${labelColor}base-url:${reset} ${baseUrl}`);
300
+ }
301
+ if (passthrough.length > 0) {
302
+ console.log(`${labelColor}extra args:${reset} ${passthrough.join(" ")}`);
303
+ }
304
+ console.log("");
305
+ }
306
+
307
+ function normalizePlatform(value: string): "c64u" | "vice" {
308
+ const lower = (value ?? "").toLowerCase();
309
+ return lower === "vice" ? "vice" : "c64u";
310
+ }
311
+
312
+ function normalizeTarget(value: string): "mock" | "device" {
313
+ const lower = (value ?? "").toLowerCase();
314
+ if (lower === "real" || lower === "device" || lower === "hardware" || lower === "vice") {
315
+ return "device";
316
+ }
317
+ return "mock";
318
+ }
319
+
320
+ function resolveBunFileLimit(raw: string | undefined): number {
321
+ const parsed = Number(raw ?? "");
322
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : DEFAULT_BUN_FILE_LIMIT;
323
+ }
324
+
325
+ function resolveBunBatchSize(raw: string | undefined): number {
326
+ const parsed = Number(raw ?? "");
327
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : DEFAULT_BUN_BATCH_SIZE;
328
+ }
329
+
330
+ function looksLikeTestFileArg(arg: string): boolean {
331
+ return /^test\/.*\.test\.(mjs|ts)$/i.test(arg) || /\.test\.(mjs|ts)$/i.test(arg);
332
+ }
333
+
334
+ function chunkFiles(files: string[], chunkSize: number): string[][] {
335
+ if (files.length === 0) {
336
+ return [];
337
+ }
338
+ const chunks: string[][] = [];
339
+ for (let index = 0; index < files.length; index += chunkSize) {
340
+ chunks.push(files.slice(index, index + chunkSize));
341
+ }
342
+ return chunks;
343
+ }
344
+
345
+ async function runBunBatches(
346
+ env: Record<string, string>,
347
+ batches: string[][],
348
+ options: { coverage: boolean; labelPrefix: string },
349
+ ): Promise<number> {
350
+ const coverageArgs = options.coverage
351
+ ? ["--coverage", "--coverage-reporter=lcov", "--coverage-reporter=text"]
352
+ : [];
353
+
354
+ for (let index = 0; index < batches.length; index += 1) {
355
+ const batch = batches[index] ?? [];
356
+ console.log(`[run-tests] ${options.labelPrefix} batch ${index + 1}/${batches.length} (${batch.length} entries)`);
357
+ const exitCode = await runExternalCommand(process.execPath, ["test", ...coverageArgs, ...batch], env);
358
+ if (exitCode !== 0) {
359
+ return exitCode;
360
+ }
361
+ }
362
+
363
+ return 0;
364
+ }
365
+
366
+ function listRepoTestFiles(testRoot: string): string[] {
367
+ const files: string[] = [];
368
+
369
+ function walk(dir: string): void {
370
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
371
+ for (const entry of entries) {
372
+ const fullPath = path.join(dir, entry.name);
373
+ if (entry.isDirectory()) {
374
+ walk(fullPath);
375
+ continue;
376
+ }
377
+ if (entry.isFile() && /\.test\.(mjs|ts)$/i.test(entry.name)) {
378
+ files.push(path.relative(repoRoot, fullPath));
379
+ }
380
+ }
381
+ }
382
+
383
+ walk(testRoot);
384
+ files.sort((a, b) => a.localeCompare(b));
385
+ return files;
386
+ }
387
+
388
+ function resolveBaseUrlFromConfig(env: Record<string, string>): string | null {
84
389
  const configPathEnv = env.C64BRIDGE_CONFIG;
85
390
  const homeConfig = os.homedir() ? path.join(os.homedir(), ".c64bridge.json") : null;
86
391
  const repoConfig = path.join(repoRoot, ".c64bridge.json");
@@ -150,7 +455,7 @@ function normalizeBase(input: string): string {
150
455
  return /^[a-z][a-z0-9+.-]*:\/\//i.test(input) ? input.replace(/\/+$/, "") : `http://${input}`;
151
456
  }
152
457
 
153
- function parseEndpoint(value: string | null): { hostname?: string; port?: number } {
458
+ function parseEndpoint(value: string | null | undefined): { hostname?: string; port?: number } {
154
459
  const input = value && value.trim() ? value.trim() : null;
155
460
  if (!input) return {};
156
461
  try {
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ import { readFileSync } from "node:fs";
2
3
  import { readFile, writeFile } from "node:fs/promises";
3
4
  import { dirname, join, resolve as resolvePath } from "node:path";
4
5
  import { fileURLToPath, pathToFileURL } from "node:url";
@@ -9,23 +10,134 @@ import { createPromptRegistry } from "../src/prompts/registry.js";
9
10
 
10
11
  const START_MARKER = "<!-- AUTO-GENERATED:MCP-DOCS-START -->";
11
12
  const END_MARKER = "<!-- AUTO-GENERATED:MCP-DOCS-END -->";
13
+ const ENV_START_MARKER = "<!-- AUTO-GENERATED:ENV-VARS-START -->";
14
+ const ENV_END_MARKER = "<!-- AUTO-GENERATED:ENV-VARS-END -->";
12
15
 
13
16
  const __filename = fileURLToPath(import.meta.url);
14
17
  const __dirname = dirname(__filename);
15
18
  const PROJECT_ROOT = join(__dirname, "..");
16
19
  const README_PATH = join(PROJECT_ROOT, "README.md");
20
+ const MCP_MANIFEST_PATH = join(PROJECT_ROOT, "mcp.json");
21
+
22
+ type ManifestEnvEntry = {
23
+ readonly description: string;
24
+ readonly default?: string;
25
+ };
26
+
27
+ type EnvCategory =
28
+ | "Server Runtime"
29
+ | "C64 Ultimate"
30
+ | "VICE Runtime"
31
+ | "VICE Audio Capture"
32
+ | "SID Playback"
33
+ | "RAG"
34
+ | "Testing";
17
35
 
18
36
  function escapeCell(value: string): string {
19
37
  return value.replace(/\|/g, "\\|").replace(/\r?\n|\r/g, " ").trim();
20
38
  }
21
39
 
22
- function renderTable(headers: readonly string[], rows: readonly (readonly string[])[]): string {
40
+ export function renderTable(headers: readonly string[], rows: readonly (readonly string[])[]): string {
23
41
  const headerLine = `| ${headers.join(" | ")} |`;
24
42
  const separator = `| ${headers.map(() => "---").join(" | ")} |`;
25
43
  const body = rows.map((row) => `| ${row.join(" | ")} |`).join("\n");
26
44
  return [headerLine, separator, body].filter(Boolean).join("\n");
27
45
  }
28
46
 
47
+ function loadManifestEnv(): Readonly<Record<string, ManifestEnvEntry>> {
48
+ const manifest = JSON.parse(readFileSync(MCP_MANIFEST_PATH, "utf8")) as {
49
+ env?: Record<string, ManifestEnvEntry>;
50
+ };
51
+ return manifest.env ?? {};
52
+ }
53
+
54
+ function classifyEnvVariable(name: string): EnvCategory {
55
+ if (name.startsWith("C64U_")) {
56
+ return "C64 Ultimate";
57
+ }
58
+ if (name.startsWith("VICE_") || name === "FORCE_XVFB" || name === "DISABLE_XVFB") {
59
+ if (name === "VICE_MODE" || name === "VICE_LIMIT_CYCLES" || name === "VICE_RUN_TIMEOUT_MS") {
60
+ return "VICE Audio Capture";
61
+ }
62
+ return "VICE Runtime";
63
+ }
64
+ if (name.startsWith("SIDPLAY")) {
65
+ return "SID Playback";
66
+ }
67
+ if (name.startsWith("RAG_") || name === "GITHUB_TOKEN") {
68
+ return "RAG";
69
+ }
70
+ if (name === "C64_TEST_TARGET") {
71
+ return "Testing";
72
+ }
73
+ return "Server Runtime";
74
+ }
75
+
76
+ function resolveJsonConfigKey(name: string): string {
77
+ const map: Record<string, string> = {
78
+ C64BRIDGE_CONFIG: "config path",
79
+ C64U_HOST: "c64u.host",
80
+ C64U_PORT: "c64u.port",
81
+ C64U_PASSWORD: "c64u.networkPassword",
82
+ VICE_BINARY: "vice.exe",
83
+ VICE_DIRECTORY: "vice.directory",
84
+ VICE_HOST: "vice.host",
85
+ VICE_PORT: "vice.port",
86
+ VICE_VISIBLE: "vice.visible",
87
+ VICE_WARP: "vice.warp",
88
+ VICE_ARGS: "vice.args",
89
+ };
90
+ return map[name] ?? "—";
91
+ }
92
+
93
+ export function renderEnvironmentSection(envEntries: Readonly<Record<string, ManifestEnvEntry>> = loadManifestEnv()): string[] {
94
+ const grouped = new Map<EnvCategory, Array<[string, ManifestEnvEntry]>>();
95
+
96
+ for (const entry of Object.entries(envEntries).sort(([left], [right]) => left.localeCompare(right))) {
97
+ const category = classifyEnvVariable(entry[0]);
98
+ const rows = grouped.get(category) ?? [];
99
+ rows.push(entry);
100
+ grouped.set(category, rows);
101
+ }
102
+
103
+ const orderedCategories: readonly EnvCategory[] = [
104
+ "Server Runtime",
105
+ "C64 Ultimate",
106
+ "VICE Runtime",
107
+ "VICE Audio Capture",
108
+ "SID Playback",
109
+ "RAG",
110
+ "Testing",
111
+ ];
112
+
113
+ const lines: string[] = [
114
+ "Every runtime environment variable documented in `mcp.json` can be set in your MCP client configuration, including `.vscode/mcp.json` under `servers.c64bridge.env`.",
115
+ "",
116
+ ];
117
+
118
+ for (const category of orderedCategories) {
119
+ const entries = grouped.get(category);
120
+ if (!entries || entries.length === 0) {
121
+ continue;
122
+ }
123
+
124
+ lines.push(`#### ${category}`);
125
+ lines.push("");
126
+ lines.push(renderTable(
127
+ ["Variable", "Default", "JSON Config Key", "Description"],
128
+ entries.map(([name, spec]) => [
129
+ `\`${name}\``,
130
+ escapeCell(spec.default ?? "—"),
131
+ escapeCell(resolveJsonConfigKey(name)),
132
+ escapeCell(spec.description),
133
+ ]),
134
+ ));
135
+ lines.push("");
136
+ }
137
+
138
+ return lines;
139
+ }
140
+
29
141
  type GroupedOperation = {
30
142
  readonly op: string;
31
143
  readonly description: string;
@@ -138,17 +250,24 @@ export function renderToolsSection(modules: readonly ToolModuleDescriptor[] = de
138
250
  if (operations.length === 0) {
139
251
  lines.push("_No operations defined._");
140
252
  } else {
253
+ const toolPlatforms: readonly string[] = tool.metadata.platforms ?? ["c64u"];
254
+ const opPlatformOverrides: Record<string, readonly string[]> = tool.metadata.operationPlatforms ?? {};
141
255
  const operationRows = operations
142
256
  .slice()
143
257
  .sort((a, b) => a.op.localeCompare(b.op))
144
- .map((operation) => [
145
- `\`${operation.op}\``,
146
- escapeCell(operation.description),
147
- escapeCell(toTableValue(operation.required.map((name) => `\`${name}\``))),
148
- operation.notes.length ? escapeCell(operation.notes.join(", ")) : "—",
149
- ]);
150
-
151
- lines.push(renderTable(["Operation", "Description", "Required Inputs", "Notes"], operationRows));
258
+ .map((operation) => {
259
+ const effectivePlatforms = opPlatformOverrides[operation.op] ?? toolPlatforms;
260
+ return [
261
+ `\`${operation.op}\``,
262
+ escapeCell(operation.description),
263
+ escapeCell(toTableValue(operation.required.map((name) => `\`${name}\``))),
264
+ operation.notes.length ? escapeCell(operation.notes.join(", ")) : "—",
265
+ effectivePlatforms.includes("c64u") ? "✅" : "",
266
+ effectivePlatforms.includes("vice") ? "✅" : "",
267
+ ];
268
+ });
269
+
270
+ lines.push(renderTable(["Operation", "Description", "Required Inputs", "Notes", "C64U", "VICE"], operationRows));
152
271
  }
153
272
 
154
273
  lines.push("");
@@ -222,19 +341,34 @@ export function buildDocumentation(): string {
222
341
  return sections.flat().join("\n").trim();
223
342
  }
224
343
 
344
+ export function buildEnvironmentDocumentation(): string {
345
+ return renderEnvironmentSection().join("\n").trim();
346
+ }
347
+
225
348
  export async function updateReadme(): Promise<boolean> {
226
349
  const readme = await readFile(README_PATH, "utf8");
227
- const pattern = new RegExp(
350
+ const docsPattern = new RegExp(
228
351
  `${START_MARKER.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}([\\s\\S]*?)${END_MARKER.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`,
229
352
  );
230
- if (!pattern.test(readme)) {
353
+ const envPattern = new RegExp(
354
+ `${ENV_START_MARKER.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}([\\s\\S]*?)${ENV_END_MARKER.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`,
355
+ );
356
+ if (!docsPattern.test(readme)) {
231
357
  throw new Error(
232
358
  `Could not find auto-generated section markers (${START_MARKER} / ${END_MARKER}) in README.md`,
233
359
  );
234
360
  }
361
+ if (!envPattern.test(readme)) {
362
+ throw new Error(
363
+ `Could not find auto-generated section markers (${ENV_START_MARKER} / ${ENV_END_MARKER}) in README.md`,
364
+ );
365
+ }
235
366
 
236
- const generated = `\n\n${buildDocumentation()}\n\n`;
237
- const nextReadme = readme.replace(pattern, `${START_MARKER}${generated}${END_MARKER}`);
367
+ const generatedDocs = `\n\n${buildDocumentation()}\n\n`;
368
+ const generatedEnv = `\n\n${buildEnvironmentDocumentation()}\n\n`;
369
+ const nextReadme = readme
370
+ .replace(docsPattern, `${START_MARKER}${generatedDocs}${END_MARKER}`)
371
+ .replace(envPattern, `${ENV_START_MARKER}${generatedEnv}${ENV_END_MARKER}`);
238
372
 
239
373
  if (nextReadme === readme) {
240
374
  return false;