cli-forge 1.0.2 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- import { type ConfigurationFiles, OptionConfig, OptionConfigToType, ParsedArgs, EnvOptionConfig, ObjectOptionConfig, StringOptionConfig, NumberOptionConfig, BooleanOptionConfig, ArrayOptionConfig, ResolveProperties, WithOptional, MakeUndefinedPropertiesOptional } from '@cli-forge/parser';
1
+ import { type ConfigurationFiles, OptionConfig, OptionConfigToType, ParsedArgs, EnvOptionConfig, ObjectOptionConfig, StringOptionConfig, NumberOptionConfig, BooleanOptionConfig, ArrayOptionConfig, ResolveProperties, WithOptional, MakeUndefinedPropertiesOptional, LocalizationDictionary, LocalizationFunction } from '@cli-forge/parser';
2
2
  import { InternalCLI } from './internal-cli';
3
3
  /**
4
4
  * Extracts the command name from a Command type.
@@ -154,6 +154,45 @@ export interface CLI<TArgs extends ParsedArgs = ParsedArgs, THandlerReturn = voi
154
154
  */
155
155
  env(prefix?: string): CLI<TArgs, THandlerReturn, TChildren, TParent>;
156
156
  env(options: EnvOptionConfig): CLI<TArgs, THandlerReturn, TChildren, TParent>;
157
+ /**
158
+ * Sets up localization for option keys and other text.
159
+ * When localization is enabled, option keys will be displayed in the specified locale in help text and documentation,
160
+ * and both the default and localized keys will be accepted when parsing arguments.
161
+ *
162
+ * @param dictionary The localization dictionary mapping keys to their translations
163
+ * @param locale The target locale (defaults to system locale if not provided)
164
+ * @returns Updated CLI instance for chaining
165
+ *
166
+ * @example
167
+ * ```ts
168
+ * cli('myapp')
169
+ * .localize({
170
+ * name: { default: 'name', 'es-ES': 'nombre' },
171
+ * port: { default: 'port', 'es-ES': 'puerto' }
172
+ * }, 'es-ES')
173
+ * .option('name', { type: 'string' })
174
+ * .option('port', { type: 'number' });
175
+ * ```
176
+ */
177
+ localize(dictionary: LocalizationDictionary, locale?: string): CLI<TArgs, THandlerReturn, TChildren, TParent>;
178
+ /**
179
+ * Sets up localization using a custom function for translating keys.
180
+ * This allows integration with existing localization libraries like i18next.
181
+ *
182
+ * @param fn A function that takes a key and returns its localized value
183
+ * @returns Updated CLI instance for chaining
184
+ *
185
+ * @example
186
+ * ```ts
187
+ * import i18next from 'i18next';
188
+ *
189
+ * cli('myapp')
190
+ * .localize((key) => i18next.t(key))
191
+ * .option('name', { type: 'string' })
192
+ * .option('port', { type: 'number' });
193
+ * ```
194
+ */
195
+ localize(fn: LocalizationFunction): CLI<TArgs, THandlerReturn, TChildren, TParent>;
157
196
  /**
158
197
  * Sets a group of options as mutually exclusive. If more than one option is provided, there will be a validation error.
159
198
  * @param options The options that should be mutually exclusive.
@@ -171,6 +210,14 @@ export interface CLI<TArgs extends ParsedArgs = ParsedArgs, THandlerReturn = voi
171
210
  * @returns Updated CLI instance.
172
211
  */
173
212
  demandCommand(): CLI<TArgs, THandlerReturn, TChildren, TParent>;
213
+ /**
214
+ * Enables or disables strict mode. When strict mode is enabled, the parser throws a validation error
215
+ * when unmatched arguments are encountered. Unmatched arguments are those that don't match any
216
+ * configured option or positional argument.
217
+ * @param enable Whether to enable strict mode. Defaults to true.
218
+ * @returns Updated CLI instance.
219
+ */
220
+ strict(enable?: boolean): CLI<TArgs, THandlerReturn, TChildren, TParent>;
174
221
  /**
175
222
  * Sets the usage text for the CLI. This text will be displayed in place of the default usage text
176
223
  * @param usageText Text displayed in place of the default usage text for `--help` and in generated docs.
@@ -194,7 +241,7 @@ export interface CLI<TArgs extends ParsedArgs = ParsedArgs, THandlerReturn = voi
194
241
  group({ label, keys, sortOrder, }: {
195
242
  label: string;
196
243
  keys: (keyof TArgs)[];
197
- sortOrder: number;
244
+ sortOrder?: number;
198
245
  }): CLI<TArgs, THandlerReturn, TChildren, TParent>;
199
246
  group(label: string, keys: (keyof TArgs)[]): CLI<TArgs, THandlerReturn, TChildren, TParent>;
200
247
  middleware<TArgs2>(callback: MiddlewareFunction<TArgs, TArgs2>): CLI<TArgs2 extends void ? TArgs : TArgs & TArgs2, THandlerReturn, TChildren, TParent>;
@@ -1 +1 @@
1
- {"version":3,"file":"public-api.js","sourceRoot":"","sources":["../../src/lib/public-api.ts"],"names":[],"mappings":";;AA8/BA,kBAsBC;AAngCD,iDAA6C;AAu+B7C;;;;;GAKG;AACH,SAAgB,GAAG,CAOjB,IAAW,EACX,wBAOC;IAED,OAAO,IAAI,0BAAW,CAAC,IAAI,EAAE,wBAA+B,CAI3D,CAAC;AACJ,CAAC;AAED,kBAAe,GAAG,CAAC"}
1
+ {"version":3,"file":"public-api.js","sourceRoot":"","sources":["../../src/lib/public-api.ts"],"names":[],"mappings":";;AAsjCA,kBAsBC;AAzjCD,iDAA6C;AA6hC7C;;;;;GAKG;AACH,SAAgB,GAAG,CAOjB,IAAW,EACX,wBAOC;IAED,OAAO,IAAI,0BAAW,CAAC,IAAI,EAAE,wBAA+B,CAI3D,CAAC;AACJ,CAAC;AAED,kBAAe,GAAG,CAAC"}
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "cli-forge",
3
- "version": "1.0.2",
3
+ "version": "1.1.0",
4
4
  "dependencies": {
5
5
  "tslib": "^2.3.0",
6
- "@cli-forge/parser": "1.0.2"
6
+ "@cli-forge/parser": "1.1.0"
7
7
  },
8
8
  "peerDependencies": {
9
9
  "markdown-factory": "^0.2.0",
@@ -0,0 +1,17 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { generateDocumentationCommand } from './generate-documentation';
3
+ import { TestHarness } from '../../lib/test-harness';
4
+
5
+ describe('generateDocumentationCommand', () => {
6
+ it('should not generate llms.txt when --no-llms is provided', async () => {
7
+ const test = new TestHarness(generateDocumentationCommand);
8
+ const { args } = await test.parse(['./some-cli', '--no-llms']);
9
+ expect(args.llms).toBe(false);
10
+ });
11
+
12
+ it('should generate llms.txt by default', async () => {
13
+ const test = new TestHarness(generateDocumentationCommand);
14
+ const { args } = await test.parse(['./some-cli']);
15
+ expect(args.llms).toBe(true);
16
+ });
17
+ });
@@ -44,10 +44,16 @@ export function withGenerateDocumentationArgs<T extends ParsedArgs>(
44
44
  type: 'string',
45
45
  description:
46
46
  'Specifies the `tsconfig` used when loading typescript based CLIs.',
47
+ })
48
+ .option('llms', {
49
+ type: 'boolean',
50
+ description:
51
+ 'Generate an llms.txt file describing the CLI for AI agents.',
52
+ default: true,
47
53
  });
48
54
  }
49
55
 
50
- export const generateDocumentationCommand: CLI<any, any, any> = cli('generate-documentation', {
56
+ export const generateDocumentationCommand = cli('generate-documentation', {
51
57
  description: 'Generate documentation for the given CLI',
52
58
  examples: [
53
59
  'cli-forge generate-documentation ./bin/my-cli',
@@ -70,6 +76,10 @@ export const generateDocumentationCommand: CLI<any, any, any> = cli('generate-do
70
76
  ensureDirSync(outdir);
71
77
  writeFileSync(outfile, JSON.stringify(documentation, null, 2));
72
78
  }
79
+
80
+ if (args.llms) {
81
+ generateLlmsTxt(documentation, args);
82
+ }
73
83
  },
74
84
  });
75
85
 
@@ -81,6 +91,156 @@ async function generateMarkdownDocumentation(
81
91
  await generateMarkdownForSingleCommand(docs, args.output, args.output, md);
82
92
  }
83
93
 
94
+ function generateLlmsTxt(docs: Documentation, args: GenerateDocsArgs) {
95
+ const content = generateLlmsTxtContent(docs);
96
+ const outfile = join(args.output, 'llms.txt');
97
+ ensureDirSync(args.output);
98
+ writeFileSync(outfile, content);
99
+ }
100
+
101
+ function generateLlmsTxtContent(
102
+ docs: Documentation,
103
+ depth = 0,
104
+ commandPath: string[] = []
105
+ ): string {
106
+ const lines: string[] = [];
107
+ const indent = ' '.repeat(depth);
108
+ const currentPath = [...commandPath, docs.name];
109
+ const fullCommand = currentPath.join(' ');
110
+
111
+ // Command header
112
+ if (depth === 0) {
113
+ lines.push(`# ${docs.name}`);
114
+ lines.push('');
115
+ if (docs.description) {
116
+ lines.push(docs.description);
117
+ lines.push('');
118
+ }
119
+ lines.push('This document describes the CLI commands and options for AI agent consumption.');
120
+ lines.push('');
121
+ } else {
122
+ lines.push(`${indent}## ${fullCommand}`);
123
+ if (docs.description) {
124
+ lines.push(`${indent}${docs.description}`);
125
+ }
126
+ lines.push('');
127
+ }
128
+
129
+ // Usage
130
+ lines.push(`${indent}Usage: ${docs.usage}`);
131
+ lines.push('');
132
+
133
+ // Positional arguments
134
+ if (docs.positionals.length > 0) {
135
+ lines.push(`${indent}Positional Arguments:`);
136
+ for (const pos of docs.positionals) {
137
+ const typeStr = formatOptionType(pos);
138
+ const reqStr = pos.required ? ' (required)' : ' (optional)';
139
+ lines.push(`${indent} <${pos.key}> - ${typeStr}${reqStr}`);
140
+ if (pos.description) {
141
+ lines.push(`${indent} ${pos.description}`);
142
+ }
143
+ if (pos.default !== undefined) {
144
+ lines.push(`${indent} Default: ${JSON.stringify(pos.default)}`);
145
+ }
146
+ }
147
+ lines.push('');
148
+ }
149
+
150
+ // Options
151
+ const optionEntries = Object.entries(docs.options);
152
+ if (optionEntries.length > 0) {
153
+ lines.push(`${indent}Options:`);
154
+ for (const [, opt] of optionEntries) {
155
+ const typeStr = formatOptionType(opt);
156
+ const aliasStr = opt.alias?.length
157
+ ? ` (aliases: ${opt.alias.map((a) => (a.length === 1 ? `-${a}` : `--${a}`)).join(', ')})`
158
+ : '';
159
+ const reqStr =
160
+ opt.required && opt.default === undefined ? ' [required]' : '';
161
+ const deprecatedStr = opt.deprecated ? ' [deprecated]' : '';
162
+ lines.push(
163
+ `${indent} --${opt.key}${aliasStr} <${typeStr}>${reqStr}${deprecatedStr}`
164
+ );
165
+ if (opt.description) {
166
+ lines.push(`${indent} ${opt.description}`);
167
+ }
168
+ if (opt.default !== undefined) {
169
+ lines.push(`${indent} Default: ${JSON.stringify(opt.default)}`);
170
+ }
171
+ if ('choices' in opt && opt.choices) {
172
+ const choicesList =
173
+ typeof opt.choices === 'function' ? opt.choices() : opt.choices;
174
+ lines.push(`${indent} Valid values: ${choicesList.join(', ')}`);
175
+ }
176
+ }
177
+ lines.push('');
178
+ }
179
+
180
+ // Grouped options
181
+ for (const group of docs.groupedOptions) {
182
+ if (group.keys.length > 0) {
183
+ lines.push(`${indent}${group.label}:`);
184
+ for (const opt of group.keys) {
185
+ const typeStr = formatOptionType(opt);
186
+ const aliasStr = opt.alias?.length
187
+ ? ` (aliases: ${opt.alias.map((a) => (a.length === 1 ? `-${a}` : `--${a}`)).join(', ')})`
188
+ : '';
189
+ const reqStr =
190
+ opt.required && opt.default === undefined ? ' [required]' : '';
191
+ lines.push(`${indent} --${opt.key}${aliasStr} <${typeStr}>${reqStr}`);
192
+ if (opt.description) {
193
+ lines.push(`${indent} ${opt.description}`);
194
+ }
195
+ if (opt.default !== undefined) {
196
+ lines.push(`${indent} Default: ${JSON.stringify(opt.default)}`);
197
+ }
198
+ }
199
+ lines.push('');
200
+ }
201
+ }
202
+
203
+ // Examples
204
+ if (docs.examples.length > 0) {
205
+ lines.push(`${indent}Examples:`);
206
+ for (const example of docs.examples) {
207
+ lines.push(`${indent} $ ${example}`);
208
+ }
209
+ lines.push('');
210
+ }
211
+
212
+ // Subcommands
213
+ if (docs.subcommands.length > 0) {
214
+ lines.push(`${indent}Subcommands:`);
215
+ for (const sub of docs.subcommands) {
216
+ lines.push(
217
+ `${indent} ${sub.name}${sub.description ? ` - ${sub.description}` : ''}`
218
+ );
219
+ }
220
+ lines.push('');
221
+
222
+ // Recursively document subcommands
223
+ for (const sub of docs.subcommands) {
224
+ lines.push(generateLlmsTxtContent(sub, depth + 1, currentPath));
225
+ }
226
+ }
227
+
228
+ // Epilogue
229
+ if (docs.epilogue && depth === 0) {
230
+ lines.push(`Note: ${docs.epilogue}`);
231
+ lines.push('');
232
+ }
233
+
234
+ return lines.join('\n');
235
+ }
236
+
237
+ function formatOptionType(opt: Documentation['options'][string]): string {
238
+ if ('items' in opt && opt.type === 'array') {
239
+ return `${opt.items}[]`;
240
+ }
241
+ return opt.type;
242
+ }
243
+
84
244
  async function generateMarkdownForSingleCommand(
85
245
  docs: Documentation,
86
246
  out: string,
@@ -367,7 +527,10 @@ async function loadCLIModule(
367
527
  });
368
528
  } else {
369
529
  const tsx = (await import('tsx/cjs/api')) as typeof import('tsx/cjs/api');
370
- return tsx.require(cliPath, join(process.cwd(), 'fake-file-for-require.ts'));
530
+ return tsx.require(
531
+ cliPath,
532
+ join(process.cwd(), 'fake-file-for-require.ts')
533
+ );
371
534
  }
372
535
  } catch {
373
536
  try {
package/src/index.ts CHANGED
@@ -10,3 +10,4 @@ export type {
10
10
  } from './lib/composable-builder';
11
11
  export type { ArgumentsOf } from './lib/utils';
12
12
  export { ConfigurationProviders } from './lib/configuration-providers';
13
+ export type { LocalizationDictionary, LocalizationFunction } from '@cli-forge/parser';
@@ -0,0 +1,197 @@
1
+ import { describe, it, expect, afterEach } from 'vitest';
2
+ import { cli } from './public-api';
3
+ import { TestHarness } from './test-harness';
4
+ import type { LocalizationDictionary } from '@cli-forge/parser';
5
+
6
+ const ORIGINAL_CONSOLE_LOG = console.log;
7
+
8
+ function mockConsoleLog() {
9
+ const lines: string[] = [];
10
+ console.log = (...contents) =>
11
+ lines.push(
12
+ contents
13
+ .map((s) => (typeof s === 'string' ? s : JSON.stringify(s)))
14
+ .join(' ')
15
+ );
16
+ return {
17
+ getOutput: () => lines.join('\n'),
18
+ restore: () => {
19
+ console.log = ORIGINAL_CONSOLE_LOG;
20
+ },
21
+ };
22
+ }
23
+
24
+ describe('CLI localization', () => {
25
+ afterEach(() => {
26
+ console.log = ORIGINAL_CONSOLE_LOG;
27
+ process.exitCode = undefined;
28
+ });
29
+
30
+ const dictionary: LocalizationDictionary = {
31
+ name: {
32
+ default: 'name',
33
+ 'es-ES': 'nombre',
34
+ },
35
+ port: {
36
+ default: 'port',
37
+ 'es-ES': 'puerto',
38
+ },
39
+ serve: {
40
+ default: 'serve',
41
+ 'es-ES': 'servir',
42
+ },
43
+ };
44
+
45
+ it('should accept localized option keys', async () => {
46
+ const testCli = cli('test')
47
+ .localize(dictionary, 'es-ES')
48
+ .option('name', { type: 'string' })
49
+ .option('port', { type: 'number' })
50
+ .command('$0', {
51
+ handler: () => {},
52
+ });
53
+
54
+ const harness = new TestHarness(testCli);
55
+ const { args } = await harness.parse(['--nombre', 'test', '--puerto', '8080']);
56
+
57
+ expect(args.name).toBe('test');
58
+ expect(args.port).toBe(8080);
59
+ });
60
+
61
+ it('should accept default option keys as aliases', async () => {
62
+ const testCli = cli('test')
63
+ .localize(dictionary, 'es-ES')
64
+ .option('name', { type: 'string' })
65
+ .option('port', { type: 'number' })
66
+ .command('$0', {
67
+ handler: () => {},
68
+ });
69
+
70
+ const harness = new TestHarness(testCli);
71
+ const { args } = await harness.parse(['--name', 'test', '--port', '8080']);
72
+
73
+ expect(args.name).toBe('test');
74
+ expect(args.port).toBe(8080);
75
+ });
76
+
77
+ it('should display localized keys in help text', async () => {
78
+ const mock = mockConsoleLog();
79
+ try {
80
+ await cli('test')
81
+ .localize(dictionary, 'es-ES')
82
+ .option('name', { type: 'string', description: 'Name option' })
83
+ .option('port', { type: 'number', description: 'Port option' })
84
+ .forge(['--help']);
85
+
86
+ const output = mock.getOutput();
87
+ expect(output).toContain('--nombre');
88
+ expect(output).toContain('--puerto');
89
+ expect(output).not.toContain('--name '); // Should not show as primary
90
+ expect(output).not.toContain('--port '); // Should not show as primary
91
+ } finally {
92
+ mock.restore();
93
+ }
94
+ });
95
+
96
+ it('should display localized command names in help text', async () => {
97
+ const mock = mockConsoleLog();
98
+ try {
99
+ await cli('test')
100
+ .localize(dictionary, 'es-ES')
101
+ .command('serve', {
102
+ builder: (cmd) => cmd,
103
+ handler: () => {},
104
+ description: 'Start the server',
105
+ })
106
+ .forge(['--help']);
107
+
108
+ const output = mock.getOutput();
109
+ expect(output).toContain('servir');
110
+ expect(output).toContain('Start the server');
111
+ } finally {
112
+ mock.restore();
113
+ }
114
+ });
115
+
116
+ it('should work with subcommands', async () => {
117
+ const testCli = cli('test')
118
+ .localize(dictionary, 'es-ES')
119
+ .command('serve', {
120
+ builder: (cmd) =>
121
+ cmd
122
+ .option('port', { type: 'number' })
123
+ .option('name', { type: 'string' }),
124
+ handler: () => {},
125
+ });
126
+
127
+ const harness = new TestHarness(testCli);
128
+ const { args, commandChain } = await harness.parse(['servir', '--puerto', '8080', '--nombre', 'test']);
129
+
130
+ expect(args.port).toBe(8080);
131
+ expect(args.name).toBe('test');
132
+ expect(commandChain).toEqual(['servir']);
133
+ });
134
+
135
+ it('should work without localization', async () => {
136
+ const testCli = cli('test')
137
+ .option('name', { type: 'string' })
138
+ .option('port', { type: 'number' })
139
+ .command('$0', {
140
+ handler: () => {},
141
+ });
142
+
143
+ const harness = new TestHarness(testCli);
144
+ const { args } = await harness.parse(['--name', 'test', '--port', '8080']);
145
+
146
+ expect(args.name).toBe('test');
147
+ expect(args.port).toBe(8080);
148
+ });
149
+
150
+ it('should chain localize with other builder methods', async () => {
151
+ const testCli = cli('test')
152
+ .localize(dictionary, 'es-ES')
153
+ .option('name', { type: 'string' })
154
+ .option('port', { type: 'number' })
155
+ .env('TEST')
156
+ .command('$0', {
157
+ handler: () => {},
158
+ });
159
+
160
+ const harness = new TestHarness(testCli);
161
+ const { args } = await harness.parse(['--nombre', 'test', '--puerto', '8080']);
162
+
163
+ expect(args.name).toBe('test');
164
+ expect(args.port).toBe(8080);
165
+ });
166
+
167
+ it('should work with localization function', async () => {
168
+ const localizer = (key: string) => {
169
+ const translations: Record<string, string> = {
170
+ name: 'nombre',
171
+ port: 'puerto',
172
+ serve: 'servir',
173
+ };
174
+ return translations[key] || key;
175
+ };
176
+
177
+ const testCli = cli('test')
178
+ .localize(localizer)
179
+ .option('name', { type: 'string' })
180
+ .option('port', { type: 'number' })
181
+ .command('serve', {
182
+ builder: (cmd) => cmd,
183
+ handler: () => {},
184
+ });
185
+
186
+ const harness = new TestHarness(testCli);
187
+
188
+ // Test options with localized keys
189
+ const { args: args1 } = await harness.parse(['--nombre', 'test', '--puerto', '8080']);
190
+ expect(args1.name).toBe('test');
191
+ expect(args1.port).toBe(8080);
192
+
193
+ // Test command with localized name
194
+ const { commandChain } = await harness.parse(['servir']);
195
+ expect(commandChain).toEqual(['servir']);
196
+ });
197
+ });
@@ -2,6 +2,7 @@ import {
2
2
  UnknownOptionConfig,
3
3
  OptionConfigToType,
4
4
  readDefaultValue,
5
+ LocalizationDictionary,
5
6
  } from '@cli-forge/parser';
6
7
  import { InternalCLI } from './internal-cli';
7
8
  import { CLI } from './public-api';
@@ -19,6 +20,11 @@ export type Documentation = {
19
20
  keys: Array<NormalizedOptionConfig>;
20
21
  }>;
21
22
  subcommands: Documentation[];
23
+ /**
24
+ * Localized keys for options and commands. Maps from default key to full localization entry.
25
+ * Only present if localization is configured.
26
+ */
27
+ localizedKeys?: LocalizationDictionary;
22
28
  };
