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.
- package/dist/bin/cli.d.ts +16 -1
- package/dist/bin/commands/generate-documentation.d.ts +19 -2
- package/dist/bin/commands/generate-documentation.js +135 -0
- package/dist/bin/commands/generate-documentation.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/lib/documentation.d.ts +6 -1
- package/dist/lib/documentation.js +35 -1
- package/dist/lib/documentation.js.map +1 -1
- package/dist/lib/format-help.js +20 -6
- package/dist/lib/format-help.js.map +1 -1
- package/dist/lib/internal-cli.d.ts +9 -1
- package/dist/lib/internal-cli.js +32 -2
- package/dist/lib/internal-cli.js.map +1 -1
- package/dist/lib/public-api.d.ts +49 -2
- package/dist/lib/public-api.js.map +1 -1
- package/package.json +2 -2
- package/src/bin/commands/generate-documentation.spec.ts +17 -0
- package/src/bin/commands/generate-documentation.ts +165 -2
- package/src/index.ts +1 -0
- package/src/lib/cli-localization.spec.ts +197 -0
- package/src/lib/documentation.ts +49 -2
- package/src/lib/format-help.ts +24 -8
- package/src/lib/internal-cli.spec.ts +36 -0
- package/src/lib/internal-cli.ts +57 -5
- package/src/lib/public-api.ts +57 -1
- package/tsconfig.lib.json.tsbuildinfo +1 -1
package/dist/lib/public-api.d.ts
CHANGED
|
@@ -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
|
|
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":";;
|
|
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
|
@@ -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
|
|
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(
|
|
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
|
@@ -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
|
+
});
|
package/src/lib/documentation.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
}
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
if (localizedKeys) {
|
|
150
|
+
result.localizedKeys = localizedKeys;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return result;
|
|
107
154
|
}
|
package/src/lib/format-help.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
` ${
|
|
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(
|
|
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
|
-
|
|
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++) {
|