Skip to content

Repository files navigation

> cli

Build CLIs. TypeScript does the rest.

Install

npm i @truyman/cli

Quick Start

import{command,run}from"@truyman/cli";constgreet=command({name: "greet",args: [{name: "name",type: "string"}],handler: ([name])=>console.log(`Hello, ${name}!`),});run(greet,process.argv.slice(2));
$ bun greet.ts World
Hello, World!

That's it. Your args are typed. Your handler knows what it's getting.

Features

  • Type-safe everything - Args and options flow into your handler with full type inference
  • Subcommands - Nest commands infinitely: cli foo bar baz
  • Built-in help - -h and --help just work
  • Graceful errors - run() catches known errors and prints them pretty
  • Short & long flags - -v and --verbose, the way nature intended
  • Shell completions - Generate completions for bash, zsh, and fish

Full Example

import{command,run}from"@truyman/cli";constgreet=command({name: "greet",description: "A friendly greeting CLI",version: "1.0.0",args: [{name: "name",type: "string",description: "Who to greet"},],options: {shout: {type: "boolean",long: "shout",short: "s",description: "LOUD MODE",},times: {type: "number",long: "times",short: "n",description: "Repeat N times",},},handler: ([name],{ shout, times })=>{letmsg=`Hello, ${name}!`;if(shout)msg=msg.toUpperCase();for(leti=0;i<(times||1);i++){console.log(msg);}},});run(greet,process.argv.slice(2));
$ bun greet.ts Ada --shout -n 3
HELLO, ADA!
HELLO, ADA!
HELLO, ADA!

API

command(options)

PropertyTypeRequiredDescription
namestringYesCommand name
descriptionstringNoShown in help
versionstringNoVersion string
argsPositionalArg[]NoPositional arguments
optionsOptionsNoFlag options
inheritsOptionsNoOptions inherited from parents
handler(args, options) => void*Your code goes here
subcommandsCommand[]*Nested commands
groupsCommandGroupsNoGroup subcommands in help
examplesExamplesNoUsage examples in help

* A command has handler, subcommands, or both. When both are provided (hybrid command), the handler runs as the default when no subcommand matches.

Subcommands

// options.tsimporttype{Options}from"@truyman/cli";exportconstGlobalOptions={verbose: {type: "boolean",long: "verbose",short: "v"},}asconstsatisfiesOptions;
// commands/add.tsimport{command}from"@truyman/cli";import{GlobalOptions}from"../options";exportconstadd=command({name: "add",inherits: GlobalOptions,args: [{name: "url",type: "string"}]asconst,handler: ([url],{ verbose })=>{if(verbose)console.log("[verbose] Adding remote...");console.log(`Added ${url}`);},});
// index.tsimport{command,run}from"@truyman/cli";import{GlobalOptions}from"./options";import{add}from"./commands/add";constremote=command({name: "remote",options: GlobalOptions,subcommands: [add],});constgit=command({name: "git",subcommands: [remote],});run(git,process.argv.slice(2));
$ git remote add https://github.com/... --verbose

The inherits property tells the leaf command which parent options it should parse and receive in its handler. This enables full type inference for inherited options.

Command Aliases

Define alternative names for subcommands using the aliases property:

constcheckout=command({name: "checkout",aliases: ["co","switch"],args: [{name: "branch",type: "string"}]asconst,handler: ([branch])=>console.log(`Switching to ${branch}`),});constgit=command({name: "git",subcommands: [checkout],});run(git,process.argv.slice(2));
$ git checkout main # works
$ git co main # also works
$ git switch main # also works

Aliases appear in help text alongside the primary name:

Commands:
checkout (co, switch) Switch branches

Command Groups

Organize subcommands into groups for cleaner help output:

constcli=command({name: "my-cli",groups: {"Project": ["init","build","test"],"Development": ["serve","watch"],},subcommands: [init,build,test,serve,watch,help],});
$ my-cli --help
Usage:
my-cli [options] <command> [args...]
Project:
init Initialize a new project
build Build the project
test Run tests
Development:
serve Start development server
watch Watch for changes
help Show help
Options:
-h, --help Show help
-V, --version Show version

Groups appear in definition order. Commands not assigned to any group appear last without a header. This is optional—omit groups for a flat command list.

Examples

Add usage examples to help output:

constcli=command({name: "my-cli",description: "A deployment tool",examples: ["my-cli deploy","my-cli deploy --env staging",{command: "my-cli deploy --env prod",description: "Deploy to production"},],handler: ()=>{},});
$ my-cli --help
A deployment tool
Examples:
my-cli deploy
my-cli deploy --env staging
my-cli deploy --env prod Deploy to production
Usage:
my-cli [options]
Options:
-h, --help Show help
-V, --version Show version

Examples can be simple strings or objects with { command, description } for annotated examples. Descriptions are shown dimmed and aligned.

Positional Args

PropertyTypeDescription
namestringArgument name shown in help
typestring"string", "number", or "boolean"
descriptionstringShown in help output
optionalbooleanShows as [name] instead of <name>
variadicbooleanCollect remaining args into array (must be last)
allowStdinbooleanAllow - to read a string arg from stdin
choicesarrayRestrict value to predefined set
validatefunctionCustom validation function

Stdin Arguments

Enable allowStdin on string positional args to support the conventional - sentinel:

constecho=command({name: "echo",args: [{name: "input",type: "string",allowStdin: true}]asconst,handler: ([input])=>console.log(input),});
$ echo"hello world"| my-cli -

The handler receives the raw stdin content. Without allowStdin, - remains a normal string argument.

Variadic Arguments

