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

text
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 server

This 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

FILE EXPLORER
cli.ts
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