@vmz/commander 0.3.5 → 0.3.7

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/README.md CHANGED
@@ -6,6 +6,7 @@ i18n-first TypeScript CLI framework under the `@vmz` scope. **Not** bound to `@v
6
6
  - **Help is derived** from the command tree — do not paste Usage walls into catalogs
7
7
  - **Locales directory loading**: `locales.json` + `<locale>/*.json` → flatten → `LocalizePlugin`
8
8
  - Framework chrome/err ids are `commander.*` with tiny English fallbacks (products may override)
9
+ - Interactive help uses a small ANSI theme for headings, commands, options, and errors; `NO_COLOR` disables it, `FORCE_COLOR` enables it outside a TTY
9
10
  - **No product language packs** in this package
10
11
 
11
12
  ## Quick start
@@ -41,10 +42,11 @@ Locales layout (any product):
41
42
  | API | Role |
42
43
  |-----|------|
43
44
  | `createCli(name)` | Program name; help `{name}` |
44
- | `.command` / `.option` / `.action` / `.passthrough` | Command tree |
45
+ | `.command` / `.option` / `.action` / `.actionCtx` / `.passthrough` | Command tree |
45
46
  | `.intro(id)` | Short banner only |
46
47
  | `.locales(root)` / `.use(plugin)` / `.catalog(loader)` | Localization (later `.use` wins) |
47
48
  | `.option` on CLI | Global flags (merged into action options) |
49
+ | `.onDiagnostic(handler)` | Route structured action failures to a product-owned renderer |
48
50
  | `loadLocalesManifest` / `loadCatalog` / `flattenCatalog` | Filesystem catalog helpers |
49
51
  | `createLocalizeFromLocales` | Build a `LocalizePlugin` from a root |
50
52
  | `assertCatalogCoverage(cli, catalog)` | Dev/CI: registered helpIds must exist |
@@ -52,6 +54,35 @@ Locales layout (any product):
52
54
 
53
55
  `t` resolution: product catalog → `commander` English fallback → `{{id}}`.
54
56
 
57
+ ## Option config
58
+
59
+ `.option(rawName, helpId, config?)` accepts runtime constraints in addition to the `<value>` / `[value]` / `...` syntax:
60
+
61
+ | Field | Behavior |
62
+ |-------|----------|
63
+ | `required` | Missing value before the action → structured parse error, exit code 2 |
64
+ | `enum` | Value outside the set → structured parse error, exit code 2 (repeatable values are checked per item) |
65
+ | `default` | Filled into `options[key]` when omitted (boolean, string, or array) |
66
+ | `coerce` | Runs on the supplied raw value, thrown errors become exit code 2 |
67
+ | `hidden` | Omitted from help, still parseable |
68
+ | `example` | Rendered after the option in help |
69
+ | `deprecated` | Annotated in help and emits a `commander::option_deprecated` warning when used |
70
+ | `group` | Rendered under its own help section while ungrouped options keep the default section |
71
+
72
+ Validation happens before the action handler runs. Failures carry stable `commander::*` codes plus structured `args`.
73
+
74
+ ## Command boundaries
75
+
76
+ - `.actionCtx(handler)` receives `(options, args, ctx)`, with `ctx.help`, `ctx.stdout`, `ctx.stderr`, `ctx.fail`, `ctx.warn`, and `ctx.note`. `.action` and `.actionCtx` are mutually exclusive.
77
+ - `--` ends option parsing. Every token after it is a raw positional and is merged into `options._` — options and help markers in the tail are never reinterpreted.
78
+ - `--help` binds to the deepest command path reached before the marker, so `demo document check --help` and `help document check` show the same node.
79
+
80
+ ## Diagnostics
81
+
82
+ Action failures may throw a language-neutral diagnostic object with `code`, `args`, `path`, `span`, `severity`, and optional legacy `message`. The configured handler receives the structured row and resolved locale context. Without a handler, commander renders the `code` through `t` with `args` interpolation, falling back to a real business message or the code. Plain `Error` exceptions are wrapped as `commander::action_failed` during migration. Commander does not own diagnostic formatting: products should connect `@vmz/diagnostic`.
83
+
84
+ Framework hints use `commander.ui.hint.available_options` (`{command}`) and `commander.ui.hint.did_you_mean` (`{suggest}`), so product catalogs can translate them. The available-options hint names the full executable command (`demo run --help`) and falls back to the root command for unknown commands.
85
+
55
86
  ## Layering
56
87
 
57
88
  ```text
package/dist/index.d.ts CHANGED
@@ -5,16 +5,19 @@
5
5
  * pluggable Localize plugin or `.locales(root)`. Framework chrome/err ids are
6
6
  * `commander.*` with tiny English fallbacks — no product language packs here.
7
7
  */
8
- export type { CatalogLoader, LocaleCatalog, LocalizePlugin } from './types.js';
8
+ export type { CatalogLoader, CommandContext, CommandDiagnostic, DiagnosticContext, DiagnosticHandler, LocaleCatalog, LocalizePlugin, OptionConfig } from './types.js';
9
+ export { CliError } from './types.js';
9
10
  export { COMMANDER_FALLBACK_EN_US, clearLocalesCache, createLocalizeFromLocales, flattenCatalog, loadCatalog, loadLocalesManifest, resolveLocale, translate, translateWithFallback, } from './locales.js';
10
11
  export type { CreateLocalizeFromLocalesOptions, LocalesManifest } from './locales.js';
11
- import type { CatalogLoader, LocaleCatalog, LocalizePlugin } from './types.js';
12
+ import type { CatalogLoader, CommandContext, DiagnosticHandler, LocaleCatalog, LocalizePlugin, OptionConfig } from './types.js';
12
13
  /** Options bag passed to actions (`_` = positionals). */
13
14
  export type ParsedOptions = Record<string, string | boolean | string[]> & {
14
15
  _: string[];
15
16
  };
16
- /** Command action after argv is parsed. */
17
+ /** Command action after argv is parsed (legacy API — no context). */
17
18
  export type ActionHandler = (options: ParsedOptions, ...args: string[]) => void | number | Promise<void | number>;
19
+ /** Command action with full framework context (preferred API — additive, no break). */
20
+ export type ActionCtxHandler = (options: ParsedOptions, args: string[], ctx: CommandContext) => void | number | Promise<void | number>;
18
21
  /** Registered option (structure only). */
