@henols/c64-re-tools 1.0.0 → 2.0.0-rc.2

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 (138) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +190 -37
  3. package/distribution/package.ts +36 -0
  4. package/distribution/plugin.ts +120 -0
  5. package/package.json +34 -40
  6. package/skills/c64-assembler/SKILL.md +73 -0
  7. package/skills/c64-assembler/scripts/assemble.ts +103 -0
  8. package/skills/c64-basic/SKILL.md +67 -0
  9. package/skills/c64-basic/scripts/basic.ts +46 -0
  10. package/skills/c64-disk/SKILL.md +79 -0
  11. package/skills/c64-disk/scripts/disk.ts +65 -0
  12. package/skills/c64-emulator/SKILL.md +124 -0
  13. package/skills/c64-knowledge/SKILL.md +102 -0
  14. package/skills/c64-knowledge/scripts/knowledge.ts +257 -0
  15. package/skills/c64-memory-map/SKILL.md +76 -0
  16. package/skills/c64-memory-map/scripts/memmap.ts +81 -0
  17. package/skills/c64-memory-map/scripts/platform.ts +424 -0
  18. package/skills/c64-provenance/SKILL.md +76 -0
  19. package/skills/c64-provenance/scripts/compare.ts +92 -0
  20. package/skills/c64-provenance/scripts/provenance.ts +83 -0
  21. package/skills/c64-reverse-engineering/SKILL.md +90 -0
  22. package/skills/c64-static-analysis/SKILL.md +96 -0
  23. package/skills/c64-static-analysis/scripts/analyze.ts +175 -0
  24. package/skills/c64-static-analysis/scripts/findings.ts +73 -0
  25. package/skills/c64-testing/SKILL.md +79 -0
  26. package/skills/c64-testing/references/scenario-format.md +76 -0
  27. package/skills/c64-testing/scripts/checklist.ts +51 -0
  28. package/skills/c64-testing/scripts/scenario.ts +473 -0
  29. package/skills/c64-testing/scripts/test.ts +55 -0
  30. package/skills/c64-unpacker/SKILL.md +82 -0
  31. package/skills/c64-unpacker/scripts/evidence.ts +135 -0
  32. package/skills/c64-unpacker/scripts/unpack.ts +216 -0
  33. package/src/c64.ts +281 -0
  34. package/src/cli/ap-sdk.ts +44 -0
  35. package/src/cli/cleanup.ts +194 -0
  36. package/src/cli/main.ts +122 -0
  37. package/src/cli/status.ts +34 -0
  38. package/src/host/main.ts +121 -0
  39. package/src/host/server.ts +409 -0
  40. package/src/host/tools/c1541.ts +255 -0
  41. package/src/host/tools/index.ts +31 -0
  42. package/src/host/tools/petcat.ts +70 -0
  43. package/src/host/tools/status.ts +30 -0
  44. package/src/host/tools/version.ts +41 -0
  45. package/src/host/vice/adapter.ts +761 -0
  46. package/src/host/vice/binary-monitor.ts +389 -0
  47. package/src/host/vice/chips.ts +108 -0
  48. package/src/host/vice/connect.ts +34 -0
  49. package/src/host/vice/process.ts +402 -0
  50. package/src/host/vice/screen.ts +183 -0
  51. package/src/host/vice/session.ts +1316 -0
  52. package/src/host/vice/text-monitor.ts +223 -0
  53. package/src/host-client/connect.ts +241 -0
  54. package/src/host-client/tools.ts +89 -0
  55. package/src/host-client/transfer.ts +100 -0
  56. package/src/host-client/vice-session.ts +254 -0
  57. package/src/knowledge/database.ts +132 -0
  58. package/src/knowledge/history.ts +128 -0
  59. package/src/knowledge/import.ts +344 -0
  60. package/src/knowledge/read.ts +191 -0
  61. package/src/knowledge/schema.ts +140 -0
  62. package/src/knowledge/write.ts +348 -0
  63. package/src/mcp/main.ts +109 -0
  64. package/src/mcp/server.ts +307 -0
  65. package/src/mcp/tools/debug.ts +237 -0
  66. package/src/mcp/tools/execution.ts +116 -0
  67. package/src/mcp/tools/input.ts +69 -0
  68. package/src/mcp/tools/machine.ts +97 -0
  69. package/src/mcp/tools/media.ts +106 -0
  70. package/src/mcp/tools/memory.ts +203 -0
  71. package/src/mcp/tools/video.ts +260 -0
  72. package/src/native/acme.ts +116 -0
  73. package/src/native/discover.ts +141 -0
  74. package/src/native/dxa.ts +166 -0
  75. package/src/native/ghidra/analyze.ts +86 -0
  76. package/src/native/ghidra/index.ts +157 -0
  77. package/src/native/ghidra/language/c64rt_6510.cspec +34 -0
  78. package/src/native/ghidra/language/c64rt_6510.ldefs +15 -0
  79. package/src/native/ghidra/language/c64rt_6510.pspec +15 -0
  80. package/src/native/ghidra/language/c64rt_6510.slaspec +596 -0
  81. package/src/native/ghidra/scripts/C64Export.java +242 -0
  82. package/src/native/ghidra/scripts/C64Prepare.java +60 -0
  83. package/src/native/local.ts +34 -0
  84. package/src/native/processes.ts +350 -0
  85. package/src/native/run.ts +172 -0
  86. package/src/native/staging.ts +83 -0
  87. package/src/native/status.ts +101 -0
  88. package/src/native/types.ts +228 -0
  89. package/src/native/watchdog.ts +78 -0
  90. package/src/project.ts +39 -0
  91. package/src/protocol/framing.ts +132 -0
  92. package/src/protocol/messages.ts +252 -0
  93. package/src/protocol/tools.ts +247 -0
  94. package/src/protocol/vice.ts +1235 -0
  95. package/src/script.ts +59 -0
  96. package/src/time.ts +15 -0
  97. package/src/trace.ts +162 -0
  98. package/THIRD-PARTY-NOTICES.md +0 -26
  99. package/bin/cli.mjs +0 -283
  100. package/skills/acme-build/SKILL.md +0 -260
  101. package/skills/acme-build/scripts/acme.mjs +0 -341
  102. package/skills/acme-build/template.a +0 -53
  103. package/skills/c64-disk-access/SKILL.md +0 -159
  104. package/skills/c64-disk-access/scripts/c1541.mjs +0 -569
  105. package/skills/c64-memory-mapping/SKILL.md +0 -631
  106. package/skills/c64-memory-mapping/memmap.json +0 -8800
  107. package/skills/c64-memory-mapping/scripts/driver.mjs +0 -553
  108. package/skills/c64-petcat/SKILL.md +0 -87
  109. package/skills/c64-petcat/scripts/petcat.mjs +0 -221
  110. package/skills/c64-program-recon/SKILL.md +0 -706
  111. package/skills/c64-program-recon/references/control-flow.md +0 -180
  112. package/skills/c64-program-recon/references/graphics.md +0 -79
  113. package/skills/c64-program-recon/references/observation-hazards.md +0 -143
  114. package/skills/c64-program-recon/references/reconstruction.md +0 -133
  115. package/skills/c64-program-recon/references/sound-and-input.md +0 -74
  116. package/skills/c64-program-recon/references/tool-selection.md +0 -78
  117. package/skills/c64-program-recon/scripts/derive.mjs +0 -364
  118. package/skills/c64-program-recon/scripts/packer-finding.mjs +0 -709
  119. package/skills/c64-program-recon/templates/memory-map.template.md +0 -101
  120. package/skills/c64-provenance-diff/SKILL.md +0 -292
  121. package/skills/c64-provenance-diff/scripts/diff-images.mjs +0 -984
  122. package/skills/c64-provenance-diff/scripts/recovery-schema.mjs +0 -395
  123. package/skills/c64-ram-capture/RELEASES.json.example +0 -17
  124. package/skills/c64-ram-capture/SKILL.md +0 -474
  125. package/skills/c64-ram-capture/scripts/compare-cross-binary.mjs +0 -525
  126. package/skills/c64-ram-capture/scripts/compare.mjs +0 -258
  127. package/skills/c64-ram-capture/scripts/derive-transients.mjs +0 -575
  128. package/skills/c64-ram-capture/scripts/dump-artifacts.mjs +0 -317
  129. package/skills/c64-ram-capture/scripts/mcp-module.mjs +0 -174
  130. package/skills/c64-ram-capture/scripts/project-paths.mjs +0 -81
  131. package/skills/c64-ram-capture/scripts/releases.mjs +0 -109
  132. package/skills/c64-ram-capture/scripts/vsf-slice.mjs +0 -147
  133. package/skills/c64-ram-capture/scripts/watch-loads.mjs +0 -581
  134. package/skills/c64-ram-capture/templates/capture-record.template.md +0 -99
  135. package/skills/c64-ram-capture/transients/README.md +0 -137
  136. package/skills/routine-queue-walker/SKILL.md +0 -394
  137. package/skills/routine-queue-walker/scripts/completeness-report.mjs +0 -465
  138. package/skills/vice-wedge-triage/SKILL.md +0 -269
