runspec-node 0.17.0 → 0.19.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.
package/src/errors.ts CHANGED
@@ -41,6 +41,54 @@ export function formatOutOfRange(value: number, range: [number, number], name: s
41
41
  ].join('\n');
42
42
  }
43
43
 
44
+ export function formatInvalidPattern(value: string, pattern: string, name: string): string {
45
+ return [
46
+ `✗ Invalid value for --${name}: ${JSON.stringify(value)}`,
47
+ ` Expected: a value matching pattern ${JSON.stringify(pattern)}`,
48
+ ` Got: ${JSON.stringify(value)}`,
49
+ ].join('\n');
50
+ }
51
+
52
+ export function formatTooShort(value: string, minLength: number, name: string): string {
53
+ return [
54
+ `✗ Value too short for --${name}: ${JSON.stringify(value)}`,
55
+ ` Expected: at least ${minLength} character${minLength === 1 ? '' : 's'}`,
56
+ ` Got: ${value.length}`,
57
+ ].join('\n');
58
+ }
59
+
60
+ export function formatTooLong(value: string, maxLength: number, name: string): string {
61
+ return [
62
+ `✗ Value too long for --${name}: ${JSON.stringify(value)}`,
63
+ ` Expected: at most ${maxLength} character${maxLength === 1 ? '' : 's'}`,
64
+ ` Got: ${value.length}`,
65
+ ].join('\n');
66
+ }
67
+
68
+ export function formatInvalidItems(
69
+ name: string,
70
+ total: number,
71
+ failures: Array<[number, unknown, string]>,
72
+ ): string {
73
+ const header = `--${name}: ${failures.length} of ${total} item(s) failed validation:`;
74
+ const blocks = failures.map(([index, value, reason]) => {
75
+ const detail = reason
76
+ .split('\n')
77
+ .map((line) => ' ' + line)
78
+ .join('\n');
79
+ return ` • item ${index} (${JSON.stringify(value)}):\n${detail}`;
80
+ });
81
+ return header + '\n\n' + blocks.join('\n\n');
82
+ }
83
+
84
+ export function formatMissingCommand(runnablePath: string, availableCommands: string[]): string {
85
+ return [
86
+ `✗ '${runnablePath}' requires a command.`,
87
+ ` Available commands: ${availableCommands.join(', ')}`,
88
+ `\n Run '${runnablePath} <command> --help' for details.`,
89
+ ].join('\n');
90
+ }
91
+
44
92
  export function formatUnknownArg(name: string, knownArgs: string[]): string {
45
93
  const lines = [
46
94
  `✗ Unknown argument: --${name}`,
package/src/loader.ts CHANGED
@@ -58,6 +58,7 @@ function normaliseScript(name: string, raw: Record<string, unknown>): ScriptSpec
58
58
  autonomy: raw['autonomy'] as string | undefined,
59
59
  autonomyReason: raw['autonomy-reason'] as string | undefined,
60
60
  output: raw['output'] as string | undefined,
61
+ requireCommand: (raw['require-command'] as boolean | undefined) ?? false,
61
62
  args: normaliseArgs((raw['args'] ?? {}) as Record<string, unknown>),
62
63
  groups: normaliseGroups((raw['groups'] ?? {}) as Record<string, unknown>),
63
64
  commands: normaliseCommands((raw['commands'] ?? {}) as Record<string, Record<string, unknown>>),
@@ -84,6 +85,9 @@ function normaliseArg(name: string, raw: Record<string, unknown>): ArgSpec {
84
85
  description: raw['description'] as string | undefined,
85
86
  options: raw['options'] as string[] | undefined,
86
87
  range: raw['range'] as [number, number] | undefined,
88
+ pattern: raw['pattern'] as string | undefined,
89
+ minLength: raw['min-length'] as number | undefined,
90
+ maxLength: raw['max-length'] as number | undefined,
87
91
  multiple: (raw['multiple'] as boolean | undefined) ?? false,
88
92
  delimiter: raw['delimiter'] as string | undefined,
89
93
  short: raw['short'] as string | undefined,
package/src/models.ts CHANGED
@@ -19,6 +19,9 @@ export interface ArgSpec {
19
19
  description?: string;
20
20
  options?: string[];
21
21
  range?: [number, number];
22
+ pattern?: string; // regex the value must fully match (str only)
23
+ minLength?: number; // minimum string length (str only)
24
+ maxLength?: number; // maximum string length (str only)
22
25
  multiple?: boolean;
23
26
  delimiter?: string;
24
27
  short?: string;
@@ -48,6 +51,7 @@ export interface ScriptSpec {
48
51
  autonomyReason?: string;
49
52
  output?: string;
50
53
  serve?: boolean | string[];
54
+ requireCommand?: boolean;
51
55
  args: Record<string, ArgSpec>;
52
56
  groups: Record<string, GroupSpec>;
53
57
  commands: Record<string, ScriptSpec>;
package/src/parser.ts CHANGED
@@ -4,7 +4,7 @@ import { loadRaw } from './loader';
4
4
  import { inferScript, effectiveAutonomy } from './inference';
5
5
  import { coerce } from './types';
6
6
  import { validateArgs, validateGroups, raiseIfErrors } from './validator';
7
- import { RunSpecError } from './errors';
7
+ import { RunSpecError, formatMissingCommand } from './errors';
8
8
  import { configureLogging } from './logging_setup';
9
9
  import type { ParsedArgs, ScriptSpec, ArgSpec } from './models';
10
10
 
@@ -13,10 +13,12 @@ export interface ParseOptions {
13
13
  argv?: string[];
14
14
  cwd?: string;
15
15
  configPath?: string;
16
+ /** Internal: enforce `require-command` (real CLI parsing). loadSpec sets false. */
17
+ _enforceRequiredCommand?: boolean;
16
18
  }
17
19
 
18
20
  export function parse(opts: ParseOptions = {}): ParsedArgs {
19
- const { scriptName, argv: argvOverride, cwd, configPath: configPathOverride } = opts;
21
+ const { scriptName, argv: argvOverride, cwd, configPath: configPathOverride, _enforceRequiredCommand = true } = opts;
20
22
 
21
23
  const { configPath } = configPathOverride ? { configPath: configPathOverride } : findConfig(cwd);
22
24
  const raw = loadRaw(configPath);
@@ -76,18 +78,35 @@ export function parse(opts: ParseOptions = {}): ParsedArgs {
76
78
  let activeScript = rawScript;
77
79
  let commandPath: string[] = [];
78
80
 
79
- const commands = rawScript.commands ?? {};
80
- if (Object.keys(commands).length > 0 && argv.length > 0 && argv[0] in commands) {
81
- commandPath = [argv[0]];
82
- activeScript = commands[argv[0]];
81
+ // Walk into nested subcommands as long as argv[0] matches a declared command.
82
+ while (argv.length > 0) {
83
+ const cmds = activeScript.commands ?? {};
84
+ if (Object.keys(cmds).length === 0 || !(argv[0] in cmds)) break;
85
+ commandPath.push(argv[0]);
86
+ activeScript = cmds[argv[0]];
83
87
  argv = argv.slice(1);
84
88
  }
85
89
 
86
90
  if (argv.includes('--help') || argv.includes('-h')) {
87
- printHelp(name, activeScript, commandPath.length > 0 ? commandPath[commandPath.length - 1] : undefined);
91
+ printHelp(name, activeScript, commandPath);
88
92
  process.exit(0);
89
93
  }
90
94
 
95
+ // Enforce require-command on the resolved leaf: if the deepest matched node
96
+ // still requires a command and has commands, none was chosen at that level.
97
+ // Checking the leaf (not commandPath.length) enforces at every nesting depth.
98
+ // Skipped by loadSpec — introspection/emit must not be blocked. Runs after
99
+ // --help so `<name> --help` still lists the commands.
100
+ if (
101
+ _enforceRequiredCommand &&
102
+ activeScript.requireCommand &&
103
+ Object.keys(activeScript.commands ?? {}).length > 0
104
+ ) {
105
+ throw new RunSpecError(
106
+ formatMissingCommand([name, ...commandPath].join(' '), Object.keys(activeScript.commands)),
107
+ );
108
+ }
109
+
91
110
  let parsedValues = parseArgv(argv, activeScript.args ?? {});
92
111
  parsedValues = applyEnv(parsedValues, activeScript.args ?? {}, name);
93
112
  parsedValues = applyDefaults(parsedValues, activeScript.args ?? {});
@@ -142,7 +161,7 @@ export function parse(opts: ParseOptions = {}): ParsedArgs {
142
161
  }
143
162
 
144
163
  export function loadSpec(opts: ParseOptions = {}): ParsedArgs {
145
- return parse({ ...opts, argv: [] });
164
+ return parse({ ...opts, argv: [], _enforceRequiredCommand: false });
146
165
  }
147
166
 
148
167
  function inferFromArgv(): string {
@@ -295,28 +314,56 @@ function coerceValues(parsed: Record<string, unknown>, argSpecs: Record<string,
295
314
  return result;
296
315
  }
297
316
 
298
- export function printHelp(name: string, script: ScriptSpec, command?: string): void {
299
- const fullName = command ? `${name} ${command}` : name;
317
+ export function printHelp(name: string, script: ScriptSpec, commandPath: string[] = []): void {
318
+ const fullName = [name, ...commandPath].join(' ');
300
319
  const args = script.args ?? {};
320
+ const commands = script.commands ?? {};
301
321
 
302
- const usageParts = [fullName];
303
- for (const [argName, spec] of Object.entries(args)) {
322
+ // Partition args into flags, positionals, and rest (mirrors Python's _print_help).
323
+ const entries = Object.entries(args);
324
+ const positionalArgs = entries
325
+ .filter(([, s]) => s.position !== undefined)
326
+ .sort(([, a], [, b]) => (a.position ?? 0) - (b.position ?? 0));
327
+ const restArgs = entries.filter(([, s]) => s.type === 'rest');
328
+ const flagArgs = entries.filter(([, s]) => s.position === undefined && s.type !== 'rest');
329
+
330
+ // Choices render their options inline; other types render as <type>.
331
+ const argToken = (spec: typeof args[string]): string =>
332
+ spec.options ? `<${spec.options.join('|')}>` : `<${spec.type ?? 'str'}>`;
333
+
334
+ // Usage line — order: name [flags] [positionals] [<command>] [-- <rest>...]
335
+ // Rest stays last because '--' terminates argument parsing.
336
+ const usageParts: string[] = [fullName];
337
+ for (const [argName, spec] of flagArgs) {
304
338
  const flag = `--${argName}`;
305
- if (spec.type === 'flag') {
306
- usageParts.push(`[${flag}]`);
307
- } else if (spec.required) {
308
- usageParts.push(`${flag} <${spec.type ?? 'str'}>`);
309
- } else {
310
- usageParts.push(`[${flag} <${spec.type ?? 'str'}>]`);
311
- }
339
+ if (spec.type === 'flag') usageParts.push(`[${flag}]`);
340
+ else if (spec.required) usageParts.push(`${flag} ${argToken(spec)}`);
341
+ else usageParts.push(`[${flag} ${argToken(spec)}]`);
342
+ }
343
+ for (const [argName, spec] of positionalArgs) {
344
+ usageParts.push(spec.required ? `<${argName}>` : `[<${argName}>]`);
345
+ }
346
+ if (Object.keys(commands).length > 0) usageParts.push('<command>');
347
+ for (const [argName] of restArgs) {
348
+ usageParts.push(`[-- <${argName}>...]`);
312
349
  }
313
350
 
314
351
  console.log(`Usage: ${usageParts.join(' ')}`);
315
352
  if (script.description) console.log(`\n${script.description}`);
316
353
 
354
+ // Commands section
355
+ if (Object.keys(commands).length > 0) {
356
+ console.log(script.requireCommand ? '\nCommands (required):' : '\nCommands:');
357
+ const cmdCol = Math.max(...Object.keys(commands).map((c) => c.length)) + 2;
358
+ for (const [cmdName, cmdSpec] of Object.entries(commands)) {
359
+ const desc = cmdSpec.description ?? '';
360
+ console.log(` ${cmdName.padEnd(cmdCol)} ${desc}`);
361
+ }
362
+ }
363
+
317
364
  if (Object.keys(args).length > 0) {
318
365
  console.log('\nArguments:');
319
- for (const [argName, spec] of Object.entries(args)) {
366
+ for (const [argName, spec] of entries) {
320
367
  const flag = ` --${argName}`;
321
368
  const parts: string[] = [];
322
369
  if (spec.type === 'flag') parts.push('flag');
@@ -335,5 +382,8 @@ export function printHelp(name: string, script: ScriptSpec, command?: string): v
335
382
  if (script.autonomyReason) console.log(` ${script.autonomyReason}`);
336
383
  }
337
384
 
385
+ if (Object.keys(commands).length > 0) {
386
+ console.log(`\nRun '${fullName} <command> --help' for focused help on a command.`);
387
+ }
338
388
  console.log('\n -h, --help Show this message and exit');
339
389
  }
package/src/types.ts CHANGED
@@ -1,6 +1,12 @@
1
1
  import * as path from 'path';
2
2
  import type { ArgSpec } from './models';
3
- import { formatInvalidChoice } from './errors';
3
+ import {
4
+ formatInvalidChoice,
5
+ formatInvalidPattern,
6
+ formatTooShort,
7
+ formatTooLong,
8
+ formatInvalidItems,
9
+ } from './errors';
4
10
 
5
11
  export type TypeCoercer = (value: unknown, spec: ArgSpec) => unknown;
6
12
 
@@ -18,6 +24,25 @@ export function coerce(value: unknown, spec: ArgSpec): unknown {
18
24
  `Unknown type '${typeName}' for argument '${spec.name}'. Registered types: ${[...registry.keys()].sort().join(', ')}\nRegister custom types with registerType().`,
19
25
  );
20
26
  }
27
+ // Multiple-valued args coerce and validate each item independently and return
28
+ // a list. The arg's type (and its pattern / length / range / choice checks)
29
+ // applies per item. `rest` manages its own list, so it is excluded.
30
+ if (spec.multiple && typeName !== 'rest') {
31
+ const items = Array.isArray(value) ? value : [value];
32
+ const out: unknown[] = [];
33
+ const failures: Array<[number, unknown, string]> = [];
34
+ items.forEach((item, i) => {
35
+ try {
36
+ out.push(coercer(item, spec));
37
+ } catch (e) {
38
+ failures.push([i + 1, item, (e as Error).message]);
39
+ }
40
+ });
41
+ if (failures.length > 0) {
42
+ throw new Error(formatInvalidItems(spec.name ?? '?', items.length, failures));
43
+ }
44
+ return out;
45
+ }
21
46
  try {
22
47
  return coercer(value, spec);
23
48
  } catch (e) {
@@ -31,8 +56,11 @@ export function listTypes(): string[] {
31
56
  return [...registry.keys()].sort();
32
57
  }
33
58
 
34
- function coerceStr(value: unknown): string {
35
- return String(value);
59
+ function coerceStr(value: unknown, spec: ArgSpec): string {
60
+ const coerced = String(value);
61
+ checkLength(coerced, spec);
62
+ checkPattern(coerced, spec);
63
+ return coerced;
36
64
  }
37
65
 
38
66
  function coerceInt(value: unknown, spec: ArgSpec): number {
@@ -87,6 +115,26 @@ function checkRange(value: number, spec: ArgSpec): void {
87
115
  }
88
116
  }
89
117
 
118
+ // String-only validation (str coercer). Mirrors Python's _check_length/_check_pattern.
119
+ function checkLength(value: string, spec: ArgSpec): void {
120
+ if (spec.minLength !== undefined && value.length < spec.minLength) {
121
+ throw new Error(formatTooShort(value, spec.minLength, spec.name ?? '?'));
122
+ }
123
+ if (spec.maxLength !== undefined && value.length > spec.maxLength) {
124
+ throw new Error(formatTooLong(value, spec.maxLength, spec.name ?? '?'));
125
+ }
126
+ }
127
+
128
+ function checkPattern(value: string, spec: ArgSpec): void {
129
+ if (spec.pattern === undefined) return;
130
+ // (?:…) anchoring reproduces Python's re.fullmatch even when the pattern
131
+ // contains a top-level alternation (plain ^p$ would mis-bind `a|b`).
132
+ const regex = new RegExp(`^(?:${spec.pattern})$`);
133
+ if (!regex.test(value)) {
134
+ throw new Error(formatInvalidPattern(value, spec.pattern, spec.name ?? '?'));
135
+ }
136
+ }
137
+
90
138
  registerType('str', coerceStr);
91
139
  registerType('int', coerceInt);
92
140
  registerType('float', coerceFloat);
@@ -0,0 +1,32 @@
1
+ import * as fs from 'fs';
2
+ import * as os from 'os';
3
+ import * as path from 'path';
4
+ import { loadRaw } from '../src/loader';
5
+ import { inferScript } from '../src/inference';
6
+ import { buildSchema } from '../src/cli';
7
+
8
+ function schemaFor(toml: string, name: string): Record<string, unknown> {
9
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'runspec-emit-test-'));
10
+ const file = path.join(dir, 'runspec.toml');
11
+ fs.writeFileSync(file, toml);
12
+ const raw = loadRaw(file);
13
+ const script = inferScript(raw.runnables[name], raw.config.autonomyDefault);
14
+ return buildSchema(name, script, 'mcp');
15
+ }
16
+
17
+ test('string validation fields emit to JSON Schema', () => {
18
+ const schema = schemaFor(
19
+ `
20
+ [greet]
21
+ [greet.args]
22
+ slug = {type = "str", pattern = "[a-z]+-[0-9]+", min-length = 3, max-length = 10}
23
+ `,
24
+ 'greet',
25
+ );
26
+ const props = (schema['inputSchema'] as Record<string, Record<string, unknown>>)['properties'];
27
+ const slug = (props as Record<string, Record<string, unknown>>)['slug'];
28
+ expect(slug['type']).toBe('string');
29
+ expect(slug['pattern']).toBe('[a-z]+-[0-9]+');
30
+ expect(slug['minLength']).toBe(3);
31
+ expect(slug['maxLength']).toBe(10);
32
+ });
@@ -171,6 +171,26 @@ describe('complex.toml', () => {
171
171
  const run = inferred.commands['run'];
172
172
  expect(run.autonomyReason).toBe('Writes output files and may call external APIs');
173
173
  });
174
+
175
+ test('run subcommand: label has pattern / min-length / max-length', () => {
176
+ const raw = loadRaw(COMPLEX);
177
+ const inferred = inferScript(raw.runnables['pipeline'], raw.config.autonomyDefault);
178
+ const label = inferred.commands['run'].args['label'];
179
+ expect(label.type).toBe('str');
180
+ expect(label.pattern).toBe('[a-z][a-z0-9-]+');
181
+ expect(label.minLength).toBe(3);
182
+ expect(label.maxLength).toBe(32);
183
+ });
184
+
185
+ test('db runnable: require-command with subcommands', () => {
186
+ const raw = loadRaw(COMPLEX);
187
+ const db = raw.runnables['db'];
188
+ expect(db.requireCommand).toBe(true);
189
+ expect(db.commands['migrate']).toBeDefined();
190
+ expect(db.commands['seed']).toBeDefined();
191
+ // Defaults to false where unset.
192
+ expect(raw.runnables['pipeline'].requireCommand).toBe(false);
193
+ });
174
194
  });
175
195
 
176
196
  // ── cross-fixture: inference rules ────────────────────────────────────────────
@@ -106,6 +106,39 @@ workers = {default = 4, range = [1, 32]}
106
106
  expect(raw.runnables['greet'].args['workers'].range).toEqual([1, 32]);
107
107
  });
108
108
 
109
+ test('normalises pattern / min-length / max-length fields', () => {
110
+ const dir = tmpDir();
111
+ const file = path.join(dir, 'runspec.toml');
112
+ fs.writeFileSync(file, `
113
+ [greet]
114
+ description = "hi"
115
+
116
+ [greet.args]
117
+ slug = {type = "str", pattern = "[a-z]+-[0-9]+", min-length = 3, max-length = 10}
118
+ `);
119
+ const raw = loadRaw(file);
120
+ const slug = raw.runnables['greet'].args['slug'];
121
+ expect(slug.pattern).toBe('[a-z]+-[0-9]+');
122
+ expect(slug.minLength).toBe(3);
123
+ expect(slug.maxLength).toBe(10);
124
+ });
125
+
126
+ test('normalises require-command on a runnable', () => {
127
+ const dir = tmpDir();
128
+ const file = path.join(dir, 'runspec.toml');
129
+ fs.writeFileSync(file, `
130
+ [db]
131
+ require-command = true
132
+
133
+ [db.commands.migrate]
134
+ description = "run migrations"
135
+ `);
136
+ const raw = loadRaw(file);
137
+ expect(raw.runnables['db'].requireCommand).toBe(true);
138
+ // Defaults to false when absent.
139
+ expect(raw.runnables['db'].commands['migrate'].requireCommand).toBe(false);
140
+ });
141
+
109
142
  test('normalises group fields', () => {
110
143
  const dir = tmpDir();
111
144
  const file = path.join(dir, 'runspec.toml');
@@ -1,7 +1,7 @@
1
1
  import * as fs from 'fs';
2
2
  import * as os from 'os';
3
3
  import * as path from 'path';
4
- import { parse } from '../src/parser';
4
+ import { parse, loadSpec } from '../src/parser';
5
5
 
6
6
  function makeTmpConfig(toml: string): string {
7
7
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'runspec-parser-test-'));
@@ -65,3 +65,177 @@ describe('env resolution', () => {
65
65
  expect(args['quality']).toBe(85);
66
66
  });
67
67
  });
68
+
69
+ // ── help display ──────────────────────────────────────────────────────────────
70
+
71
+ describe('help display', () => {
72
+ let logSpy: jest.SpyInstance;
73
+ let exitSpy: jest.SpyInstance;
74
+ let output: string[];
75
+
76
+ beforeEach(() => {
77
+ output = [];
78
+ logSpy = jest.spyOn(console, 'log').mockImplementation((...args) => {
79
+ output.push(args.join(' '));
80
+ });
81
+ exitSpy = jest.spyOn(process, 'exit').mockImplementation((() => {
82
+ throw new Error('__exit__');
83
+ }) as never);
84
+ });
85
+
86
+ afterEach(() => {
87
+ logSpy.mockRestore();
88
+ exitSpy.mockRestore();
89
+ });
90
+
91
+ const runHelp = (toml: string, name: string, argv: string[]): string => {
92
+ const configPath = makeTmpConfig(toml);
93
+ try {
94
+ parse({ scriptName: name, argv, configPath });
95
+ } catch (e) {
96
+ if ((e as Error).message !== '__exit__') throw e;
97
+ }
98
+ return output.join('\n');
99
+ };
100
+
101
+ test('usage line renders <command> after parent args', () => {
102
+ const toml = `
103
+ [pipeline]
104
+ description = "Run a pipeline"
105
+ [pipeline.args]
106
+ verbose = {type = "flag"}
107
+ config = {type = "path"}
108
+ [pipeline.commands.run]
109
+ description = "Run it"
110
+ `;
111
+ const out = runHelp(toml, 'pipeline', ['--help']);
112
+ const usage = out.split('\n').find((l) => l.startsWith('Usage:'))!;
113
+ expect(usage.indexOf('--verbose')).toBeLessThan(usage.indexOf('<command>'));
114
+ expect(usage.indexOf('--config')).toBeLessThan(usage.indexOf('<command>'));
115
+ expect(usage.trimEnd().endsWith('<command>')).toBe(true);
116
+ expect(out).toContain('Commands:');
117
+ expect(out).toContain('run');
118
+ });
119
+
120
+ test('nested subcommands resolve to full path in usage', () => {
121
+ const toml = `
122
+ [outer]
123
+ [outer.commands.inner]
124
+ [outer.commands.inner.commands.deep]
125
+ description = "Deepest"
126
+ [outer.commands.inner.commands.deep.args]
127
+ bar = {type = "str", required = true}
128
+ `;
129
+ const out = runHelp(toml, 'outer', ['inner', 'deep', '--help']);
130
+ expect(out).toContain('Usage: outer inner deep');
131
+ expect(out).toContain('--bar');
132
+ expect(out).toContain('Deepest');
133
+ });
134
+
135
+ test('choice options render inline in usage', () => {
136
+ const toml = `
137
+ [greet]
138
+ [greet.args]
139
+ fmt = {type = "choice", options = ["text", "json", "xml"], default = "text"}
140
+ `;
141
+ const out = runHelp(toml, 'greet', ['--help']);
142
+ expect(out).toContain('[--fmt <text|json|xml>]');
143
+ });
144
+
145
+ test('usage places <command> before -- <rest>', () => {
146
+ const toml = `
147
+ [multi]
148
+ [multi.args]
149
+ host = {type = "str", position = 1}
150
+ extra = {type = "rest"}
151
+ [multi.commands.run]
152
+ description = "Run it"
153
+ `;
154
+ const out = runHelp(toml, 'multi', ['--help']);
155
+ const usage = out.split('\n').find((l) => l.startsWith('Usage:'))!;
156
+ expect(usage.indexOf('<host>')).toBeLessThan(usage.indexOf('<command>'));
157
+ expect(usage.indexOf('<command>')).toBeLessThan(usage.indexOf('-- <extra>'));
158
+ });
159
+ });
160
+
161
+ // ── require-command ─────────────────────────────────────────────────────────────
162
+
163
+ describe('require-command', () => {
164
+ const DB_TOML = `
165
+ [db]
166
+ require-command = true
167
+ [db.commands.migrate]
168
+ [db.commands.seed]
169
+ `;
170
+
171
+ test('no command errors and lists commands', () => {
172
+ const configPath = makeTmpConfig(DB_TOML);
173
+ let msg = '';
174
+ try {
175
+ parse({ scriptName: 'db', argv: [], configPath });
176
+ throw new Error('expected parse to throw');
177
+ } catch (e) {
178
+ msg = (e as Error).message;
179
+ }
180
+ expect(msg).toContain('requires a command');
181
+ expect(msg).toContain('migrate');
182
+ expect(msg).toContain('seed');
183
+ });
184
+
185
+ test('valid command passes', () => {
186
+ const configPath = makeTmpConfig(DB_TOML);
187
+ const args = parse({ scriptName: 'db', argv: ['migrate'], configPath });
188
+ expect(args.runspec_command_path).toEqual(['migrate']);
189
+ expect(args.runspec_command).toBe('migrate');
190
+ });
191
+
192
+ test('nested requirement enforced at each depth', () => {
193
+ const toml = `
194
+ [app]
195
+ require-command = true
196
+ [app.commands.db]
197
+ require-command = true
198
+ [app.commands.db.commands.migrate]
199
+ `;
200
+ const configPath = makeTmpConfig(toml);
201
+ // Choosing the intermediate command isn't enough — db itself requires one.
202
+ expect(() => parse({ scriptName: 'app', argv: ['db'], configPath })).toThrow(/requires a command/);
203
+ // Going all the way down satisfies it.
204
+ const args = parse({ scriptName: 'app', argv: ['db', 'migrate'], configPath });
205
+ expect(args.runspec_command_path).toEqual(['db', 'migrate']);
206
+ });
207
+
208
+ test('backward compatible: commands without require-command run at root', () => {
209
+ const toml = `
210
+ [tool]
211
+ [tool.commands.run]
212
+ `;
213
+ const configPath = makeTmpConfig(toml);
214
+ const args = parse({ scriptName: 'tool', argv: [], configPath });
215
+ expect(args.runspec_command_path).toEqual([]);
216
+ });
217
+
218
+ test('loadSpec bypasses enforcement', () => {
219
+ const configPath = makeTmpConfig(DB_TOML);
220
+ const spec = loadSpec({ scriptName: 'db', configPath });
221
+ expect(spec.runspec_command_path).toEqual([]);
222
+ });
223
+
224
+ test('--help still works at a required level', () => {
225
+ const configPath = makeTmpConfig(DB_TOML);
226
+ const output: string[] = [];
227
+ const logSpy = jest.spyOn(console, 'log').mockImplementation((...a) => { output.push(a.join(' ')); });
228
+ const exitSpy = jest.spyOn(process, 'exit').mockImplementation((() => { throw new Error('__exit__'); }) as never);
229
+ try {
230
+ parse({ scriptName: 'db', argv: ['--help'], configPath });
231
+ } catch (e) {
232
+ if ((e as Error).message !== '__exit__') throw e;
233
+ } finally {
234
+ logSpy.mockRestore();
235
+ exitSpy.mockRestore();
236
+ }
237
+ const out = output.join('\n');
238
+ expect(out).toContain('Commands (required):');
239
+ expect(out).toContain('migrate');
240
+ });
241
+ });