Skip to content

Commit 3745ccb

Browse files
committed
feat(cli-forge): generate llms.txt when generating markdown docs
1 parent 869a889 commit 3745ccb

File tree

7 files changed

+247
-34
lines changed

7 files changed

+247
-34
lines changed

docs-site/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ docs/api/cli-forge
2525
docs/api/parser
2626
docs/examples/**/*.md
2727
docs/cli/**/*.md
28+
docs/cli/**/*.txt
2829
docs/changelog.md
2930
docs/index.md
3031
docs/CONTRIBUTING.md

examples/composable-builders/cli.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,18 @@ import { chain, cli } from 'cli-forge';
33
import { buildCommand } from './commands/build';
44
import { serveCommand } from './commands/serve';
55

6+
const subcommand = cli('subcommand', {
7+
builder: (args) => args.option('foo', { type: 'string' }),
8+
});
9+
610
/**
711
* Main CLI that composes commands from separate modules.
812
* Each command uses shared option builders for consistency.
913
*/
1014
const app = cli('composable-demo', {
1115
description: 'Demonstrates composable option builders',
12-
builder: (args) => chain(args, buildCommand, serveCommand),
16+
builder: (args) =>
17+
chain(args, buildCommand, serveCommand).command(subcommand),
1318
});
1419

1520
export default app;

examples/multi-command-cli/cli.ts

Lines changed: 39 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,13 @@ import { cli, chain, makeComposableBuilder } from 'cli-forge';
33
import { withInitCommand } from './commands/init';
44
import { withBuildCommand, BuildResult } from './commands/build';
55
import { withServeCommand, ServerInfo } from './commands/serve';
6+
import { group } from 'console';
67

78
const app = cli('project-cli', {
89
description: 'Project management CLI',
910
builder: (args) =>
10-
chain(args, withInitCommand, withBuildCommand, withServeCommand).command(
11-
'build-and-serve',
12-
{
11+
chain(args, withInitCommand, withBuildCommand, withServeCommand)
12+
.command('build-and-serve', {
1313
builder: (args) => {
1414
const siblings = args.getParent().getChildren();
1515
const withBuildArgs = siblings.build.getBuilder()!;
@@ -45,32 +45,46 @@ const app = cli('project-cli', {
4545
serverInfo,
4646
};
4747
},
48-
}
49-
),
50-
handler: async (_args, ctx) => {
51-
// Child handlers are typed - can invoke programmatically if needed
52-
const children = ctx.command.getChildren();
48+
})
49+
.command({
50+
name: 'foo',
51+
builder: (args) =>
52+
args.command('bar', {
53+
handler: () => {
54+
console.log('bar');
55+
},
56+
}),
57+
})
58+
.option('foo', {
59+
type: 'string',
60+
group: 'Bar',
61+
})
62+
.group('Bar', ['foo'])
63+
.enableInteractiveShell(),
64+
// handler: async (_args, ctx) => {
65+
// // Child handlers are typed - can invoke programmatically if needed
66+
// const children = ctx.command.getChildren();
5367

54-
const { init, build } = {
55-
init: children.init.getHandler(),
56-
build: children.build.getHandler(),
57-
};
68+
// const { init, build } = {
69+
// init: children.init.getHandler(),
70+
// build: children.build.getHandler(),
71+
// };
5872

59-
const result = init?.({
60-
git: true,
61-
name: 'MyProject',
62-
template: 'typescript',
63-
});
73+
// const result = init?.({
74+
// git: true,
75+
// name: 'MyProject',
76+
// template: 'typescript',
77+
// });
6478

65-
const buildResult = build?.({
66-
minify: true,
67-
sourcemap: false,
68-
outDir: 'dist',
69-
});
79+
// const buildResult = build?.({
80+
// minify: true,
81+
// sourcemap: false,
82+
// outDir: 'dist',
83+
// });
7084

71-
console.log('Build result:', buildResult);
72-
},
73-
}).demandCommand();
85+
// console.log('Build result:', buildResult);
86+
// },
87+
});
7488

