c64bridge 0.8.1 → 0.9.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.
Files changed (77) hide show
  1. package/.github/prompts/steer.prompt.md +55 -0
  2. package/AGENTS.md +9 -7
  3. package/README.md +139 -95
  4. package/data/embeddings_asm.json +1 -1
  5. package/data/embeddings_basic.json +1 -1
  6. package/data/embeddings_hardware.json +1 -1
  7. package/data/embeddings_mixed.json +1 -1
  8. package/data/embeddings_other.json +1 -1
  9. package/data/graphics/petscii-style-guide.md +4 -4
  10. package/data/graphics/sprite-charset-best-practices.md +9 -9
  11. package/data/vice/vice-binary-monitor-spec.md +921 -0
  12. package/dist/.tsbuildinfo +1 -1
  13. package/dist/device.js +86 -10
  14. package/dist/mcp-server.js +1 -1
  15. package/dist/prompts/registry.js +27 -25
  16. package/dist/rag/knowledgeIndex.js +112 -79
  17. package/dist/rag/resourceUris.js +93 -0
  18. package/dist/tools/audio.js +11 -11
  19. package/dist/tools/debug.js +1 -1
  20. package/dist/tools/developer.js +13 -13
  21. package/dist/tools/graphics.js +10 -10
  22. package/dist/tools/machineControl.js +5 -5
  23. package/dist/tools/memory.js +19 -11
  24. package/dist/tools/meta/index.js +1 -1
  25. package/dist/tools/printer.js +11 -12
  26. package/dist/tools/programRunners.js +16 -16
  27. package/dist/tools/rag.js +9 -9
  28. package/dist/tools/registry/config.js +1 -1
  29. package/dist/tools/registry/disk.js +1 -1
  30. package/dist/tools/registry/drive.js +1 -1
  31. package/dist/tools/registry/extract.js +1 -1
  32. package/dist/tools/registry/graphics.js +1 -1
  33. package/dist/tools/registry/memory.js +1 -1
  34. package/dist/tools/registry/platform.js +1 -1
  35. package/dist/tools/registry/printer.js +1 -1
  36. package/dist/tools/registry/program.js +1 -1
  37. package/dist/tools/registry/rag.js +1 -1
  38. package/dist/tools/registry/sound.js +1 -1
  39. package/dist/tools/registry/stream.js +1 -1
  40. package/dist/tools/registry/system.js +1 -1
  41. package/dist/tools/storage.js +15 -15
  42. package/dist/tools/streaming.js +3 -3
  43. package/dist/tools/types.js +41 -4
  44. package/dist/tools/vice.js +2 -5
  45. package/dist/vice/process.js +79 -2
  46. package/dist/vice/readiness.js +35 -0
  47. package/dist/vice/smokeOptions.js +44 -0
  48. package/dist/viceRunner.js +2 -0
  49. package/doc/plans/prompts/README.md +7 -7
  50. package/mcp/prompts.json +42 -59
  51. package/mcp/protocol-examples.json +2 -2
  52. package/mcp/resources.json +179 -166
  53. package/mcp/schemas/c64_config.schema.json +109 -326
  54. package/mcp/schemas/c64_debug.schema.json +158 -337
  55. package/mcp/schemas/c64_disk.schema.json +180 -262
  56. package/mcp/schemas/c64_drive.schema.json +31 -111
  57. package/mcp/schemas/c64_extract.schema.json +193 -291
  58. package/mcp/schemas/c64_graphics.schema.json +208 -308
  59. package/mcp/schemas/c64_memory.schema.json +94 -144
  60. package/mcp/schemas/c64_printer.schema.json +151 -205
  61. package/mcp/schemas/c64_program.schema.json +229 -367
  62. package/mcp/schemas/c64_rag.schema.json +25 -59
  63. package/mcp/schemas/c64_select_backend.schema.json +19 -26
  64. package/mcp/schemas/c64_sound.schema.json +346 -622
  65. package/mcp/schemas/c64_stream.schema.json +25 -54
  66. package/mcp/schemas/c64_system.schema.json +79 -212
  67. package/mcp/schemas/c64_vice.schema.json +24 -55
  68. package/mcp/server.json +1 -1
  69. package/mcp/tools.json +1935 -3443
  70. package/mcp.json +3 -3
  71. package/package.json +17 -14
  72. package/scripts/fetch-external-sources.mjs +1 -2
  73. package/scripts/run-coverage.mjs +26 -1
  74. package/scripts/run-tests.mjs +2 -2
  75. package/scripts/run-tests.ts +273 -52
  76. package/scripts/update-readme.ts +54 -2
  77. package/server.json +2 -2
