pico-node
v0.1.0
Published
Annotation-driven CLI framework — pico-cli for TypeScript/Node.js, built on dic-nj
Downloads
124
Maintainers
Readme
pico-node
Annotation-driven CLI framework for TypeScript, inspired by picocli. Built on dic-nj — zero extra dependencies beyond Node's built-in
util.parseArgs.
@Command({ name: 'greet', description: 'Print a greeting', version: '1.0.0' })
class GreetCommand implements Runnable {
@Option({ names: ['-n', '--name'], description: 'Who to greet' })
name = 'World';
run() { console.log(`Hello, ${this.name}!`); }
}
@Module({ providers: [GreetCommand] })
class AppModule {}
await CommandLine.run(GreetCommand, AppModule, process.argv.slice(2));$ greet --name Alice
Hello, Alice!
$ greet --help
Print a greeting
Usage: greet [OPTIONS]
Options:
-n, --name <name> Who to greet
--help, -h Show this help message and exit
--version, -V Print version information and exitFeatures
- Stage 3 decorators — TypeScript 5.0+, no
experimentalDecoratorsorreflect-metadata - NestJS-style DI — commands are resolved from the dic-nj container; use
inject()freely util.parseArgs— zero-dep parsing via Node's built-in (Node ≥ 18.3)- Subcommands — arbitrary nesting, aliases, per-command
--help - Auto
--help/--version— generated from metadata, no boilerplate - Typed exit codes —
ExitCode.OK | USAGE | SOFTWARE - Dual ESM / CJS output — built with zshy
Installation
npm install pico-node dic-njRequirements: Node ≥ 18.3 or Bun, TypeScript ≥ 5.0
TypeScript config
{
"compilerOptions": {
"target": "ES2022",
"strict": true
// do NOT set "experimentalDecorators": true
}
}Quick start
1. Define a command
import { Command, Option, Runnable } from 'pico-node';
@Command({ name: 'greet', description: 'Print a greeting', version: '1.0.0' })
export class GreetCommand implements Runnable {
@Option({ names: ['-n', '--name'], description: 'Who to greet' })
name = 'World';
@Option({ names: ['-c', '--count'], type: 'number', description: 'Repeat N times' })
count = 1;
@Option({ names: ['-u', '--uppercase'], type: 'boolean', description: 'Uppercase output' })
uppercase = false;
run(): void {
const text = `Hello, ${this.name}!`;
const line = this.uppercase ? text.toUpperCase() : text;
for (let i = 0; i < this.count; i++) console.log(line);
}
}2. Register a module
import { Module } from 'dic-nj';
import { GreetCommand } from './greet.command.ts';
@Module({ providers: [GreetCommand] })
export class AppModule {}3. Bootstrap
import { CommandLine } from 'pico-node';
import { GreetCommand } from './greet.command.ts';
import { AppModule } from './app.module.ts';
await CommandLine.run(GreetCommand, AppModule, process.argv.slice(2));Decorators
@Command(options)
Marks a class as a CLI command and registers it as a TRANSIENT DI provider.
| Option | Type | Description |
|---|---|---|
| name | string | Command name as typed on the CLI |
| description | string? | Short description shown in help |
| version | string? | Enables --version / -V flag |
| subcommands | Constructor[]? | Nested sub-command classes |
| aliases | string[]? | Alternative names for this command |
| header | string? | Text printed above options in help |
| footer | string? | Text printed below options in help |
@Option(options)
Binds a CLI flag to a class field. The field initializer sets the default; the parsed value overwrites it before run() / call() is invoked.
| Option | Type | Description |
|---|---|---|
| names | [string, ...string[]] | Flag names — at least one --long form required |
| description | string? | Shown in help output |
| required | boolean? | Returns ExitCode.USAGE if flag is absent |
| type | 'string' \| 'boolean' \| 'number' | Defaults to 'string' |
| multiple | boolean? | Accept the flag multiple times; field receives string[] |
| defaultValue | unknown? | Shown in help text (does not override the field initializer) |
| paramLabel | string? | Value placeholder shown in help, e.g. <file> |
| hidden | boolean? | Hide from help output |
@Option({ names: ['-o', '--output'], paramLabel: '<file>', description: 'Output file' })
output = 'out.txt';
@Option({ names: ['-v', '--verbose'], type: 'boolean' })
verbose = false;
@Option({ names: ['-p', '--port'], type: 'number', defaultValue: 3000 })
port = 3000;
@Option({ names: ['-t', '--tag'], multiple: true, description: 'Tags (repeatable)' })
tags: string[] = [];@Parameters(options)
Binds positional (non-flag) arguments to a class field.
| Option | Type | Description |
|---|---|---|
| description | string? | Shown in help output |
| paramLabel | string? | Placeholder in usage line, e.g. <files...> |
| index | number? | 0-based position to capture a single argument; omit to receive all positionals |
// Capture all positionals
@Parameters({ paramLabel: '<files...>', description: 'Files to process' })
files: string[] = [];
// Capture one argument at a specific position
@Parameters({ index: 0, paramLabel: '<src>' })
src = '';
@Parameters({ index: 1, paramLabel: '<dest>' })
dest = '';CommandLine
CommandLine.run(commandClass, moduleOrApp, args) — static
Creates the Application (or reuses an existing one), executes the root command, and calls process.exit() with the resulting exit code.
await CommandLine.run(GreetCommand, AppModule, process.argv.slice(2));Pass an existing Application to skip re-bootstrapping (useful in tests):
const app = await Application.create(AppModule);
await CommandLine.run(GreetCommand, app, args);new CommandLine(app) + cli.execute(commandClass, args)
Lower-level API — does not call process.exit(). Returns the exit code instead.
const app = await Application.create(AppModule);
const cli = new CommandLine(app);
const exitCode = await cli.execute(GreetCommand, process.argv.slice(2));
process.exit(exitCode);Runnable and Callable
Commands implement one of these interfaces:
// No meaningful exit code — CommandLine.execute returns ExitCode.OK automatically
interface Runnable {
run(): void | Promise<void>;
}
// Control the exit code explicitly
interface Callable<T = number> {
call(): T | Promise<T>;
}@Command({ name: 'check' })
class CheckCommand implements Callable<number> {
call(): number {
return isHealthy() ? ExitCode.OK : ExitCode.SOFTWARE;
}
}Subcommands
import { Command, Runnable } from 'pico-node';
import { AddCommand } from './commands/add.command.ts';
import { ListCommand } from './commands/list.command.ts';
@Command({
name: 'todo',
description: 'Manage tasks',
version: '1.0.0',
subcommands: [AddCommand, ListCommand],
footer: 'Run `todo <command> --help` for command-specific help.',
})
export class TodoCommand implements Runnable {
run(): void {
process.stdout.write('Use --help to see available commands.\n');
}
}$ todo --help
Manage tasks
Usage: todo [OPTIONS] <command>
Commands:
add Add a new task
list List all tasks
Options:
--help, -h Show this help message and exit
--version, -V Print version information and exit
Run `todo <command> --help` for command-specific help.All subcommands must be registered as providers in the same module.
Dependency injection in commands
Because commands are resolved from the dic-nj container, you can inject any provider:
import { inject } from 'dic-nj';
import { Command, Option, Runnable } from 'pico-node';
import { TaskService } from './task.service.ts';
@Command({ name: 'add', description: 'Add a task' })
export class AddCommand implements Runnable {
@Option({ names: ['-t', '--title'], description: 'Task title', required: true })
title = '';
private readonly tasks = inject(TaskService);
run(): void {
const task = this.tasks.add(this.title);
console.log(`Created #${task.id}: ${task.title}`);
}
}Exit codes
import { ExitCode } from 'pico-node';
ExitCode.OK // 0 — success
ExitCode.SOFTWARE // 1 — generic error
ExitCode.USAGE // 2 — bad options / missing required argsError reference
All errors extend CliError:
import { CliError } from 'pico-node';
try {
await cli.execute(MyCommand, args);
} catch (e) {
if (e instanceof CliError) console.error(e.message);
}| Error | When thrown |
|---|---|
| NotACommandError | Class passed to execute() is not decorated with @Command |
| CommandExecutionError | run() / call() threw an exception (wraps the original cause) |
| MissingRequiredOptionError | Constructed manually when doing custom validation |
License
MIT