19
22
  export type OptionDef = {
20
23
  rawName: string;
@@ -29,6 +32,8 @@ export type OptionDef = {
29
32
  optionalValue: boolean;
30
33
  /** True when the option may repeat (`--dirty <path>...` ⇒ `string[]`). */
31
34
  repeatable: boolean;
35
+ /** Runtime constraints (enum / required / default / coerce / deprecated). */
36
+ config?: OptionConfig;
32
37
  };
33
38
  /** Registered command node. */
34
39
  export type CommandDef = {
@@ -37,14 +42,20 @@ export type CommandDef = {
37
42
  /** Match tokens from `rawName` split on `|`. */
38
43
  names: string[];
39
44
  options: OptionDef[];
45
+ /** Legacy action handler (no ctx). */
40
46
  action?: ActionHandler;
47
+ /** Phase 3 action handler (with ctx — preferred). */
48
+ actionCtx?: ActionCtxHandler;
41
49
  children: CommandDef[];
42
50
  /** If true, do not parse options; remaining argv is positionals / raw rest. */
43
51
  passthrough: boolean;
44
52
  };
45
53
  export interface Command {
46
- option(rawName: string, helpId: string): this;
54
+ option(rawName: string, helpId: string, config?: OptionConfig): this;
55
+ /** Legacy API — handler receives `(options, ...args)` without ctx. */
47
56
  action(handler: ActionHandler): this;
57
+ /** Phase 3 API — handler receives `(options, args[], ctx)` with full command context. */
58
+ actionCtx(handler: ActionCtxHandler): this;
48
59
  command(rawName: string, helpId: string): Command;
49
60
  /** Leave remaining argv unparsed (for nested domain CLIs). */
50
61
  passthrough(): this;
@@ -71,6 +82,8 @@ export interface Cli {
71
82
  */
72
83
  help(helpId: string): this;
73
84
  command(rawName: string, helpId: string): Command;
85
+ /** Route structured action failures to a product-owned renderer. */
86
+ onDiagnostic(handler: DiagnosticHandler): this;
74
87
  parse(argv?: string[]): Promise<number>;
75
88
  }
76
89
  /** Derive command help from one node (children + options). */
@@ -96,6 +109,10 @@ export declare function normalizeArgv(argv: string[]): string[];
96
109
  /** Parse `--out-dir <dir>` / `-o, --out-dir <dir>` / `--dirty <path>...` into an OptionDef. */
97
110
  export declare function parseOptionDef(rawName: string, helpId: string): OptionDef;
98
111
  /**
99
- * Parse argv against registered options. Throws `unknown_option:…` / `missing_value:…`.
112
+ * Parse argv against registered options (strict mode).
113
+ *
114
+ * Throws {@link CliError} with stable code `commander::unknown_option` /
115
+ * `commander::missing_option_value` (exit code 2) on validation failure.
116
+ * Non-option tokens become positionals under `options._`.
100
117
  */
101
118
  export declare function parseOptions(argv: string[], optionDefs: OptionDef[]): ParsedOptions;
package/dist/index.js CHANGED
@@ -5,8 +5,147 @@
5
5
  * pluggable Localize plugin or `.locales(root)`. Framework chrome/err ids are
6
6
  * `commander.*` with tiny English fallbacks — no product language packs here.
7
7
  */
8
+ export { CliError } from './types.js';
8
9
  export { COMMANDER_FALLBACK_EN_US, clearLocalesCache, createLocalizeFromLocales, flattenCatalog, loadCatalog, loadLocalesManifest, resolveLocale, translate, translateWithFallback, } from './locales.js';
10
+ import { CliError } from './types.js';
9
11
  import { COMMANDER_FALLBACK_EN_US, createLocalizeFromLocales, translate, translateWithFallback } from './locales.js';
12
+ const ANSI = {
13
+ bold: '\u001b[1m',
14
+ dim: '\u001b[2m',
15
+ cyan: '\u001b[36m',
16
+ blue: '\u001b[34m',
17
+ green: '\u001b[32m',
18
+ yellow: '\u001b[33m',
19
+ red: '\u001b[31m',
20
+ };
21
+ const ANSI_RESET = '\u001b[0m';
22
+ /** Keep CI and redirected output plain while making interactive help scannable. */
23
+ function colorEnabled() {
24
+ if (process.env.NO_COLOR !== undefined)
25
+ return false;
26
+ if (process.env.FORCE_COLOR && process.env.FORCE_COLOR !== '0')
27
+ return true;
28
+ // Node.js reports `isTTY` as `undefined` in many Windows shells (PowerShell,
29
+ // Git Bash) even when the host (Windows Terminal, ConEmu, mintty) does emit
30
+ // ANSI. `tty.isatty(fd)` is reliable on every platform because it asks the
31
+ // OS directly. On top of that we also check well-known Windows-terminal
32
+ // env vars (`WT_SESSION`, `ConEmuANSI`, `ANSICON`) that legacy clients use
33
+ // to declare ANSI support. A `TERM=dumb` redirect still falls through.
34
+ const tty = tryImportTty();
35
+ if (tty && tty.isatty(process.stdout?.fd ?? 0))
36
+ return true;
37
+ if (tty && tty.isatty(process.stderr?.fd ?? 2))
38
+ return true;
39
+ if (process.env.WT_SESSION)
40
+ return true;
41
+ if (process.env.ConEmuANSI === 'ON')
42
+ return true;
43
+ if (process.env.ANSICON)
44
+ return true;
45
+ if (process.env.TERM && process.env.TERM !== 'dumb')
46
+ return true;
47
+ return Boolean(process.stdout?.isTTY || process.stderr?.isTTY);
48
+ }
49
+ /** Dynamically resolve Node's `node:tty` module — safe in non-Node runners. */
50
+ function tryImportTty() {
51
+ try {
52
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
53
+ return require('node:tty');
54
+ }
55
+ catch {
56
+ return undefined;
57
+ }
58
+ }
59
+ function paint(value, ...colors) {
60
+ return colorEnabled() && colors.length ? `${colors.map((color) => ANSI[color]).join('')}${value}${ANSI_RESET}` : value;
61
+ }
62
+ function errorText(value) {
63
+ return paint(value, 'bold', 'red');
64
+ }
65
+ /**
66
+ * Render one help section (header + padded table).
67
+ *
68
+ * Both the root help and the command-node help share the same 3-line pattern:
69
+ * push section header (`paint(header, 'bold', 'blue')`), then a loop of
70
+ * ` ${paint(label.padEnd(colWidth), color)} ${t(helpId)}`.
71
+ *
72
+ * Returns `lines[]` so callers can spread-append without trailing-empty concerns.
73
+ */
74
+ function renderHelpSectionWithHeader(t, header, rows, color, colWidth = 28) {
75
+ if (!rows.length)
76
+ return [];
77
+ const out = [paint(header, 'bold', 'blue')];
78
+ for (const r of rows) {
79
+ const label = r.suffix ? `${r.rawName} ${r.suffix}` : r.rawName;
80
+ out.push(` ${paint(label.padEnd(colWidth), color)} ${t(r.helpId)}`);
81
+ }
82
+ return out;
83
+ }
84
+ function renderHelpSection(t, headerId, rows, color, colWidth = 28) {
85
+ return renderHelpSectionWithHeader(t, t(headerId), rows, color, colWidth);
86
+ }
87
+ /** Parse/validation diagnostics that get the framework-level "available options" hint. */
88
+ const HINT_CODES = new Set([
89
+ 'commander::unknown_command',
90
+ 'commander::unknown_option',
91
+ 'commander::missing_option_value',
92
+ 'commander::parse_failed',
93
+ 'commander::missing_required_option',
94
+ 'commander::option_not_in_enum',
95
+ 'commander::option_coerce_failed',
96
+ ]);
97
+ /** Resolve a catalog id, treating `{{id}}` (unresolved) as absent. */
98
+ function localizedOrId(t, id, args) {
99
+ const value = t(id, args);
100
+ return value === `{{${id}}}` ? '' : value;
101
+ }
102
+ /** Localized suffix appended to one option row in help. */
103
+ function optionSuffix(t, def) {
104
+ const config = def.config;
105
+ if (!config)
106
+ return '';
107
+ const parts = [];
108
+ if (config.example)
109
+ parts.push(t('commander.ui.option_example', { example: config.example }));
110
+ if (config.deprecated) {
111
+ const label = typeof config.deprecated === 'string' && config.deprecated
112
+ ? (localizedOrId(t, config.deprecated) || config.deprecated)
113
+ : t('commander.ui.deprecated');
114
+ if (label)
115
+ parts.push(label);
116
+ }
117
+ return parts.join(' ');
118
+ }
119
+ /**
120
+ * Render option help, splitting grouped options into their own sections.
121
+ *
122
+ * Options without `group` keep the default `Options:` section. `hidden` options
123
+ * are omitted from help but remain parseable.
124
+ */
125
+ function renderOptionSections(t, options, color) {
126
+ const visible = options.filter((o) => !o.config?.hidden);
127
+ const ungrouped = [];
128
+ const grouped = new Map();
129
+ for (const option of visible) {
130
+ const group = option.config?.group;
131
+ if (!group) {
132
+ ungrouped.push(option);
133
+ continue;
134
+ }
135
+ const list = grouped.get(group) ?? [];
136
+ list.push(option);
137
+ grouped.set(group, list);
138
+ }
139
+ const rows = (list) => list.map((o) => ({ rawName: o.rawName, helpId: o.helpId, suffix: optionSuffix(t, o) }));
140
+ const out = renderHelpSection(t, 'commander.ui.options', rows(ungrouped), color);
141
+ for (const [group, list] of grouped) {
142
+ const header = localizedOrId(t, group) || group;
143
+ if (out.length)
144
+ out.push('');
145
+ out.push(...renderHelpSectionWithHeader(t, header, rows(list), color));
146
+ }
147
+ return out;
148
+ }
10
149
  class CommandBuilder {
11
150
  def;
12
151
  constructor(rawName, helpId) {
@@ -20,15 +159,26 @@ class CommandBuilder {
20
159
  passthrough: false,
21
160
  };
22
161
  }
23
- option(rawName, helpId) {
162
+ option(rawName, helpId, config) {
24
163
  assertHelpId(helpId, 'option');
25
- this.def.options.push(parseOptionDef(rawName, helpId));
164
+ const def = parseOptionDef(rawName, helpId);
165
+ if (config)
166
+ def.config = config;
167
+ this.def.options.push(def);
26
168
  return this;
27
169
  }
28
170
  action(handler) {
171
+ if (this.def.actionCtx)
172
+ throw CliError.configError('commander::config::action_conflict', { detail: 'Cannot set both .action() and .actionCtx()' });
29
173
  this.def.action = handler;
30
174
  return this;
31
175
  }
176
+ actionCtx(handler) {
177
+ if (this.def.action)
178
+ throw CliError.configError('commander::config::action_conflict', { detail: 'Cannot set both .action() and .actionCtx()' });
179
+ this.def.actionCtx = handler;
180
+ return this;
181
+ }
32
182
  passthrough() {
33
183
  this.def.passthrough = true;
34
184
  return this;
@@ -45,6 +195,7 @@ class CliBuilder {
45
195
  /** When set, `parse` rebuilds localize from this root (argv/env/`--locale`). */
46
196
  localesRoot = null;
47
197
  localesEnvKeys;
198
+ diagnosticHandler;
48
199
  introId = null;
49
200
  roots = [];
50
201
  rootOptions = [];
@@ -53,7 +204,7 @@ class CliBuilder {
53
204
  }
54
205
  use(plugin) {
55
206
  if (!plugin || typeof plugin.t !== 'function') {
56
- throw new Error('@vmz/commander: LocalizePlugin.t is required');
207
+ throw CliError.configError('commander::config::invalid_localize_plugin', { detail: 'LocalizePlugin.t is required' });
57
208
  }
58
209
  this.localesRoot = null;
59
210
  this.localesEnvKeys = undefined;
@@ -62,13 +213,13 @@ class CliBuilder {
62
213
  }
63
214
  catalog(loader) {
64
215
  if (typeof loader !== 'function') {
65
- throw new Error('@vmz/commander: catalog(loader) requires a function');
216
+ throw CliError.configError('commander::config::catalog_loader_not_function');
66
217
  }
67
218
  return this.use({
68
219
  t: (id, args) => {
69
220
  const table = loader('en-US');
70
221
  if (table && typeof table.then === 'function') {
71
- throw new Error('@vmz/commander: async CatalogLoader via .catalog() is not supported; use .use({ t })');
222
+ throw CliError.configError('commander::config::async_catalog_unsupported');
72
223
  }
73
224
  return translateWithFallback(id, args, table);
74
225
  },
@@ -76,7 +227,7 @@ class CliBuilder {
76
227
  }
77
228
  locales(root, opts = {}) {
78
229
  if (!root || typeof root !== 'string') {
79
- throw new Error('@vmz/commander: locales(root) requires a non-empty path');
230
+ throw CliError.configError('commander::config::invalid_locales_root');
80
231
  }
81
232
  this.localesRoot = root;
82
233
  this.localesEnvKeys = opts.envKeys;
@@ -105,6 +256,12 @@ class CliBuilder {
105
256
  this.roots.push(cmd);
106
257
  return cmd;
107
258
  }
259
+ onDiagnostic(handler) {
260
+ if (typeof handler !== 'function')
261
+ throw CliError.configError('commander::config::diagnostic_handler_not_function');
262
+ this.diagnosticHandler = handler;
263
+ return this;
264
+ }
108
265
  /** Help ids registered on this CLI (for {@link assertCatalogCoverage}). */
109
266
  collectHelpIds() {
110
267
  const ids = [];
@@ -125,7 +282,11 @@ class CliBuilder {
125
282
  }
126
283
  async parse(argvInput = process.argv) {
127
284
  const argv0 = normalizeArgv(argvInput);
128
- const { options: globalOpts, rest: argv } = peelKnownOptions(argv0, this.rootOptions);
285
+ const peeled = peelKnownOptions(argv0, this.rootOptions);
286
+ const globalOpts = peeled.options;
287
+ // A leading separator at program level only marks the end of global
288
+ // options. Keep the following command token dispatchable.
289
+ const argv = peeled.rest[0] === '--' ? peeled.rest.slice(1) : peeled.rest;
129
290
  let localize = this.localize;
130
291
  if (this.localesRoot) {
131
292
  const localeFlag = typeof globalOpts.locale === 'string' && globalOpts.locale ? globalOpts.locale : undefined;
@@ -138,21 +299,91 @@ class CliBuilder {
138
299
  }));
139
300
  }
140
301
  if (!localize) {
141
- throw new Error(translate('commander.err.localize_required', undefined, COMMANDER_FALLBACK_EN_US));
302
+ throw CliError.configError('commander::config::localize_required');
142
303
  }
143
304
  const t = localize.t.bind(localize);
144
- if (argv.length === 0 || isHelpToken(argv[0])) {
305
+ const diagnosticContext = {
306
+ locale: localize.resolveLocale?.({ argv: argv0, env: process.env }) ?? 'en-US',
307
+ t,
308
+ };
309
+ // `help <cmd> ...` 路由:把 help 后面的 token 当命令路径查找帮助
310
+ if (argv.length && argv[0] === 'help') {
311
+ const pathTokens = argv.slice(1);
312
+ return this.routeHelp(pathTokens, t, diagnosticContext);
313
+ }
314
+ if (argv.length === 0 || argv.every(isHelpToken)) {
145
315
  console.log(this.formatRootHelp(t));
146
316
  return 0;
147
317
  }
148
318
  const cmdToken = argv[0];
149
319
  const matched = this.roots.find((c) => c.def.names.includes(cmdToken));
150
320
  if (!matched) {
151
- console.error(t('commander.err.unknown_command', { cmd: cmdToken }));
321
+ // 未知命令:加上 suggestion
322
+ const names = this.roots.flatMap((r) => r.def.names);
323
+ const suggest = closestMatch(cmdToken, names);
324
+ this.emitDiagnostic({
325
+ code: 'commander::unknown_command',
326
+ severity: 'error',
327
+ args: suggest ? { cmd: cmdToken, suggest } : { cmd: cmdToken },
328
+ }, diagnosticContext, t);
329
+ return 2;
330
+ }
331
+ return await this.dispatch(matched.def, argv.slice(1), t, [cmdToken], globalOpts, diagnosticContext);
332
+ }
333
+ /** 路由 `help <cmd> [subcmd] ...` 到对应命令的帮助文本。 */
334
+ routeHelp(pathTokens, t, _ctx) {
335
+ if (!pathTokens.length) {
152
336
  console.log(this.formatRootHelp(t));
153
- return 1;
337
+ return 0;
338
+ }
339
+ let pool = this.roots.map((r) => r.def);
340
+ let matched;
341
+ let consumed = 0;
342
+ for (const tok of pathTokens) {
343
+ const found = pool?.find((c) => c.names.includes(tok));
344
+ if (!found)
345
+ break;
346
+ matched = found;
347
+ consumed += 1;
348
+ pool = found.children;
349
+ }
350
+ if (!matched) {
351
+ // 路径里没有一个有效命令 → 仍显示根帮助
352
+ console.log(this.formatRootHelp(t));
353
+ return 0;
354
+ }
355
+ const path = pathTokens.slice(0, consumed);
356
+ console.log(formatCommandHelp(this.name, matched, t, path, this.rootOptions));
357
+ return 0;
358
+ }
359
+ /** Emit a diagnostic through `onDiagnostic` when registered, else console.error with a short hint. */
360
+ emitDiagnostic(diag, ctx, t) {
361
+ if (this.diagnosticHandler) {
362
+ this.diagnosticHandler(diag, ctx);
363
+ }
364
+ else {
365
+ const code = diag.code ?? '';
366
+ // Catalog template wins so structured args stay interpolated (`--oops`).
367
+ // A business message is only a fallback for codes without a template.
368
+ const fromCatalog = code ? localizedOrId(t, code, diag.args) : '';
369
+ const businessMessage = diag.message && diag.message !== code ? diag.message : '';
370
+ const msg = fromCatalog || businessMessage || code || String(diag);
371
+ console.error(errorText(msg));
372
+ }
373
+ // hint 和 suggestion 是框架级 UI,无论 handler 是否存在都统一追加
374
+ if (diag.code && HINT_CODES.has(diag.code)) {
375
+ // unknown_command: hint 指向根命令(因为命令不存在)
376
+ // 其他: hint 指向完整的可执行命令路径
377
+ const cmd = typeof diag.args?.cmd === 'string' ? diag.args.cmd.trim() : '';
378
+ const hintTarget = diag.code === 'commander::unknown_command' || !cmd
379
+ ? this.name
380
+ : `${this.name} ${cmd}`;
381
+ console.error(paint(t('commander.ui.hint.available_options', { command: hintTarget }), 'dim'));
382
+ }
383
+ const suggest = typeof diag.args?.suggest === 'string' ? diag.args.suggest : '';
384
+ if (suggest) {
385
+ console.error(paint(t('commander.ui.hint.did_you_mean', { suggest }), 'dim'));
154
386
  }
155
- return await this.dispatch(matched.def, argv.slice(1), t, [cmdToken], globalOpts);
156
387
  }
157
388
  /** Derived root help: optional intro + commands/options from the registration tree. */
158
389
  formatRootHelp(t) {
@@ -160,62 +391,81 @@ class CliBuilder {
160
391
  if (this.introId) {
161
392
  lines.push(t(this.introId), '');
162
393
  }
163
- lines.push(t('commander.ui.usage', { name: this.name }), '', t('commander.ui.commands'));
164
- for (const c of this.roots) {
165
- lines.push(` ${c.def.rawName.padEnd(28)} ${t(c.def.helpId)}`);
166
- }
394
+ lines.push(paint(t('commander.ui.usage', { name: this.name }), 'bold', 'cyan'));
395
+ lines.push('');
396
+ lines.push(...renderHelpSection(t, 'commander.ui.commands', this.roots.map((c) => ({ rawName: c.def.rawName, helpId: c.def.helpId })), 'green'));
167
397
  const opts = [
168
398
  ...this.rootOptions,
169
399
  ...collectRootOptions(this.roots.map((c) => c.def)).filter((o) => !this.rootOptions.some((r) => r.key === o.key)),
170
400
  ];
171
- if (opts.length) {
172
- lines.push('', t('commander.ui.options'));
173
- for (const o of opts) {
174
- lines.push(` ${o.rawName.padEnd(28)} ${t(o.helpId)}`);
175
- }
401
+ const optionSection = renderOptionSections(t, opts, 'yellow');
402
+ if (optionSection.length) {
403
+ lines.push('');
404
+ lines.push(...optionSection);
176
405
  }
177
406
  return lines.join('\n');
178
407
  }
179
- async dispatch(def, rest, t, path, globalOpts) {
180
- if (rest.length && isHelpToken(rest[0])) {
181
- console.log(formatCommandHelp(this.name, def, t, path, this.rootOptions));
408
+ async dispatch(def, rest, t, path, globalOpts, diagnosticContext) {
409
+ // `--` ends option parsing. Keep head (options + command tokens) separate
410
+ // from tail (raw positionals) before help scanning or option parsing.
411
+ const separatorAt = rest.indexOf('--');
412
+ const head = separatorAt === -1 ? rest : rest.slice(0, separatorAt);
413
+ const tail = separatorAt === -1 ? [] : rest.slice(separatorAt + 1);
414
+ // Resolve the deepest command node before interpreting help markers, so
415
+ // `demo document check --help` binds to `check` instead of `document`.
416
+ let target = def;
417
+ const targetPath = path.slice();
418
+ const remaining = head.slice();
419
+ while (target.children.length && remaining.length) {
420
+ const token = remaining[0];
421
+ if (token.startsWith('-') || isHelpToken(token))
422
+ break;
423
+ const child = target.children.find((c) => c.names.includes(token));
424
+ if (!child)
425
+ break;
426
+ target = child;
427
+ targetPath.push(token);
428
+ remaining.shift();
429
+ }
430
+ // 扫描 --help / -h(只扫命令路径之后的 head,`--` 之后的 tail 不参与)
431
+ if (hasHelpToken(remaining)) {
432
+ console.log(formatCommandHelp(this.name, target, t, targetPath, this.rootOptions));
182
433
  return 0;
183
434
  }
184
- if (def.children.length && rest.length && !rest[0].startsWith('-')) {
185
- const childTok = rest[0];
186
- const child = def.children.find((c) => c.names.includes(childTok));
187
- if (child) {
188
- return await this.dispatch(child, rest.slice(1), t, [...path, childTok], globalOpts);
435
+ if (!target.action && !target.actionCtx) {
436
+ if (remaining.length || tail.length) {
437
+ const childNames = target.children.flatMap((c) => c.names);
438
+ const unknownToken = remaining[0];
439
+ const suggest = unknownToken && !unknownToken.startsWith('-')
440
+ ? closestMatch(unknownToken, childNames)
441
+ : '';
442
+ const cmd = [...targetPath, ...remaining, ...tail].join(' ');
443
+ this.emitDiagnostic({
444
+ code: 'commander::unknown_command',
445
+ severity: 'error',
446
+ args: suggest ? { cmd, suggest } : { cmd },
447
+ }, diagnosticContext, t);
448
+ return 2;
189
449
  }
190
- if (!def.action) {
191
- console.error(t('commander.err.unknown_command', { cmd: [...path, childTok].join(' ') }));
192
- console.log(formatCommandHelp(this.name, def, t, path, this.rootOptions));
193
- return 1;
194
- }
195
- }
196
- if (!def.action) {
197
- console.log(formatCommandHelp(this.name, def, t, path, this.rootOptions));
198
- return rest.length ? 1 : 0;
450
+ console.log(formatCommandHelp(this.name, target, t, targetPath, this.rootOptions));
451
+ return 0;
199
452
  }
200
453
  let options;
454
+ let usedOptions = new Set();
201
455
  try {
202
- options = def.passthrough ? { _: rest.slice() } : parseOptions(rest, def.options);
203
- }
204
- catch (err) {
205
- const msg = err instanceof Error ? err.message : String(err);
206
- if (msg.startsWith('unknown_option:')) {
207
- console.error(t('commander.err.unknown_option', { option: msg.slice('unknown_option:'.length) }));
208
- }
209
- else if (msg.startsWith('missing_value:')) {
210
- console.error(t('commander.err.missing_option_value', {
211
- option: msg.slice('missing_value:'.length),
212
- }));
456
+ if (target.passthrough) {
457
+ options = { _: [...remaining, ...tail] };
213
458
  }
214
459
  else {
215
- console.error(msg);
460
+ ({ options, used: usedOptions } = parseOptionsDetailed(remaining, target.options));
461
+ options._.push(...tail);
216
462
  }
217
- console.log(formatCommandHelp(this.name, def, t, path, this.rootOptions));
218
- return 1;
463
+ }
464
+ catch (err) {
465
+ const cliErr = CliError.from(err, 'commander::parse_failed');
466
+ const args = { ...(cliErr.args ?? {}), cmd: targetPath.join(' ') };
467
+ this.emitDiagnostic({ code: cliErr.code, severity: 'error', args, message: cliErr.message }, diagnosticContext, t);
468
+ return cliErr.exitCode;
219
469
  }
220
470
  for (const [k, v] of Object.entries(globalOpts)) {
221
471
  if (k === '_')
@@ -223,28 +473,81 @@ class CliBuilder {
223
473
  if (options[k] === undefined)
224
474
  options[k] = v;
225
475
  }
226
- const result = await def.action(options, ...options._);
227
- if (typeof result === 'number')
228
- return result;
229
- return 0;
476
+ for (const option of target.options) {
477
+ const deprecated = option.config?.deprecated;
478
+ if (!deprecated || !usedOptions.has(option))
479
+ continue;
480
+ const args = { option: option.key };
481
+ if (typeof deprecated === 'string' && deprecated) {
482
+ args.detail = localizedOrId(t, deprecated) || deprecated;
483
+ }
484
+ this.emitDiagnostic({ code: 'commander::option_deprecated', severity: 'warning', args }, diagnosticContext, t);
485
+ }
486
+ try {
487
+ let result;
488
+ if (target.actionCtx) {
489
+ const ctx = this.buildCommandContext(target, targetPath, diagnosticContext.locale, t);
490
+ result = await target.actionCtx(options, options._, ctx);
491
+ }
492
+ else if (target.action) {
493
+ result = await target.action(options, ...options._);
494
+ }
495
+ else {
496
+ console.log(formatCommandHelp(this.name, target, t, targetPath, this.rootOptions));
497
+ return 0;
498
+ }
499
+ if (typeof result === 'number')
500
+ return result;
501
+ return 0;
502
+ }
503
+ catch (err) {
504
+ const diagnostic = normalizeDiagnostic(err);
505
+ this.emitDiagnostic(diagnostic, diagnosticContext, t);
506
+ // CliError.exitCode is POSIX-correct (2 for parse/validation, 1 for config/action)
507
+ if (err instanceof CliError)
508
+ return err.exitCode;
509
+ return 1;
510
+ }
511
+ }
512
+ /** Build a CommandContext for .actionCtx() handlers. */
513
+ buildCommandContext(def, path, locale, t) {
514
+ const self = this;
515
+ const name = path.join(' ');
516
+ return {
517
+ commandPath: [this.name, ...path],
518
+ commandName: name,
519
+ locale,
520
+ t,
521
+ help: () => console.log(formatCommandHelp(this.name, def, t, path, this.rootOptions)),
522
+ stderr: (...msgs) => { for (const m of msgs)
523
+ console.error(m); },
524
+ stdout: (...msgs) => { for (const m of msgs)
525
+ console.log(m); },
526
+ fail: (code, args) => {
527
+ self.emitDiagnostic({ code, severity: 'error', args: args ?? {}, message: args ? (args.message ?? '') : '' }, { locale, t }, t);
528
+ throw CliError.configError(code, args ?? {});
529
+ },
530
+ warn: (code, args) => {
531
+ self.emitDiagnostic({ code, severity: 'warning', args: args ?? {}, message: args ? (args.message ?? '') : '' }, { locale, t }, t);
532
+ },
533
+ note: (code, args) => {
534
+ self.emitDiagnostic({ code, severity: 'advice', args: args ?? {}, message: args ? (args.message ?? '') : '' }, { locale, t }, t);
535
+ },
536
+ };
230
537
  }
231
538
  }
232
539
  /** Derive command help from one node (children + options). */
233
540
  export function formatCommandHelp(cliName, def, t, path, rootOptions = []) {
234
- const lines = [`${cliName} ${path.join(' ')} — ${t(def.helpId)}`, ''];
541
+ const lines = [paint(`${cliName} ${path.join(' ')}`, 'bold', 'cyan') + ` — ${t(def.helpId)}`];
235
542
  if (def.children.length) {
236
- lines.push(t('commander.ui.commands'));
237
- for (const c of def.children) {
238
- lines.push(` ${c.rawName.padEnd(28)} ${t(c.helpId)}`);
239
- }
240
543
  lines.push('');
544
+ lines.push(...renderHelpSection(t, 'commander.ui.commands', def.children.map((c) => ({ rawName: c.rawName, helpId: c.helpId })), 'green'));
241
545
  }
242
546
  const opts = [...rootOptions, ...def.options.filter((o) => !rootOptions.some((r) => r.key === o.key))];
243
- if (opts.length) {
244
- lines.push(t('commander.ui.options'));
245
- for (const o of opts) {
246
- lines.push(` ${o.rawName.padEnd(28)} ${t(o.helpId)}`);
247
- }
547
+ const optionSection = renderOptionSections(t, opts, 'yellow');
548
+ if (optionSection.length) {
549
+ lines.push('');
550
+ lines.push(...optionSection);
248
551
  }
249
552
  return lines.join('\n').trimEnd();
250
553
  }
@@ -267,7 +570,7 @@ function collectRootOptions(roots) {
267
570
  */
268
571
  export function createCli(name) {
269
572
  if (!name || typeof name !== 'string') {
270
- throw new Error('@vmz/commander: createCli(name) requires a non-empty program name');
573
+ throw CliError.configError('commander::config::invalid_cli_name', { detail: String(name) });
271
574
  }
272
575
  return new CliBuilder(name);
273
576
  }
@@ -278,7 +581,7 @@ export function assertCatalogCoverage(cli, catalog) {
278
581
  const ids = typeof cli.collectHelpIds === 'function' ? cli.collectHelpIds() : [];
279
582
  const missing = ids.filter((id) => !Object.prototype.hasOwnProperty.call(catalog, id) && !Object.prototype.hasOwnProperty.call(COMMANDER_FALLBACK_EN_US, id));
280
583
  if (missing.length) {
281
- throw new Error(`@vmz/commander: catalog missing help ids:\n ${missing.sort().join('\n ')}`);
584
+ throw CliError.configError('commander::config::catalog_missing_help_ids', { ids: missing.sort().join(', ') });
282
585
  }
283
586
  }
284
587
  function wrapWithCommanderFallback(plugin) {
@@ -296,10 +599,15 @@ function wrapWithCommanderFallback(plugin) {
296
599
  };
297
600
  }
298
601
  /**
299
- * Consume known root options; leave unknown flags and positionals in `rest`
300
- * (unlike {@link parseOptions}, which throws on unknown options).
602
+ * Shared argv → { options, rest } parser used by both strict and permissive modes.
603
+ *
604
+ * - `strict: true` — throws {@link CliError} (exit code 2) on unknown option or
605
+ * missing required value. Used by {@link parseOptions} before dispatching to an action.
606
+ * - `strict: false` — unknown flags / bare tokens stay in `rest`. Used by
607
+ * {@link peelKnownOptions} so `.locales(root)` / other early-bound global options
608
+ * never reject argv that belongs to a later subcommand.
301
609
  */
302
- export function peelKnownOptions(argv, optionDefs) {
610
+ function parseArgvCore(argv, optionDefs, strict) {
303
611
  const byAlias = new Map();
304
612
  for (const def of optionDefs) {
305
613
  for (const a of def.aliases)
@@ -307,70 +615,108 @@ export function peelKnownOptions(argv, optionDefs) {
307
615
  }
308
616
  const options = { _: [] };
309
617
  const rest = [];
618
+ /** Options explicitly supplied on the command line (defaults are not "used"). */
619
+ const used = new Set();
620
+ const handleMissing = (flagName) => {
621
+ if (strict) {
622
+ throw CliError.parseError('commander::missing_option_value', { option: flagName });
623
+ }
624
+ rest.push(flagName);
625
+ return true;
626
+ };
627
+ const handleUnknown = (token) => {
628
+ if (strict) {
629
+ throw CliError.parseError('commander::unknown_option', { option: token });
630
+ }
631
+ rest.push(token);
632
+ return true;
633
+ };
310
634
  for (let i = 0; i < argv.length; i++) {
311
635
  const a = argv[i];
312
636
  if (a === '--') {
313
- rest.push(...argv.slice(i));
637
+ // Strict parsing consumes the separator. Permissive peeling must keep
638
+ // both `--` and its tail so dispatch can split head/tail and never
639
+ // interpret tail tokens as options or help markers.
640
+ if (strict)
641
+ rest.push(...argv.slice(i + 1));
642
+ else
643
+ rest.push('--', ...argv.slice(i + 1));
314
644
  break;
315
645
  }
646
+ // help token 不在这里 skip:让它进 rest,由上层 dispatch 的 hasHelpToken 路由
316
647
  if (a.startsWith('--')) {
317
648
  const eq = a.indexOf('=');
318
649
  const long = eq === -1 ? a.slice(2) : a.slice(2, eq);
319
650
  const def = byAlias.get(long);
320
651
  if (!def) {
321
- rest.push(a);
652
+ if (!handleUnknown(a))
653
+ continue;
322
654
  continue;
323
655
  }
324
656
  if (def.takesValue) {
325
657
  if (eq !== -1) {
326
- assignOption(options, def, a.slice(eq + 1));
658
+ assignOption(options, def, used, a.slice(eq + 1));
327
659
  }
328
660
  else {
329
661
  const next = argv[i + 1];
330
662
  if (next != null && !next.startsWith('-')) {
331
- assignOption(options, def, next);
663
+ assignOption(options, def, used, next);
332
664
  i += 1;
333
665
  }
334
666
  else if (def.optionalValue) {
335
- assignOption(options, def, true);
667
+ assignOption(options, def, used, true);
336
668
  }
337
669
  else {
338
- throw new Error(`missing_value:--${long}`);
670
+ if (!handleMissing(`--${long}`))
671
+ continue;
672
+ continue;
339
673
  }
340
674
  }
341
675
  }
342
676
  else {
343
- assignOption(options, def, true);
677
+ assignOption(options, def, used, true);
344
678
  }
345
679
  continue;
346
680
  }
347
- if (a.startsWith('-') && a.length === 2) {
681
+ // 短参数:-x 或 -xyz(多字符),只要不以 -- 开头且以 - 开头且长度 > 1 就认作短选项
682
+ if (a.startsWith('-') && a.length > 1) {
348
683
  const short = a.slice(1);
349
684
  const def = byAlias.get(short);
350
685
  if (!def) {
351
- rest.push(a);
686
+ if (!handleUnknown(a))
687
+ continue;
352
688
  continue;
353
689
  }
354
690
  if (def.takesValue) {
355
691
  const next = argv[i + 1];
356
692
  if (next != null && !next.startsWith('-')) {
357
- assignOption(options, def, next);
693
+ assignOption(options, def, used, next);
358
694
  i += 1;
359
695
  }
360
696
  else if (def.optionalValue) {
361
- assignOption(options, def, true);
697
+ assignOption(options, def, used, true);
362
698
  }
363
699
  else {
364
- throw new Error(`missing_value:-${short}`);
700
+ if (!handleMissing(`-${short}`))
701
+ continue;
702
+ continue;
365
703
  }
366
704
  }
367
705
  else {
368
- assignOption(options, def, true);
706
+ assignOption(options, def, used, true);
369
707
  }
370
708
  continue;
371
709
  }
372
710
  rest.push(a);
373
711
  }
712
+ return { options, rest, used };
713
+ }
714
+ /**
715
+ * Consume known root options; leave unknown flags and positionals in `rest`
716
+ * (unlike {@link parseOptions}, which throws on unknown options).
717
+ */
718
+ export function peelKnownOptions(argv, optionDefs) {
719
+ const { options, rest } = parseArgvCore(argv, optionDefs, false);
374
720
  return { options, rest };
375
721
  }
376
722
  /** Strip `node` + script when callers pass full `process.argv`. */
@@ -398,10 +744,10 @@ function splitNames(rawName) {
398
744
  }
399
745
  function assertHelpId(helpId, kind) {
400
746
  if (!helpId || typeof helpId !== 'string' || !helpId.trim()) {
401
- throw new Error(`@vmz/commander: ${kind} helpId must be a non-empty catalog key`);
747
+ throw CliError.configError('commander::config::help_id_empty', { kind });
402
748
  }
403
749
  if (/\s/.test(helpId)) {
404
- throw new Error(`@vmz/commander: ${kind} helpId must be a catalog key (no spaces); got ${JSON.stringify(helpId)}`);
750
+ throw CliError.configError('commander::config::help_id_has_spaces', { kind, id: helpId });
405
751
  }
406
752
  }
407
753
  /** Parse `--out-dir <dir>` / `-o, --out-dir <dir>` / `--dirty <path>...` into an OptionDef. */
@@ -425,84 +771,98 @@ export function parseOptionDef(rawName, helpId) {
425
771
  aliases.push(p.slice(1));
426
772
  }
427
773
  if (!aliases.length) {
428
- throw new Error(`@vmz/commander: invalid option rawName ${JSON.stringify(rawName)}`);
774
+ throw CliError.configError('commander::config::invalid_option_raw', { raw: rawName });
429
775
  }
430
776
  const key = aliases.find((a) => a.length > 1) ?? aliases[0];
431
777
  return { rawName, helpId, key, aliases, takesValue, optionalValue, repeatable };
432
778
  }
779
+ function normalizeDiagnostic(error) {
780
+ if (error && typeof error === 'object') {
781
+ const value = error;
782
+ const candidate = value.diagnostic && typeof value.diagnostic === 'object' ? value.diagnostic : value;
783
+ if (typeof candidate.code === 'string' || typeof candidate.severity === 'string' || typeof candidate.path === 'string') {
784
+ return {
785
+ code: typeof candidate.code === 'string' ? candidate.code : undefined,
786
+ severity: typeof candidate.severity === 'string' ? candidate.severity : 'error',
787
+ args: candidate.args && typeof candidate.args === 'object' ? candidate.args : undefined,
788
+ path: typeof candidate.path === 'string' ? candidate.path : undefined,
789
+ span: candidate.span && typeof candidate.span === 'object' ? candidate.span : undefined,
790
+ message: typeof candidate.message === 'string' ? candidate.message : undefined,
791
+ };
792
+ }
793
+ }
794
+ const text = error instanceof Error ? error.message : String(error);
795
+ // 同时放 message 和 detail:不同产品 catalog 模板可能用 {message} 或 {detail}
796
+ return { severity: 'error', code: 'commander::action_failed', args: { message: text, detail: text }, message: text };
797
+ }
433
798
  /**
434
- * Parse argv against registered options. Throws `unknown_option:…` / `missing_value:…`.
799
+ * Parse argv against registered options (strict mode).
800
+ *
801
+ * Throws {@link CliError} with stable code `commander::unknown_option` /
802
+ * `commander::missing_option_value` (exit code 2) on validation failure.
803
+ * Non-option tokens become positionals under `options._`.
435
804
  */
436
805
  export function parseOptions(argv, optionDefs) {
437
- const byAlias = new Map();
806
+ return parseOptionsDetailed(argv, optionDefs).options;
807
+ }
808
+ /** Strict parse plus which option defs were actually supplied (for deprecation warnings). */
809
+ function parseOptionsDetailed(argv, optionDefs) {
810
+ const { options, rest, used } = parseArgvCore(argv, optionDefs, true);
811
+ options._.push(...rest);
812
+ applyOptionConfig(options, optionDefs, used);
813
+ return { options, used };
814
+ }
815
+ /**
816
+ * Apply `.option(..., config)` runtime constraints (default / required / enum / coerce).
817
+ *
818
+ * Runs before any action handler executes and throws {@link CliError.parseError}
819
+ * (exit code 2) so callers receive structured `code` + `args` diagnostics.
820
+ */
821
+ function applyOptionConfig(options, optionDefs, used) {
438
822
  for (const def of optionDefs) {
439
- for (const a of def.aliases)
440
- byAlias.set(a, def);
441
- }
442
- const out = { _: [] };
443
- for (let i = 0; i < argv.length; i++) {
444
- const a = argv[i];
445
- if (a === '--') {
446
- out._.push(...argv.slice(i + 1));
447
- break;
823
+ const config = def.config;
824
+ if (!config)
825
+ continue;
826
+ const provided = used.has(def);
827
+ if (!provided && config.default !== undefined) {
828
+ options[def.key] = Array.isArray(config.default) ? config.default.slice() : config.default;
448
829
  }
449
- if (a.startsWith('--')) {
450
- const eq = a.indexOf('=');
451
- const long = eq === -1 ? a.slice(2) : a.slice(2, eq);
452
- const def = byAlias.get(long);
453
- if (!def)
454
- throw new Error(`unknown_option:--${long}`);
455
- if (def.takesValue) {
456
- if (eq !== -1) {
457
- assignOption(out, def, a.slice(eq + 1));
458
- }
459
- else {
460
- const next = argv[i + 1];
461
- if (next != null && !next.startsWith('-')) {
462
- assignOption(out, def, next);
463
- i += 1;
464
- }
465
- else if (def.optionalValue) {
466
- assignOption(out, def, true);
467
- }
468
- else {
469
- throw new Error(`missing_value:--${long}`);
470
- }
830
+ const value = options[def.key];
831
+ if (config.required && value === undefined) {
832
+ throw CliError.parseError('commander::missing_required_option', { option: def.key });
833
+ }
834
+ if (value === undefined)
835
+ continue;
836
+ if (config.enum && provided) {
837
+ const values = Array.isArray(value) ? value : [value];
838
+ for (const item of values) {
839
+ if (typeof item !== 'string')
840
+ continue;
841
+ if (!config.enum.includes(item)) {
842
+ throw CliError.parseError('commander::option_not_in_enum', {
843
+ option: def.key,
844
+ value: item,
845
+ allowed: config.enum.join(', '),
846
+ });
471
847
  }
472
848
  }
473
- else {
474
- assignOption(out, def, true);
475
- }
476
- continue;
477
849
  }
478
- if (a.startsWith('-') && a.length === 2) {
479
- const short = a.slice(1);
480
- const def = byAlias.get(short);
481
- if (!def)
482
- throw new Error(`unknown_option:-${short}`);
483
- if (def.takesValue) {
484
- const next = argv[i + 1];
485
- if (next != null && !next.startsWith('-')) {
486
- assignOption(out, def, next);
487
- i += 1;
488
- }
489
- else if (def.optionalValue) {
490
- assignOption(out, def, true);
491
- }
492
- else {
493
- throw new Error(`missing_value:-${short}`);
494
- }
850
+ if (config.coerce && provided) {
851
+ try {
852
+ options[def.key] = config.coerce(value);
495
853
  }
496
- else {
497
- assignOption(out, def, true);
854
+ catch (err) {
855
+ throw CliError.parseError('commander::option_coerce_failed', {
856
+ option: def.key,
857
+ value: Array.isArray(value) ? value.join(', ') : String(value),
858
+ detail: err instanceof Error ? err.message : String(err),
859
+ });
498
860
  }
499
- continue;
500
861
  }
501
- out._.push(a);
502
862
  }
503
- return out;
504
863
  }
505
- function assignOption(out, def, value) {
864
+ function assignOption(out, def, used, value) {
865
+ used.add(def);
506
866
  if (def.repeatable) {
507
867
  const cur = out[def.key];
508
868
  const list = Array.isArray(cur) ? cur : [];
@@ -512,3 +872,58 @@ function assignOption(out, def, value) {
512
872
  }
513
873
  out[def.key] = value;
514
874
  }
875
+ /** 扫描 argv 是否包含 help token(跳过 -- 后面的内容)。 */
876
+ function hasHelpToken(argv) {
877
+ for (const tok of argv) {
878
+ if (tok === '--')
879
+ return false;
880
+ if (isHelpToken(tok))
881
+ return true;
882
+ }
883
+ return false;
884
+ }
885
+ /**
886
+ * 简单 Levenshtein 距离:找最接近的候选字符串。
887
+ * 返回值是建议的命令名(可能有多个用逗号连接),找不到返回空字符串。
888
+ */
889
+ function closestMatch(input, candidates) {
890
+ if (!input || !candidates.length)
891
+ return '';
892
+ const lower = input.toLowerCase();
893
+ let best = [];
894
+ for (const c of candidates) {
895
+ const d = levenshtein(lower, c.toLowerCase());
896
+ // 只接受距离 ≤ 3 且不超过输入长度 60% 的候选,避免把完全不相关的命令也建议出来
897
+ const threshold = Math.max(2, Math.floor(input.length * 0.6) + 1);
898
+ if (d <= threshold) {
899
+ best.push({ name: c, dist: d });
900
+ }
901
+ }
902
+ if (!best.length)
903
+ return '';
904
+ best.sort((a, b) => a.dist - b.dist);
905
+ // 只返回 top 1:多候选用逗号分隔看起来像一个命令
906
+ return best[0].name;
907
+ }
908
+ function levenshtein(a, b) {
909
+ const m = a.length;
910
+ const n = b.length;
911
+ if (m === 0)
912
+ return n;
913
+ if (n === 0)
914
+ return m;
915
+ // 只维护两行,节省内存
916
+ let prev = new Array(n + 1);
917
+ let curr = new Array(n + 1);
918
+ for (let j = 0; j <= n; j++)
919
+ prev[j] = j;
920
+ for (let i = 1; i <= m; i++) {
921
+ curr[0] = i;
922
+ for (let j = 1; j <= n; j++) {
923
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
924
+ curr[j] = Math.min(curr[j - 1] + 1, prev[j] + 1, prev[j - 1] + cost);
925
+ }
926
+ [prev, curr] = [curr, prev];
927
+ }
928
+ return prev[n];
929
+ }
package/dist/locales.js CHANGED
@@ -6,16 +6,37 @@
6
6
  */
7
7
  import { existsSync, readdirSync, readFileSync } from 'node:fs';
8
8
  import path from 'node:path';
9
+ import { CliError } from './types.js';
9
10
  /** Framework chrome / errors — products may override the same ids in their locales/. */
10
11
  export const COMMANDER_FALLBACK_EN_US = {
11
12
  'commander.ui.usage': 'Usage: {name} <command> [options]',
12
13
  'commander.ui.commands': 'Commands:',
13
14
  'commander.ui.options': 'Options:',
15
+ 'commander.ui.option_example': '(e.g. {example})',
16
+ 'commander.ui.deprecated': '(deprecated)',
17
+ 'commander.ui.hint.available_options': "Run '{command} --help' for available options.",
18
+ 'commander.ui.hint.did_you_mean': "Did you mean '{suggest}'?",
14
19
  'commander.opt.locale': 'Locale id',
20
+ // Legacy dotted ids (product catalogs may override these).
15
21
  'commander.err.unknown_command': 'unknown command `{cmd}`',
16
22
  'commander.err.unknown_option': 'unknown option `{option}`',
17
23
  'commander.err.missing_option_value': 'missing value for `{option}`',
18
24
  'commander.err.localize_required': 'call .use(LocalizePlugin) or .locales(root) before parse()',
25
+ // New stable `::` codes — used when the framework itself emits diagnostics.
26
+ 'commander::unknown_command': 'unknown command `{cmd}`',
27
+ 'commander::unknown_option': 'unknown option `{option}`',
28
+ 'commander::missing_option_value': 'missing value for `{option}`',
29
+ 'commander::parse_failed': 'parse failed: {detail}',
30
+ 'commander::missing_required_option': 'missing required option `{option}`',
31
+ 'commander::option_not_in_enum': 'invalid value `{value}` for `{option}` (allowed: {allowed})',
32
+ 'commander::option_coerce_failed': 'invalid value `{value}` for `{option}`: {detail}',
33
+ 'commander::option_deprecated': 'option `{option}` is deprecated',
34
+ 'commander::action_failed': 'action failed: {detail}',
35
+ 'commander::config::invalid_cli_name': 'invalid CLI name: {detail}',
36
+ 'commander::config::localize_required': 'call .use(LocalizePlugin) or .locales(root) before parse()',
37
+ 'commander::config::missing_locales_manifest': 'missing locales manifest at {file}',
38
+ 'commander::config::invalid_locales_manifest': 'invalid locales manifest at {file}',
39
+ 'commander::config::empty_locale_catalog': 'empty catalog for locale {locale} under {root}',
19
40
  };
20
41
  const catalogCache = new Map();
21
42
  const manifestCache = new Map();
@@ -25,11 +46,11 @@ export function loadLocalesManifest(root) {
25
46
  return cached;
26
47
  const file = path.join(root, 'locales.json');
27
48
  if (!existsSync(file)) {
28
- throw new Error(`@vmz/commander: missing locales manifest at ${file}`);
49
+ throw CliError.configError('commander::config::missing_locales_manifest', { file });
29
50
  }
30
51
  const raw = JSON.parse(readFileSync(file, 'utf8'));
31
52
  if (!raw?.defaultLocale || !Array.isArray(raw.locales) || !raw.locales.length) {
32
- throw new Error(`@vmz/commander: invalid locales manifest ${file}`);
53
+ throw CliError.configError('commander::config::invalid_locales_manifest', { file });
33
54
  }
34
55
  manifestCache.set(root, raw);
35
56
  return raw;
@@ -81,7 +102,7 @@ export function loadCatalog(locale, root) {
81
102
  Object.assign(merged, loadLocaleDir(root, id));
82
103
  }
83
104
  if (!Object.keys(merged).length) {
84
- throw new Error(`@vmz/commander: empty catalog for locale ${JSON.stringify(locale)} under ${root}`);
105
+ throw CliError.configError('commander::config::empty_locale_catalog', { locale, root });
85
106
  }
86
107
  catalogCache.set(cacheKey, merged);
87
108
  return merged;
package/dist/types.d.ts CHANGED
@@ -1,6 +1,30 @@
1
1
  /**
2
2
  * Shared types for `@vmz/commander` (kept separate so locales.ts can import without cycles).
3
3
  */
4
+ /**
5
+ * Structured error thrown by `@vmz/commander` itself.
6
+ *
7
+ * Exit code contract (POSIX CLI conventions):
8
+ * - 2: parse / validation failure (unknown command, unknown/missing option).
9
+ * - 1: config / factory / action-handler failure.
10
+ */
11
+ export declare class CliError extends Error {
12
+ /** Stable code (`commander::*`) consumed by callers; also doubles as catalog id. */
13
+ readonly code: string;
14
+ /** Exit code a framework user should return for this failure. */
15
+ readonly exitCode: number;
16
+ /** Structured catalog args (`{ name: value }`) for message interpolation. */
17
+ readonly args: Record<string, string>;
18
+ /** Optional root cause. */
19
+ readonly cause?: unknown;
20
+ private constructor();
21
+ /** Parse / validation failures (exit code 2). */
22
+ static parseError(code: string, args?: Record<string, string>): CliError;
23
+ /** Factory / configuration failures (exit code 1). */
24
+ static configError(code: string, args?: Record<string, string>): CliError;
25
+ /** Wrap an arbitrary exception as a CliError (exit code 1). */
26
+ static from(err: unknown, fallbackCode?: string): CliError;
27
+ }
4
28
  /** Message id → template. Owned by the localize plugin / product, not this package. */
5
29
  export type LocaleCatalog = Record<string, string>;
6
30
  /** Load messages for one locale (sugar for building a {@link LocalizePlugin}). */
@@ -16,3 +40,75 @@ export type LocalizePlugin = {
16
40
  }) => string;
17
41
  t: (id: string, args?: Record<string, string>) => string;
18
42
  };
43
+ /** Language-neutral diagnostic row accepted at a command boundary. */
44
+ export type CommandDiagnostic = {
45
+ severity?: 'error' | 'warning' | 'advice' | string;
46
+ code?: string;
47
+ args?: Record<string, string>;
48
+ path?: string;
49
+ span?: {
50
+ start: number;
51
+ end: number;
52
+ };
53
+ message?: string;
54
+ };
55
+ /** Product-owned sink. Formatting remains in the product diagnostic package. */
56
+ export type DiagnosticContext = {
57
+ locale: string;
58
+ t: LocalizePlugin['t'];
59
+ };
60
+ /** Product-owned sink. Formatting remains outside commander. */
61
+ export type DiagnosticHandler = (diagnostic: CommandDiagnostic, context: DiagnosticContext) => void;
62
+ /**
63
+ * Option registration config (additive — `.option(rawName, helpId, config?)`).
64
+ *
65
+ * Existing `parseOptionDef(rawName)` already extracts `<required>` / `[optional]` / `...repeatable`
66
+ * from the raw name string. This config adds runtime constraints that cannot be expressed in the DSL.
67
+ */
68
+ export type OptionConfig = {
69
+ /** 默认值(不传时 options[key] = default) */
70
+ default?: string | boolean | string[];
71
+ /** 允许值集合;parse 时 strict 校验(失败 exit code 2) */
72
+ enum?: string[];
73
+ /** 是否必须(parse 时缺失 → CliError,exit code 2) */
74
+ required?: boolean;
75
+ /** 自定义转换(parse 后对值执行,coerce 失败 → CliError.exitCode 2) */
76
+ coerce?: (raw: string | boolean | string[]) => unknown;
77
+ /** 帮助文本中的简短示例(显示在 `--profile <name>` 后) */
78
+ example?: string;
79
+ /** 选项分组(帮助里的 section header) */
80
+ group?: string;
81
+ /** 标记 deprecated — 帮助里划线显示,调用时走 warn */
82
+ deprecated?: boolean | string;
83
+ /** 标记 hidden — 不显示在帮助(但仍可解析) */
84
+ hidden?: boolean;
85
+ };
86
+ /**
87
+ * Command action context — constructed by the framework before dispatching to `.actionCtx()`.
88
+ *
89
+ * Provides everything a command handler needs without re-parsing argv or re-discovering
90
+ * product internals. Error paths (`fail` / `warn` / `note`) route through the configured
91
+ * DiagnosticHandler (or stderr fallback) so downstream code never needs ad-hoc `console.error`.
92
+ */
93
+ export type CommandContext = {
94
+ /** 完整命令路径(根命令在 [0])— e.g. `['vmz', 'application', 'check']` */
95
+ commandPath: string[];
96
+ /** 命令路径的人类可读串 — e.g. `'vmz application check'` */
97
+ commandName: string;
98
+ /** 当前 locale id — e.g. `'en-US'` */
99
+ locale: string;
100
+ /** 产品 LocalizePlugin.t — action 可以用它渲染 helpId / 错误 id */
101
+ t: LocalizePlugin['t'];
102
+ /** 打印当前命令的帮助文本(框架级,从 command tree 自动派生) */
103
+ help: () => void;
104
+ /** 写入 stderr(正常错误输出) */
105
+ stderr: (...msgs: string[]) => void;
106
+ /** 写入 stdout(正常进度 / 结果输出) */
107
+ stdout: (...msgs: string[]) => void;
108
+ /** 框架级 fail:走 onDiagnostic + 退出码 2(parse/validation 错误) */
109
+ fail: (code: string, args?: Record<string, string>) => never;
110
+ /** 框架级 warn:走 onDiagnostic(warning severity,不退出) */
111
+ warn: (code: string, args?: Record<string, string>) => void;
112
+ /** 框架级 note:走 onDiagnostic(advice severity,不退出) */
113
+ note: (code: string, args?: Record<string, string>) => void;
114
+ };
package/dist/types.js CHANGED
@@ -1,4 +1,44 @@
1
1
  /**
2
2
  * Shared types for `@vmz/commander` (kept separate so locales.ts can import without cycles).
3
3
  */
4
- export {};
4
+ /**
5
+ * Structured error thrown by `@vmz/commander` itself.
6
+ *
7
+ * Exit code contract (POSIX CLI conventions):
8
+ * - 2: parse / validation failure (unknown command, unknown/missing option).
9
+ * - 1: config / factory / action-handler failure.
10
+ */
11
+ export class CliError extends Error {
12
+ /** Stable code (`commander::*`) consumed by callers; also doubles as catalog id. */
13
+ code;
14
+ /** Exit code a framework user should return for this failure. */
15
+ exitCode;
16
+ /** Structured catalog args (`{ name: value }`) for message interpolation. */
17
+ args;
18
+ /** Optional root cause. */
19
+ cause;
20
+ constructor(opts) {
21
+ super(opts.cause instanceof Error ? opts.cause.message : opts.code);
22
+ this.name = 'CliError';
23
+ this.code = opts.code;
24
+ this.exitCode = opts.exitCode;
25
+ this.args = opts.args ?? {};
26
+ this.cause = opts.cause;
27
+ }
28
+ /** Parse / validation failures (exit code 2). */
29
+ static parseError(code, args = {}) {
30
+ return new CliError({ code, exitCode: 2, args });
31
+ }
32
+ /** Factory / configuration failures (exit code 1). */
33
+ static configError(code, args = {}) {
34
+ return new CliError({ code, exitCode: 1, args });
35
+ }
36
+ /** Wrap an arbitrary exception as a CliError (exit code 1). */
37
+ static from(err, fallbackCode = 'commander::action_failed') {
38
+ if (err instanceof CliError)
39
+ return err;
40
+ const message = err instanceof Error ? err.message : String(err);
41
+ // 同时放 message 和 detail:不同产品 catalog 模板可能用 {message} 或 {detail}
42
+ return new CliError({ code: fallbackCode, exitCode: 1, cause: err, args: { detail: message, message } });
43
+ }
44
+ }
package/package.json CHANGED
@@ -35,9 +35,9 @@
35
35
  },
36
36
  "scripts": {
37
37
  "build": "tsc -p tsconfig.json",
38
- "test": "node --import ../../../scripts/test/resolve-ts-from-js.mjs --test --experimental-strip-types ./tests/locales.test.ts"
38
+ "test": "node --import ../../../scripts/test/resolve-ts-from-js.mjs --test --experimental-strip-types ./tests/*.test.ts"
39
39
  },
40
40
  "type": "module",
41
41
  "types": "./dist/index.d.ts",
42
- "version": "0.3.5"
42
+ "version": "0.3.7"
43
43
  }