23
29
 
24
30
  function normalizeOptionConfigForDocumentation<T extends UnknownOptionConfig>(
@@ -85,7 +91,42 @@ export function generateDocumentation(
85
91
  generateDocumentation(cmd.clone(), [...commandChain, cli.name])
86
92
  );
87
93
 
88
- return {
94
+ // Get the localization dictionary if configured
95
+ const dictionary = parser.getLocalizationDictionary();
96
+ let localizedKeys: LocalizationDictionary | undefined;
97
+
98
+ if (dictionary) {
99
+ // Filter to only include keys that are actually used in this CLI
100
+ const usedKeys: LocalizationDictionary = {};
101
+ let hasUsedKeys = false;
102
+
103
+ for (const key in parser.configuredOptions) {
104
+ if (dictionary[key]) {
105
+ usedKeys[key] = dictionary[key];
106
+ hasUsedKeys = true;
107
+ }
108
+ }
109
+
110
+ // Also include command names - track unique commands by instance to avoid duplicates
111
+ const seenCommands = new Set<InternalCLI<any, any, any, any>>();
112
+ for (const cmdKey in cli.getSubcommands()) {
113
+ const cmdInstance = cli.getSubcommands()[cmdKey];
114
+ if (!seenCommands.has(cmdInstance)) {
115
+ seenCommands.add(cmdInstance);
116
+ const defaultName = cmdInstance.name;
117
+ if (dictionary[defaultName]) {
118
+ usedKeys[defaultName] = dictionary[defaultName];
119
+ hasUsedKeys = true;
120
+ }
121
+ }
122
+ }
123
+
124
+ if (hasUsedKeys) {
125
+ localizedKeys = usedKeys;
126
+ }
127
+ }
128
+
129
+ const result: Documentation = {
89
130
  name: cli.name,
90
131
  description: cli.configuration?.description,
91
132
  usage: cli.configuration?.usage
@@ -103,5 +144,11 @@ export function generateDocumentation(
103
144
  options,
104
145
  positionals,
105
146
  subcommands,
106
- } as Documentation;
147
+ };
148
+
149
+ if (localizedKeys) {
150
+ result.localizedKeys = localizedKeys;
151
+ }
152
+
153
+ return result;
107
154
  }
@@ -24,9 +24,10 @@ export function formatHelp(parentCLI: InternalCLI<any>): string {
24
24
  : [
25
25
  parentCLI.name,
26
26
  ...parentCLI.commandChain,
27
- ...command.parser.configuredPositionals.map((p) =>
28
- p.required ? `<${p.key}>` : `[${p.key}]`
29
- ),
27
+ ...command.parser.configuredPositionals.map((p) => {
28
+ const displayKey = command.parser.getDisplayKey(p.key);
29
+ return p.required ? `<${displayKey}>` : `[${displayKey}]`;
30
+ }),
30
31
  ].join(' ')
31
32
  }`
32
33
  );
@@ -37,10 +38,19 @@ export function formatHelp(parentCLI: InternalCLI<any>): string {
37
38
  help.push('');
38
39
  help.push('Commands:');
39
40
  }
41
+ // Track displayed commands by their actual CLI instance to avoid duplicates
42
+ const displayedCommands = new Set<InternalCLI<any, any, any, any>>();
40
43
  for (const key in command.registeredCommands) {
41
44
  const subcommand = command.registeredCommands[key];
45
+ // Skip if we've already displayed this command instance
46
+ if (displayedCommands.has(subcommand)) {
47
+ continue;
48
+ }
49
+ displayedCommands.add(subcommand);
50
+ // Use the localized command name for display based on the command's default name
51
+ const displayKey = command.getLocalizedCommandName(subcommand.name);
42
52
  help.push(
43
- ` ${key}${
53
+ ` ${displayKey}${
44
54
  subcommand.configuration?.description
45
55
  ? ' - ' + subcommand.configuration.description
46
56
  : ''
@@ -52,10 +62,10 @@ export function formatHelp(parentCLI: InternalCLI<any>): string {
52
62
  command.parser.configuredOptions
53
63
  ).filter((c) => !c.positional);
54
64
 
55
- help.push(...getOptionBlock('Options', nonpositionalOptions));
65
+ help.push(...getOptionBlock('Options', nonpositionalOptions, command.parser));
56
66
 
57
67
  for (const { label, keys } of groupedOptions) {
58
- help.push(...getOptionBlock(label, keys));
68
+ help.push(...getOptionBlock(label, keys, command.parser));
59
69
  }
60
70
 
61
71
  if (command.configuration?.examples?.length) {
@@ -117,7 +127,11 @@ function removeTrailingAndLeadingQuotes(str: string) {
117
127
  return str.replace(/^['"]/, '').replace(/['"]$/, '');
118
128
  }
119
129
 
120
- function getOptionBlock(label: string, options: InternalOptionConfig[]) {
130
+ function getOptionBlock(
131
+ label: string,
132
+ options: InternalOptionConfig[],
133
+ parser: import('@cli-forge/parser').ReadonlyArgvParser<any>
134
+ ) {
121
135
  const lines: string[] = [];
122
136
 
123
137
  if (options.length > 0) {
@@ -127,7 +141,9 @@ function getOptionBlock(label: string, options: InternalOptionConfig[]) {
127
141
 
128
142
  const allParts: Array<[key: string, ...parts: string[]]> = [];
129
143
  for (const option of options) {
130
- allParts.push([option.key, ...getOptionParts(option)]);
144
+ // Use the display key (localized) instead of the storage key
145
+ const displayKey = parser.getDisplayKey(option.key);
146
+ allParts.push([displayKey, ...getOptionParts(option)]);
131
147
  }
132
148
  const paddingValues: number[] = [];
133
149
  for (let i = 0; i < allParts.length; i++) {