Composable Sub-commands
In the previous chapter, you learned how to create type-safe commands using the define function.
Now, let's extend that knowledge to build CLIs with multiple sub-commands while maintaining the same type safety benefits.
Gunshi's composable sub-command system allows you to create modular, organized CLIs similar to tools like Git (with commands like git commit and git push).
Why Use Sub-commands?
Sub-commands are useful when your CLI needs to perform different operations that warrant separate commands.
Benefits include:
- Organization: Group related functionality logically
- Scalability: Add new commands without modifying existing ones
- User experience: Provide a consistent interface for different operations
- Help system: Each sub-command can have its own help documentation
- Plugin integration: Plugins are shared across all sub-commands for consistent functionality
Basic Structure
A CLI with sub-commands typically has this structure:
cli <command> [command options]For example:
your-cli create --name my-resourceCreating Type-Safe Sub-commands
Building on the define function from the previous chapter, let's create a CLI with multiple sub-commands:
import { cli, define } from 'gunshi'
// Define type-safe sub-commands
const createCommand = define({
name: 'create',
description: 'Create a new resource',
args: {
name: { type: 'string', short: 'n', required: true }
},
run: ctx => {
// ctx.values is fully typed
console.log(`Creating resource: ${ctx.values.name}`)
}
})
const listCommand = define({
name: 'list',
description: 'List all resources',
run: () => {
console.log('Listing all resources...')
}
})
// Define the main command
const mainCommand = define({
name: 'manage',
description: 'Manage resources',
run: ctx => {
// This runs when no sub-command is provided
console.log('Available commands: create, list')
console.log('Run "manage --help" for more information')
}
})
// Run the CLI with composable sub-commands
await cli(process.argv.slice(2), mainCommand, {
name: 'my-app',
version: '1.0.0',
subCommands: {
create: createCommand,
list: listCommand
}
})TIP
The example fully code is here.
This structure provides:
- Full type safety for all commands and sub-commands
- Automatic help generation for each command level
- Shared configuration across the command hierarchy
Automatic Help for Sub-commands
Gunshi automatically generates help documentation for your sub-commands.
Using the code from the previous section, you can see the help for each command level:
# Show main command help
$ npx tsx cli.ts --help
# Show sub-command help
$ npx tsx cli.ts create --helpTIP
tsx is a TypeScript execution tool that allows you to run TypeScript files directly without compilation. Use it directly with npx tsx.
NOTE
On Node.js v22.6.0, you can run TypeScript with --experimental-strip-types:
node --experimental-strip-types cli.ts --helpFrom Node.js v23.6.0 and newer, type stripping is enabled by default (no flag needed for erasable TS). Features requiring transformation (e.g., enum) still need --experimental-transform-types.
Each sub-command's help includes its description, available options, and usage examples.
Organizing Your Commands
As your CLI grows, organizing commands in separate files improves maintainability.
Here's a recommended project structure:
my-cli/
├── src/
│ ├── commands/
│ │ ├── create.ts # Create command implementation
│ │ └── list.ts # List command implementation
│ ├── main.ts # Main command definition
│ └── cli.ts # CLI entry point
├── package.json
└── tsconfig.jsonTIP
The example fully code is here.
This structure provides:
- Clear separation of concerns
- Shared utilities across commands
- Centralized type definitions
- Easy testing of individual components
Individual Command Files
import { define } from 'gunshi'
export default define({
name: 'create',
description: 'Create a new resource',
args: {
name: {
type: 'string',
short: 'n',
required: true,
description: 'Name of the resource'
},
type: {
type: 'string',
short: 't',
default: 'default',
description: 'Type of resource'
}
},
run: ctx => {
console.log(`Creating ${ctx.values.type} resource: ${ctx.values.name}`)
}
})import { define } from 'gunshi'
export default define({
name: 'list',
description: 'List all resources',
args: {
filter: {
type: 'string',
short: 'f',
description: 'Filter resources'
}
},
run: ctx => {
const filter = ctx.values.filter || 'all'
console.log(`Listing resources with filter: ${filter}`)
}
})Main Command File
import { define } from 'gunshi'
export default define({
name: 'manage',
description: 'Manage resources',
run: () => {
console.log('Use a sub-command')
console.log('Run "resource-manager --help" for available commands')
}
})Entry Point
NOTE
Some code examples in this guide include TypeScript file extensions (.ts) in import/export statements. If you use this pattern in your application, you'll need to enable allowImportingTsExtensions in your tsconfig.json.
import { cli } from 'gunshi'
import main from './main.ts'
import create from './commands/create.ts'
import list from './commands/list.ts'
await cli(process.argv.slice(2), main, {
name: 'resource-manager',
version: '1.0.0',
subCommands: {
create,
list
}
})Handling Unknown Sub-commands
By default, Gunshi shows an error when users provide an unknown sub-command.
You can customize this behavior using the fallbackToEntry option:
await cli(process.argv.slice(2), main, {
name: 'resource-manager',
version: '1.0.0',
fallbackToEntry: true,
subCommands: {
create,
list
}
})This option enables flexible command handling:
# Runs the create sub-command
npx tsx src/cli.ts create --name resource
resource-manager (resource-manager v1.0.0)
Creating default resource: resource
# Runs the list sub-command
npx tsx src/cli.ts list --filter active
resource-manager (resource-manager v1.0.0)
Listing resources with filter: active
# Falls back to main command when "unknown" sub-command is not found
npx tsx src/cli.ts unknown --flag value
resource-manager (resource-manager v1.0.0)
Use a sub-command
Run "resource-manager --help" for available commands
# Runs the main command directly
npx tsx src/cli.ts --help
resource-manager (resource-manager v1.0.0)
USAGE:
resource-manager [COMMANDS] <OPTIONS>
COMMANDS:
[manage] <OPTIONS> Manage resources
create <OPTIONS> Create a new resource
list <OPTIONS> List all resources
For more info, run any command with the `--help` flag:
resource-manager --help
resource-manager create --help
resource-manager list --help
OPTIONS:
-h, --help Display this help message
-v, --version Display this versionThis approach is particularly useful for CLIs that:
- Need to handle file paths or patterns as direct arguments
- Want to provide a default action when no sub-command matches
- Implement dynamic command resolution based on context
Next Steps
Throughout this guide, you've learned how to build composable sub-commands that scale from simple to complex CLI applications.
You've seen how Gunshi maintains type safety across nested command structures, enables powerful routing patterns with default commands, and supports both synchronous and asynchronous command execution.
Now that you understand how to compose commands into well-organized hierarchies, you're ready to explore how to optimize their performance.
The next section on Lazy & Async Command Loading will show you how to significantly improve your CLI's startup time by loading commands only when they're actually needed.
With composable sub-commands as your foundation, adding lazy loading will make your CLI applications both powerful and performant, especially as they grow to include many commands with varying resource requirements.