package/mcp.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "c64bridge",
3
- "version": "0.8.1",
3
+ "version": "0.9.0",
4
4
  "description": "Stdio-based Model Context Protocol (MCP) server that bridges Commodore 64 Ultimate hardware with AI agents.",
5
5
  "entry": "dist/index.js",
6
6
  "type": "stdio",
@@ -113,11 +113,11 @@
113
113
  "default": "120000000"
114
114
  },
115
115
  "VICE_BINARY": {
116
- "description": "VICE binary to launch for managed emulator sessions and audio capture",
116
+ "description": "VICE binary to launch for managed emulator sessions and audio capture; automatic search is used only when this override is missing or invalid",
117
117
  "default": "x64sc"
118
118
  },
119
119
  "VICE_DIRECTORY": {
120
- "description": "Override the VICE resource directory used for ROM and UI asset discovery",
120
+ "description": "Override the VICE resource directory used for ROM and UI asset discovery; automatic search is used only when this override is missing or invalid",
121
121
  "default": "auto-detect"
122
122
  },
123
123
  "VICE_HOST": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "c64bridge",
3
- "version": "0.8.1",
3
+ "version": "0.9.0",
4
4
  "mcpName": "io.github.chrisgleissner/c64bridge",
5
5
  "description": "C64 Bridge MCP server for Commodore 64 Ultimate hardware and VICE emulator control",
6
6
  "type": "module",
@@ -22,9 +22,11 @@
22
22
  "lint": "npm run check",
23
23
  "test": "node scripts/invoke-bun.mjs scripts/run-tests.ts",
24
24
  "test:node": "node scripts/run-tests.mjs",
25
+ "test:vice:mock": "npm run test -- --platform=vice --target=mock",
26
+ "test:vice:device": "FORCE_XVFB=1 VICE_VISIBLE=false VICE_WARP=true npm run test -- --platform=vice --target=device",
25
27
  "coverage": "node scripts/run-coverage.mjs",
26
28
  "coverage:single": "node scripts/invoke-bun.mjs scripts/run-tests.ts --coverage",
27
- "test:matrix": "npm run test -- --platform=c64u --target=mock && npm run test -- --platform=vice --target=mock && npm run test -- --platform=vice --target=device",
29
+ "test:matrix": "npm run test -- --platform=c64u --target=mock && npm run test:vice:mock && npm run test:vice:device",
28
30
  "mcp:generate": "node scripts/invoke-bun.mjs scripts/generate-mcp-interface.ts",
29
31
  "check:package": "node scripts/check-package.mjs",
30
32
  "check:run-local": "bash scripts/run-mcp-check.sh local 5",
@@ -40,7 +42,8 @@
40
42
  "changelog:generate": "node scripts/generate-changelog.mjs",
41
43
  "release:prepare": "node scripts/prepare-release.mjs",
42
44
  "compare:rest": "node scripts/compare-rest-apis.mjs",
43
- "vice:smoke": "node scripts/invoke-bun.mjs test/vice/viceSmokeTest.ts"
45
+ "vice:smoke": "node scripts/invoke-bun.mjs test/vice/viceSmokeTest.ts",
46
+ "vice:smoke:visible": "node scripts/invoke-bun.mjs test/vice/viceSmokeTest.ts --visible-demo"
44
47
  },
45
48
  "keywords": [
46
49
  "mcp",
@@ -59,24 +62,24 @@
59
62
  "node": ">=24"
60
63
  },
61
64
  "dependencies": {
62
- "@modelcontextprotocol/sdk": "^1.20.2",
63
- "axios": "^1.7.7",
65
+ "@modelcontextprotocol/sdk": "^1.29.0",
66
+ "axios": "^1.16.0",
64
67
  "date-fns": "^4.1.0",
65
- "fastify": "^5.6.1",
66
- "jimp": "^1.6.0",
67
- "meyda": "^5.6.2",
68
- "octokit": "^5.0.4",
69
- "pitchfinder": "^2.2.0",
68
+ "fastify": "^5.8.5",
69
+ "jimp": "^1.6.1",
70
+ "meyda": "^5.6.3",
71
+ "octokit": "^5.0.5",
72
+ "pitchfinder": "^2.3.4",
70
73
  "reflect-metadata": "^0.2.2",
71
- "yaml": "^2.6.0"
74
+ "yaml": "^2.8.4"
72
75
  },
73
76
  "optionalDependencies": {
74
77
  "naudiodon": "^2.3.6"
75
78
  },
76
79
  "devDependencies": {
77
- "@types/node": "^24.12.0",
78
- "ts-node": "^10.9.2",
79
- "typescript": "^5.9.3"
80
+ "@types/node": "^25.6.0",
81
+ "tsx": "^4.21.0",
82
+ "typescript": "^6.0.3"
80
83
  },
81
84
  "files": [
82
85
  "mcp.json",
@@ -7,8 +7,7 @@ import { register } from 'node:module';
7
7
  async function main() {
8
8
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
9
9
  const projectRoot = path.resolve(__dirname, '..');
10
- const tsNodeLoader = pathToFileURL(path.join(projectRoot, 'node_modules', 'ts-node', 'esm.mjs')).href;
11
- register(tsNodeLoader, pathToFileURL(projectRoot));
10
+ register("tsx/esm", pathToFileURL(projectRoot));
12
11
  const { fetchFromCsv } = await import('../src/rag/externalFetcher.ts');
13
12
  // Resolve sources.csv from env, dist/rag, or src/rag (in that order)
14
13
  let csvPath = process.env.RAG_SOURCES_CSV;
@@ -13,6 +13,22 @@ const runner = path.join(repoRoot, "scripts", "invoke-bun.mjs");
13
13
  const configPath = path.join(repoRoot, ".c8rc.json");
14
14
  const testRoot = path.join(repoRoot, "test");
15
15
  const DEFAULT_COVERAGE_SHARD_SIZE = 12;
16
+ const DEFAULT_VICE_MOCK_TEST_FILES = [
17
+ "test/device.test.mjs",
18
+ "test/viceIntegration.test.mjs",
19
+ "test/viceModule.test.mjs",
20
+ "test/groupedToolsShims.test.mjs",
21
+ "test/toolsTypes.test.mjs",
22
+ "test/platformRegistry.test.mjs",
23
+ "test/meta/program.test.mjs",
24
+ "test/mcpServerIntegration.test.mjs",
25
+ "test/c64Client.test.mjs",
26
+ "test/vice/viceSmokeTest.ts",
27
+ ];
28
+ const DEFAULT_VICE_DEVICE_TEST_FILES = [
29
+ "test/device.test.mjs",
30
+ "test/vice/viceSmokeTest.ts",
31
+ ];
16
32
 
17
33
  const legs = [
18
34
  { name: "c64u-mock", args: ["--platform=c64u", "--target=mock"] },
@@ -50,11 +66,20 @@ export async function main() {
50
66
  const allTestFiles = await listRepoTestFiles(testRoot);
51
67
  const isolatedTestSet = new Set(isolatedTests);
52
68
  const defaultTestFiles = allTestFiles.filter((f) => !isolatedTestSet.has(f));
53
- const coverageBatches = buildCoverageBatches(defaultTestFiles, [...supplementalTests, ...isolatedTests], process.env);
54
69
  for (const leg of legs) {
55
70
  const legDir = path.join(coverageDir, "matrix", leg.name);
56
71
  await fs.mkdir(legDir, { recursive: true });
57
72
 
73
+ const legDefaultFiles = leg.name === "vice-mock"
74
+ ? DEFAULT_VICE_MOCK_TEST_FILES
75
+ : leg.name === "vice-device"
76
+ ? DEFAULT_VICE_DEVICE_TEST_FILES
77
+ : defaultTestFiles;
78
+ const legSupplementalTests = leg.name.startsWith("vice")
79
+ ? []
80
+ : [...supplementalTests, ...isolatedTests];
81
+ const coverageBatches = buildCoverageBatches(legDefaultFiles, legSupplementalTests, process.env);
82
+
58
83
  const reports = [];
59
84
  for (const batch of coverageBatches) {
60
85
  reports.push(await runCoverageLeg(leg, legDir, batch.label, batch.files));
@@ -8,7 +8,7 @@ import { fileURLToPath } from "node:url";
8
8
  export const DEFAULT_TARGET = "mock";
9
9
 
10
10
  const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
11
- const registerDataUri = "data:text/javascript,import { register } from \"node:module\"; import { pathToFileURL } from \"node:url\"; register(\"ts-node/esm\", pathToFileURL(\"./\"));";
11
+ const tsLoader = "tsx/esm";
12
12
  const { nodeArgs, target, explicitBaseUrl } = buildNodeTestArgs(process.argv.slice(2));
13
13
  const defaultEmbeddingsDir = path.join(repoRoot, "artifacts", "test-embeddings");
14
14
  if (!fs.existsSync(defaultEmbeddingsDir)) {
@@ -180,7 +180,7 @@ function isMainModule(moduleUrl) {
180
180
 
181
181
  export function buildNodeTestArgs(args, root = repoRoot) {
182
182
  const defaultTestFiles = listRepoTestFiles(path.join(root, "test"), root);
183
- const nodeArgs = ["--import", registerDataUri, "--test"];
183
+ const nodeArgs = ["--import", tsLoader, "--test"];
184
184
  let target = DEFAULT_TARGET;
185
185
  let explicitBaseUrl = null;
186
186
  let hasExplicitTestFiles = false;
@@ -9,14 +9,77 @@ import { fileURLToPath } from "node:url";
9
9
  const DEFAULT_TARGET = "mock";
10
10
  const DEFAULT_PLATFORM = "c64u";
11
11
  const DEFAULT_BUN_FILE_LIMIT = 4;
12
- const DEFAULT_BUN_BATCH_SIZE = 12;
12
+ // Keep default Bun shards small enough to avoid descriptor exhaustion on broad suites.
13
+ const DEFAULT_BUN_BATCH_SIZE = DEFAULT_BUN_FILE_LIMIT;
14
+ const DEFAULT_VICE_MOCK_TEST_FILES = [
15
+ "test/device.test.mjs",
16
+ "test/viceIntegration.test.mjs",
17
+ "test/viceModule.test.mjs",
18
+ "test/groupedToolsShims.test.mjs",
19
+ "test/toolsTypes.test.mjs",
20
+ "test/platformRegistry.test.mjs",
21
+ "test/meta/program.test.mjs",
22
+ "test/mcpServerIntegration.test.mjs",
23
+ "test/c64Client.test.mjs",
24
+ "test/vice/viceSmokeTest.ts",
25
+ ];
26
+ const DEFAULT_VICE_DEVICE_TEST_FILES = [
27
+ "test/device.test.mjs",
28
+ "test/vice/viceSmokeTest.ts",
29
+ ];
30
+ const BUN_ONLY_TEST_IMPORT_RE = /from\s+["']bun:test["']/;
31
+ const BUN_RUNTIME_REQUIRED_TEST_FILES = new Set([
32
+ "test/generateMcpInterface.test.mjs",
33
+ "test/scripts/start.test.mjs",
34
+ ]);
35
+ const ISOLATED_NODE_TEST_FILES = new Set([
36
+ "test/mcpServerIntegration.test.mjs",
37
+ "test/mcpServerPlatformInit.test.mjs",
38
+ "test/meta/background.test.mjs",
39
+ "test/pollIntegration.test.mjs",
40
+ "test/pollValidator.test.mjs",
41
+ "test/programRunnersModule.test.mjs",
42
+ "test/toolsCoverage.test.mjs",
43
+ ]);
13
44
  const ISOLATED_BUN_TEST_FILES = new Set([
14
45
  "test/audioRuntime.test.mjs",
46
+ "test/mcpServerIntegration.test.mjs",
47
+ "test/mcpServerPlatformInit.test.mjs",
15
48
  ]);
16
49
  const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
17
50
  const defaultEmbeddingsDir = path.join(repoRoot, "artifacts", "test-embeddings");
18
51
  const defaultTestFiles = listRepoTestFiles(path.join(repoRoot, "test"));
19
52
 
53
+ function findExecutableOnPath(binary: string, envPath: string | undefined = process.env.PATH): string | null {
54
+ const normalized = binary.trim();
55
+ if (!normalized) {
56
+ return null;
57
+ }
58
+ if (path.isAbsolute(normalized) || normalized.includes(path.sep)) {
59
+ try {
60
+ fs.accessSync(normalized, fs.constants.X_OK);
61
+ return normalized;
62
+ } catch {
63
+ return null;
64
+ }
65
+ }
66
+
67
+ for (const entry of (envPath ?? "").split(path.delimiter)) {
68
+ if (!entry) {
69
+ continue;
70
+ }
71
+ const candidate = path.join(entry, normalized);
72
+ try {
73
+ fs.accessSync(candidate, fs.constants.X_OK);
74
+ return candidate;
75
+ } catch {
76
+ // Continue searching PATH.
77
+ }
78
+ }
79
+
80
+ return null;
81
+ }
82
+
20
83
  function resolveNodeExecutable(): string {
21
84
  const candidates = [
22
85
  process.env.C64BRIDGE_TEST_NODE_BIN,
@@ -35,6 +98,31 @@ function resolveNodeExecutable(): string {
35
98
  return "node";
36
99
  }
37
100
 
101
+ function resolveBunExecutable(env: NodeJS.ProcessEnv = process.env): string | null {
102
+ if (typeof (globalThis as { Bun?: unknown }).Bun !== "undefined") {
103
+ return process.execPath;
104
+ }
105
+
106
+ const candidates = [
107
+ env.C64BRIDGE_TEST_BUN_BIN,
108
+ env.C64BRIDGE_BUN_BIN,
109
+ process.env.C64BRIDGE_TEST_BUN_BIN,
110
+ process.env.C64BRIDGE_BUN_BIN,
111
+ "bun",
112
+ ];
113
+
114
+ for (const candidate of candidates) {
115
+ if (candidate && typeof candidate === "string" && candidate.trim()) {
116
+ const resolved = findExecutableOnPath(candidate.trim(), env.PATH ?? process.env.PATH);
117
+ if (resolved) {
118
+ return resolved;
119
+ }
120
+ }
121
+ }
122
+
123
+ return null;
124
+ }
125
+
38
126
  export type RunTestsArgs = {
39
127
  target: string;
40
128
  platform: "c64u" | "vice";
@@ -43,6 +131,53 @@ export type RunTestsArgs = {
43
131
  passthrough: string[];
44
132
  };
45
133
 
134
+ export function buildMatrixEnv(
135
+ platform: "c64u" | "vice",
136
+ target: "mock" | "device",
137
+ explicitBaseUrl: string | null,
138
+ envSource: NodeJS.ProcessEnv = process.env,
139
+ ): Record<string, string> {
140
+ const env: Record<string, string> = { ...envSource } as Record<string, string>;
141
+ env.C64_MODE = platform;
142
+ env.C64_TEST_TARGET = target === "device" ? "real" : "mock";
143
+
144
+ if (platform === "vice") {
145
+ const useViceMock = target !== "device";
146
+ env.VICE_TEST_TARGET = useViceMock ? "mock" : "vice";
147
+ env.C64_TEST_ENABLE_VICE_MOCK = useViceMock ? "1" : "0";
148
+ if (!useViceMock) {
149
+ env.VICE_AVAILABLE = "1";
150
+ }
151
+ } else {
152
+ delete env.VICE_TEST_TARGET;
153
+ delete env.C64_TEST_ENABLE_VICE_MOCK;
154
+ }
155
+
156
+ if (!env.RAG_EMBEDDINGS_DIR) {
157
+ env.RAG_EMBEDDINGS_DIR = defaultEmbeddingsDir;
158
+ }
159
+ if (explicitBaseUrl) {
160
+ env.C64_TEST_BASE_URL = explicitBaseUrl;
161
+ }
162
+ if (platform === "c64u" && target === "device" && !env.C64_TEST_BASE_URL) {
163
+ env.C64_TEST_BASE_URL = resolveBaseUrlFromConfig(env) ?? "http://c64u";
164
+ }
165
+
166
+ return env;
167
+ }
168
+
169
+ export function resolveDefaultMatrixTestFiles(
170
+ platform: "c64u" | "vice",
171
+ target: "mock" | "device",
172
+ ): string[] {
173
+ if (platform !== "vice") {
174
+ return [...defaultTestFiles];
175
+ }
176
+ return target === "device"
177
+ ? [...DEFAULT_VICE_DEVICE_TEST_FILES]
178
+ : [...DEFAULT_VICE_MOCK_TEST_FILES];
179
+ }
180
+
46
181
  export function parseRunTestsArgs(args: string[]): RunTestsArgs {
47
182
  let target = DEFAULT_TARGET;
48
183
  let platform: "c64u" | "vice" = DEFAULT_PLATFORM;
@@ -106,6 +241,10 @@ export function shouldUseNodeFallback(runCoverage: boolean, passthrough: string[
106
241
  if (runCoverage) {
107
242
  return false;
108
243
  }
244
+ if (String(env.C64_MODE ?? "").trim().toLowerCase() === "vice"
245
+ && String(env.VICE_TEST_TARGET ?? "").trim().toLowerCase() === "vice") {
246
+ return true;
247
+ }
109
248
 
110
249
  const explicitFiles = passthrough.filter(looksLikeTestFileArg);
111
250
  if (passthrough.length === 0) {
@@ -139,11 +278,72 @@ export function buildBunTestBatches(passthrough: string[], env: NodeJS.ProcessEn
139
278
  ];
140
279
  }
141
280
 
281
+ export function splitPassthroughByRuntime(
282
+ passthrough: string[],
283
+ root: string = repoRoot,
284
+ ): { nodePassthrough: string[]; bunPassthrough: string[] } {
285
+ const explicitFiles = passthrough.filter(looksLikeTestFileArg);
286
+ if (explicitFiles.length === 0) {
287
+ return { nodePassthrough: [...passthrough], bunPassthrough: [] };
288
+ }
289
+
290
+ const sharedArgs = passthrough.filter((arg) => !looksLikeTestFileArg(arg));
291
+ const bunFiles = explicitFiles.filter((file) => isBunOnlyTestFile(path.join(root, file)));
292
+ const nodeFiles = explicitFiles.filter((file) => !bunFiles.includes(file));
293
+
294
+ return {
295
+ nodePassthrough: nodeFiles.length > 0 ? [...nodeFiles, ...sharedArgs] : [],
296
+ bunPassthrough: bunFiles.length > 0 ? [...bunFiles, ...sharedArgs] : [],
297
+ };
298
+ }
299
+
300
+ export function buildNodeFallbackBatches(
301
+ passthrough: string[],
302
+ root: string = repoRoot,
303
+ ): string[][] {
304
+ const explicitFiles = passthrough.filter(looksLikeTestFileArg);
305
+ if (explicitFiles.length === 0) {
306
+ return [passthrough];
307
+ }
308
+
309
+ const sharedArgs = passthrough.filter((arg) => !looksLikeTestFileArg(arg));
310
+ const isolatedFiles = explicitFiles.filter((file) => isNodeIsolatedTestFile(path.join(root, file)));
311
+ const sharedFiles = explicitFiles.filter((file) => !isolatedFiles.includes(file));
312
+ const batches: string[][] = [];
313
+
314
+ if (sharedFiles.length > 0) {
315
+ batches.push([...sharedFiles, ...sharedArgs]);
316
+ }
317
+ for (const file of isolatedFiles) {
318
+ batches.push([file, ...sharedArgs]);
319
+ }
320
+
321
+ return batches.length > 0 ? batches : [passthrough];
322
+ }
323
+
324
+ function isBunOnlyTestFile(filePath: string): boolean {
325
+ const relativePath = path.relative(repoRoot, filePath).split(path.sep).join("/");
326
+ if (BUN_RUNTIME_REQUIRED_TEST_FILES.has(relativePath)) {
327
+ return true;
328
+ }
329
+ try {
330
+ return BUN_ONLY_TEST_IMPORT_RE.test(fs.readFileSync(filePath, "utf8"));
331
+ } catch {
332
+ return false;
333
+ }
334
+ }
335
+
336
+ function isNodeIsolatedTestFile(filePath: string): boolean {
337
+ const relativePath = path.relative(repoRoot, filePath).split(path.sep).join("/");
338
+ return ISOLATED_NODE_TEST_FILES.has(relativePath);
339
+ }
340
+
142
341
  async function runNodeFallback(target: string, explicitBaseUrl: string | null, passthrough: string[], env: Record<string, string>, runCoverage: boolean): Promise<number> {
143
342
  console.warn("[run-tests] Using Node runner for this test set to avoid Bun memory growth on broad suites");
144
343
  if (runCoverage) {
145
344
  console.warn("[run-tests] Coverage reporting is unavailable in Node fallback mode");
146
345
  }
346
+ const { nodePassthrough, bunPassthrough } = splitPassthroughByRuntime(passthrough);
147
347
  const nodeExecutable = resolveNodeExecutable();
148
348
  const nodeScript = path.join(repoRoot, "scripts", "run-tests.mjs");
149
349
  const fallbackArgs = [] as string[];
@@ -153,23 +353,53 @@ async function runNodeFallback(target: string, explicitBaseUrl: string | null, p
153
353
  if (explicitBaseUrl) {
154
354
  fallbackArgs.push(`--base-url=${explicitBaseUrl}`);
155
355
  }
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
- });
356
+ if (nodePassthrough.length > 0 || bunPassthrough.length === 0) {
357
+ const nodeBatches = buildNodeFallbackBatches(nodePassthrough);
358
+ for (let index = 0; index < nodeBatches.length; index += 1) {
359
+ const batch = nodeBatches[index] ?? [];
360
+ if (nodeBatches.length > 1) {
361
+ console.warn(`[run-tests] Node fallback batch ${index + 1}/${nodeBatches.length} (${batch.filter(looksLikeTestFileArg).length} files)`);
362
+ }
363
+ const nodeExitCode = await new Promise<number>((resolve) => {
364
+ const childProcess = spawn(nodeExecutable, [nodeScript, ...fallbackArgs, ...batch], {
365
+ cwd: repoRoot,
366
+ env,
367
+ stdio: "inherit",
368
+ }) as unknown as {
369
+ on(event: "error", listener: (error: Error) => void): void;
370
+ on(event: "exit", listener: (code: number | null) => void): void;
371
+ };
372
+ childProcess.on("error", (error) => {
373
+ console.error("[run-tests] Failed to launch Node fallback:", error);
374
+ resolve(1);
375
+ });
376
+ childProcess.on("exit", (code) => {
377
+ resolve(typeof code === "number" ? code : 1);
378
+ });
379
+ });
380
+
381
+ if (nodeExitCode !== 0) {
382
+ return nodeExitCode;
383
+ }
384
+ }
385
+ }
386
+
387
+ if (bunPassthrough.length === 0) {
388
+ return 0;
389
+ }
390
+
391
+ const bunExecutable = resolveBunExecutable(env);
392
+ if (!bunExecutable) {
393
+ console.error("[run-tests] Bun-only tests were selected but no Bun executable is available");
394
+ return 1;
395
+ }
396
+
397
+ console.warn("[run-tests] Running Bun-only tests under Bun to preserve runtime coverage");
398
+ const bunBatches = buildBunTestBatches(bunPassthrough, env);
399
+ return await runBunBatches(env, bunBatches, {
400
+ coverage: false,
401
+ bunExecutable,
402
+ labelPrefix: "bun-only-suite",
173
403
  });
174
404
  }
175
405
 
@@ -200,24 +430,11 @@ async function main(): Promise<number> {
200
430
  fs.mkdirSync(defaultEmbeddingsDir, { recursive: true });
201
431
  }
202
432
 
203
- const env: Record<string, string> = { ...process.env } as Record<string, string>;
204
433
  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
- }
434
+ const env = buildMatrixEnv(platform, normalizedTarget, explicitBaseUrl);
435
+ const effectivePassthrough = passthrough.length > 0
436
+ ? passthrough
437
+ : resolveDefaultMatrixTestFiles(platform, normalizedTarget);
221
438
 
222
439
  printMatrixHeading({
223
440
  platform,
@@ -230,24 +447,20 @@ async function main(): Promise<number> {
230
447
  const bunRuntime = (globalThis as { Bun?: unknown }).Bun;
231
448
  if (!bunRuntime) {
232
449
  console.warn("[run-tests] Bun runtime not detected; falling back to Node runner");
233
- return await runNodeFallback(target, explicitBaseUrl, passthrough, env, runCoverage);
450
+ return await runNodeFallback(target, explicitBaseUrl, effectivePassthrough, env, runCoverage);
234
451
  }
235
452
 
236
- if (!runCoverage && passthrough.length === 0) {
237
- return await runBunBatches(env, buildBunTestBatches([], env), {
238
- coverage: false,
239
- labelPrefix: "default-suite",
240
- });
453
+ if (shouldUseNodeFallback(runCoverage, effectivePassthrough, env)) {
454
+ return await runNodeFallback(target, explicitBaseUrl, effectivePassthrough, env, runCoverage);
241
455
  }
242
456
 
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);
457
+ if (!runCoverage) {
458
+ const batches = buildBunTestBatches(effectivePassthrough, env);
459
+ return await runBunBatches(env, batches, {
460
+ bunExecutable: process.execPath,
461
+ coverage: false,
462
+ labelPrefix: passthrough.length === 0 ? "default-suite" : "sharded-suite",
463
+ });
251
464
  }
252
465
 
253
466
  const bunArgs = [
@@ -259,7 +472,7 @@ async function main(): Promise<number> {
259
472
  "--coverage-reporter=text",
260
473
  ]
261
474
  : []),
262
- ...(passthrough.length > 0 ? passthrough : defaultTestFiles),
475
+ ...effectivePassthrough.map((arg) => normalizeBunBatchArg(arg)),
263
476
  ];
264
477
  return await runExternalCommand(process.execPath, bunArgs, env);
265
478
  }
@@ -345,7 +558,7 @@ function chunkFiles(files: string[], chunkSize: number): string[][] {
345
558
  async function runBunBatches(
346
559
  env: Record<string, string>,
347
560
  batches: string[][],
348
- options: { coverage: boolean; labelPrefix: string },
561
+ options: { coverage: boolean; labelPrefix: string; bunExecutable: string },
349
562
  ): Promise<number> {
350
563
  const coverageArgs = options.coverage
351
564
  ? ["--coverage", "--coverage-reporter=lcov", "--coverage-reporter=text"]
@@ -354,7 +567,8 @@ async function runBunBatches(
354
567
  for (let index = 0; index < batches.length; index += 1) {
355
568
  const batch = batches[index] ?? [];
356
569
  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);
570
+ const bunArgs = batch.map((arg) => normalizeBunBatchArg(arg));
571
+ const exitCode = await runExternalCommand(options.bunExecutable, ["test", ...coverageArgs, ...bunArgs], env);
358
572
  if (exitCode !== 0) {
359
573
  return exitCode;
360
574
  }
@@ -363,6 +577,13 @@ async function runBunBatches(
363
577
  return 0;
364
578
  }
365
579
 
580
+ export function normalizeBunBatchArg(arg: string): string {
581
+ if (looksLikeTestFileArg(arg) && !arg.startsWith("./") && !arg.startsWith("../") && !path.isAbsolute(arg)) {
582
+ return `./${arg}`;
583
+ }
584
+ return arg;
585
+ }
586
+
366
587
  function listRepoTestFiles(testRoot: string): string[] {
367
588
  const files: string[] = [];
368
589
 
@@ -173,10 +173,14 @@ function collectGroupedOperations(schema?: JsonSchema): readonly GroupedOperatio
173
173
  const discriminator = (schema as JsonSchema & { discriminator?: { propertyName?: string } }).discriminator;
174
174
  const variants = (schema as JsonSchema & { oneOf?: readonly JsonSchema[] }).oneOf;
175
175
 
176
- if (!discriminator || discriminator.propertyName !== "op" || !Array.isArray(variants)) {
177
- return [];
176
+ if (discriminator && discriminator.propertyName === "op" && Array.isArray(variants)) {
177
+ return collectFromVariantList(variants);
178
178
  }
179
179
 
180
+ return collectFromFlattenedSchema(schema);
181
+ }
182
+
183
+ function collectFromVariantList(variants: readonly JsonSchema[]): readonly GroupedOperation[] {
180
184
  const operations: GroupedOperation[] = [];
181
185
 
182
186
  for (const variant of variants) {
@@ -227,6 +231,54 @@ function collectGroupedOperations(schema?: JsonSchema): readonly GroupedOperatio
227
231
  return operations;
228
232
  }
229
233
 
234
+ function collectFromFlattenedSchema(schema: JsonSchema): readonly GroupedOperation[] {
235
+ const properties = (schema.properties ?? {}) as Record<string, JsonSchema | undefined>;
236
+ const opSchema = properties.op;
237
+ if (!opSchema || !isObject(opSchema)) {
238
+ return [];
239
+ }
240
+
241
+ const enumValues = (opSchema as JsonSchema).enum;
242
+ if (!Array.isArray(enumValues) || enumValues.length === 0) {
243
+ return [];
244
+ }
245
+
246
+ const opNames = enumValues.filter((value): value is string => typeof value === "string" && value.length > 0);
247
+ if (opNames.length === 0) {
248
+ return [];
249
+ }
250
+
251
+ const descriptionMap = parseOperationDescriptions(getString(schema.description));
252
+ const hasVerificationProperty = Object.keys(properties).some((name) =>
253
+ name !== "op" && name.toLowerCase().startsWith("verify"),
254
+ );
255
+ const notes = hasVerificationProperty ? ["supports verify"] : [];
256
+
257
+ return opNames.map((opName) => ({
258
+ op: opName,
259
+ description: descriptionMap.get(opName) ?? `Operation ${opName}`,
260
+ required: [] as readonly string[],
261
+ notes,
262
+ }));
263
+ }
264
+
265
+ function parseOperationDescriptions(description: string): Map<string, string> {
266
+ const result = new Map<string, string>();
267
+ if (!description) {
268
+ return result;
269
+ }
270
+
271
+ const lines = description.split(/\r?\n/);
272
+ for (const line of lines) {
273
+ const match = line.match(/^\s*-\s+([A-Za-z0-9_]+):\s*(.+)$/);
274
+ if (match) {
275
+ result.set(match[1], match[2].trim());
276
+ }
277
+ }
278
+
279
+ return result;
280
+ }
281
+
230
282
  export function renderToolsSection(modules: readonly ToolModuleDescriptor[] = describeToolModules()): string[] {
231
283
  const lines: string[] = ["### Tools", ""];
232
284
  const groupedTools: GroupedToolInfo[] = [];
package/server.json CHANGED
@@ -8,12 +8,12 @@
8
8
  "source": "github"
9
9
  },
10
10
  "websiteUrl": "https://github.com/chrisgleissner/c64bridge",
11
- "version": "0.8.1",
11
+ "version": "0.9.0",
12
12
  "packages": [
13
13
  {
14
14
  "registryType": "npm",
15
15
  "identifier": "c64bridge",
16
- "version": "0.8.1",
16
+ "version": "0.9.0",
17
17
  "transport": {
18
18
  "type": "stdio"
19
19
  },