@vmz/commander 0.3.5 → 0.3.6

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,63 +1,67 @@
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
- - **No product language packs** in this package
10
-
11
- ## Quick start
12
-
13
- ```ts
14
- import { createCli } from '@vmz/commander';
15
-
16
- const cli = createCli('my-cli')
17
- .locales(new URL('./locales', import.meta.url).pathname) // or absolute path
18
- .intro('cli.intro')
19
- .command('build', 'cli.cmd.build')
20
- .option('--out-dir, -o <dir>', 'cli.opt.out-dir')
21
- .action(async (options) => {
22
- /* options._ positionals; options['out-dir'] … */
23
- });
24
-
25
- await cli.parse(process.argv);
26
- ```
27
-
28
- Locales layout (any product):
29
-
30
- ```text
31
- <localesRoot>/
32
- locales.json # { defaultLocale, locales: [{ id }], fallback? }
33
- en-US/*.json # flat or nested → dotted ids
34
- zh-CN/*.json # optional
35
- ```
36
-
37
- `.locales(root)` also registers root `--locale <id>` (peeled before the command). Later `.use(plugin)` overrides the locales plugin.
38
-
39
- ## API surface
40
-
41
- | API | Role |
42
- |-----|------|
43
- | `createCli(name)` | Program name; help `{name}` |
44
- | `.command` / `.option` / `.action` / `.passthrough` | Command tree |
45
- | `.intro(id)` | Short banner only |
46
- | `.locales(root)` / `.use(plugin)` / `.catalog(loader)` | Localization (later `.use` wins) |
47
- | `.option` on CLI | Global flags (merged into action options) |
48
- | `loadLocalesManifest` / `loadCatalog` / `flattenCatalog` | Filesystem catalog helpers |
49
- | `createLocalizeFromLocales` | Build a `LocalizePlugin` from a root |
50
- | `assertCatalogCoverage(cli, catalog)` | Dev/CI: registered helpIds must exist |
51
- | `COMMANDER_FALLBACK_EN_US` | Minimal `commander.ui.*` / `commander.err.*` / `commander.opt.locale` |
52
-
53
- `t` resolution: product catalog → `commander` English fallback → `{{id}}`.
54
-
55
- ## Layering
56
-
57
- ```text
58
- @vmz/commander = CLI tree + locales loader + commander.* chrome
59
- @vmz/vmz = product bin + command registration + locales/ content
60
- @vmz/diagnostic = diagnostic layout (caller injects catalog / t)
61
- ```
62
-
63
- 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` / `.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.
package/dist/index.d.ts CHANGED
@@ -5,10 +5,11 @@
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, CommandDiagnostic, DiagnosticContext, DiagnosticHandler, LocaleCatalog, LocalizePlugin } 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, DiagnosticHandler, LocaleCatalog, LocalizePlugin } from './types.js';
12
13
  /** Options bag passed to actions (`_` = positionals). */
13
14
  export type ParsedOptions = Record<string, string | boolean | string[]> & {
14
15
  _: string[];
@@ -71,6 +72,8 @@ export interface Cli {
71
72
  */
72
73
  help(helpId: string): this;
73
74
  command(rawName: string, helpId: string): Command;
75
+ /** Route structured action failures to a product-owned renderer. */
76
+ onDiagnostic(handler: DiagnosticHandler): this;
74
77
  parse(argv?: string[]): Promise<number>;
75
78
  }
76
79
  /** Derive command help from one node (children + options). */
@@ -96,6 +99,10 @@ export declare function normalizeArgv(argv: string[]): string[];
96
99
  /** Parse `--out-dir <dir>` / `-o, --out-dir <dir>` / `--dirty <path>...` into an OptionDef. */
97
100
  export declare function parseOptionDef(rawName: string, helpId: string): OptionDef;
98
101
  /**
99
- * Parse argv against registered options. Throws `unknown_option:…` / `missing_value:…`.
102
+ * Parse argv against registered options (strict mode).
103
+ *
104
+ * Throws {@link CliError} with stable code `commander::unknown_option` /
105
+ * `commander::missing_option_value` (exit code 2) on validation failure.
106
+ * Non-option tokens become positionals under `options._`.
100
107
  */
101
108
  export declare function parseOptions(argv: string[], optionDefs: OptionDef[]): ParsedOptions;
package/dist/index.js CHANGED
@@ -5,8 +5,34 @@
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
+ return Boolean(process.stdout?.isTTY || process.stderr?.isTTY);
29
+ }
30
+ function paint(value, ...colors) {
31
+ return colorEnabled() && colors.length ? `${colors.map((color) => ANSI[color]).join('')}${value}${ANSI_RESET}` : value;
32
+ }
33
+ function errorText(value) {
34
+ return paint(value, 'bold', 'red');
35
+ }
10
36
  class CommandBuilder {
11
37
  def;
12
38
  constructor(rawName, helpId) {
@@ -45,6 +71,7 @@ class CliBuilder {
45
71
  /** When set, `parse` rebuilds localize from this root (argv/env/`--locale`). */
46
72
  localesRoot = null;
47
73
  localesEnvKeys;
74
+ diagnosticHandler;
48
75
  introId = null;
49
76
  roots = [];
50
77
  rootOptions = [];
@@ -53,7 +80,7 @@ class CliBuilder {
53
80
  }
54
81
  use(plugin) {
55
82
  if (!plugin || typeof plugin.t !== 'function') {
56
- throw new Error('@vmz/commander: LocalizePlugin.t is required');
83
+ throw CliError.configError('commander::config::invalid_localize_plugin', { detail: 'LocalizePlugin.t is required' });
57
84
  }
58
85
  this.localesRoot = null;
59
86
  this.localesEnvKeys = undefined;
@@ -62,13 +89,13 @@ class CliBuilder {
62
89
  }
63
90
  catalog(loader) {
64
91
  if (typeof loader !== 'function') {
65
- throw new Error('@vmz/commander: catalog(loader) requires a function');
92
+ throw CliError.configError('commander::config::catalog_loader_not_function');
66
93
  }
67
94
  return this.use({
68
95
  t: (id, args) => {
69
96
  const table = loader('en-US');
70
97
  if (table && typeof table.then === 'function') {
71
- throw new Error('@vmz/commander: async CatalogLoader via .catalog() is not supported; use .use({ t })');
98
+ throw CliError.configError('commander::config::async_catalog_unsupported');
72
99
  }
73
100
  return translateWithFallback(id, args, table);
74
101
  },
@@ -76,7 +103,7 @@ class CliBuilder {
76
103
  }
77
104
  locales(root, opts = {}) {
78
105
  if (!root || typeof root !== 'string') {
79
- throw new Error('@vmz/commander: locales(root) requires a non-empty path');
106
+ throw CliError.configError('commander::config::invalid_locales_root');
80
107
  }
81
108
  this.localesRoot = root;
82
109
  this.localesEnvKeys = opts.envKeys;
@@ -105,6 +132,12 @@ class CliBuilder {
105
132
  this.roots.push(cmd);
106
133
  return cmd;
107
134
  }
135
+ onDiagnostic(handler) {
136
+ if (typeof handler !== 'function')
137
+ throw CliError.configError('commander::config::diagnostic_handler_not_function');
138
+ this.diagnosticHandler = handler;
139
+ return this;
140
+ }
108
141
  /** Help ids registered on this CLI (for {@link assertCatalogCoverage}). */
109
142
  collectHelpIds() {
110
143
  const ids = [];
@@ -138,9 +171,13 @@ class CliBuilder {
138
171
  }));
139
172
  }
140
173
  if (!localize) {
141
- throw new Error(translate('commander.err.localize_required', undefined, COMMANDER_FALLBACK_EN_US));
174
+ throw CliError.configError('commander::config::localize_required');
142
175
  }
143
176
  const t = localize.t.bind(localize);
177
+ const diagnosticContext = {
178
+ locale: localize.resolveLocale?.({ argv: argv0, env: process.env }) ?? 'en-US',
179
+ t,
180
+ };
144
181
  if (argv.length === 0 || isHelpToken(argv[0])) {
145
182
  console.log(this.formatRootHelp(t));
146
183
  return 0;
@@ -148,11 +185,25 @@ class CliBuilder {
148
185
  const cmdToken = argv[0];
149
186
  const matched = this.roots.find((c) => c.def.names.includes(cmdToken));
150
187
  if (!matched) {
151
- console.error(t('commander.err.unknown_command', { cmd: cmdToken }));
152
- console.log(this.formatRootHelp(t));
153
- return 1;
188
+ return this.emitAndHelp({ code: 'commander::unknown_command', severity: 'error', args: { cmd: cmdToken } }, diagnosticContext, t, this.formatRootHelp(t), 2);
189
+ }
190
+ return await this.dispatch(matched.def, argv.slice(1), t, [cmdToken], globalOpts, diagnosticContext);
191
+ }
192
+ /** Emit a diagnostic through `onDiagnostic` when registered, else console.error. */
193
+ emitDiagnostic(diag, ctx, t) {
194
+ if (this.diagnosticHandler) {
195
+ this.diagnosticHandler(diag, ctx);
196
+ }
197
+ else {
198
+ const msg = diag.message ?? (diag.code ? t(diag.code, diag.args) : '');
199
+ console.error(errorText(msg || diag.code || String(diag)));
154
200
  }
155
- return await this.dispatch(matched.def, argv.slice(1), t, [cmdToken], globalOpts);
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;
156
207
  }
157
208
  /** Derived root help: optional intro + commands/options from the registration tree. */
158
209
  formatRootHelp(t) {
@@ -160,23 +211,23 @@ class CliBuilder {
160
211
  if (this.introId) {
161
212
  lines.push(t(this.introId), '');
162
213
  }
163
- lines.push(t('commander.ui.usage', { name: this.name }), '', t('commander.ui.commands'));
214
+ lines.push(paint(t('commander.ui.usage', { name: this.name }), 'bold', 'cyan'), '', paint(t('commander.ui.commands'), 'bold', 'blue'));
164
215
  for (const c of this.roots) {
165
- lines.push(` ${c.def.rawName.padEnd(28)} ${t(c.def.helpId)}`);
216
+ lines.push(` ${paint(c.def.rawName.padEnd(28), 'green')} ${t(c.def.helpId)}`);
166
217
  }
167
218
  const opts = [
168
219
  ...this.rootOptions,
169
220
  ...collectRootOptions(this.roots.map((c) => c.def)).filter((o) => !this.rootOptions.some((r) => r.key === o.key)),
170
221
  ];
171
222
  if (opts.length) {
172
- lines.push('', t('commander.ui.options'));
223
+ lines.push('', paint(t('commander.ui.options'), 'bold', 'blue'));
173
224
  for (const o of opts) {
174
- lines.push(` ${o.rawName.padEnd(28)} ${t(o.helpId)}`);
225
+ lines.push(` ${paint(o.rawName.padEnd(28), 'yellow')} ${t(o.helpId)}`);
175
226
  }
176
227
  }
177
228
  return lines.join('\n');
178
229
  }
179
- async dispatch(def, rest, t, path, globalOpts) {
230
+ async dispatch(def, rest, t, path, globalOpts, diagnosticContext) {
180
231
  if (rest.length && isHelpToken(rest[0])) {
181
232
  console.log(formatCommandHelp(this.name, def, t, path, this.rootOptions));
182
233
  return 0;
@@ -185,37 +236,26 @@ class CliBuilder {
185
236
  const childTok = rest[0];
186
237
  const child = def.children.find((c) => c.names.includes(childTok));
187
238
  if (child) {
188
- return await this.dispatch(child, rest.slice(1), t, [...path, childTok], globalOpts);
239
+ return await this.dispatch(child, rest.slice(1), t, [...path, childTok], globalOpts, diagnosticContext);
189
240
  }
190
241
  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;
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);
194
243
  }
195
244
  }
196
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);
248
+ }
197
249
  console.log(formatCommandHelp(this.name, def, t, path, this.rootOptions));
198
- return rest.length ? 1 : 0;
250
+ return 0;
199
251
  }
200
252
  let options;
201
253
  try {
202
254
  options = def.passthrough ? { _: rest.slice() } : parseOptions(rest, def.options);
203
255
  }
204
256
  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
- }));
213
- }
214
- else {
215
- console.error(msg);
216
- }
217
- console.log(formatCommandHelp(this.name, def, t, path, this.rootOptions));
218
- return 1;
257
+ 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);
219
259
  }