constrm=command({name: "rm",args: [{name: "files",type: "string",variadic: true}]asconst,handler: ([files])=>files.forEach(f=>console.log(`Removing ${f}`)),});// rm file1.txt file2.txt file3.txt → files = ["file1.txt", "file2.txt", "file3.txt"]

Options

PropertyTypeDescription
typestring"string", "number", or "boolean"
longstringLong flag name (defaults to key name)
shortstringSingle-character short flag
descriptionstringShown in help output
defaultanyDefault value when not provided
requiredbooleanThrow error if not provided
multiplebooleanCollect repeated flags into array
negatablebooleanAllow --no-<flag> syntax (boolean only)
placeholderstringCustom placeholder in help (e.g., "path")
envstringEnvironment variable name as fallback
choicesarrayRestrict value to predefined set
validatefunctionCustom validation function

Default Values

port: {type: "number",default: 3000}// --port 8080 → 8080, (omitted) → 3000

Required Options

config: {type: "string",required: true}// Missing --config throws MissingOptionError

Multiple Values

tag: {type: "string",multiple: true}// --tag foo --tag bar → ["foo", "bar"]

Negatable Flags

color: {type: "boolean",negatable: true}// --color → true, --no-color → false

Environment Variable Fallbacks

Use the env property to specify an environment variable as a fallback when the option isn't provided via CLI:

constdeploy=command({name: "deploy",options: {token: {type: "string",env: "API_TOKEN",description: "Authentication token",},port: {type: "number",env: "PORT",default: 3000,},debug: {type: "boolean",env: "DEBUG",},},handler: (_,{ token, port, debug })=>{// token comes from --token, API_TOKEN, or undefined// port comes from --port, PORT, or 3000},});

Value precedence: CLI argument > environment variable > default value.

For booleans, the following env values are parsed as true (case-insensitive): "1", "true", "yes". All other values are false.

Environment variables are shown in help text:

Options:
--token=<str> Authentication token [$API_TOKEN]
--port=<num> (default: 3000) [$PORT]

Custom Validation

Use the validate function for custom validation logic. Return true if valid, or an error message string if invalid:

constserve=command({name: "serve",options: {port: {type: "number",validate: (v)=>(v>=1&&v<=65535)||"Port must be between 1 and 65535",},host: {type: "string",validate: (v)=>v.length>0||"Host cannot be empty",},},handler: (_,{ port, host })=>{// port is guaranteed to be 1-65535 if provided},});
$ serve --port 99999
Error: Port must be between 1 and 65535

Validation runs after type coercion, so you receive the typed value (not a raw string). Validation is not called on undefined values (optional args/options that weren't provided). For multiple options, validation runs on each value individually.

Choices (Enum Constraint)

Use choices to restrict values to a predefined set:

constbuild=command({name: "build",args: [{name: "env",type: "string",choices: ["development","staging","production"]asconst,},]asconst,options: {format: {type: "string",choices: ["json","yaml","toml"]asconst,default: "json",},level: {type: "number",choices: [1,2,3]asconst,},},handler: ([env],{ format, level })=>{// env: "development" | "staging" | "production"// format: "json" | "yaml" | "toml"// level: 1 | 2 | 3 | undefined},});

Use as const on the choices array for precise type inference.

Invalid choices show a helpful error:

$ build production --format xml
Error: Invalid value 'xml'for format. Valid choices: json, yaml, toml

Help text displays available choices:

Arguments:
<env> (development|staging|production)
Options:
--format=<json|yaml|toml> (default: json)
--level=<1|2|3>

Shell Completions

run() automatically adds a completions subcommand that generates shell completion scripts for bash, zsh, and fish:

# Bash - add to ~/.bashrceval"$(my-cli completions bash)"# Zsh - add to ~/.zshrc (before compinit)eval"$(my-cli completions zsh)"# Fish - save to completions directory
my-cli completions fish >~/.config/fish/completions/my-cli.fish

The generated scripts provide context-aware completions for subcommands, options (including short flags), and nested command trees. Hidden commands are excluded automatically.

Async Handlers

Handlers can be async. run() awaits completion:

handler: async([url])=>{constres=awaitfetch(url);console.log(awaitres.text());}

Error Handling

run() catches errors and displays helpful messages. For custom handling, call command.run() directly:

import{MissingArgumentError}from"@truyman/cli";try{myCommand.run(argv);}catch(err){if(errinstanceofMissingArgumentError){// Custom handling}}
ErrorCause
MissingArgumentErrorRequired positional argument not provided
InvalidArgumentErrorArgument value doesn't match expected type
MissingOptionErrorRequired option not provided
InvalidOptionErrorOption value doesn't match expected type
UnknownOptionErrorUnknown flag provided
MissingSubcommandErrorParent command invoked without subcommand
UnknownSubcommandErrorUnknown subcommand name (shows suggestions)
ValidationErrorCustom validation function returned error
InvalidChoiceErrorValue not in allowed choices

Type Safety

Use as const on args for precise type inference:

// ✓ Handler receives [string, number | undefined]args: [{name: "file",type: "string"},{name: "count",type: "number",optional: true},]asconst,// ✗ Without as const: handler receives unknown[]

For reusable options, use as const satisfies Options:

constGlobalOptions={verbose: {type: "boolean",short: "v"},}asconstsatisfiesOptions;

run(command, argv)

Runs the command. Handles -h/--help automatically. Missing args? Shows help. Bad option? Red error + usage.

command.help()

Returns the auto-generated help string. For when you need it manually.

License

MIT

About

Build CLIs. TypeScript does the rest.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages