cli-forge 1.0.2 → 1.2.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.
Files changed (34) hide show
  1. package/dist/bin/cli.d.ts +16 -1
  2. package/dist/bin/commands/generate-documentation.d.ts +19 -2
  3. package/dist/bin/commands/generate-documentation.js +135 -0
  4. package/dist/bin/commands/generate-documentation.js.map +1 -1
  5. package/dist/index.d.ts +1 -0
  6. package/dist/lib/composable-builder.d.ts +5 -1
  7. package/dist/lib/composable-builder.js +24 -3
  8. package/dist/lib/composable-builder.js.map +1 -1
  9. package/dist/lib/documentation.d.ts +6 -1
  10. package/dist/lib/documentation.js +35 -1
  11. package/dist/lib/documentation.js.map +1 -1
  12. package/dist/lib/format-help.js +20 -6
  13. package/dist/lib/format-help.js.map +1 -1
  14. package/dist/lib/interactive-shell.js +2 -0
  15. package/dist/lib/interactive-shell.js.map +1 -1
  16. package/dist/lib/internal-cli.d.ts +26 -5
  17. package/dist/lib/internal-cli.js +166 -38
  18. package/dist/lib/internal-cli.js.map +1 -1
  19. package/dist/lib/public-api.d.ts +59 -3
  20. package/dist/lib/public-api.js.map +1 -1
  21. package/package.json +2 -2
  22. package/src/bin/commands/generate-documentation.spec.ts +17 -0
  23. package/src/bin/commands/generate-documentation.ts +165 -2
  24. package/src/index.ts +1 -0
  25. package/src/lib/cli-localization.spec.ts +197 -0
  26. package/src/lib/composable-builder.spec.ts +73 -0
  27. package/src/lib/composable-builder.ts +26 -5
  28. package/src/lib/documentation.ts +49 -2
  29. package/src/lib/format-help.ts +24 -8
  30. package/src/lib/interactive-shell.ts +2 -0
  31. package/src/lib/internal-cli.spec.ts +720 -1
  32. package/src/lib/internal-cli.ts +223 -52
  33. package/src/lib/public-api.ts +80 -9
  34. package/tsconfig.lib.json.tsbuildinfo +1 -1
@@ -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: () => { /* noop */ },
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: () => { /* noop */ },
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: () => { /* noop */ },
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: () => { /* noop */ },
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: () => { /* noop */ },
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: () => { /* noop */ },
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: () => { /* noop */ },
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
+ });
@@ -0,0 +1,73 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { makeComposableBuilder } from './composable-builder';
3
+ import cli from './public-api';
4
+ import { chain } from '@cli-forge/parser';
5
+
6
+ describe('makeComposableBuilder', () => {
7
+ describe('capture-and-replay', () => {
8
+ it('should produce stable middleware references across applications', () => {
9
+ const builder = makeComposableBuilder((cmd) =>
10
+ cmd
11
+ .option('verbose', { type: 'boolean' })
12
+ .middleware((args: any) => args)
13
+ );
14
+
15
+ const cli1 = builder(cli('test1'));
16
+ const cli2 = builder(cli('test2'));
17
+
18
+ const mw1 = [...(cli1 as any).registeredMiddleware];
19
+ const mw2 = [...(cli2 as any).registeredMiddleware];
20
+ expect(mw1.length).toBe(1);
21
+ expect(mw2.length).toBe(1);
22
+ expect(mw1[0]).toBe(mw2[0]);
23
+ });
24
+
25
+ it('should correctly replay option registrations', async () => {
26
+ const builder = makeComposableBuilder((cmd) =>
27
+ cmd.option('verbose', { type: 'boolean' })
28
+ );
29
+
30
+ let handlerArgs: any;
31
+ await chain(cli('test'), builder)
32
+ .command('$0', {
33
+ handler: (args) => {
34
+ handlerArgs = args;
35
+ },
36
+ })
37
+ .forge(['--verbose']);
38
+ expect(handlerArgs.verbose).toBe(true);
39
+ });
40
+
41
+ it('should deduplicate middleware when builder applied to parent and child', async () => {
42
+ let mwCallCount = 0;
43
+ const builder = makeComposableBuilder((cmd) =>
44
+ cmd.option('verbose', { type: 'boolean' }).middleware((args: any) => {
45
+ mwCallCount++;
46
+ return args;
47
+ })
48
+ );
49
+
50
+ await chain(cli('parent'), builder)
51
+ .command('child', {
52
+ builder: (cmd) =>
53
+ chain(cmd, builder).option('format', { type: 'string' }),
54
+ handler: () => { /* noop */ },
55
+ })
56
+ .forge(['child']);
57
+ expect(mwCallCount).toBe(1);
58
+ });
59
+
60
+ it('should replay commands registered by the builder', () => {
61
+ const builder = makeComposableBuilder((cmd) =>
62
+ cmd.command('sub', {
63
+ builder: (c) => c.option('flag', { type: 'boolean' }),
64
+ handler: () => { /* noop */ },
65
+ })
66
+ );
67
+
68
+ const myCli = builder(cli('test'));
69
+ const children = myCli.getChildren();
70
+ expect(children).toHaveProperty('sub');
71
+ });
72
+ });
73
+ });
@@ -1,4 +1,4 @@
1
- import { ParsedArgs } from '@cli-forge/parser';
1
+ import type { ParsedArgs } from '@cli-forge/parser';
2
2
  import { CLI } from './public-api';