220
260
  for (const [k, v] of Object.entries(globalOpts)) {
221
261
  if (k === '_')
@@ -223,27 +263,34 @@ class CliBuilder {
223
263
  if (options[k] === undefined)
224
264
  options[k] = v;
225
265
  }
226
- const result = await def.action(options, ...options._);
227
- if (typeof result === 'number')
228
- return result;
229
- return 0;
266
+ try {
267
+ const result = await def.action(options, ...options._);
268
+ if (typeof result === 'number')
269
+ return result;
270
+ return 0;
271
+ }
272
+ catch (err) {
273
+ const diagnostic = normalizeDiagnostic(err);
274
+ this.emitDiagnostic(diagnostic, diagnosticContext, t);
275
+ return 1;
276
+ }
230
277
  }
231
278
  }
232
279
  /** Derive command help from one node (children + options). */
233
280
  export function formatCommandHelp(cliName, def, t, path, rootOptions = []) {
234
- const lines = [`${cliName} ${path.join(' ')} — ${t(def.helpId)}`, ''];
281
+ const lines = [paint(`${cliName} ${path.join(' ')}`, 'bold', 'cyan') + ` — ${t(def.helpId)}`, ''];
235
282
  if (def.children.length) {
236
- lines.push(t('commander.ui.commands'));
283
+ lines.push(paint(t('commander.ui.commands'), 'bold', 'blue'));
237
284
  for (const c of def.children) {
238
- lines.push(` ${c.rawName.padEnd(28)} ${t(c.helpId)}`);
285
+ lines.push(` ${paint(c.rawName.padEnd(28), 'green')} ${t(c.helpId)}`);
239
286
  }
240
287
  lines.push('');
241
288
  }
242
289
  const opts = [...rootOptions, ...def.options.filter((o) => !rootOptions.some((r) => r.key === o.key))];
243
290
  if (opts.length) {
244
- lines.push(t('commander.ui.options'));
291
+ lines.push(paint(t('commander.ui.options'), 'bold', 'blue'));
245
292
  for (const o of opts) {
246
- lines.push(` ${o.rawName.padEnd(28)} ${t(o.helpId)}`);
293
+ lines.push(` ${paint(o.rawName.padEnd(28), 'yellow')} ${t(o.helpId)}`);
247
294
  }
248
295
  }
249
296
  return lines.join('\n').trimEnd();
@@ -267,7 +314,7 @@ function collectRootOptions(roots) {
267
314
  */
268
315
  export function createCli(name) {
269
316
  if (!name || typeof name !== 'string') {
270
- throw new Error('@vmz/commander: createCli(name) requires a non-empty program name');
317
+ throw CliError.configError('commander::config::invalid_cli_name', { detail: String(name) });
271
318
  }
272
319
  return new CliBuilder(name);
273
320
  }
@@ -278,7 +325,7 @@ export function assertCatalogCoverage(cli, catalog) {
278
325
  const ids = typeof cli.collectHelpIds === 'function' ? cli.collectHelpIds() : [];
279
326
  const missing = ids.filter((id) => !Object.prototype.hasOwnProperty.call(catalog, id) && !Object.prototype.hasOwnProperty.call(COMMANDER_FALLBACK_EN_US, id));
280
327
  if (missing.length) {
281
- throw new Error(`@vmz/commander: catalog missing help ids:\n ${missing.sort().join('\n ')}`);
328
+ throw CliError.configError('commander::config::catalog_missing_help_ids', { ids: missing.sort().join(', ') });
282
329
  }
283
330
  }
284
331
  function wrapWithCommanderFallback(plugin) {
@@ -296,10 +343,15 @@ function wrapWithCommanderFallback(plugin) {
296
343
  };
297
344
  }
298
345
  /**
299
- * Consume known root options; leave unknown flags and positionals in `rest`
300
- * (unlike {@link parseOptions}, which throws on unknown options).
346
+ * Shared argv → { options, rest } parser used by both strict and permissive modes.
347
+ *
348
+ * - `strict: true` — throws {@link CliError} (exit code 2) on unknown option or
349
+ * missing required value. Used by {@link parseOptions} before dispatching to an action.
350
+ * - `strict: false` — unknown flags / bare tokens stay in `rest`. Used by
351
+ * {@link peelKnownOptions} so `.locales(root)` / other early-bound global options
352
+ * never reject argv that belongs to a later subcommand.
301
353
  */
302
- export function peelKnownOptions(argv, optionDefs) {
354
+ function parseArgvCore(argv, optionDefs, strict) {
303
355
  const byAlias = new Map();
304
356
  for (const def of optionDefs) {
305
357
  for (const a of def.aliases)
@@ -307,6 +359,20 @@ export function peelKnownOptions(argv, optionDefs) {
307
359
  }
308
360
  const options = { _: [] };
309
361
  const rest = [];
362
+ const handleMissing = (flagName) => {
363
+ if (strict) {
364
+ throw CliError.parseError('commander::missing_option_value', { option: flagName });
365
+ }
366
+ rest.push(flagName);
367
+ return true;
368
+ };
369
+ const handleUnknown = (token) => {
370
+ if (strict) {
371
+ throw CliError.parseError('commander::unknown_option', { option: token });
372
+ }
373
+ rest.push(token);
374
+ return true;
375
+ };
310
376
  for (let i = 0; i < argv.length; i++) {
311
377
  const a = argv[i];
312
378
  if (a === '--') {
@@ -318,7 +384,8 @@ export function peelKnownOptions(argv, optionDefs) {
318
384
  const long = eq === -1 ? a.slice(2) : a.slice(2, eq);
319
385
  const def = byAlias.get(long);
320
386
  if (!def) {
321
- rest.push(a);
387
+ if (!handleUnknown(`--${long}`))
388
+ continue;
322
389
  continue;
323
390
  }
324
391
  if (def.takesValue) {
@@ -335,7 +402,9 @@ export function peelKnownOptions(argv, optionDefs) {
335
402
  assignOption(options, def, true);
336
403
  }
337
404
  else {
338
- throw new Error(`missing_value:--${long}`);
405
+ if (!handleMissing(`--${long}`))
406
+ continue;
407
+ continue;
339
408
  }
340
409
  }
341
410
  }
@@ -348,7 +417,8 @@ export function peelKnownOptions(argv, optionDefs) {
348
417
  const short = a.slice(1);
349
418
  const def = byAlias.get(short);
350
419
  if (!def) {
351
- rest.push(a);
420
+ if (!handleUnknown(`-${short}`))
421
+ continue;
352
422
  continue;
353
423
  }
354
424
  if (def.takesValue) {
@@ -361,7 +431,9 @@ export function peelKnownOptions(argv, optionDefs) {
361
431
  assignOption(options, def, true);
362
432
  }
363
433
  else {
364
- throw new Error(`missing_value:-${short}`);
434
+ if (!handleMissing(`-${short}`))
435
+ continue;
436
+ continue;
365
437
  }
366
438
  }
367
439
  else {
@@ -373,6 +445,13 @@ export function peelKnownOptions(argv, optionDefs) {
373
445
  }
374
446
  return { options, rest };
375
447
  }
448
+ /**
449
+ * Consume known root options; leave unknown flags and positionals in `rest`
450
+ * (unlike {@link parseOptions}, which throws on unknown options).
451
+ */
452
+ export function peelKnownOptions(argv, optionDefs) {
453
+ return parseArgvCore(argv, optionDefs, false);
454
+ }
376
455
  /** Strip `node` + script when callers pass full `process.argv`. */
377
456
  export function normalizeArgv(argv) {
378
457
  if (argv.length >= 2 && looksLikeNode(argv[0]) && looksLikeScript(argv[1])) {
@@ -398,10 +477,10 @@ function splitNames(rawName) {
398
477
  }
399
478
  function assertHelpId(helpId, kind) {
400
479
  if (!helpId || typeof helpId !== 'string' || !helpId.trim()) {
401
- throw new Error(`@vmz/commander: ${kind} helpId must be a non-empty catalog key`);
480
+ throw CliError.configError('commander::config::help_id_empty', { kind });
402
481
  }
403
482
  if (/\s/.test(helpId)) {
404
- throw new Error(`@vmz/commander: ${kind} helpId must be a catalog key (no spaces); got ${JSON.stringify(helpId)}`);
483
+ throw CliError.configError('commander::config::help_id_has_spaces', { kind, id: helpId });
405
484
  }
406
485
  }
407
486
  /** Parse `--out-dir <dir>` / `-o, --out-dir <dir>` / `--dirty <path>...` into an OptionDef. */
@@ -425,82 +504,39 @@ export function parseOptionDef(rawName, helpId) {
425
504
  aliases.push(p.slice(1));
426
505
  }
427
506
  if (!aliases.length) {
428
- throw new Error(`@vmz/commander: invalid option rawName ${JSON.stringify(rawName)}`);
507
+ throw CliError.configError('commander::config::invalid_option_raw', { raw: rawName });
429
508
  }
430
509
  const key = aliases.find((a) => a.length > 1) ?? aliases[0];
431
510
  return { rawName, helpId, key, aliases, takesValue, optionalValue, repeatable };
432
511
  }
512
+ function normalizeDiagnostic(error) {
513
+ if (error && typeof error === 'object') {
514
+ const value = error;
515
+ const candidate = value.diagnostic && typeof value.diagnostic === 'object' ? value.diagnostic : value;
516
+ if (typeof candidate.code === 'string' || typeof candidate.severity === 'string' || typeof candidate.path === 'string') {
517
+ return {
518
+ code: typeof candidate.code === 'string' ? candidate.code : undefined,
519
+ severity: typeof candidate.severity === 'string' ? candidate.severity : 'error',
520
+ args: candidate.args && typeof candidate.args === 'object' ? candidate.args : undefined,
521
+ path: typeof candidate.path === 'string' ? candidate.path : undefined,
522
+ span: candidate.span && typeof candidate.span === 'object' ? candidate.span : undefined,
523
+ message: typeof candidate.message === 'string' ? candidate.message : undefined,
524
+ };
525
+ }
526
+ }
527
+ return { severity: 'error', code: 'commander::action_failed', message: error instanceof Error ? error.message : String(error) };
528
+ }
433
529
  /**
434
- * Parse argv against registered options. Throws `unknown_option:…` / `missing_value:…`.
530
+ * Parse argv against registered options (strict mode).
531
+ *
532
+ * Throws {@link CliError} with stable code `commander::unknown_option` /
533
+ * `commander::missing_option_value` (exit code 2) on validation failure.
534
+ * Non-option tokens become positionals under `options._`.
435
535
  */
436
536
  export function parseOptions(argv, optionDefs) {
437
- const byAlias = new Map();
438
- 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;
448
- }
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
- }
471
- }
472
- }
473
- else {
474
- assignOption(out, def, true);
475
- }
476
- continue;
477
- }
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
- }
495
- }
496
- else {
497
- assignOption(out, def, true);
498
- }
499
- continue;
500
- }
501
- out._.push(a);
502
- }
503
- return out;
537
+ const { options, rest } = parseArgvCore(argv, optionDefs, true);
538
+ options._.push(...rest);
539
+ return options;
504
540
  }
505
541
  function assignOption(out, def, value) {
506
542
  if (def.repeatable) {
package/dist/locales.js CHANGED
@@ -6,16 +6,29 @@
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:',
14
15
  'commander.opt.locale': 'Locale id',
16
+ // Legacy dotted ids (product catalogs may override these).
15
17
  'commander.err.unknown_command': 'unknown command `{cmd}`',
16
18
  'commander.err.unknown_option': 'unknown option `{option}`',
17
19
  'commander.err.missing_option_value': 'missing value for `{option}`',
18
20
  'commander.err.localize_required': 'call .use(LocalizePlugin) or .locales(root) before parse()',
21
+ // New stable `::` codes — used when the framework itself emits diagnostics.
22
+ 'commander::unknown_command': 'unknown command `{cmd}`',
23
+ 'commander::unknown_option': 'unknown option `{option}`',
24
+ 'commander::missing_option_value': 'missing value for `{option}`',
25
+ 'commander::parse_failed': 'parse failed: {detail}',
26
+ 'commander::action_failed': 'action failed: {detail}',
27
+ 'commander::config::invalid_cli_name': 'invalid CLI name: {detail}',
28
+ 'commander::config::localize_required': 'call .use(LocalizePlugin) or .locales(root) before parse()',
29
+ 'commander::config::missing_locales_manifest': 'missing locales manifest at {file}',
30
+ 'commander::config::invalid_locales_manifest': 'invalid locales manifest at {file}',
31
+ 'commander::config::empty_locale_catalog': 'empty catalog for locale {locale} under {root}',
19
32
  };
20
33
  const catalogCache = new Map();
21
34
  const manifestCache = new Map();
@@ -25,11 +38,11 @@ export function loadLocalesManifest(root) {
25
38
  return cached;
26
39
  const file = path.join(root, 'locales.json');
27
40
  if (!existsSync(file)) {
28
- throw new Error(`@vmz/commander: missing locales manifest at ${file}`);
41
+ throw CliError.configError('commander::config::missing_locales_manifest', { file });
29
42
  }
30
43
  const raw = JSON.parse(readFileSync(file, 'utf8'));
31
44
  if (!raw?.defaultLocale || !Array.isArray(raw.locales) || !raw.locales.length) {
32
- throw new Error(`@vmz/commander: invalid locales manifest ${file}`);
45
+ throw CliError.configError('commander::config::invalid_locales_manifest', { file });
33
46
  }
34
47
  manifestCache.set(root, raw);
35
48
  return raw;
@@ -81,7 +94,7 @@ export function loadCatalog(locale, root) {
81
94
  Object.assign(merged, loadLocaleDir(root, id));
82
95
  }
83
96
  if (!Object.keys(merged).length) {
84
- throw new Error(`@vmz/commander: empty catalog for locale ${JSON.stringify(locale)} under ${root}`);
97
+ throw CliError.configError('commander::config::empty_locale_catalog', { locale, root });
85
98
  }
86
99
  catalogCache.set(cacheKey, merged);
87
100
  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,22 @@ 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;
package/dist/types.js CHANGED
@@ -1,4 +1,43 @@
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
+ return new CliError({ code: fallbackCode, exitCode: 1, cause: err, args: { detail: message } });
42
+ }
43
+ }
package/package.json CHANGED
@@ -39,5 +39,5 @@
39
39
  },
40
40
  "type": "module",
41
41
  "types": "./dist/index.d.ts",
42
- "version": "0.3.5"
42
+ "version": "0.3.6"
43
43
  }