CLI Package
On this page 53
A powerful command-line interface framework for building interactive CLI applications, featuring command parsing, prompts, spinners, and beautiful output formatting.
Installation
bun add @stacksjs/cli
Basic Usage
import { CLI, log } from '@stacksjs/cli'
// Create a simple command
const cli = new CLI('myapp')
.command('greet', 'Greet a user')
.option('-n, --name <name>', 'Name to greet')
.action((options) => {
log.info(`Hello, ${options.name || 'World'}!`)
})
cli.run()
Bounded commands and cancellation
Import runBoundedProcess from @stacksjs/cli/process when a finite worker or
inspection command needs explicit time and stdout/stderr byte limits. It passes
an argument vector directly, drains both streams, waits for exit and returns
{ exitCode, stdout, stderr }, including nonzero exits. Set strictUtf8: true
when malformed text must be rejected.
The optional signal accepts a native AbortSignal, such as request.signal.
A canceled request prevents launch if it is already aborted, or terminates and
settles the owned process and readers before rejection. The error message is
<label> canceled; the original reason stays in cause without coercion.
The first failure retains precedence, and cancellation after completion has no
effect. On POSIX cleanup covers the owned process group, including inherited
pipes; Windows cleanup covers the direct child and readers. Descendants that
escape the owned group are outside this contract.
The package reference documents the limits and full options. Choose the executable, argument vector, working directory and environment on the server. A bounded worker does not authenticate its caller or grant authority to publish its result.
Creating Commands
Basic Command
There is no Command class. A command is a definition object passed to
defineCommand(), which is what app/Commands/*.ts export:
import { defineCommand } from '@stacksjs/cli'
export default defineCommand({
name: 'deploy',
description: 'Deploy the application',
handle() {
console.log('Deploying...')
},
})
Command with Options
The declarative form INFERS the handler's options from the flags it declares,
so there is no hand-written options interface to keep in sync:
export default defineCommand({
name: 'build',
description: 'Build the application',
options: {
'-e, --env <environment>': { description: 'Target environment', default: 'production' },
'-m, --minify': { description: 'Minify output', default: false },
'-w, --watch': 'Watch for changes',
},
handle(options) {
console.log(`Building for ${options.env}`)
if (options.minify) console.log('Minification enabled')
if (options.watch) console.log('Watch mode enabled')
})
Command with Arguments
const command = new Command('generate')
.description('Generate a resource')
.argument('<type>', 'Resource type (model, controller, action)')
.argument('<name>', 'Resource name')
.argument('[path]', 'Optional path')
.action((type, name, path, options) => {
console.log(`Generating ${type}: ${name}`)
if (path) console.log(`At path: ${path}`)
})
Subcommands
const cli = new CLI('buddy')
cli.command('make')
.description('Generate resources')
.command('model')
.description('Generate a model')
.argument('<name>')
.action((name) => {
console.log(`Creating model: ${name}`)
})
.command('controller')
.description('Generate a controller')
.argument('<name>')
.action((name) => {
console.log(`Creating controller: ${name}`)
})
Prompts
Text Input
import { prompts } from '@stacksjs/cli'
const name = await prompts.text({
message: 'What is your name?',
placeholder: 'Enter your name',
defaultValue: 'Anonymous',
validate: (value) => {
if (value.length < 2) return 'Name must be at least 2 characters'
}
})
Password Input
const password = await prompts.password({
message: 'Enter your password:',
mask: '*',
validate: (value) => {
if (value.length < 8) return 'Password must be at least 8 characters'
}
})
Confirm
const confirmed = await prompts.confirm({
message: 'Are you sure you want to continue?',
initialValue: false
})
if (confirmed) {
// Proceed
}
Select (Single Choice)
const framework = await prompts.select({
message: 'Choose a framework:',
options: [
{ value: 'stx', label: 'STX', hint: 'Recommended' },
{ value: 'react', label: 'React' },
{ value: 'svelte', label: 'Svelte' },
],
initialValue: 'stx'
})
Multi-Select
const features = await prompts.multiselect({
message: 'Select features to install:',
options: [
{ value: 'auth', label: 'Authentication' },
{ value: 'api', label: 'API Routes' },
{ value: 'queue', label: 'Queue System' },
{ value: 'cache', label: 'Caching' },
],
required: true,
initialValues: ['auth']
})
Autocomplete
const project = await prompt.autocomplete({
message: 'Select a project:',
options: async (input) => {
const projects = await fetchProjects(input)
return projects.map(p => ({
value: p.id,
label: p.name
}))
},
placeholder: 'Type to search...'
})
Group Prompts
const answers = await prompt.group({
name: () => prompts.text({ message: 'Project name:' }),
type: () => prompts.select({
message: 'Project type:',
options: ['app', 'library', 'plugin']
}),
features: () => prompts.multiselect({
message: 'Features:',
options: ['typescript', 'eslint', 'prettier']
}),
}, {
onCancel: () => {
log.error('Setup cancelled')
process.exit(1)
}
})
Path Selection
const file = await prompt.path({
message: 'Select a file:',
type: 'file',
validate: (path) => {
if (!path.endsWith('.ts')) return 'Must be a TypeScript file'
}
})
const directory = await prompt.path({
message: 'Select output directory:',
type: 'directory'
})
Output and Logging
Plain command results
Import from @stacksjs/cli/output when output must remain visible regardless of
the application's log level. The same helpers are exported by @stacksjs/cli.
They preserve Node formatting and inspection and append one newline.
Use await printAsync(...args) for large results and piped output, and
await printErrorAsync(...args) for diagnostics on stderr. Both return
Promise<void> and wait for the stream's write callback, allowing a slow pipe
to drain without a synchronous EAGAIN failure. Await completion before
calling process.exit(). Write failures, including a closed pipe (EPIPE),
reject the promise; the caller chooses the exit policy.
import { printAsync, printErrorAsync } from '@stacksjs/cli/output'
await printAsync('Processed %d items', 100000)
await printErrorAsync('Warning: %s', 'Some items were skipped')
process.exit(0)
The synchronous print(...args): void and printError(...args): void helpers
remain suitable for small, bounded messages before an immediate exit. They
write directly to the file descriptor and can throw when a nonblocking pipe
is full. Neither diagnostic helper changes the process exit code.
Log Levels
import { log } from '@stacksjs/cli'
log.info('Information message')
log.success('Operation completed successfully')
log.warn('Warning: Something might be wrong')
log.error('Error: Something went wrong')
log.debug('Debug information')
Styled Output
import { bold, blue, cyan, dim, green, italic, log, red, underline, yellow } from '@stacksjs/cli'
// Colors
console.log(red('Error text'))
console.log(green('Success text'))
console.log(yellow('Warning text'))
console.log(blue('Info text'))
console.log(cyan('Highlighted text'))
// Formatting
console.log(bold('Bold text'))
console.log(dim('Dimmed text'))
console.log(italic('Italic text'))
console.log(underline('Underlined text'))
// Combinations compose as functions - there is no `.bold.red` chain
console.log(bold(red('Bold red text')))
console.log(dim(yellow('Dim yellow text')))
Notes and Messages
import { note, outro, intro } from '@stacksjs/cli'
intro('Welcome to the CLI')
note('Some important information', 'Note')
outro('Setup complete!')
Spinners
Basic Spinner
import { spinner } from '@stacksjs/cli'
const spin = spinner('Loading...')
spin.start()
try {
await performTask()
spin.succeed('Task completed!')
} catch (error) {
spin.fail('Task failed!')
}
Spinner with Messages
const spin = spinner('Initializing...')
spin.update('Downloading dependencies...')
await downloadDeps()
spin.update('Building project...')
await build()
spin.update('Running tests...')
await runTests()
spin.succeed('All done!')
Multiple Spinners
import { Spinner } from '@stacksjs/cli'
const spinner1 = new Spinner('Task 1')
const spinner2 = new Spinner('Task 2')
spinner1.start()
spinner2.start()
await Promise.all([
task1().then(() => spinner1.success('Task 1 done')),
task2().then(() => spinner2.success('Task 2 done'))
])
Progress Bars
import { progress } from '@stacksjs/cli'
const bar = progress({
total: 100,
format: 'Progress |{bar}| {percentage}% | {value}/{total}'
})
for (let i = 0; i <= 100; i++) {
bar.update(i)
await sleep(50)
}
bar.stop()
Tasks
Task Lists
import { tasks } from '@stacksjs/cli'
await tasks([
{
title: 'Installing dependencies',
task: async () => {
await installDependencies()
}
},
{
title: 'Building project',
task: async () => {
await buildProject()
}
},
{
title: 'Running tests',
enabled: (ctx) => ctx.runTests,
task: async () => {
await runTests()
}
}
])
Task with Context
await tasks([
{
title: 'Fetch data',
task: async (ctx) => {
ctx.data = await fetchData()
}
},
{
title: 'Process data',
task: async (ctx) => {
await processData(ctx.data)
}
}
], { concurrent: false })
Command Execution
Running Commands
import { runCommand, exec } from '@stacksjs/cli'
// Run a command and get result
const result = await runCommand('npm install')
if (result.isOk) {
console.log('Success:', result.value)
} else {
console.error('Error:', result.error)
}
// Execute with options
const output = await exec('ls -la', {
cwd: '/path/to/dir',
env: { NODE_ENV: 'production' }
})
Streaming Output
import { exec } from '@stacksjs/cli'
const proc = exec('npm run build', {
stdout: 'pipe',
stderr: 'pipe'
})
for await (const chunk of proc.stdout) {
process.stdout.write(chunk)
}
Argument Parsing
import { parseArgs } from '@stacksjs/cli'
const args = parseArgs(process.argv.slice(2), {
string: ['name', 'output'],
boolean: ['verbose', 'force'],
alias: {
n: 'name',
o: 'output',
v: 'verbose',
f: 'force'
},
default: {
verbose: false
}
})
// buddy command -n myproject -v --force
// args = { name: 'myproject', verbose: true, force: true, _: ['command'] }
Helper Functions
Dump and Die
These live in @stacksjs/logging, not here - they go through the logger, so a
dump lands in the log file as well as the terminal. Both are async: await them
before a process.exit, or the pending write is dropped and the line vanishes.
import { dd, dump } from '@stacksjs/logging'
await dump(someObject)
await dump('Label', anotherObject)
// Dump and exit
await dd(object)
Echo
import { echo } from '@stacksjs/logging'
await echo('Simple output message')
await echo(object)
CLI Configuration
Global Options
const cli = new CLI('myapp')
.version('1.0.0')
.description('My awesome CLI application')
.option('-v, --verbose', 'Enable verbose output')
.option('-c, --config <path>', 'Config file path')
// Global options are available to all commands
cli.command('build')
.action((options) => {
if (options.verbose) log.info('Verbose mode enabled')
})
Help Generation
const cli = new CLI('myapp')
.command('serve')
.description('Start the development server')
.option('-p, --port <number>', 'Port to listen on', '3000')
.option('-h, --host <hostname>', 'Host to bind to', 'localhost')
.example('myapp serve --port 8080')
.example('myapp serve -h 0.0.0.0')
Error Handling
cli.command('deploy')
.action(async () => {
try {
await deploy()
} catch (error) {
log.error('Deployment failed:', error.message)
process.exit(1)
}
})
// Global error handler
cli.catch((error) => {
log.error('An unexpected error occurred:', error)
process.exit(1)
})
Edge Cases
Handling Cancellation
const name = await prompts.text({
message: 'Enter name:'
})
// User pressed Ctrl+C
if (prompt.isCancel(name)) {
log.warn('Operation cancelled')
process.exit(0)
}
Handling Empty Input
const value = await prompts.text({
message: 'Enter value:',
validate: (v) => {
if (!v || v.trim() === '') {
return 'Value cannot be empty'
}
}
})
Terminal Size
import { getTerminalSize } from '@stacksjs/cli'
const { columns, rows } = getTerminalSize()
if (columns < 80) {
log.warn('Terminal is too narrow for optimal display')
}
Non-Interactive Mode
import { isInteractive } from '@stacksjs/cli'
if (!isInteractive()) {
// Running in CI or piped
log.info('Running in non-interactive mode')
// Use default values instead of prompts
} else {
const name = await prompts.text({ message: 'Name:' })
}
API Reference
CLI Class
| Method | Description |
|---|---|
command(name) | Add a command |
option(flags, desc, default?) | Add global option |
version(version) | Set version |
description(desc) | Set description |
run() | Parse and execute |
catch(handler) | Set error handler |
Command Class
| Method | Description |
|---|---|
description(desc) | Set description |
argument(name, desc?) | Add argument |
option(flags, desc, default?) | Add option |
action(handler) | Set action handler |
example(text) | Add example |
alias(name) | Add command alias |
Prompt Functions
| Function | Description |
|---|---|
prompts.text(options) | Text input |
prompts.password(options) | Password input |
prompts.confirm(options) | Yes/no confirmation |
prompts.select(options) | Single selection |
prompts.multiselect(options) | Multiple selection |
prompt.autocomplete(options) | Autocomplete input |
prompt.path(options) | File/directory path |
prompt.group(prompts) | Group multiple prompts |
Log Functions
| Function | Description |
|---|---|
log.info(msg) | Info message |
log.success(msg) | Success message |
log.warn(msg) | Warning message |
log.error(msg) | Error message |
log.debug(msg) | Debug message |
log.dump(obj) | Dump object |
log.dd(obj) | Dump and die |
Spinner Functions
| Method | Description |
|---|---|
spinner(message) | Create spinner |
spinner().start() | Start spinning |
spinner().stop() | Stop spinning |
spinner().succeed(msg) | Stop with success |
spinner().fail(msg) | Stop with error |
spinner().update(msg) | Update message |
withSpinner(msg, fn) | Run fn under a spinner |
Execution Functions
| Function | Description |
|---|---|
runCommand(cmd) | Run shell command |
exec(cmd, opts) | Execute with options |
parseArgs(args, opts) | Parse CLI arguments |