3
3
 
4
4
  /**
@@ -30,6 +30,10 @@ export type ComposableBuilder<
30
30
  * Can be used to add options, commands, or any other CLI modifications.
31
31
  * Children added by the builder function are properly tracked in the type.
32
32
  *
33
+ * The builder function runs once at creation time against a recording Proxy.
34
+ * Subsequent applications replay the captured operations, ensuring inline
35
+ * middleware closures have stable references for Set-based deduplication.
36
+ *
33
37
  * @typeParam TArgs2 - The args type after the builder runs
34
38
  * @typeParam TChildren2 - The children type added by the builder
35
39
  */
@@ -43,15 +47,32 @@ export function makeComposableBuilder<
43
47
  init: CLI<ParsedArgs, any, {}, any>
44
48
  ) => CLI<TArgs2, any, TChildren2, any>
45
49
  ) {
50
+ // Run builder once against a recording proxy to capture operations.
51
+ // Replaying these ensures inline closures (e.g. middleware) keep stable
52
+ // references across applications, enabling Set-based deduplication.
53
+ const operations: { method: string; args: any[] }[] = [];
54
+ const proxy = new Proxy({} as CLI, {
55
+ get(_target, prop) {
56
+ return (...args: any[]) => {
57
+ operations.push({ method: prop as string, args });
58
+ return proxy;
59
+ };
60
+ },
61
+ });
62
+ fn(proxy);
63
+
46
64
  return <TInit extends ParsedArgs, THandlerReturn, TChildren, TParent>(
47
65
  init: CLI<TInit, THandlerReturn, TChildren, TParent>
48
- ) =>
49
- // eslint-disable-next-line @typescript-eslint/ban-types
50
- fn(init as unknown as CLI<ParsedArgs, any, {}, any>) as unknown as CLI<
66
+ ) => {
67
+ let current: any = init;
68
+ for (const op of operations) {
69
+ current = current[op.method](...op.args);
70
+ }
71
+ return current as unknown as CLI<
51
72
  TInit & TArgs2,
52
73
  THandlerReturn,
53
74
  TChildren & TChildren2,
54
75
  TParent
55
76
  >;
77
+ };
56
78
  }
57
-
@@ -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++) {
@@ -58,6 +58,8 @@ export class InteractiveShell {
58
58
  process.emit('SIGINT');
59
59
  });
60
60
 
61
+ // Show the cursor (in case it was hidden)
62
+ process.stdout.write('\x1b[?25h');
61
63
  this.rl.prompt();
62
64
 
63
65
  this.registerLineListener(async (line) => {