package/src/c64.ts ADDED
@@ -0,0 +1,281 @@
1
+ const ADDRESS_PATTERN = /^\$([0-9a-fA-F]{4})$/;
2
+
3
+ /** Returns the value when it is a C64 address (0-$ffff). Throws RangeError otherwise. */
4
+ function checkAddress(value: number): number {
5
+ if (!Number.isInteger(value) || value < 0 || value > 0xffff) {
6
+ throw new RangeError(`C64 address out of range: ${value}`);
7
+ }
8
+ return value;
9
+ }
10
+
11
+ export function parseC64Address(value: string): number {
12
+ const match = ADDRESS_PATTERN.exec(value);
13
+ if (match === null) {
14
+ throw new TypeError(`Invalid C64 address: ${value}`);
15
+ }
16
+ return checkAddress(Number.parseInt(match[1]!, 16));
17
+ }
18
+
19
+ export function formatC64Address(value: number): string {
20
+ return `$${checkAddress(value).toString(16).padStart(4, "0")}`;
21
+ }
22
+
23
+ /** A byte as $xx. Throws RangeError outside 0-255. */
24
+ export function formatC64Byte(value: number): string {
25
+ if (!Number.isInteger(value) || value < 0 || value > 0xff) throw new RangeError(`C64 byte out of range: ${value}`);
26
+ return `$${value.toString(16).padStart(2, "0")}`;
27
+ }
28
+
29
+ /**
30
+ * The memory a PRG loads into: the 2-byte load address, then the body.
31
+ * Throws RangeError for a file shorter than 3 bytes or one that runs past $ffff.
32
+ */
33
+ export function prgRange(prg: Uint8Array): { start: number; end: number; body: Uint8Array } {
34
+ if (prg.length < 3) throw new RangeError("A PRG has a 2-byte load address and at least one byte.");
35
+ const start = prg[0]! | (prg[1]! << 8);
36
+ if (start + prg.length - 2 > 0x10000) throw new RangeError("The PRG runs past $ffff.");
37
+ return { start, end: start + prg.length - 3, body: prg.subarray(2) };
38
+ }
39
+
40
+ /** How 6502 code refers to an address. */
41
+ export const REFERENCE_KINDS = ["call", "jump", "read", "write", "reference"] as const;
42
+ export type ReferenceKind = (typeof REFERENCE_KINDS)[number];
43
+
44
+ /** A symbol or label name: assembler label syntax, at most 64 characters; a leading dot marks a local label. */
45
+ export const SYMBOL_NAME = /^\.?[A-Za-z_][A-Za-z0-9_]{0,63}$/;
46
+
47
+ /** The VIC-II's 16 KiB bank base, from CIA 2 port A bits 0-1 (inverted); undriven lines read high. */
48
+ export function vicBankBase(cia2PortA: number, cia2DdrA = 0xff): number {
49
+ const lines = (cia2PortA | ~cia2DdrA) & 0x03;
50
+ return (3 - lines) * 0x4000;
51
+ }
52
+
53
+ /** The offsets inside the VIC-II bank that $d018 selects. */
54
+ export function d018Offsets(d018: number): { screen: number; characters: number; bitmap: number } {
55
+ return { screen: ((d018 >> 4) & 0x0f) * 0x400, characters: ((d018 >> 1) & 0x07) * 0x800, bitmap: (d018 & 0x08) !== 0 ? 0x2000 : 0 };
56
+ }
57
+
58
+ /**
59
+ * Converts text to the PETSCII bytes the C64 keyboard types after a reset
60
+ * (upper case/graphics mode). Letters of either case type the unshifted key,
61
+ * which shows as an upper-case letter. "\n" and "\r\n" type RETURN.
62
+ * Throws RangeError naming the first character that has no key.
63
+ */
64
+ export function textToPetscii(text: string): Uint8Array {
65
+ const bytes: number[] = [];
66
+ const normalized = text.replace(/\r\n/g, "\n");
67
+ for (const character of normalized) {
68
+ const code = character.codePointAt(0)!;
69
+ if (character === "\n" || character === "\r") bytes.push(0x0d);
70
+ else if (code >= 0x61 && code <= 0x7a) bytes.push(code - 0x20);
71
+ else if (code >= 0x20 && code <= 0x5b) bytes.push(code);
72
+ else if (character === "]") bytes.push(0x5d);
73
+ else if (character === "£") bytes.push(0x5c);
74
+ else if (character === "↑" || character === "^") bytes.push(0x5e);
75
+ else if (character === "←") bytes.push(0x5f);
76
+ else throw new RangeError(`There is no C64 key for ${JSON.stringify(character)}`);
77
+ }
78
+ return Uint8Array.from(bytes);
79
+ }
80
+
81
+ /**
82
+ * Shows a CBM file or disk name (PETSCII bytes) as text, the way a directory
83
+ * listing shows it in upper case/graphics mode. Bytes $20-$5f are their
84
+ * characters ($5c is £, $5e is ↑, $5f is ←); every other byte is written as
85
+ * {$xx}, so the text converts back to the same bytes.
86
+ */
87
+ export function petsciiNameToText(bytes: Uint8Array): string {
88
+ let text = "";
89
+ for (const byte of bytes) {
90
+ if (byte === 0x5c) text += "£";
91
+ else if (byte === 0x5e) text += "↑";
92
+ else if (byte === 0x5f) text += "←";
93
+ else if (byte >= 0x20 && byte <= 0x5d) text += String.fromCharCode(byte);
94
+ else text += `{$${byte.toString(16).padStart(2, "0")}}`;
95
+ }
96
+ return text;
97
+ }
98
+
99
+ /**
100
+ * The reverse of petsciiNameToText. Letters of either case are the unshifted
101
+ * letters ($41-$5a). Throws RangeError for text that is no name.
102
+ */
103
+ export function textToPetsciiName(text: string): Uint8Array {
104
+ const bytes: number[] = [];
105
+ const escape = /^\{\$([0-9a-fA-F]{2})\}/;
106
+ for (let index = 0; index < text.length; ) {
107
+ const escaped = escape.exec(text.slice(index));
108
+ if (escaped !== null) {
109
+ bytes.push(Number.parseInt(escaped[1]!, 16));
110
+ index += escaped[0].length;
111
+ continue;
112
+ }
113
+ const character = text[index]!;
114
+ const code = character.charCodeAt(0);
115
+ if (code >= 0x61 && code <= 0x7a) bytes.push(code - 0x20);
116
+ else if (character === "£") bytes.push(0x5c);
117
+ else if (character === "↑") bytes.push(0x5e);
118
+ else if (character === "←") bytes.push(0x5f);
119
+ else if (code >= 0x20 && code <= 0x5d) bytes.push(code);
120
+ else throw new RangeError(`${JSON.stringify(character)} is not a name character. Write other bytes as {$xx}.`);
121
+ index++;
122
+ }
123
+ return Uint8Array.from(bytes);
124
+ }
125
+
126
+ // C64 BASIC V2 program text, as LOAD puts it in memory.
127
+
128
+ /** BASIC refuses higher line numbers when a line is entered. */
129
+ const MAX_LINE_NUMBER = 63999;
130
+
131
+ const TOKEN = { data: 0x83, rem: 0x8f, sys: 0x9e, usr: 0xb7, plus: 0xaa, minus: 0xab, times: 0xac, divide: 0xad } as const;
132
+ const QUOTE = 0x22;
133
+ const COLON = 0x3a;
134
+
135
+ export interface TokenizedLine {
136
+ number: number;
137
+ /** The line's bytes after the line number, without the terminating zero. */
138
+ tokens: Uint8Array;
139
+ }
140
+
141
+ /** A machine-code handoff in BASIC program text: SYS with a constant address, or a SYS or USR whose target needs run-time values. */
142
+ export type BasicHandoff = { kind: "sys"; line: number; address: number } | { kind: "sys" | "usr"; line: number; computed: true };
143
+
144
+ export type TokenizedProgram = { loadAddress: number; basicEnd: number; lines: TokenizedLine[] } | { reason: string };
145
+
146
+ /**
147
+ * Splits a PRG into BASIC lines the way BASIC relinks a loaded program: each
148
+ * line ends at a zero byte, and a link whose high byte is zero ends the
149
+ * program. The stored links are not trusted.
150
+ */
151
+ export function parseTokenized(program: Uint8Array): TokenizedProgram {
152
+ if (program.length < 4) return { reason: "The file is too short for a BASIC program." };
153
+ const loadAddress = program[0]! | (program[1]! << 8);
154
+ const lines: TokenizedLine[] = [];
155
+ let at = 2;
156
+ // File offset k (from 2 on) loads at loadAddress + k - 2.
157
+ for (;;) {
158
+ // A file that ends right after a line leaves the end marker to the zeros after it in memory;
159
+ // basicEnd is the address after that marker, as when the file holds it.
160
+ if (at === program.length && lines.length > 0) return { loadAddress, basicEnd: loadAddress + at, lines };
161
+ if (at + 1 >= program.length) return { reason: "The BASIC program has no end marker." };
162
+ if (program[at + 1] === 0) {
163
+ // An end marker before any line, with more bytes after it, is how machine code at a non-BASIC address often starts.
164
+ if (lines.length === 0 && at + 2 < program.length) return { reason: "The file holds no BASIC line before other bytes, so it is no BASIC program." };
165
+ return { loadAddress, basicEnd: loadAddress + at, lines };
166
+ }
167
+ if (at + 4 > program.length) return { reason: "The last BASIC line is cut off." };
168
+ const number = program[at + 2]! | (program[at + 3]! << 8);
169
+ if (number > MAX_LINE_NUMBER) return { reason: `Line number ${number} is above ${MAX_LINE_NUMBER}, so the bytes are no BASIC program.` };
170
+ const end = program.indexOf(0, at + 4);
171
+ if (end < 0) return { reason: `BASIC line ${number} has no end.` };
172
+ lines.push({ number, tokens: program.subarray(at + 4, end) });
173
+ at = end + 1;
174
+ }
175
+ }
176
+
177
+ /**
178
+ * Evaluates a SYS argument that holds only numbers, + - * / and brackets.
179
+ * Returns undefined for anything that needs run-time values.
180
+ */
181
+ export function constantExpression(bytes: Uint8Array): number | undefined {
182
+ const tokens = [...bytes].filter((byte) => byte !== 0x20);
183
+ let at = 0;
184
+ const peek = () => tokens[at];
185
+ const isDigit = () => peek() !== undefined && peek()! >= 0x30 && peek()! <= 0x39;
186
+ // A number as BASIC reads it: digits and a point, then an optional E with an
187
+ // optional sign (token or character) and exponent digits, so 2E3 is 2000.
188
+ const number = (): number | undefined => {
189
+ let text = "";
190
+ while (isDigit() || peek() === 0x2e) text += String.fromCharCode(tokens[at++]!);
191
+ if (text === "" || text === ".") return undefined;
192
+ if (peek() === 0x45) {
193
+ at++;
194
+ let exponent = "";
195
+ if (peek() === TOKEN.minus || peek() === 0x2d) {
196
+ exponent = "-";
197
+ at++;
198
+ } else if (peek() === TOKEN.plus || peek() === 0x2b) at++;
199
+ let digits = "";
200
+ while (isDigit()) digits += String.fromCharCode(tokens[at++]!);
201
+ text += `e${exponent}${digits === "" ? "0" : digits}`;
202
+ }
203
+ return Number(text);
204
+ };
205
+ const primary = (): number | undefined => {
206
+ if (peek() === TOKEN.minus || peek() === TOKEN.plus) {
207
+ const negative = tokens[at++] === TOKEN.minus;
208
+ const value = primary();
209
+ return value === undefined ? undefined : negative ? -value : value;
210
+ }
211
+ if (peek() === 0x28) {
212
+ at++;
213
+ const value = sum();
214
+ if (value === undefined || tokens[at++] !== 0x29) return undefined;
215
+ return value;
216
+ }
217
+ return number();
218
+ };
219
+ const product = (): number | undefined => {
220
+ let value = primary();
221
+ while (value !== undefined && (peek() === TOKEN.times || peek() === TOKEN.divide)) {
222
+ const operator = tokens[at++];
223
+ const right = primary();
224
+ if (right === undefined) return undefined;
225
+ value = operator === TOKEN.times ? value * right : value / right;
226
+ }
227
+ return value;
228
+ };
229
+ const sum = (): number | undefined => {
230
+ let value = product();
231
+ while (value !== undefined && (peek() === TOKEN.plus || peek() === TOKEN.minus)) {
232
+ const operator = tokens[at++];
233
+ const right = product();
234
+ if (right === undefined) return undefined;
235
+ value = operator === TOKEN.plus ? value + right : value - right;
236
+ }
237
+ return value;
238
+ };
239
+ const value = sum();
240
+ if (value === undefined || !Number.isFinite(value)) return undefined;
241
+ // BASIC's evaluator ends the expression at the first byte that is no operator,
242
+ // so "SYS2073 TCS-CRUNCH!" jumps to 2073. An operator
243
+ // this evaluator does not handle (^, AND, OR, a comparison) needs run-time values.
244
+ if (at < tokens.length && BASIC_OPERATORS.has(tokens[at]!)) return undefined;
245
+ return value;
246
+ }
247
+
248
+ /** BASIC V2 operator tokens: + - * / ^ AND OR > = <. */
249
+ const BASIC_OPERATORS = new Set([0xaa, 0xab, 0xac, 0xad, 0xae, 0xaf, 0xb0, 0xb1, 0xb2, 0xb3]);
250
+
251
+ /** SYS and USR in program text: not in strings, REM or DATA. */
252
+ export function findHandoffs(lines: TokenizedLine[]): BasicHandoff[] {
253
+ const handoffs: BasicHandoff[] = [];
254
+ for (const { number, tokens } of lines) {
255
+ let quoted = false;
256
+ let inData = false;
257
+ for (let at = 0; at < tokens.length; at++) {
258
+ const byte = tokens[at]!;
259
+ if (byte === QUOTE) quoted = !quoted;
260
+ if (quoted) continue;
261
+ if (inData) {
262
+ if (byte === COLON) inData = false;
263
+ continue;
264
+ }
265
+ if (byte === TOKEN.rem) break;
266
+ if (byte === TOKEN.data) inData = true;
267
+ else if (byte === TOKEN.usr) handoffs.push({ kind: "usr", line: number, computed: true });
268
+ else if (byte === TOKEN.sys) {
269
+ // The argument ends at the statement end or at a comma (extra SYS parameters).
270
+ let end = at + 1;
271
+ while (end < tokens.length && tokens[end] !== COLON && tokens[end] !== 0x2c) end++;
272
+ const value = constantExpression(tokens.subarray(at + 1, end));
273
+ if (value === undefined) handoffs.push({ kind: "sys", line: number, computed: true });
274
+ // BASIC drops the fraction; outside 0-65535 SYS stops with ILLEGAL QUANTITY and jumps nowhere.
275
+ else if (Math.trunc(value) >= 0 && Math.trunc(value) <= 0xffff) handoffs.push({ kind: "sys", line: number, address: Math.trunc(value) });
276
+ at = end - 1;
277
+ }
278
+ }
279
+ }
280
+ return handoffs;
281
+ }
@@ -0,0 +1,44 @@
1
+ // Runs the AP SDK CLI of this package's own dependency as a bounded native
2
+ // run: its own process group, a time limit, and the whole group stopped when
3
+ // the limit ends or the CLI stops.
4
+
5
+ import { dirname, join } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+
8
+ import { runToolOrFail, type ToolContext } from "../native/run.ts";
9
+ import { WireFailure } from "../protocol/messages.ts";
10
+
11
+ /** How long one AP SDK install, update or uninstall may take. */
12
+ export const AP_SDK_TIMEOUT_MS = 120_000;
13
+
14
+ const PLUGIN = fileURLToPath(new URL("../../distribution/plugin.ts", import.meta.url));
15
+
16
+ export interface ApSdkRun {
17
+ code: number;
18
+ stdout: string;
19
+ stderr: string;
20
+ }
21
+
22
+ /**
23
+ * Runs `ap-sdk <command> <plugin or id> <flags>`. Returns its exit status and
24
+ * what it printed. Throws WireFailure(operation-failed) with the name AP SDK
25
+ * when it does not start, stops on a signal, does not finish within the time
26
+ * limit, or the run is cancelled.
27
+ */
28
+ export async function runApSdk(
29
+ command: "install" | "uninstall",
30
+ flags: readonly string[],
31
+ context: ToolContext,
32
+ options: { cli?: string; timeoutMs?: number; env?: NodeJS.ProcessEnv } = {},
33
+ ): Promise<ApSdkRun> {
34
+ const cli = options.cli ?? join(dirname(fileURLToPath(import.meta.resolve("@jalco/ap-sdk"))), "cli.js");
35
+ // execArgv keeps the TypeScript loader (tsx) for the plugin module under node_modules.
36
+ const argv = [process.execPath, ...process.execArgv, cli, command, command === "uninstall" ? "c64-re-tools" : PLUGIN, ...flags];
37
+ const run = await runToolOrFail("AP SDK", "The AP SDK run", { argv, cwd: process.cwd(), timeoutMs: options.timeoutMs ?? AP_SDK_TIMEOUT_MS }, {
38
+ ...context,
39
+ env: options.env ?? context.env,
40
+ });
41
+ // runToolOrFail refuses a run that did not start or that a signal stopped, so an exit status is here.
42
+ if (run.code === null) throw new WireFailure("operation-failed", "AP SDK ended without an exit status.");
43
+ return { code: run.code, stdout: run.stdout, stderr: run.stderr };
44
+ }
@@ -0,0 +1,194 @@
1
+ // Tidying that AP SDK leaves to its caller:
2
+ // - A project install must not change the home directory. AP SDK writes the
3
+ // Windsurf MCP declaration to ~/.codeium/windsurf/mcp_config.json even in
4
+ // project scope, and records it in the project manifest, so a project
5
+ // uninstall would also remove a declaration that a global install made.
6
+ // - AP SDK's uninstall deletes the installed files but leaves their
7
+ // directories, and leaves each MCP configuration file with an empty server
8
+ // list. Only empty directories and files with nothing else in them go.
9
+
10
+ import { existsSync, readFileSync, rmdirSync, unlinkSync, writeFileSync } from "node:fs";
11
+ import { homedir } from "node:os";
12
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
13
+
14
+ const PLUGIN_ID = "c64-re-tools";
15
+
16
+ export type Scope = "project" | "global";
17
+
18
+ export interface InstallItem {
19
+ harness: string;
20
+ kind: string;
21
+ name: string;
22
+ files: string[];
23
+ detail?: { mergeKey?: string; names?: string[] };
24
+ }
25
+
26
+ interface Manifest {
27
+ version: number;
28
+ plugins: Record<string, { items: InstallItem[] } & Record<string, unknown>>;
29
+ }
30
+
31
+ /** The directory that the manifest and relative item paths belong to. */
32
+ function scopeRoot(scope: Scope, cwd = process.cwd()): string {
33
+ return scope === "global" ? homedir() : cwd;
34
+ }
35
+
36
+ function manifestPath(scope: Scope, cwd = process.cwd()): string {
37
+ return join(scopeRoot(scope, cwd), ".ap-sdk", "install-manifest.json");
38
+ }
39
+
40
+ function readManifest(path: string): Manifest | undefined {
41
+ if (!existsSync(path)) return undefined;
42
+ try {
43
+ return JSON.parse(readFileSync(path, "utf8")) as Manifest;
44
+ } catch {
45
+ return undefined;
46
+ }
47
+ }
48
+
49
+ /** The recorded items of this plugin, for the given harnesses (all when undefined). */
50
+ export function installedItems(scope: Scope, targets: readonly string[] | undefined, cwd = process.cwd()): InstallItem[] {
51
+ const items = readManifest(manifestPath(scope, cwd))?.plugins[PLUGIN_ID]?.items ?? [];
52
+ return targets === undefined ? items : items.filter((item) => targets.includes(item.harness));
53
+ }
54
+
55
+ /** The harnesses of a --target value; undefined means all of them. */
56
+ export function parseTargets(value: string | undefined): string[] | undefined {
57
+ return value === undefined ? undefined : value.split(",").map((target) => target.trim()).filter((target) => target !== "");
58
+ }
59
+
60
+ const WINDSURF_MCP = () => join(homedir(), ".codeium", "windsurf", "mcp_config.json");
61
+ const WINDSURF_KEY = "mcpServers";
62
+
63
+ function readJson(path: string): Record<string, unknown> | undefined {
64
+ try {
65
+ const value = JSON.parse(readFileSync(path, "utf8")) as unknown;
66
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : undefined;
67
+ } catch {
68
+ return undefined;
69
+ }
70
+ }
71
+
72
+ /** What the Windsurf configuration held before an install, to put it back after a project install. */
73
+ export interface WindsurfBefore {
74
+ /** The file's bytes, or undefined when the file did not exist. */
75
+ bytes: Buffer | undefined;
76
+ hadServer: boolean;
77
+ }
78
+
79
+ export function windsurfBefore(): WindsurfBefore {
80
+ const path = WINDSURF_MCP();
81
+ const servers = readJson(path)?.[WINDSURF_KEY] as Record<string, unknown> | undefined;
82
+ let bytes: Buffer | undefined;
83
+ try {
84
+ bytes = readFileSync(path);
85
+ } catch {
86
+ bytes = undefined;
87
+ }
88
+ return { bytes, hadServer: servers !== undefined && PLUGIN_ID in servers };
89
+ }
90
+
91
+ /**
92
+ * After a project install for Windsurf: puts the home configuration back as
93
+ * it was, and removes the Windsurf MCP item from the project manifest.
94
+ * Returns a note for the user, or undefined when Windsurf was not a target.
95
+ */
96
+ export function undoWindsurfProjectMcp(before: WindsurfBefore, cwd = process.cwd()): string | undefined {
97
+ const manifestFile = manifestPath("project", cwd);
98
+ const manifest = readManifest(manifestFile);
99
+ const entry = manifest?.plugins[PLUGIN_ID];
100
+ const item = entry?.items.find((candidate) => candidate.harness === "windsurf" && candidate.kind === "mcp" && candidate.files.length > 0);
101
+ if (manifest === undefined || entry === undefined || item === undefined) return undefined;
102
+
103
+ const path = WINDSURF_MCP();
104
+ if (before.bytes !== undefined) {
105
+ // The file existed: its bytes go back unchanged.
106
+ if (!existsSync(path) || !readFileSync(path).equals(before.bytes)) writeFileSync(path, before.bytes);
107
+ } else {
108
+ // The install made the file: it goes when it holds only this declaration.
109
+ const root = readJson(path);
110
+ const servers = root?.[WINDSURF_KEY] as Record<string, unknown> | undefined;
111
+ if (root !== undefined && servers !== undefined) {
112
+ delete servers[PLUGIN_ID];
113
+ if (isEmptyConfig(root, WINDSURF_KEY)) {
114
+ unlinkSync(path);
115
+ removeEmptyDirectories(dirname(path), homedir());
116
+ } else {
117
+ writeFileSync(path, `${JSON.stringify(root, null, 2)}\n`);
118
+ }
119
+ }
120
+ }
121
+ entry.items = entry.items.filter((candidate) => candidate !== item);
122
+ writeFileSync(manifestFile, `${JSON.stringify(manifest, null, 2)}\n`);
123
+ return before.hadServer
124
+ ? "Windsurf reads MCP servers only from your home directory. Its c64-re-tools declaration from a global install stays as it is."
125
+ : "Windsurf reads MCP servers only from your home directory, so this project install did not declare the MCP for Windsurf. To declare it, run: c64-re-tools install --global --target windsurf";
126
+ }
127
+
128
+ /** A configuration that holds nothing but an empty server list (and perhaps a $schema). */
129
+ function isEmptyConfig(root: Record<string, unknown>, mergeKey: string): boolean {
130
+ const bucket = root[mergeKey];
131
+ if (typeof bucket !== "object" || bucket === null || Object.keys(bucket).length > 0) return false;
132
+ return Object.keys(root).every((key) => key === mergeKey || key === "$schema");
133
+ }
134
+
135
+ /** True when `path` is `root` itself or inside it. */
136
+ function isWithin(root: string, path: string): boolean {
137
+ const rel = relative(root, path);
138
+ return !isAbsolute(rel) && rel.split(sep)[0] !== "..";
139
+ }
140
+
141
+ /** Removes `directory` and its parents while they are empty, up to `stop` (never `stop` itself or anything outside it). */
142
+ export function removeEmptyDirectories(directory: string, stop: string): void {
143
+ let current = resolve(directory);
144
+ const limit = resolve(stop);
145
+ while (current !== limit) {
146
+ if (relative(limit, current) === "" || !isWithin(limit, current)) return;
147
+ try {
148
+ rmdirSync(current);
149
+ } catch {
150
+ return; // not empty, or gone
151
+ }
152
+ current = dirname(current);
153
+ }
154
+ }
155
+
156
+ /**
157
+ * After an uninstall: deletes MCP configuration files that hold nothing but
158
+ * an empty server list, then the directories that the items left empty.
159
+ */
160
+ export function tidyAfterUninstall(items: readonly InstallItem[], scope: Scope, cwd = process.cwd()): void {
161
+ const root = scopeRoot(scope, cwd);
162
+ const absolute = (file: string) => (isAbsolute(file) ? file : resolve(root, file));
163
+ for (const item of items) {
164
+ if (item.kind !== "mcp" || item.detail?.mergeKey === undefined) continue;
165
+ for (const file of item.files.map(absolute)) {
166
+ const config = existsSync(file) ? readJson(file) : undefined;
167
+ if (config !== undefined && isEmptyConfig(config, item.detail.mergeKey)) unlinkSync(file);
168
+ }
169
+ }
170
+ const directories = new Set<string>([dirname(manifestPath(scope, cwd))]);
171
+ for (const item of items) for (const file of item.files) directories.add(dirname(absolute(file)));
172
+ // Deepest first, so a parent is tried after its children.
173
+ for (const directory of [...directories].sort((a, b) => b.length - a.length)) {
174
+ // Each file's own home: the scope root, or the home directory for a file outside it.
175
+ const stop = isWithin(root, directory) ? root : homedir();
176
+ removeEmptyDirectories(directory, stop);
177
+ }
178
+ }
179
+
180
+ const ANSI = new RegExp(String.fromCharCode(27) + "\\[[0-9;]*m", "g");
181
+
182
+ /** AP SDK's report without its Windsurf MCP line and that line's detail, for when the CLI undid that item. */
183
+ export function withoutWindsurfMcp(output: string): string {
184
+ const lines = output.split("\n");
185
+ const kept: string[] = [];
186
+ for (let index = 0; index < lines.length; index++) {
187
+ if (/^\s*\S+\s+windsurf\s+mcp\s/.test(lines[index]!.replace(ANSI, ""))) {
188
+ if (lines[index + 1]?.replace(ANSI, "").trim().startsWith("\u21b3")) index++;
189
+ continue;
190
+ }
191
+ kept.push(lines[index]!);
192
+ }
193
+ return kept.join("\n");
194
+ }
@@ -0,0 +1,122 @@
1
+ #!/usr/bin/env tsx
2
+ // The human CLI: installs the skills and the VICE MCP
3
+ // declaration into agent harnesses through AP SDK, and reports status. It is
4
+ // thin: AP SDK does the harness-specific work.
5
+
6
+ import { dirname, join } from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+ import { parseArgs } from "node:util";
9
+
10
+ import { PACKAGE } from "../../distribution/package.ts";
11
+ import { localToolContext } from "../native/local.ts";
12
+ import { localToolStatus } from "../native/status.ts";
13
+ import { WireFailure } from "../protocol/messages.ts";
14
+ import { startTrace } from "../trace.ts";
15
+ import { runApSdk } from "./ap-sdk.ts";
16
+ import { installedItems, parseTargets, tidyAfterUninstall, undoWindsurfProjectMcp, windsurfBefore, withoutWindsurfMcp, type Scope } from "./cleanup.ts";
17
+ import { hostStatusLines, toolLines } from "./status.ts";
18
+
19
+ const HELP = `c64-re-tools: Commodore 64 reverse engineering and development
20
+
21
+ Usage:
22
+ c64-re-tools install [--target <harnesses>] [--global] install the skills and the VICE MCP
23
+ c64-re-tools update [--target <harnesses>] [--global] install this version over an earlier one
24
+ c64-re-tools uninstall [--target <harnesses>] [--global] remove them
25
+ c64-re-tools status version, package, tools and the Host Runtime
26
+ c64-re-tools --help
27
+
28
+ Harnesses: claude, codex, pi, opencode, gemini, copilot, cursor, windsurf
29
+ (comma-separated; default: all). Without --global the files go into the
30
+ current project; with --global into your home directory.
31
+
32
+ Run it in the project: npx -y ${PACKAGE.name}@${PACKAGE.tag} install. The VICE
33
+ MCP declaration starts the latest c64-re-tools-mcp through npx. The Host
34
+ Runtime runs on the machine with VICE: start it there with
35
+ npx -y --package=${PACKAGE.name}@${PACKAGE.tag} c64-re-tools-host.
36
+ It runs VICE and the tools that come with it (c1541, petcat). The skill
37
+ scripts run ACME, DXA and Ghidra themselves, on the machine of the agent.`;
38
+
39
+ const here = dirname(fileURLToPath(import.meta.url));
40
+
41
+ /** Runs the AP SDK CLI. Writes its error output to stderr and returns its exit status and its report. */
42
+ async function apSdk(command: "install" | "uninstall", flags: readonly string[]): Promise<{ code: number; output: string }> {
43
+ // The report is printed after the CLI's own tidying, so the colours stay when this is a terminal.
44
+ const run = await runApSdk(command, flags, localToolContext(), process.stdout.isTTY ? { env: { ...process.env, FORCE_COLOR: "1" } } : {});
45
+ process.stderr.write(run.stderr);
46
+ return { code: run.code, output: run.stdout };
47
+ }
48
+
49
+ async function status(): Promise<number> {
50
+ console.log(`c64-re-tools ${PACKAGE.version}`);
51
+ console.log(`Package: ${join(here, "..", "..")}`);
52
+ console.log(`Node: ${process.version} (${process.execPath})`);
53
+ // The skill scripts run these tools here, on this machine.
54
+ for (const line of toolLines("Tools here:", await localToolStatus(localToolContext()))) console.log(line);
55
+ for (const line of await hostStatusLines()) console.log(line);
56
+ return 0;
57
+ }
58
+
59
+ async function main(argv: string[]): Promise<number> {
60
+ startTrace("cli", { warn: (line) => console.error(line) });
61
+ const { values, positionals } = parseArgs({
62
+ args: argv,
63
+ strict: true,
64
+ allowPositionals: true,
65
+ options: { target: { type: "string", short: "t" }, global: { type: "boolean", short: "g" }, help: { type: "boolean", short: "h" } },
66
+ });
67
+ const [command, ...rest] = positionals;
68
+ if (values.help || command === undefined) {
69
+ console.log(HELP);
70
+ return 0;
71
+ }
72
+ if (rest.length > 0) {
73
+ console.error(`Too many arguments.\n\n${HELP}`);
74
+ return 2;
75
+ }
76
+ const flags = [...(values.target === undefined ? [] : ["--target", values.target]), ...(values.global ? ["--global"] : [])];
77
+ const scope: Scope = values.global ? "global" : "project";
78
+ const targets = parseTargets(values.target);
79
+ switch (command) {
80
+ case "install":
81
+ case "update": {
82
+ const before = windsurfBefore();
83
+ const { code, output } = await apSdk("install", flags);
84
+ // A project install leaves the home directory as it was.
85
+ const note = scope === "project" && (targets === undefined || targets.includes("windsurf")) ? undoWindsurfProjectMcp(before) : undefined;
86
+ process.stdout.write(note === undefined ? output : withoutWindsurfMcp(output));
87
+ if (note !== undefined) console.log(` ${note}\n`);
88
+ return code;
89
+ }
90
+ case "uninstall": {
91
+ const items = installedItems(scope, targets);
92
+ const { code, output } = await apSdk("uninstall", flags);
93
+ process.stdout.write(output);
94
+ if (code === 0) tidyAfterUninstall(items, scope);
95
+ return code;
96
+ }
97
+ case "status":
98
+ if (flags.length > 0) {
99
+ console.error(`status takes no --target or --global.\n\n${HELP}`);
100
+ return 2;
101
+ }
102
+ return status();
103
+ default:
104
+ console.error(`Unknown command ${command}.\n\n${HELP}`);
105
+ return 2;
106
+ }
107
+ }
108
+
109
+ try {
110
+ process.exitCode = await main(process.argv.slice(2));
111
+ } catch (error) {
112
+ if ((error as NodeJS.ErrnoException).code?.startsWith("ERR_PARSE_ARGS")) {
113
+ console.error(`${(error as Error).message}\n\n${HELP}`);
114
+ process.exitCode = 2;
115
+ } else if (error instanceof WireFailure) {
116
+ // A refusal by name, for example AP SDK that did not finish in time.
117
+ console.error(error.message);
118
+ process.exitCode = 1;
119
+ } else {
120
+ throw error;
121
+ }
122
+ }
@@ -0,0 +1,34 @@
1
+ // The status report of the CLI: the tools on this machine and the Host
2
+ // Runtime with its tools.
3
+
4
+ import { hostTools, type ToolCallOptions } from "../host-client/tools.ts";
5
+ import type { ToolStatus } from "../protocol/tools.ts";
6
+
7
+ /** One line per tool under `heading`: its version, or why it does not run. */
8
+ export function toolLines(heading: string, tools: readonly ToolStatus[]): string[] {
9
+ return [
10
+ heading,
11
+ ...tools.map((tool) =>
12
+ tool.runs
13
+ ? ` ${tool.name}: ${tool.version} (${tool.path})`
14
+ : tool.found
15
+ ? ` ${tool.name}: found at ${tool.path} but it does not run. ${tool.problem}`
16
+ : ` ${tool.name}: missing. ${tool.problem}`,
17
+ ),
18
+ ];
19
+ }
20
+
21
+ /**
22
+ * Whether the Host Runtime answers, and the tools it has. VICE and its tools
23
+ * live on the host; it finds and runs each one. One request shows both that
24
+ * the host answers and what it has.
25
+ */
26
+ export async function hostStatusLines(options: ToolCallOptions = {}): Promise<string[]> {
27
+ let tools: ToolStatus[];
28
+ try {
29
+ tools = await hostTools(options);
30
+ } catch (error) {
31
+ return [`Host Runtime: not reachable. ${(error as Error).message}`];
32
+ }
33
+ return ["Host Runtime: reachable", ...toolLines("Tools on the host:", tools)];
34
+ }