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.
- 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/composable-builder.d.ts +5 -1
- package/dist/lib/composable-builder.js +24 -3
- package/dist/lib/composable-builder.js.map +1 -1
- 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/interactive-shell.js +2 -0
- package/dist/lib/interactive-shell.js.map +1 -1
- package/dist/lib/internal-cli.d.ts +26 -5
- package/dist/lib/internal-cli.js +166 -38
- package/dist/lib/internal-cli.js.map +1 -1
- package/dist/lib/public-api.d.ts +59 -3
- 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/composable-builder.spec.ts +73 -0
- package/src/lib/composable-builder.ts +26 -5
- package/src/lib/documentation.ts +49 -2
- package/src/lib/format-help.ts +24 -8
- package/src/lib/interactive-shell.ts +2 -0
- package/src/lib/internal-cli.spec.ts +720 -1
- package/src/lib/internal-cli.ts +223 -52
- package/src/lib/public-api.ts +80 -9
- 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
|
|
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: () => { /* 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
|
-
|
|
50
|
-
|
|
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
|
-
|
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++) {
|