7589
export default app;
7690

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
});

packages/cli-forge/src/bin/commands/generate-documentation.ts

Lines changed: 165 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,10 +44,16 @@ export function withGenerateDocumentationArgs<T extends ParsedArgs>(
4444
type: 'string',
4545
description:
4646
'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,
4753
});
4854
}
4955

50-
export const generateDocumentationCommand: CLI<any, any, any> = cli('generate-documentation', {
56+
export const generateDocumentationCommand = cli('generate-documentation', {
5157
description: 'Generate documentation for the given CLI',
5258
examples: [
5359
'cli-forge generate-documentation ./bin/my-cli',
@@ -70,6 +76,10 @@ export const generateDocumentationCommand: CLI<any, any, any> = cli('generate-do
7076
ensureDirSync(outdir);
7177
writeFileSync(outfile, JSON.stringify(documentation, null, 2));
7278
}
79+
80+
if (args.llms) {
81+
generateLlmsTxt(documentation, args);
82+
}
7383
},
7484
});
7585

@@ -81,6 +91,156 @@ async function generateMarkdownDocumentation(
8191
await generateMarkdownForSingleCommand(docs, args.output, args.output, md);
8292
}
8393

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+
84244
async function generateMarkdownForSingleCommand(
85245
docs: Documentation,
86246
out: string,
@@ -367,7 +527,10 @@ async function loadCLIModule(
367527
});
368528
} else {
369529
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+
);
371534
}
372535
} catch {
373536
try {

packages/cli-forge/src/lib/internal-cli.ts

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -539,9 +539,19 @@ export class InternalCLI<
539539
}
540540

541541
getBuilder():
542-
| (<TInit extends ParsedArgs, TInitHandlerReturn, TInitChildren, TInitParent>(
542+
| (<
543+
TInit extends ParsedArgs,
544+
TInitHandlerReturn,
545+
TInitChildren,
546+
TInitParent
547+
>(
543548
parser: CLI<TInit, TInitHandlerReturn, TInitChildren, TInitParent>
544-
) => CLI<TInit & TArgs, TInitHandlerReturn, TInitChildren & TChildren, TInitParent>)
549+
) => CLI<
550+
TInit & TArgs,
551+
TInitHandlerReturn,
552+
TInitChildren & TChildren,
553+
TInitParent
554+
>)
545555
| undefined {
546556
const builder = this.configuration?.builder;
547557
if (!builder) return undefined;
@@ -742,7 +752,7 @@ export class InternalCLI<
742752
group(
743753
labelOrConfigObject:
744754
| string
745-
| { label: string; keys: (keyof TArgs)[]; sortOrder: number },
755+
| { label: string; keys: (keyof TArgs)[]; sortOrder?: number },
746756
keys?: (keyof TArgs)[]
747757
): CLI<TArgs, THandlerReturn, TChildren, TParent> {
748758
const config =
@@ -751,14 +761,17 @@ export class InternalCLI<
751761
: {
752762
label: labelOrConfigObject,
753763
keys: keys as (keyof TArgs)[],
754-
sortOrder: Object.keys(this.registeredOptionGroups).length,
755764
};
756765

757766
if (!config.keys) {
758767
throw new Error('keys must be provided when calling `group`.');
759768
}
760769

761-
this.registeredOptionGroups.push(config);
770+
this.registeredOptionGroups.push({
771+
...config,
772+
sortOrder:
773+
config.sortOrder ?? Object.keys(this.registeredOptionGroups).length,
774+
});
762775
return this as unknown as CLI<TArgs, THandlerReturn, TChildren, TParent>;
763776
}
764777

packages/cli-forge/src/lib/public-api.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -765,7 +765,7 @@ export interface CLI<
765765
}: {
766766
label: string;
767767
keys: (keyof TArgs)[];
768-
sortOrder: number;
768+
sortOrder?: number;
769769
}): CLI<TArgs, THandlerReturn, TChildren, TParent>;
770770
group(
771771
label: string,

0 commit comments

Comments
 (0)