@vmz/commander 0.3.6 → 0.3.8

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
@@ -1,67 +1,94 @@
1
- # `@vmz/commander`
2
-
3
- i18n-first TypeScript CLI framework under the `@vmz` scope. **Not** bound to `@vmz/vmz`.
4
-
5
- - Fluent registration; command / option second args are **message ids**
6
- - **Help is derived** from the command tree — do not paste Usage walls into catalogs
7
- - **Locales directory loading**: `locales.json` + `<locale>/*.json` → flatten → `LocalizePlugin`
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
10
- - **No product language packs** in this package
11
-
12
- ## Quick start
13
-
14
- ```ts
15
- import { createCli } from '@vmz/commander';
16
-
17
- const cli = createCli('my-cli')
18
- .locales(new URL('./locales', import.meta.url).pathname) // or absolute path
19
- .intro('cli.intro')
20
- .command('build', 'cli.cmd.build')
21
- .option('--out-dir, -o <dir>', 'cli.opt.out-dir')
22
- .action(async (options) => {
23
- /* options._ positionals; options['out-dir'] … */
24
- });
25
-
26
- await cli.parse(process.argv);
27
- ```
28
-
29
- Locales layout (any product):
30
-
31
- ```text
32
- <localesRoot>/
33
- locales.json # { defaultLocale, locales: [{ id }], fallback? }
34
- en-US/*.json # flat or nested → dotted ids
35
- zh-CN/*.json # optional
36
- ```
37
-
38
- `.locales(root)` also registers root `--locale <id>` (peeled before the command). Later `.use(plugin)` overrides the locales plugin.
39
-
40
- ## API surface
41
-
42
- | API | Role |
43
- |-----|------|
44
- | `createCli(name)` | Program name; help `{name}` |
45
- | `.command` / `.option` / `.action` / `.passthrough` | Command tree |
46
- | `.intro(id)` | Short banner only |
47
- | `.locales(root)` / `.use(plugin)` / `.catalog(loader)` | Localization (later `.use` wins) |
48
- | `.option` on CLI | Global flags (merged into action options) |
49
- | `.onDiagnostic(handler)` | Route structured action failures to a product-owned renderer |
50
- | `loadLocalesManifest` / `loadCatalog` / `flattenCatalog` | Filesystem catalog helpers |
51
- | `createLocalizeFromLocales` | Build a `LocalizePlugin` from a root |
52
- | `assertCatalogCoverage(cli, catalog)` | Dev/CI: registered helpIds must exist |
53
- | `COMMANDER_FALLBACK_EN_US` | Minimal `commander.ui.*` / `commander.err.*` / `commander.opt.locale` |
54
-
55
- `t` resolution: product catalog → `commander` English fallback → `{{id}}`.
56
-
57
- 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 prints the legacy message or code. Plain `Error` exceptions are wrapped as `commander::action_failed` during migration. Commander does not own diagnostic formatting; products should connect `@vmz/diagnostic`.
58
-
59
- ## Layering
60
-
61
- ```text
62
- @vmz/commander = CLI tree + locales loader + commander.* chrome
63
- @vmz/vmz = product bin + command registration + locales/ content
64
- @vmz/diagnostic = diagnostic layout (caller injects catalog / t)
65
- ```
66
-
67
- Do **not** create a separate `@vmz/i18n` package for official product strings.
1
+ # `@vmz/commander`
2
+
3
+ i18n-first TypeScript CLI framework under the `@vmz` scope. **Not** bound to `@vmz/vmz`.
4
+
5
+ - Fluent registration; command / option second args are **message ids**
6
+ - **Help is derived** from the command tree — do not paste Usage walls into catalogs
7
+ - **Locales directory loading**: `locales.json` + `<locale>/*.json` → flatten → `LocalizePlugin`
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
10
+ - **No product language packs** in this package
11
+
12
+ ## Quick start
13
+
14
+ ```ts
15
+ import { createCli } from '@vmz/commander';
16
+
17
+ const cli = createCli('my-cli')
18
+ .locales(new URL('./locales', import.meta.url).pathname) // or absolute path
19
+ .intro('cli.intro')
20
+ .command('build', 'cli.cmd.build')
21
+ .option('--out-dir, -o <dir>', 'cli.opt.out-dir')
22
+ .action(async (options) => {
23
+ /* options._ positionals; options['out-dir'] … */
24
+ });
25
+
26
+ await cli.parse(process.argv);
27
+ ```
28
+
29
+ Locales layout (any product):
30
+
31
+ ```text
32
+ <localesRoot>/
33
+ locales.json # { defaultLocale, locales: [{ id }], fallback? }
34
+ en-US/*.json # flat or nested → dotted ids
35
+ zh-CN/*.json # optional
36
+ ```
37
+
38
+ `.locales(root)` also registers root `--locale <id>` (peeled before the command). Later `.use(plugin)` overrides the locales plugin.
39
+
40
+ ## API surface
41
+
42
+ | API | Role |
43
+ |-----|------|
44
+ | `createCli(name)` | Program name; help `{name}` |
45
+ | `.command` / `.option` / `.action` / `.actionCtx` / `.passthrough` | Command tree |
46
+ | `.intro(id)` | Short banner only |
47
+ | `.locales(root)` / `.use(plugin)` / `.catalog(loader)` | Localization (later `.use` wins) |
48
+ | `.option` on CLI | Global flags (merged into action options) |
49
+ | `.onDiagnostic(handler)` | Route structured action failures to a product-owned renderer |
50
+ | `loadLocalesManifest` / `loadCatalog` / `flattenCatalog` | Filesystem catalog helpers |
51
+ | `createLocalizeFromLocales` | Build a `LocalizePlugin` from a root |
52
+ | `assertCatalogCoverage(cli, catalog)` | Dev/CI: registered helpIds must exist |
53
+ | `COMMANDER_FALLBACK_EN_US` | Minimal `commander.ui.*` / `commander.err.*` / `commander.opt.locale` |
54
+
55
+ `t` resolution: product catalog → `commander` English fallback → `{{id}}`.
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
+
86
+ ## Layering
87
+
88
+ ```text
89
+ @vmz/commander = CLI tree + locales loader + commander.* chrome
90
+ @vmz/vmz = product bin + command registration + locales/ content
91
+ @vmz/diagnostic = diagnostic layout (caller injects catalog / t)
92
+ ```
93
+
94
+ Do **not** create a separate `@vmz/i18n` package for official product strings.
package/dist/index.d.ts CHANGED
@@ -5,17 +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, CommandDiagnostic, DiagnosticContext, DiagnosticHandler, LocaleCatalog, LocalizePlugin } from './types.js';
8
+ export type { CatalogLoader, CommandContext, CommandDiagnostic, DiagnosticContext, DiagnosticHandler, LocaleCatalog, LocalizePlugin, OptionConfig } from './types.js';
9
9
  export { CliError } from './types.js';
