@henols/c64-re-tools 2.1.0 → 2.1.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 (73) hide show
  1. package/package.json +1 -1
  2. package/skills/c64-assembler/SKILL.md +7 -3
  3. package/skills/c64-assembler/scripts/assembler.ts +29 -11
  4. package/skills/c64-assembler/scripts/lib/compare.ts +4 -3
  5. package/skills/c64-basic/SKILL.md +1 -0
  6. package/skills/c64-disk/SKILL.md +3 -2
  7. package/skills/c64-disk/scripts/disk.ts +26 -12
  8. package/skills/c64-disk/scripts/lib/containers.ts +10 -11
  9. package/skills/c64-docs/SKILL.md +2 -1
  10. package/skills/c64-docs/scripts/docs.ts +1 -1
  11. package/skills/c64-docs/scripts/lib/links.ts +5 -3
  12. package/skills/c64-docs/scripts/lib/reference.ts +2 -2
  13. package/skills/c64-emulator/SKILL.md +9 -2
  14. package/skills/c64-knowledge/SKILL.md +9 -5
  15. package/skills/c64-knowledge/scripts/knowledge.ts +91 -53
  16. package/skills/c64-knowledge/scripts/lib/listing.ts +8 -30
  17. package/skills/c64-memory-map/SKILL.md +2 -1
  18. package/skills/c64-memory-map/scripts/memory-map.ts +4 -3
  19. package/skills/c64-provenance/SKILL.md +6 -4
  20. package/skills/c64-provenance/scripts/lib/library.ts +18 -6
  21. package/skills/c64-provenance/scripts/lib/separate.ts +45 -6
  22. package/skills/c64-provenance/scripts/provenance.ts +14 -10
  23. package/skills/c64-source/SKILL.md +3 -0
  24. package/skills/c64-source/scripts/lib/source.ts +9 -31
  25. package/skills/c64-source/scripts/source.ts +14 -10
  26. package/skills/c64-static-analysis/SKILL.md +2 -0
  27. package/skills/c64-static-analysis/scripts/lib/idioms.ts +6 -2
  28. package/skills/c64-static-analysis/scripts/static-analysis.ts +18 -8
  29. package/skills/c64-testing/SKILL.md +14 -3
  30. package/skills/c64-testing/references/recording-format.md +2 -0
  31. package/skills/c64-testing/references/scenario-format.md +6 -0
  32. package/skills/c64-testing/scripts/lib/diff-files.ts +76 -14
  33. package/skills/c64-testing/scripts/lib/diff.ts +3 -3
  34. package/skills/c64-testing/scripts/lib/recording.ts +50 -15
  35. package/skills/c64-testing/scripts/lib/replay.ts +12 -1
  36. package/skills/c64-testing/scripts/lib/runs.ts +84 -4
  37. package/skills/c64-testing/scripts/lib/scenario.ts +43 -6
  38. package/skills/c64-testing/scripts/testing.ts +20 -7
  39. package/skills/c64-unpacker/SKILL.md +17 -10
  40. package/skills/c64-unpacker/scripts/lib/evidence.ts +4 -5
  41. package/skills/c64-unpacker/scripts/lib/loads.ts +14 -7
  42. package/skills/c64-unpacker/scripts/lib/machine.ts +30 -3
  43. package/skills/c64-unpacker/scripts/lib/stages.ts +15 -7
  44. package/skills/c64-unpacker/scripts/unpacker.ts +93 -42
  45. package/src/host/vice/adapter.ts +4 -1
  46. package/src/host/vice/session.ts +148 -13
  47. package/src/host-client/image.ts +40 -8
  48. package/src/host-client/steps.ts +145 -9
  49. package/src/host-client/tools.ts +1 -1
  50. package/src/host-client/transfer.ts +33 -18
  51. package/src/knowledge/expression.ts +1 -1
  52. package/src/knowledge/history.ts +74 -4
  53. package/src/knowledge/import.ts +79 -7
  54. package/src/knowledge/names.ts +2 -1
  55. package/src/knowledge/program.ts +32 -18
  56. package/src/knowledge/read.ts +46 -0
  57. package/src/knowledge/schema.ts +4 -2
  58. package/src/knowledge/text.ts +2 -0
  59. package/src/knowledge/write.ts +58 -16
  60. package/src/mcp/inputs.ts +6 -3
  61. package/src/mcp/output.ts +5 -0
  62. package/src/mcp/server.ts +20 -4
  63. package/src/mcp/tools/debug.ts +36 -9
  64. package/src/mcp/tools/execution.ts +18 -5
  65. package/src/mcp/tools/machine.ts +3 -1
  66. package/src/mcp/tools/media.ts +14 -2
  67. package/src/mcp/tools/memory.ts +11 -6
  68. package/src/mcp/tools/video.ts +5 -3
  69. package/src/media.ts +42 -0
  70. package/src/opcodes.ts +51 -18
  71. package/src/project.ts +111 -3
  72. package/src/protocol/vice.ts +8 -2
  73. package/src/script.ts +55 -3
