Multi-Command CLI Structure
Organize a CLI with multiple commands in separate files using
makeComposableBuilder and chain. Each command returns typed
results that can be accessed programmatically via getChildren().
Command Files
Each command lives in its own file under commands/. This separation makes commands easier to test in isolation and keeps the codebase organized as it grows.
Init Command
The init command creates a new project. It uses choices for the template option, which TypeScript narrows to a literal union type.
1import { makeComposableBuilder } from 'cli-forge';
2
3export const withInitCommand = makeComposableBuilder((args) =>
4 args.command('init', {
5 description: 'Initialize a new project',
6 builder: (cmd) =>
7 cmd
8 .option('name', {
9 type: 'string',
10 description: 'Project name',
11 required: true,
12 })
13 .option('template', {
14 type: 'string',
15 description: 'Template to use',
16 choices: ['basic', 'typescript', 'react'] as const,
17 default: 'basic' as const,
18 })
19 .option('git', {
20 type: 'boolean',
21 description: 'Initialize git repository',
22 default: true,
23 }),
24
25 handler: (args): { projectPath: string; template: string } => {
26 console.log(`Initializing project: ${args.name}`);
27 console.log(` Template: ${args.template}`);
28 console.log(` Git: ${args.git ? 'yes' : 'no'}`);
29
30 return {
31 projectPath: `./${args.name}`,
32 template: args.template,
33 };
34 },
35 })
36);Build Command
The build command handles production builds with options for minification and source maps.
1import { makeComposableBuilder } from 'cli-forge';
2
3export interface BuildResult {
4 success: boolean;
5 outputDir: string;
6 files: string[];
7}
8
9export const withBuildCommand = makeComposableBuilder((args) =>
10 args.command('build', {
11 description: 'Build the project for production',
12 builder: (cmd) =>
13 cmd
14 .option('outDir', {
15 type: 'string',
16 description: 'Output directory',
17 default: 'dist',
18 })
19 .option('minify', {
20 type: 'boolean',
21 description: 'Minify output',
22 default: false,
23 })
24 .option('sourcemap', {
25 type: 'boolean',
26 description: 'Generate source maps',
27 default: true,
28 }),
29
30 handler: (args): BuildResult => {
31 console.log('Building project...');
32 console.log(` outDir: ${args.outDir}`);
33 console.log(` minify: ${args.minify}`);
34 console.log(` sourcemap: ${args.sourcemap}`);
35
36 return {
37 success: true,
38 outputDir: args.outDir,
39 files: ['index.js', 'styles.css'],
40 };
41 },
42 })
43);Serve Command
The serve command starts a development server with configurable port and host.
1import { makeComposableBuilder } from 'cli-forge';
2
3export interface ServerInfo {
4 url: string;
5 port: number;
6 host: string;
7}
8
9export const withServeCommand = makeComposableBuilder((args) =>
10 args.command('serve', {
11 description: 'Start development server',
12 builder: (cmd) =>
13 cmd
14 .option('port', {
15 type: 'number',
16 alias: ['p'],
17 description: 'Port to listen on',
18 default: 3000,
19 })
20 .option('host', {
21 type: 'string',
22 description: 'Host to bind to',
23 default: 'localhost',
24 })
25 .option('open', {
26 type: 'boolean',
27 description: 'Open browser automatically',
28 default: false,
29 }),
30
31 handler: (args): ServerInfo => {
32 const url = `http://${args.host}:${args.port}`;
33 console.log(`Starting server on port ${args.port}...`);
34 console.log(` Host: ${args.host}`);
35 console.log(` URL: ${url}`);
36
37 if (args.open) {
38 console.log('Opening browser...');
39 }
40
41 return {
42 url,
43 port: args.port,
44 host: args.host,
45 };
46 },
47 })
48);Main Entry Point
The main CLI file imports and registers all commands. This keeps the entry point minimal and focused on composition.
1import { cli, chain, makeComposableBuilder } from 'cli-forge';
2
3import { withInitCommand } from './commands/init';
4import { withBuildCommand, BuildResult } from './commands/build';
5import { withServeCommand, ServerInfo } from './commands/serve';
6import { group } from 'console';
7
8const app = cli('project-cli', {
9 description: 'Project management CLI',
10 builder: (args) =>
11 chain(args, withInitCommand, withBuildCommand, withServeCommand)
12 .command('build-and-serve', {
13 builder: (args) => {
14 const siblings = args.getParent().getChildren();
15 const withBuildArgs = siblings.build.getBuilder()!;
16 const withServeArgs = siblings.serve.getBuilder()!;
17 return chain(args, withBuildArgs, withServeArgs);
18 },
19 handler: async (args, ctx) => {
20 const siblings = ctx.command.getParent().getChildren();
21 const buildHandler = siblings.build.getHandler();
22 const serveHandler = siblings.serve.getHandler();
23
24 const buildResult = buildHandler
25 ? await buildHandler({
26 minify: args.minify,
27 sourcemap: args.sourcemap,
28 outDir: args.outDir,
29 })
30 : undefined;
31
32 const serverInfo = serveHandler
33 ? await serveHandler({
34 port: args.port,
35 host: args.host,
36 open: args.open,
37 })
38 : undefined;
39
40 console.log('Build successful:', buildResult);
41 console.log('Server info:', serverInfo);
42
43 return {
44 buildResult,
45 serverInfo,
46 };
47 },
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();
67
68 // const { init, build } = {
69 // init: children.init.getHandler(),
70 // build: children.build.getHandler(),
71 // };
72
73 // const result = init?.({
74 // git: true,
75 // name: 'MyProject',
76 // template: 'typescript',
77 // });
78
79 // const buildResult = build?.({
80 // minify: true,
81 // sourcemap: false,
82 // outDir: 'dist',
83 // });
84
85 // console.log('Build result:', buildResult);
86 // },
87});
88
89export default app;
90
91// Export types for consumers who want to use commands programmatically
92export type { BuildResult, ServerInfo };
93
94if (require.main === module) {
95 app.forge();
96}Directory Structure
1multi-command-cli/
2 cli.ts # Entry point, registers commands
3 commands/
4 init.ts # Initialize project
5 build.ts # Build for production
6 serve.ts # Development serverThis structure is common in production CLIs. Each command can be developed and tested independently, and new commands are added by creating a new file and registering it in the main CLI.
All Example Files
1import { cli, chain, makeComposableBuilder } from 'cli-forge';
2
3import { withInitCommand } from './commands/init';
4import { withBuildCommand, BuildResult } from './commands/build';
5import { withServeCommand, ServerInfo } from './commands/serve';
6import { group } from 'console';
7
8const app = cli('project-cli', {
9 description: 'Project management CLI',
10 builder: (args) =>
11 chain(args, withInitCommand, withBuildCommand, withServeCommand)
12 .command('build-and-serve', {
13 builder: (args) => {
14 const siblings = args.getParent().getChildren();
15 const withBuildArgs = siblings.build.getBuilder()!;
16 const withServeArgs = siblings.serve.getBuilder()!;
17 return chain(args, withBuildArgs, withServeArgs);
18 },
19 handler: async (args, ctx) => {
20 const siblings = ctx.command.getParent().getChildren();
21 const buildHandler = siblings.build.getHandler();
22 const serveHandler = siblings.serve.getHandler();
23
24 const buildResult = buildHandler
25 ? await buildHandler({
26 minify: args.minify,
27 sourcemap: args.sourcemap,
28 outDir: args.outDir,
29 })
30 : undefined;
31
32 const serverInfo = serveHandler
33 ? await serveHandler({
34 port: args.port,
35 host: args.host,
36 open: args.open,
37 })
38 : undefined;
39
40 console.log('Build successful:', buildResult);
41 console.log('Server info:', serverInfo);
42
43 return {
44 buildResult,
45 serverInfo,
46 };
47 },
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();
67
68 // const { init, build } = {
69 // init: children.init.getHandler(),
70 // build: children.build.getHandler(),
71 // };
72
73 // const result = init?.({
74 // git: true,
75 // name: 'MyProject',
76 // template: 'typescript',
77 // });
78
79 // const buildResult = build?.({
80 // minify: true,
81 // sourcemap: false,
82 // outDir: 'dist',
83 // });
84
85 // console.log('Build result:', buildResult);
86 // },
87});
88
89export default app;
90
91// Export types for consumers who want to use commands programmatically
92export type { BuildResult, ServerInfo };
93
94if (require.main === module) {
95 app.forge();
96}
97