10
10
  export { COMMANDER_FALLBACK_EN_US, clearLocalesCache, createLocalizeFromLocales, flattenCatalog, loadCatalog, loadLocalesManifest, resolveLocale, translate, translateWithFallback, } from './locales.js';
11
11
  export type { CreateLocalizeFromLocalesOptions, LocalesManifest } from './locales.js';
12
- import type { CatalogLoader, DiagnosticHandler, LocaleCatalog, LocalizePlugin } from './types.js';
12
+ import type { CatalogLoader, CommandContext, DiagnosticHandler, LocaleCatalog, LocalizePlugin, OptionConfig } from './types.js';
13
13
  /** Options bag passed to actions (`_` = positionals). */
14
14
  export type ParsedOptions = Record<string, string | boolean | string[]> & {
15
15
  _: string[];
16
16
  };
17
- /** Command action after argv is parsed. */
17
+ /** Command action after argv is parsed (legacy API — no context). */
18
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>;
19
21
  /** Registered option (structure only). */
20
22
  export type OptionDef = {
21
23
  rawName: string;
@@ -30,6 +32,8 @@ export type OptionDef = {
30
32
  optionalValue: boolean;
31
33
  /** True when the option may repeat (`--dirty <path>...` ⇒ `string[]`). */
32
34
  repeatable: boolean;
35
+ /** Runtime constraints (enum / required / default / coerce / deprecated). */
36
+ config?: OptionConfig;
33
37
  };
34
38
  /** Registered command node. */
35
39
  export type CommandDef = {
@@ -38,14 +42,20 @@ export type CommandDef = {
38
42
  /** Match tokens from `rawName` split on `|`. */
39
43
  names: string[];
40
44
  options: OptionDef[];
45
+ /** Legacy action handler (no ctx). */
41
46
  action?: ActionHandler;
47
+ /** Phase 3 action handler (with ctx — preferred). */
48
+ actionCtx?: ActionCtxHandler;
42
49
  children: CommandDef[];
43
50
  /** If true, do not parse options; remaining argv is positionals / raw rest. */
44
51
  passthrough: boolean;
45
52
  };
46
53
  export interface Command {
47
- option(rawName: string, helpId: string): this;
54
+ option(rawName: string, helpId: string, config?: OptionConfig): this;
55
+ /** Legacy API — handler receives `(options, ...args)` without ctx. */
48
56
  action(handler: ActionHandler): this;
57
+ /** Phase 3 API — handler receives `(options, args[], ctx)` with full command context. */
58
+ actionCtx(handler: ActionCtxHandler): this;
49
59
  command(rawName: string, helpId: string): Command;
50
60
  /** Leave remaining argv unparsed (for nested domain CLIs). */
51
61
  passthrough(): this;
package/dist/index.js CHANGED
@@ -25,14 +25,127 @@ function colorEnabled() {
25
25
  return false;
26
26
  if (process.env.FORCE_COLOR && process.env.FORCE_COLOR !== '0')
27
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;
28
47
  return Boolean(process.stdout?.isTTY || process.stderr?.isTTY);
29
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
+ }
30
59
  function paint(value, ...colors) {
31
60
  return colorEnabled() && colors.length ? `${colors.map((color) => ANSI[color]).join('')}${value}${ANSI_RESET}` : value;
32
61
  }
33
62
  function errorText(value) {
34
63
  return paint(value, 'bold', 'red');
35
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
+ }
36
149
  class CommandBuilder {
37
150
  def;
38
151
  constructor(rawName, helpId) {
@@ -46,15 +159,26 @@ class CommandBuilder {
46
159
  passthrough: false,
47
160
  };
48
161
  }
49
- option(rawName, helpId) {
162
+ option(rawName, helpId, config) {
50
163
  assertHelpId(helpId, 'option');
51
- 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);
52
168
  return this;
53
169
  }
54
170
  action(handler) {
171
+ if (this.def.actionCtx)
172
+ throw CliError.configError('commander::config::action_conflict', { detail: 'Cannot set both .action() and .actionCtx()' });
55
173
  this.def.action = handler;
56
174
  return this;
57
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
+ }
58
182
  passthrough() {
59
183
  this.def.passthrough = true;
60
184
  return this;
@@ -158,7 +282,11 @@ class CliBuilder {
158
282
  }
159
283
  async parse(argvInput = process.argv) {
160
284
  const argv0 = normalizeArgv(argvInput);
161
- 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;
162
290
  let localize = this.localize;
163
291
  if (this.localesRoot) {
164
292
  const localeFlag = typeof globalOpts.locale === 'string' && globalOpts.locale ? globalOpts.locale : undefined;
@@ -178,32 +306,84 @@ class CliBuilder {
178
306
  locale: localize.resolveLocale?.({ argv: argv0, env: process.env }) ?? 'en-US',
179
307
  t,
180
308
  };
181
- if (argv.length === 0 || isHelpToken(argv[0])) {
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)) {
182
315
  console.log(this.formatRootHelp(t));
183
316
  return 0;
184
317
  }
185
318
  const cmdToken = argv[0];
186
319
  const matched = this.roots.find((c) => c.def.names.includes(cmdToken));
187
320
  if (!matched) {
188
- return this.emitAndHelp({ code: 'commander::unknown_command', severity: 'error', args: { cmd: cmdToken } }, diagnosticContext, t, this.formatRootHelp(t), 2);
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;
189
330
  }
190
331
  return await this.dispatch(matched.def, argv.slice(1), t, [cmdToken], globalOpts, diagnosticContext);
191
332
  }
192
- /** Emit a diagnostic through `onDiagnostic` when registered, else console.error. */
333
+ /** 路由 `help <cmd> [subcmd] ...` 到对应命令的帮助文本。 */
334
+ routeHelp(pathTokens, t, _ctx) {
335
+ if (!pathTokens.length) {
336
+ console.log(this.formatRootHelp(t));
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. */
193
360
  emitDiagnostic(diag, ctx, t) {
194
361
  if (this.diagnosticHandler) {
195
362
  this.diagnosticHandler(diag, ctx);
196
363
  }
197
364
  else {
198
- const msg = diag.message ?? (diag.code ? t(diag.code, diag.args) : '');
199
- console.error(errorText(msg || diag.code || String(diag)));
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'));
200
386
  }
201
- }
202
- /** Emit diagnostic, show help text, return exit code. Shorthand for parse/dispatch error paths. */
203
- emitAndHelp(diag, ctx, t, helpText, exitCode) {
204
- this.emitDiagnostic(diag, ctx, t);
205
- console.log(helpText);
206
- return exitCode;
207
387
  }
208
388
  /** Derived root help: optional intro + commands/options from the registration tree. */
209
389
  formatRootHelp(t) {
@@ -211,51 +391,81 @@ class CliBuilder {
211
391
  if (this.introId) {
212
392
  lines.push(t(this.introId), '');
213
393
  }
214
- lines.push(paint(t('commander.ui.usage', { name: this.name }), 'bold', 'cyan'), '', paint(t('commander.ui.commands'), 'bold', 'blue'));
215
- for (const c of this.roots) {
216
- lines.push(` ${paint(c.def.rawName.padEnd(28), 'green')} ${t(c.def.helpId)}`);
217
- }
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'));
218
397
  const opts = [
219
398
  ...this.rootOptions,
220
399
  ...collectRootOptions(this.roots.map((c) => c.def)).filter((o) => !this.rootOptions.some((r) => r.key === o.key)),
221
400
  ];
222
- if (opts.length) {
223
- lines.push('', paint(t('commander.ui.options'), 'bold', 'blue'));
224
- for (const o of opts) {
225
- lines.push(` ${paint(o.rawName.padEnd(28), 'yellow')} ${t(o.helpId)}`);
226
- }
401
+ const optionSection = renderOptionSections(t, opts, 'yellow');
402
+ if (optionSection.length) {
403
+ lines.push('');
404
+ lines.push(...optionSection);
227
405
  }
228
406
  return lines.join('\n');
229
407
  }
230
408
  async dispatch(def, rest, t, path, globalOpts, diagnosticContext) {
231
- if (rest.length && isHelpToken(rest[0])) {
232
- console.log(formatCommandHelp(this.name, def, t, path, this.rootOptions));
233
- return 0;
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();
234
429
  }
235
- if (def.children.length && rest.length && !rest[0].startsWith('-')) {
236
- const childTok = rest[0];
237
- const child = def.children.find((c) => c.names.includes(childTok));
238
- if (child) {
239
- return await this.dispatch(child, rest.slice(1), t, [...path, childTok], globalOpts, diagnosticContext);
240
- }
241
- if (!def.action) {
242
- return this.emitAndHelp({ code: 'commander::unknown_command', severity: 'error', args: { cmd: [...path, childTok].join(' ') } }, diagnosticContext, t, formatCommandHelp(this.name, def, t, path, this.rootOptions), 2);
243
- }
430
+ // 扫描 --help / -h(只扫命令路径之后的 head,`--` 之后的 tail 不参与)
431
+ if (hasHelpToken(remaining)) {
432
+ console.log(formatCommandHelp(this.name, target, t, targetPath, this.rootOptions));
433
+ return 0;
244
434
  }
245
- if (!def.action) {
246
- if (rest.length) {
247
- return this.emitAndHelp({ code: 'commander::unknown_command', severity: 'error', args: { cmd: [...path, ...rest].join(' ') } }, diagnosticContext, t, formatCommandHelp(this.name, def, t, path, this.rootOptions), 2);
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;
248
449
  }
249
- console.log(formatCommandHelp(this.name, def, t, path, this.rootOptions));
450
+ console.log(formatCommandHelp(this.name, target, t, targetPath, this.rootOptions));
250
451
  return 0;
251
452
  }
252
453
  let options;
454
+ let usedOptions = new Set();
253
455
  try {
254
- options = def.passthrough ? { _: rest.slice() } : parseOptions(rest, def.options);
456
+ if (target.passthrough) {
457
+ options = { _: [...remaining, ...tail] };
458
+ }
459
+ else {
460
+ ({ options, used: usedOptions } = parseOptionsDetailed(remaining, target.options));
461
+ options._.push(...tail);
462
+ }
255
463
  }
256
464
  catch (err) {
257
465
  const cliErr = CliError.from(err, 'commander::parse_failed');
258
- return this.emitAndHelp({ code: cliErr.code, severity: 'error', args: cliErr.args, message: cliErr.message }, diagnosticContext, t, formatCommandHelp(this.name, def, t, path, this.rootOptions), cliErr.exitCode);
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;
259
469
  }
260
470
  for (const [k, v] of Object.entries(globalOpts)) {
261
471
  if (k === '_')
@@ -263,8 +473,29 @@ class CliBuilder {
263
473
  if (options[k] === undefined)
264
474
  options[k] = v;
265
475
  }
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
+ }
266
486
  try {
267
- const result = await def.action(options, ...options._);
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
+ }
268
499
  if (typeof result === 'number')
269
500
  return result;
270
501
  return 0;
@@ -272,26 +503,51 @@ class CliBuilder {
272
503
  catch (err) {
273
504
  const diagnostic = normalizeDiagnostic(err);
274
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;
275
509
  return 1;
276
510
  }
277
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
+ };
537
+ }
278
538
  }
279
539
  /** Derive command help from one node (children + options). */
280
540
  export function formatCommandHelp(cliName, def, t, path, rootOptions = []) {
281
- const lines = [paint(`${cliName} ${path.join(' ')}`, 'bold', 'cyan') + ` — ${t(def.helpId)}`, ''];
541
+ const lines = [paint(`${cliName} ${path.join(' ')}`, 'bold', 'cyan') + ` — ${t(def.helpId)}`];
282
542
  if (def.children.length) {
283
- lines.push(paint(t('commander.ui.commands'), 'bold', 'blue'));
284
- for (const c of def.children) {
285
- lines.push(` ${paint(c.rawName.padEnd(28), 'green')} ${t(c.helpId)}`);
286
- }
287
543
  lines.push('');
544
+ lines.push(...renderHelpSection(t, 'commander.ui.commands', def.children.map((c) => ({ rawName: c.rawName, helpId: c.helpId })), 'green'));
288
545
  }
289
546
  const opts = [...rootOptions, ...def.options.filter((o) => !rootOptions.some((r) => r.key === o.key))];
290
- if (opts.length) {
291
- lines.push(paint(t('commander.ui.options'), 'bold', 'blue'));
292
- for (const o of opts) {
293
- lines.push(` ${paint(o.rawName.padEnd(28), 'yellow')} ${t(o.helpId)}`);
294
- }
547
+ const optionSection = renderOptionSections(t, opts, 'yellow');
548
+ if (optionSection.length) {
549
+ lines.push('');
550
+ lines.push(...optionSection);
295
551
  }
296
552
  return lines.join('\n').trimEnd();
297
553
  }
@@ -359,6 +615,8 @@ function parseArgvCore(argv, optionDefs, strict) {
359
615
  }
360
616
  const options = { _: [] };
361
617
  const rest = [];
618
+ /** Options explicitly supplied on the command line (defaults are not "used"). */
619
+ const used = new Set();
362
620
  const handleMissing = (flagName) => {
363
621
  if (strict) {
364
622
  throw CliError.parseError('commander::missing_option_value', { option: flagName });
@@ -376,30 +634,37 @@ function parseArgvCore(argv, optionDefs, strict) {
376
634
  for (let i = 0; i < argv.length; i++) {
377
635
  const a = argv[i];
378
636
  if (a === '--') {
379
- 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));
380
644
  break;
381
645
  }
646
+ // help token 不在这里 skip:让它进 rest,由上层 dispatch 的 hasHelpToken 路由
382
647
  if (a.startsWith('--')) {
383
648
  const eq = a.indexOf('=');
384
649
  const long = eq === -1 ? a.slice(2) : a.slice(2, eq);
385
650
  const def = byAlias.get(long);
386
651
  if (!def) {
387
- if (!handleUnknown(`--${long}`))
652
+ if (!handleUnknown(a))
388
653
  continue;
389
654
  continue;
390
655
  }
391
656
  if (def.takesValue) {
392
657
  if (eq !== -1) {
393
- assignOption(options, def, a.slice(eq + 1));
658
+ assignOption(options, def, used, a.slice(eq + 1));
394
659
  }
395
660
  else {
396
661
  const next = argv[i + 1];
397
662
  if (next != null && !next.startsWith('-')) {
398
- assignOption(options, def, next);
663
+ assignOption(options, def, used, next);
399
664
  i += 1;
400
665
  }
401
666
  else if (def.optionalValue) {
402
- assignOption(options, def, true);
667
+ assignOption(options, def, used, true);
403
668
  }
404
669
  else {
405
670
  if (!handleMissing(`--${long}`))
@@ -409,26 +674,27 @@ function parseArgvCore(argv, optionDefs, strict) {
409
674
  }
410
675
  }
411
676
  else {
412
- assignOption(options, def, true);
677
+ assignOption(options, def, used, true);
413
678
  }
414
679
  continue;
415
680
  }
416
- if (a.startsWith('-') && a.length === 2) {
681
+ // 短参数:-x 或 -xyz(多字符),只要不以 -- 开头且以 - 开头且长度 > 1 就认作短选项
682
+ if (a.startsWith('-') && a.length > 1) {
417
683
  const short = a.slice(1);
418
684
  const def = byAlias.get(short);
419
685
  if (!def) {
420
- if (!handleUnknown(`-${short}`))
686
+ if (!handleUnknown(a))
421
687
  continue;
422
688
  continue;
423
689
  }
424
690
  if (def.takesValue) {
425
691
  const next = argv[i + 1];
426
692
  if (next != null && !next.startsWith('-')) {
427
- assignOption(options, def, next);
693
+ assignOption(options, def, used, next);
428
694
  i += 1;
429
695
  }
430
696
  else if (def.optionalValue) {
431
- assignOption(options, def, true);
697
+ assignOption(options, def, used, true);
432
698
  }
433
699
  else {
434
700
  if (!handleMissing(`-${short}`))
@@ -437,20 +703,21 @@ function parseArgvCore(argv, optionDefs, strict) {
437
703
  }
438
704
  }
439
705
  else {
440
- assignOption(options, def, true);
706
+ assignOption(options, def, used, true);
441
707
  }
442
708
  continue;
443
709
  }
444
710
  rest.push(a);
445
711
  }
446
- return { options, rest };
712
+ return { options, rest, used };
447
713
  }
448
714
  /**
449
715
  * Consume known root options; leave unknown flags and positionals in `rest`
450
716
  * (unlike {@link parseOptions}, which throws on unknown options).
451
717
  */
452
718
  export function peelKnownOptions(argv, optionDefs) {
453
- return parseArgvCore(argv, optionDefs, false);
719
+ const { options, rest } = parseArgvCore(argv, optionDefs, false);
720
+ return { options, rest };
454
721
  }
455
722
  /** Strip `node` + script when callers pass full `process.argv`. */
456
723
  export function normalizeArgv(argv) {
@@ -524,7 +791,9 @@ function normalizeDiagnostic(error) {
524
791
  };
525
792
  }
526
793
  }
527
- return { severity: 'error', code: 'commander::action_failed', message: error instanceof Error ? error.message : String(error) };
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 };
528
797
  }
529
798
  /**
530
799
  * Parse argv against registered options (strict mode).
@@ -534,11 +803,66 @@ function normalizeDiagnostic(error) {
534
803
  * Non-option tokens become positionals under `options._`.
535
804
  */
536
805
  export function parseOptions(argv, optionDefs) {
537
- const { options, rest } = parseArgvCore(argv, optionDefs, true);
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);
538
811
  options._.push(...rest);
539
- return options;
812
+ applyOptionConfig(options, optionDefs, used);
813
+ return { options, used };
540
814
  }
541
- function assignOption(out, def, value) {
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) {
822
+ for (const def of optionDefs) {
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;
829
+ }
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
+ });
847
+ }
848
+ }
849
+ }
850
+ if (config.coerce && provided) {
851
+ try {
852
+ options[def.key] = config.coerce(value);
853
+ }
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
+ });
860
+ }
861
+ }
862
+ }
863
+ }
864
+ function assignOption(out, def, used, value) {
865
+ used.add(def);
542
866
  if (def.repeatable) {
543
867
  const cur = out[def.key];
544
868
  const list = Array.isArray(cur) ? cur : [];
@@ -548,3 +872,58 @@ function assignOption(out, def, value) {
548
872
  }
549
873
  out[def.key] = value;
550
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
@@ -12,6 +12,10 @@ export const COMMANDER_FALLBACK_EN_US = {
12
12
  'commander.ui.usage': 'Usage: {name} <command> [options]',
13
13
  'commander.ui.commands': 'Commands:',
14
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}'?",
15
19
  'commander.opt.locale': 'Locale id',
16
20
  // Legacy dotted ids (product catalogs may override these).
17
21
  'commander.err.unknown_command': 'unknown command `{cmd}`',
@@ -23,6 +27,10 @@ export const COMMANDER_FALLBACK_EN_US = {
23
27
  'commander::unknown_option': 'unknown option `{option}`',
24
28
  'commander::missing_option_value': 'missing value for `{option}`',
25
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',
26
34
  'commander::action_failed': 'action failed: {detail}',
27
35
  'commander::config::invalid_cli_name': 'invalid CLI name: {detail}',
28
36
  'commander::config::localize_required': 'call .use(LocalizePlugin) or .locales(root) before parse()',
package/dist/types.d.ts CHANGED
@@ -59,3 +59,56 @@ export type DiagnosticContext = {
59
59
  };
60
60
  /** Product-owned sink. Formatting remains outside commander. */
61
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
@@ -38,6 +38,7 @@ export class CliError extends Error {
38
38
  if (err instanceof CliError)
39
39
  return err;
40
40
  const message = err instanceof Error ? err.message : String(err);
41
- return new CliError({ code: fallbackCode, exitCode: 1, cause: err, args: { detail: message } });
41
+ // 同时放 message 和 detail:不同产品 catalog 模板可能用 {message} 或 {detail}
42
+ return new CliError({ code: fallbackCode, exitCode: 1, cause: err, args: { detail: message, message } });
42
43
  }
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.6"
42
+ "version": "0.3.8"
43
43
  }