package/src/opcodes.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  // spelling, ACME writes only one of them: the generator finds those with a
5
5
  // probe and writes the others as bytes.
6
6
 
7
- import { formatC64Byte, formatC64Address } from "./c64.ts";
7
+ import { formatC64Byte, formatC64Address, SYMBOL_NAME } from "./c64.ts";
8
8
 
9
9
  export type Mode = "imp" | "imm" | "zp" | "zpx" | "zpy" | "abs" | "abx" | "aby" | "ind" | "izx" | "izy" | "rel";
10
10
 
@@ -45,6 +45,17 @@ export const MODE_LENGTH: Record<Mode, 1 | 2 | 3> = { imp: 1, imm: 2, zp: 2, zpx
45
45
  /** Every mnemonic in the table: a symbol with one of these names would read as an instruction. */
46
46
  export const MNEMONICS: ReadonlySet<string> = new Set(OPCODES.map((opcode) => opcode.mnemonic));
47
47
 
48
+ /**
49
+ * Why ACME cannot take a name as a label, or undefined when it can. A label
50
+ * has label syntax (SYMBOL_NAME) and is no mnemonic in upper or lower case.
51
+ * The one rule for a symbol of the knowledge and a label of c64-source.
52
+ */
53
+ export function labelProblem(name: string): string | undefined {
54
+ if (!SYMBOL_NAME.test(name)) return "A symbol name is a letter or underscore, then letters, digits or underscores (at most 64), with an optional leading dot.";
55
+ if (MNEMONICS.has(name.toLowerCase())) return `${name} is the name of a 6502 instruction, which ACME cannot read as a label. Give another name.`;
56
+ return undefined;
57
+ }
58
+
48
59
  const ZERO_PAGE_OF: Partial<Record<Mode, Mode>> = { abs: "zp", abx: "zpx", aby: "zpy" };
49
60
 
50
61
  /** True when the mnemonic also has the zero-page form of an absolute mode: ACME would pick it for a small operand. */
@@ -78,26 +89,48 @@ export function useKind(opcode: number): UseKind {
78
89
  }
79
90
 
80
91
 
81
- /** The text of the instruction in `bytes` at `pc`, for example "sta $d011" or "bne $c002". */
82
- export function instructionText(bytes: readonly number[], pc: number): string {
83
- const { mnemonic, mode } = OPCODES[bytes[0]!]!;
84
- const low = bytes[1] ?? 0;
85
- const word = low | ((bytes[2] ?? 0) << 8);
92
+ /**
93
+ * The ACME text of an instruction with its operand as an expression: the
94
+ * mnemonic, the size suffix (+1 or +2, or none) and the operand in the syntax
95
+ * of its addressing mode, for example "lda #<table", "sta screen,x" or
96
+ * "lda (pointer),y". ACME reads an operand in brackets as indirect, so a
97
+ * non-immediate expression that starts with a bracket gets 0+ in front.
98
+ * The one place that writes an instruction: the generated source, the
99
+ * listing of the knowledge and the disassembly of the emulator use it.
100
+ */
101
+ export function instructionWith(opcode: number, expression: string, suffix = ""): string {
102
+ const { mnemonic, mode } = OPCODES[opcode]!;
103
+ const value = mode !== "imm" && expression.startsWith("(") ? `0+${expression}` : expression;
86
104
  const operand: Record<Mode, string> = {
87
105
  imp: "",
88
- imm: `#${formatC64Byte(low)}`,
89
- zp: formatC64Byte(low),
90
- zpx: `${formatC64Byte(low)},x`,
91
- zpy: `${formatC64Byte(low)},y`,
92
- abs: formatC64Address(word),
93
- abx: `${formatC64Address(word)},x`,
94
- aby: `${formatC64Address(word)},y`,
95
- ind: `(${formatC64Address(word)})`,
96
- izx: `(${formatC64Byte(low)},x)`,
97
- izy: `(${formatC64Byte(low)}),y`,
98
- rel: formatC64Address((pc + 2 + (low < 0x80 ? low : low - 0x100)) & 0xffff),
106
+ imm: `#${value}`,
107
+ zp: value,
108
+ zpx: `${value},x`,
109
+ zpy: `${value},y`,
110
+ abs: value,
111
+ abx: `${value},x`,
112
+ aby: `${value},y`,
113
+ ind: `(${value})`,
114
+ izx: `(${value},x)`,
115
+ izy: `(${value}),y`,
116
+ rel: value,
99
117
  };
100
- return operand[mode] === "" ? mnemonic : `${mnemonic} ${operand[mode]}`;
118
+ return operand[mode] === "" ? `${mnemonic}${suffix}` : `${mnemonic}${suffix} ${operand[mode]}`;
119
+ }
120
+
121
+ /** The value of the operand of the instruction in `bytes` at `pc` as a number: a byte, a word, or the target of a branch. Empty for no operand. */
122
+ export function operandNumber(bytes: readonly number[], pc: number): string {
123
+ const { mode } = OPCODES[bytes[0]!]!;
124
+ const low = bytes[1] ?? 0;
125
+ if (mode === "imp") return "";
126
+ if (mode === "rel") return formatC64Address((pc + 2 + (low < 0x80 ? low : low - 0x100)) & 0xffff);
127
+ return MODE_LENGTH[mode] === 3 ? formatC64Address(low | ((bytes[2] ?? 0) << 8)) : formatC64Byte(low);
128
+ }
129
+
130
+ /** The text of the instruction in `bytes` at `pc` as the monitor shows it, for example "STA $d011" or "BNE $c002". */
131
+ export function instructionText(bytes: readonly number[], pc: number): string {
132
+ const { mnemonic } = OPCODES[bytes[0]!]!;
133
+ return `${mnemonic.toUpperCase()}${instructionWith(bytes[0]!, operandNumber(bytes, pc)).slice(mnemonic.length)}`;
101
134
  }
102
135
 
103
136
  /**
package/src/project.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  // the agent saves for itself and ignores itself in git.
6
6
 
7
7
  import { Refusal } from "./errors.ts";
8
- import { existsSync, mkdirSync, readdirSync, realpathSync, statSync, writeFileSync } from "node:fs";
8
+ import { accessSync, constants, existsSync, lstatSync, mkdirSync, readdirSync, realpathSync, statSync, writeFileSync } from "node:fs";
9
9
  import { homedir } from "node:os";
10
10
  import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
11
11
 
@@ -117,9 +117,10 @@ export function namedProject(folder: string, cwd: string = process.cwd()): strin
117
117
  if (stat === undefined) throw new Refusal("not-found", `There is no folder ${folder}.`);
118
118
  if (!stat.isDirectory()) throw new Refusal("invalid-input", `${folder} is not a folder.`);
119
119
  if (path === homedir()) throw new Refusal("invalid-input", "The home folder is not a project folder.");
120
- const outer = foldersUpFrom(path).slice(1).find(isProject);
120
+ // A project inside another project is a project of its own, as the lookup from a working directory in it finds.
121
+ const outer = isProject(path) ? undefined : foldersUpFrom(path).slice(1).find(isProject);
121
122
  if (outer !== undefined) {
122
- throw new Refusal("invalid-input", `${folder} is inside the project ${relative(cwd, outer) || "."}. Give that project folder.`);
123
+ throw new Refusal("invalid-input", `${folder} is inside the project ${relative(cwd, outer) || outer}. Give that project folder.`);
123
124
  }
124
125
  return path;
125
126
  }
@@ -180,6 +181,113 @@ export function resolveProjectPath(path: string, root = projectRoot()): string {
180
181
  throw new Refusal("invalid-input", `${path} leads out of the project folder: give a path that stays inside it.`);
181
182
  }
182
183
 
184
+ /** The folder of the committed knowledge text in PROJECT_DIRECTORY. The knowledge (src/knowledge/database.ts) has the same name. */
185
+ export const KNOWLEDGE_TEXT_DIRECTORY = "knowledge";
186
+
187
+ /** The index of the knowledge in LOCAL_DIRECTORY, and the files that SQLite keeps beside it. The knowledge has the same name. */
188
+ const KNOWLEDGE_INDEX = /^knowledge\.db(?:-wal|-shm|-journal)?$/;
189
+
190
+ /**
191
+ * The part of the knowledge that a path relative to the project folder is in:
192
+ * the text in .c64-re-tools/knowledge/ or the index in .c64-re-tools/local/.
193
+ * Undefined for every other path. Letter case does not count: some file
194
+ * systems do not tell the cases apart.
195
+ */
196
+ export function knowledgePart(fromRoot: string): string | undefined {
197
+ const parts = fromRoot.split(/[\\/]+/).filter((part) => part !== "" && part !== ".").map((part) => part.toLowerCase());
198
+ if (parts[0] !== PROJECT_DIRECTORY) return undefined;
199
+ if (parts[1] === KNOWLEDGE_TEXT_DIRECTORY) return `${PROJECT_DIRECTORY}/${KNOWLEDGE_TEXT_DIRECTORY}/`;
200
+ if (parts[1] === LOCAL_DIRECTORY && parts.length === 3 && KNOWLEDGE_INDEX.test(parts[2]!)) return `${PROJECT_DIRECTORY}/${LOCAL_DIRECTORY}/${parts[2]}`;
201
+ return undefined;
202
+ }
203
+
204
+ /** What a command writes at an output path: one file, or files in a folder. */
205
+ export type OutputKind = "file" | "folder";
206
+
207
+ /** Why a file cannot be written, by the error code of the file system; the codes that are a fault of the path. */
208
+ const WRITE_PROBLEMS: Record<string, string> = {
209
+ EISDIR: "is a folder. Give the path of a file",
210
+ EEXIST: "has a file where a folder must be. Give a path whose folders are folders",
211
+ ENOTDIR: "has a file where a folder must be. Give a path whose folders are folders",
212
+ ENOENT: "is in a folder that does not exist",
213
+ EACCES: "cannot be written: there is no permission. Give a path that you can write",
214
+ EPERM: "cannot be written: there is no permission. Give a path that you can write",
215
+ EROFS: "is on a file system that is read-only. Give another path",
216
+ ENAMETOOLONG: "is too long. Give a shorter path",
217
+ };
218
+
219
+ /**
220
+ * The message for a write that the file system refused because of the path
221
+ * (a folder, a file in place of a folder, no permission): the path and what
222
+ * to do. Undefined for another error, which is no fault of the path.
223
+ */
224
+ export function writeProblem(error: unknown, path: string, what?: string): string | undefined {
225
+ const problem = WRITE_PROBLEMS[(error as NodeJS.ErrnoException | undefined)?.code ?? ""];
226
+ return problem === undefined ? undefined : `${what === undefined ? "" : `${what}: `}${path} ${problem}.`;
227
+ }
228
+
229
+ /**
230
+ * Checks a path that a command writes, before the command does any work. The
231
+ * path must stay inside the project, also through symbolic links, and must
232
+ * not be in the knowledge (only the c64-knowledge skill writes it). A file
233
+ * must not be a folder or a symbolic link; a folder must be a folder. The
234
+ * nearest folder that exists must be a folder that can be written. Returns
235
+ * the absolute path. Throws a Refusal (invalid-input) whose message starts
236
+ * with `what` (for example --out). writeProjectFile does the same check for
237
+ * each file that it writes.
238
+ */
239
+ export function checkOutputPath(path: string, kind: OutputKind, options: { root?: string; what?: string } = {}): string {
240
+ const root = options.root ?? projectRoot();
241
+ const what = options.what;
242
+ const outside = () => new Refusal("invalid-input", `${what ?? path}: give a path relative to the project folder that stays inside it, not ${path}.`);
243
+ const problem = (error: unknown) => {
244
+ const message = writeProblem(error, path, what);
245
+ return message === undefined ? error : new Refusal("invalid-input", message);
246
+ };
247
+ const inKnowledge = (fromRoot: string) => {
248
+ const part = knowledgePart(fromRoot);
249
+ if (part !== undefined) {
250
+ throw new Refusal("invalid-input", `${what === undefined ? "" : `${what}: `}${path} is in ${part}, the knowledge of the project. Only the c64-knowledge skill writes it: a write there destroys the knowledge. Give a path outside ${PROJECT_DIRECTORY}/${KNOWLEDGE_TEXT_DIRECTORY}/, for example ${PROJECT_DIRECTORY}/${LOCAL_DIRECTORY}/<file>.`);
251
+ }
252
+ };
253
+ let target: string;
254
+ try {
255
+ target = resolveProjectPath(path, root);
256
+ } catch {
257
+ throw outside();
258
+ }
259
+ inKnowledge(relative(root, target));
260
+ // The nearest entry that exists: the path itself, or a folder above it.
261
+ let existing = target;
262
+ for (;;) {
263
+ let found;
264
+ try {
265
+ found = lstatSync(existing, { throwIfNoEntry: false });
266
+ } catch (error) {
267
+ throw problem(error);
268
+ }
269
+ if (found !== undefined) break;
270
+ existing = dirname(existing);
271
+ }
272
+ const realRoot = realpathSync(root);
273
+ if (existing === target && kind === "file" && lstatSync(target).isSymbolicLink()) throw outside();
274
+ const real = realpathSync(existing);
275
+ if (!isInside(realRoot, real)) throw outside();
276
+ inKnowledge(relative(realRoot, join(real, relative(existing, target))));
277
+ const stat = statSync(real);
278
+ if (existing === target && kind === "file") {
279
+ if (stat.isDirectory()) throw problem({ code: "EISDIR" });
280
+ } else if (!stat.isDirectory()) {
281
+ throw problem({ code: "ENOTDIR" });
282
+ }
283
+ try {
284
+ accessSync(real, constants.W_OK);
285
+ } catch (error) {
286
+ throw problem(error);
287
+ }
288
+ return target;
289
+ }
290
+
183
291
  /** A project path in one spelling: relative to the project folder, with / and without ./, .. or a double /. Refuses a path as resolveProjectPath does. */
184
292
  export function projectRelative(path: string, root = projectRoot()): string {
185
293
  return relative(root, resolveProjectPath(path, root)).split(sep).join("/");
@@ -385,6 +385,8 @@ export interface MachineStatus {
385
385
  stopReason?: PointStopReason;
386
386
  /** The id of the breakpoint or watchpoint that stopped the machine. */
387
387
  pointId?: number;
388
+ /** With pc: the CPU whose address pc is, drive8 when a point in the drive stopped the machine. */
389
+ space?: "c64" | "drive8";
388
390
  }
389
391
 
390
392
  /** What stopped the machine outside a request that stops it. */
@@ -462,6 +464,8 @@ export interface ExecutionResult {
462
464
  stopReason?: "frames" | PointStopReason;
463
465
  /** The id of the breakpoint or watchpoint that stopped the run. */
464
466
  pointId?: number;
467
+ /** advance-frames only: the CPU that stopped, and so the space of pc. */
468
+ space?: "c64" | "drive8";
465
469
  }
466
470
 
467
471
  /** c64_run_until targets. */
@@ -1090,7 +1094,8 @@ export function validateViceResult<O extends ViceOperation>(op: O, value: unknow
1090
1094
  (value.pc === undefined || isInteger(value.pc, 0, 0xffff)) &&
1091
1095
  typeof value.cartridge === "boolean" &&
1092
1096
  (value.stopReason === undefined || isOneOf(POINT_STOP_REASONS, value.stopReason)) &&
1093
- (value.pointId === undefined || isInteger(value.pointId, 1, Number.MAX_SAFE_INTEGER));
1097
+ (value.pointId === undefined || isInteger(value.pointId, 1, Number.MAX_SAFE_INTEGER)) &&
1098
+ (value.space === undefined || isOneOf(SPACES, value.space));
1094
1099
  if (!ok) throw new ProtocolError("status result is malformed");
1095
1100
  break;
1096
1101
  }
@@ -1171,7 +1176,8 @@ export function validateViceResult<O extends ViceOperation>(op: O, value: unknow
1171
1176
  (value.executed === undefined || isInteger(value.executed, 0, MAX_EXECUTION_COUNT)) &&
1172
1177
  (value.advancedFrames === undefined || isInteger(value.advancedFrames, 0, MAX_EXECUTION_COUNT)) &&
1173
1178
  (value.stopReason === undefined || value.stopReason === "frames" || isOneOf(POINT_STOP_REASONS, value.stopReason)) &&
1174
- (value.pointId === undefined || isInteger(value.pointId, 1, Number.MAX_SAFE_INTEGER));
1179
+ (value.pointId === undefined || isInteger(value.pointId, 1, Number.MAX_SAFE_INTEGER)) &&
1180
+ (value.space === undefined || isOneOf(SPACES, value.space));
1175
1181
  if (!ok) throw new ProtocolError("execution result is malformed");
1176
1182
  break;
1177
1183
  }
package/src/script.ts CHANGED
@@ -13,7 +13,7 @@ import { parseArgs } from "node:util";
13
13
 
14
14
  import { c64AddressOf, formatC64Address, SPACE_PATTERN, SYMBOL_NAME, type AddressRange } from "./c64.ts";
15
15
  import { isRefusal, Refusal, type ErrorCode } from "./errors.ts";
16
- import { projectRoot, resolveProjectPath, useProject } from "./project.ts";
16
+ import { checkOutputPath, projectRoot, resolveProjectPath, useProject, type OutputKind } from "./project.ts";
17
17
  import { shownMessage } from "./protocol/messages.ts";
18
18
  import { elapsedMs, startTrace, trace, type TraceFields } from "./trace.ts";
19
19
 
@@ -59,6 +59,12 @@ export interface OptionSpec {
59
59
  max?: number;
60
60
  /** The value when the option is not given. */
61
61
  default?: number | string;
62
+ /**
63
+ * path only: the command writes there, a file or files in a folder. The
64
+ * path is checked before the command runs (checkOutputPath). Every option
65
+ * --out and --out-<name> has it.
66
+ */
67
+ output?: OutputKind;
62
68
  }
63
69
 
64
70
  /** The options that have one meaning in every script. A command can change the help, the value and whether it is required, never the kind. */
@@ -157,7 +163,7 @@ export class Call {
157
163
 
158
164
  /** The knowledge space of the command: --space, else c64. */
159
165
  get space(): string {
160
- return "space" in this.#options ? this.string("space")! : "c64";
166
+ return ("space" in this.#options ? this.string("space") : undefined) ?? "c64";
161
167
  }
162
168
 
163
169
  /** Subject `index` as text. */
@@ -339,6 +345,8 @@ function commandOptions(command: CommandSpec): Record<string, OptionSpec> {
339
345
  const shared = (OPTIONS as Record<string, OptionSpec>)[name];
340
346
  if (shared !== undefined && shared.kind !== spec.kind) throw fault(`--${name} of ${command.name} is a ${spec.kind}, but OPTIONS has it as a ${shared.kind}`);
341
347
  if (name === "help" || name === "project") throw fault(`--${name} of ${command.name}: the contract owns this option`);
348
+ if (spec.kind === "path" && (name === "out" || name.startsWith("out-")) && spec.output === undefined) throw fault(`--${name} of ${command.name} has no output kind (file or folder)`);
349
+ if (spec.output !== undefined && spec.kind !== "path") throw fault(`--${name} of ${command.name} has an output kind, but it is a ${spec.kind}, not a path`);
342
350
  }
343
351
  return options;
344
352
  }
@@ -391,9 +399,49 @@ function checkPath(text: string, what: string): void {
391
399
  }
392
400
  }
393
401
 
402
+ /**
403
+ * Refuses a range in two parts: two subjects '$1bc2' '$1bd0' where the command
404
+ * takes one <range>, or --start and --end where it takes a range option. The
405
+ * message gives the range as one token.
406
+ */
407
+ function refuseSplitRange(command: CommandSpec, options: Readonly<Record<string, OptionSpec>>, argv: readonly string[], subjects?: readonly string[]): void {
408
+ if (subjects === undefined) {
409
+ const end = argv.indexOf("--");
410
+ const words = end < 0 ? argv : argv.slice(0, end);
411
+ const valueOf = (name: string) => {
412
+ const index = words.findIndex((word) => word === `--${name}` || word.startsWith(`--${name}=`));
413
+ if (index < 0) return undefined;
414
+ const word = words[index]!;
415
+ return word.includes("=") ? word.slice(word.indexOf("=") + 1) : (words[index + 1] ?? "");
416
+ };
417
+ const [start, last] = [valueOf("start"), valueOf("end")];
418
+ const range = Object.entries(options).find(([, spec]) => spec.kind === "range")?.[0];
419
+ if ((start === undefined && last === undefined) || range === undefined || "start" in options || "end" in options) return;
420
+ throw new UsageError(`${command.name} takes no --start or --end. Give the range as one option: --${range} '${start || "<start>"}-${last || "<end>"}'`);
421
+ }
422
+ const specs = command.subjects ?? [];
423
+ for (let index = 0; index + 1 < subjects.length; index++) {
424
+ const spec = specs[Math.min(index, specs.length - 1)];
425
+ const [first, second] = [subjects[index]!, subjects[index + 1]!];
426
+ if (spec === undefined || !spec.name.includes("range") || rangeParts(first) !== undefined || !isAddressText(first) || c64AddressOf(second) === undefined) continue;
427
+ throw new UsageError(`${spec.name} is one token <start>-<end>: give '${first}-${second}', not '${first}' '${second}'`);
428
+ }
429
+ }
430
+
431
+ /**
432
+ * Refuses an output path before the command does any work: a path that
433
+ * leaves the project, a path in the knowledge, a folder where a file must be,
434
+ * a file where a folder must be, or a folder that cannot be written.
435
+ */
436
+ function checkOutput(text: string, what: string, kind: OutputKind): void {
437
+ checkPath(text, what);
438
+ checkOutputPath(text, kind, { root: projectRoot(), what });
439
+ }
440
+
394
441
  /** Parses the command line of one command. Refuses a wrong count of subjects, an unknown option, a missing required option or a value of the wrong form. */
395
442
  export function parseCall(script: ScriptSpec, command: CommandSpec, argv: readonly string[]): Call {
396
443
  const options = commandOptions(command);
444
+ refuseSplitRange(command, options, argv);
397
445
  let parsed: ReturnType<typeof parseArgs>;
398
446
  try {
399
447
  parsed = parseArgs({
@@ -407,6 +455,7 @@ export function parseCall(script: ScriptSpec, command: CommandSpec, argv: readon
407
455
  }
408
456
  const values = parsed.values as Record<string, string | boolean | string[] | undefined>;
409
457
  const subjects = parsed.positionals;
458
+ refuseSplitRange(command, options, argv, subjects);
410
459
  const specs = command.subjects ?? [];
411
460
  const last = specs.at(-1);
412
461
  const least = specs.filter((subject) => !subject.optional).length;
@@ -428,7 +477,10 @@ export function parseCall(script: ScriptSpec, command: CommandSpec, argv: readon
428
477
  if (spec.kind === "range" && rangeParts(text) === undefined) throw new UsageError(`--${name} must be <start>-<end>, each ${ADDRESS_FORM}, for example '$c000-$cfff', not ${JSON.stringify(text)}`);
429
478
  if (spec.kind === "space" && !SPACE_PATTERN.test(text)) throw new UsageError(`--${name} must be c64, drive8, or bank0 to bank255, not ${JSON.stringify(text)}`);
430
479
  if (spec.kind === "choice" && !(spec.choices ?? []).includes(text)) throw new UsageError(`--${name} must be one of ${(spec.choices ?? []).join(", ")}, not ${JSON.stringify(text)}`);
431
- if (spec.kind === "path") checkPath(text, `--${name}`);
480
+ if (spec.kind === "path") {
481
+ if (spec.output === undefined) checkPath(text, `--${name}`);
482
+ else checkOutput(text, `--${name}`, spec.output);
483
+ }
432
484
  }
433
485
  }
434
486
  const call = new Call(script.name, command.name, subjects, options, values, script.names);