@push.rocks/smartcli
Build modern TypeScript CLIs without hand-rolling argument dispatch or terminal plumbing. @push.rocks/smartcli gives you two focused tools: a tiny observable command router powered by yargs-parser, and a terminal task renderer that looks great in an interactive TTY while staying clean and parseable in CI logs.
It is ESM-first, ships TypeScript declarations, and understands user arguments across Node.js, Deno, Deno-compiled executables, Bun, tsx, and ts-node.
Issue Reporting and Security
For reporting bugs, issues, or security vulnerabilities, please visit community.foss.global/. This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a code.foss.global/ account to submit Pull Requests directly.
Install
pnpm add @push.rocks/smartcli
What You Get
- ⚡ Command dispatch through RxJS
Subjects, so CLI actions fit naturally into reactive code. - 🧠 Runtime-aware argument slicing for Node.js, Deno, compiled Deno binaries, Bun,
tsx, andts-node. - 🎛️
yargs-parserintegration for positional arguments and flags. - 🧯 Standard fallback command, named unknown-command failures, version output, help command, parse-completion hooks, and manual command triggering.
- 🖥️
SmartcliTerminalfor live multi-task output with fixed rows, progress updates, spinners, timers, success collapse, and persistent errors. - 📜 Automatic non-interactive fallback that keeps CI/Docker/piped logs append-only and prefixes every emitted line with the task name.
Quick Start
Create a CLI entrypoint, for example cli.ts:
#!/usr/bin/env node
import { Smartcli } from '@push.rocks/smartcli';
const cli = new Smartcli();
cli.addVersion('1.0.0');
cli.addHelp({
helpText: `
Usage:
demo greet --name Ada
demo --version
demo help
Commands:
greet Print a greeting
help Print this help text
`,
});
cli.addCommand('greet').subscribe((argv) => {
const name = argv.name || 'World';
console.log(`Hello, ${name}!`);
});
cli.standardCommand().subscribe(() => {
console.log('Usage: demo greet --name Ada');
console.log('Run "demo help" for details.');
});
cli.startParse();
Run it:
demo greet --name Ada
# Hello, Ada!
demo --version
# 1.0.0
demo help
# prints the configured help text
Command Routing
Smartcli treats the first positional argument as the command name and emits the parsed yargs-parser object into the matching command subject.
import { Smartcli } from '@push.rocks/smartcli';
const cli = new Smartcli();
cli.addCommand('deploy').subscribe((argv) => {
const environment = argv.env || 'development';
const force = Boolean(argv.force);
console.log(`Deploying to ${environment}`);
if (force) {
console.log('Force mode enabled');
}
});
cli.standardCommand().subscribe(() => {
console.log('Usage: deployer deploy --env production [--force]');
});
cli.startParse();
deployer deploy --env production --force
The handler receives a parsed object similar to:
{
_: ['deploy'],
env: 'production',
force: true
}
Positional Arguments
The first positional argument selects the command. Additional positional values stay in argv._.
import { Smartcli } from '@push.rocks/smartcli';
const cli = new Smartcli();
cli.addCommand('run').subscribe((argv) => {
const taskName = argv._[1];
if (!taskName) {
console.log('Usage: tool run <taskName>');
return;
}
console.log(`Running task: ${taskName}`);
});
cli.startParse();
tool run build
# Running task: build
Standard Command
Use standardCommand() for the no-command case:
import { Smartcli } from '@push.rocks/smartcli';
const cli = new Smartcli();
cli.addCommand('status').subscribe(() => {
console.log('Everything is operational.');
});
cli.standardCommand().subscribe(() => {
console.log('Available commands: status');
});
cli.startParse();
If no standard command is registered and the user runs the CLI without a command, smartcli prints no smartcli standard task was created or assigned..
The standard command runs only when argv has no positional argument. A first positional argument always names a command, so status --verbose dispatches status, while --verbose alone runs the standard command.
Unknown Commands
A first positional argument that names no registered command is a failure by default. For the CLI above, tool stauts prints this line to stderr, sets process.exitCode = 1 and dispatches nothing:
tool stauts
# Unknown command "stauts". Registered commands: status.
echo $?
# 1
parseCompleted rejects with a SmartcliUnknownCommandError that carries commandName and registeredCommands, so code awaiting the parse sees the failure too:
import { Smartcli, SmartcliUnknownCommandError } from '@push.rocks/smartcli';
const cli = new Smartcli();
cli.addCommand('status').subscribe(() => console.log('ok'));
cli.startParse();
try {
await cli.parseCompleted.promise;
} catch (error) {
if (error instanceof SmartcliUnknownCommandError) {
// stderr and the exit code already report it; add your own follow-up here
}
}
A CLI that deliberately accepts a free first positional argument, or wants its own unknown-command output, takes ownership with unknownCommand(). Once it is registered, smartcli prints nothing, leaves the exit code alone and resolves parseCompleted:
import { Smartcli } from '@push.rocks/smartcli';
const cli = new Smartcli();
cli.addCommand('status').subscribe(() => console.log('ok'));
cli.unknownCommand().subscribe(({ commandName, registeredCommands, argv }) => {
// e.g. treat the positional as a file path: `tool ./notes.md --watch`
console.log(`Opening ${commandName}`, argv.watch ? 'in watch mode' : '');
});
cli.startParse();
Help And Version Output
addVersion() enables -v and --version when no command is provided:
cli.addVersion('2.3.0');
tool --version
# 2.3.0
addHelp() registers a help command. It does not create a --help flag:
cli.addHelp({
helpText: `
Usage:
tool build --target app
tool status
`,
});
tool help
Programmatic Dispatch
Use triggerCommand() when you want to invoke a registered command yourself, for example in orchestration code or focused tests:
import { Smartcli } from '@push.rocks/smartcli';
const cli = new Smartcli();
cli.addCommand('build').subscribe((argv) => {
console.log(`Building ${argv.target}`);
});
cli.triggerCommand('build', {
_: ['build'],
target: 'docs',
});
triggerCommand() throws if the command has not been registered.
Terminal Task Rendering
SmartcliTerminal is for the part of a CLI users actually stare at: long-running jobs. It renders active tasks under each other with a fixed number of rows, collapses completed tasks into one permanent success line, and can keep errors visible for inspection.
import { SmartcliTerminal } from '@push.rocks/smartcli';
const terminal = new SmartcliTerminal();
const buildTask = terminal.task('Build package', {
rows: 4,
showTimer: true,
showSpinner: true,
});
buildTask.update('Installing dependencies');
buildTask.setProgress(1, 3, 'Compiling sources');
buildTask.setProgress(2, 3, 'Bundling output');
buildTask.complete('Build finished');
Interactive terminals get live output with cursor-safe redraws:
⠸ Build package 66% (2/3) 4s
Installing dependencies
Compiling sources 33% (1/3)
Bundling output 66% (2/3)
Non-interactive terminals get append-only lifecycle logs that are easy to search and parse:
start Build package
update Build package: Installing dependencies
update Build package: Compiling sources 33% (1/3)
update Build package: Bundling output 66% (2/3)
done Build package in 4s: Build finished
Every non-interactive message line is prefixed with the task name, including multiline errors and stack traces.
Terminal Task Lifecycle
Use task.run() for scoped work. The task completes automatically when the function resolves and fails automatically when the function throws.
const terminal = new SmartcliTerminal();
await terminal.task('Generate assets', {
rows: 4,
timer: true,
spinner: true,
}).run(async (task) => {
task.setProgress(1, 3, 'Reading source files');
await readSourceFiles();
task.setProgress(2, 3, 'Rendering assets');
await renderAssets();
task.setProgress(3, 3, 'Writing output');
}, {
successMessage: 'Assets generated',
});
Attach a persistent error when you want the failed task to stay in the live area:
const workerTask = terminal.task('Watch worker', {
rows: 5,
showTimer: true,
showSpinner: true,
});
try {
await startWorker();
} catch (error) {
workerTask.attachError(error, { keepOpen: true });
}
Use fail(error) or attachError(error) without keepOpen when failure should collapse into permanent output:
const publishTask = terminal.task('Publish release');
try {
await publishRelease();
publishTask.complete('Published');
} catch (error) {
publishTask.fail(error);
}
Terminal Options
new SmartcliTerminal(options) accepts:
| Option | Purpose |
|---|---|
stream |
Custom writable stream, useful for tests or alternate output targets. Defaults to process.stdout when available. |
interactive |
Force interactive or non-interactive rendering. By default, smartcli checks TTY and common CI environment variables. |
colors |
Force color output on or off. Defaults to colors only in interactive mode unless NO_COLOR is set. |
symbols |
auto, unicode, or ascii. Controls status symbols and default spinner frames. |
cleanup |
Enables cursor restore/process cleanup hooks in interactive mode. Defaults to true. |
nonInteractiveThrottleMs |
Minimum interval between append-only update lines for a task. Defaults to 250. |
terminal.task(job, options) and terminal.createTask(options) accept:
| Option | Purpose |
|---|---|
job |
Human-readable task name. Required for createTask(), provided as first argument to task(). |
rows |
Fixed live row count while the task is active. Defaults to 3. |
logLimit |
Number of recent log lines retained in memory. Defaults to 100, never below rows. |
showTimer / timer |
Enables a second-precision live timer formatted by @push.rocks/smarttime, for example 4s or 1m 30s. |
showSpinner / spinner |
Enables an animated running indicator in interactive terminals. |
spinnerFrames |
Custom spinner frames, for example ['.', 'o', 'O', 'o']. |
spinnerIntervalMs |
Spinner render interval. Defaults to 80, minimum 20. |
Terminal API
| API | Purpose |
|---|---|
terminal.task(job, options?) |
Short form for creating a task. |
terminal.createTask(options) |
Creates a fixed-row terminal task. |
terminal.createProcess(options) |
Alias for createTask() for process-like jobs. |
terminal.isInteractive() |
Returns the selected render mode. |
terminal.getTasks() |
Returns active tasks. |
terminal.clear() |
Clears active live rows, restores the cursor, and drops active task references. |
task.update(message) / task.log(message) |
Adds a log line and updates output. Multiline messages are split and preserved. |
task.setProgress(current, total, message?) |
Stores progress and logs a progress update such as 40% (2/5). |
task.setTimerEnabled(enabled?) |
Toggles the live timer for a running task. |
task.setSpinnerEnabled(enabled?) |
Toggles the live spinner for a running task. |
task.run(fn, options?) |
Runs async/sync work and auto-completes or auto-fails the task. |
task.complete(message?) |
Collapses the task into a permanent success line. |
task.attachError(error, options?) |
Attaches an error. With keepOpen, the task remains visible; otherwise it collapses as failed. |
task.fail(error) |
Alias for collapsed failure output. |
task.getElapsedText() |
Returns smarttime-formatted elapsed time. |
task.getTimerText() |
Returns the current smarttime-formatted timer text. |
Cross-Runtime Argument Handling
smartcli uses getUserArgs() internally to remove runtime-specific executable and script entries before parsing:
| Runtime mode | Argument strategy |
|---|---|
| Deno without an explicit test argv | Uses Deno.args, which is already user-only. |
| Node.js | Uses process.argv and skips executable plus script path for known launchers. |
| Bun | Uses process.argv and skips executable plus script path for known launchers. |
tsx and ts-node |
Treated as known launchers and parsed like Node.js. |
| Unknown compiled executable | Skips only the executable path, keeping the first user argument intact. |
This matters for compiled CLIs where process.argv[0] is the binary itself and the first user argument should not be mistaken for a script path.
Testing CLIs
startParse() accepts an optional argv override. Pass a full runtime-style argument array for deterministic tests:
import { Smartcli } from '@push.rocks/smartcli';
const cli = new Smartcli();
const parsedPromise = cli.parseCompleted.promise;
cli.addCommand('publish').subscribe((argv) => {
console.log(`Publishing with tag ${argv.tag}`);
});
cli.startParse(['node', 'test-cli.js', 'publish', '--tag', 'next']);
const parsed = await parsedPromise;
console.log(parsed.tag);
// next
For terminal tests, inject a fake stream and force render mode:
import { SmartcliTerminal, type ISmartcliWritable } from '@push.rocks/smartcli';
class TestWritable implements ISmartcliWritable {
chunks: string[] = [];
isTTY = false;
write(chunk: string) {
this.chunks.push(chunk);
return true;
}
}
const stream = new TestWritable();
const terminal = new SmartcliTerminal({
stream,
interactive: false,
colors: false,
nonInteractiveThrottleMs: 0,
});
terminal.task('build').update('running');
console.log(stream.chunks.join(''));
Create a fresh Smartcli instance per parse. parseCompleted is a single deferred value on the instance and resolves once for normal dispatch.
Aliases
The class exposes addCommandAlias(original, alias) and the public aliasObject for keeping alias metadata:
cli.addCommandAlias('deploy', 'd');
console.log(cli.aliasObject.deploy);
// ['d']
Current command dispatch is exact-match based on argv._[0], so an alias that is not also registered as a command fails like any unknown command. If you want executable aliases, register the alias as a command and subscribe it to the same handler:
import { Smartcli } from '@push.rocks/smartcli';
const cli = new Smartcli();
const deploy = (argv: any) => {
console.log(`Deploying ${argv.target || 'default target'}`);
};
cli.addCommand('deploy').subscribe(deploy);
cli.addCommand('d').subscribe(deploy);
cli.addCommandAlias('deploy', 'd');
cli.startParse();
Smartcli API
| API | Purpose |
|---|---|
new Smartcli() |
Creates an isolated CLI parser and command registry. |
addCommand(commandName) |
Registers a command and returns its RxJS Subject. If the command already exists, the existing subject is reused. |
standardCommand() |
Registers and returns the fallback subject for runs without a command. |
unknownCommand() |
Registers and returns the subject that receives an IUnknownCommandEvent (commandName, registeredCommands, argv) for unregistered commands, replacing the default failure. |
getRegisteredCommandNames() |
Returns the command names argv can invoke, in registration order. |
startParse(testArgv?) |
Parses current user args, or the provided full argv array, and dispatches to the matching command. An unregistered command without an unknownCommand() handler prints a named error to stderr and sets process.exitCode = 1. |
triggerCommand(commandName, argvObject) |
Manually emits an argv object into a registered command subject. |
getCommandSubject(commandName) |
Returns the subject for a registered command or null. |
getOption(optionName) |
Parses current runtime args and returns one option by name. Inside command handlers, prefer the provided argv object. |
addHelp({ helpText }) |
Registers a help command that logs the provided help text. |
addVersion(version) |
Stores the version printed by -v or --version when no command is selected. |
addCommandAlias(original, alias) |
Stores alias metadata in aliasObject. Register alias command names separately for direct dispatch. |
parseCompleted.promise |
Resolves with the parsed argv object after normal startParse() dispatch; rejects with SmartcliUnknownCommandError for an unregistered command without an unknownCommand() handler. |
Practical End-To-End Example
This wires a tiny task runner with command routing and live terminal tasks:
#!/usr/bin/env node
import { Smartcli, SmartcliTerminal } from '@push.rocks/smartcli';
const cli = new Smartcli();
const jobs = new Map<string, () => Promise<void> | void>([
['build', async () => console.log('Building project...')],
['test', async () => console.log('Running tests...')],
['release', async () => console.log('Preparing release...')],
]);
cli.addVersion('1.4.0');
cli.addHelp({
helpText: `
Task Runner
Usage:
tasker list
tasker run <taskName> [--dry]
tasker --version
Available tasks:
build
test
release
`,
});
cli.addCommand('list').subscribe(() => {
for (const taskName of jobs.keys()) {
console.log(taskName);
}
});
cli.addCommand('run').subscribe(async (argv) => {
const taskName = argv._[1];
const dryRun = Boolean(argv.dry);
if (!taskName) {
console.log('Usage: tasker run <taskName> [--dry]');
return;
}
const job = jobs.get(taskName);
if (!job) {
console.log(`Unknown task: ${taskName}`);
return;
}
if (dryRun) {
console.log(`Would run task: ${taskName}`);
return;
}
const terminal = new SmartcliTerminal();
await terminal.task(`Run ${taskName}`, {
rows: 4,
showTimer: true,
showSpinner: true,
}).run(async (task) => {
task.update('Starting');
await job();
task.update('Finishing');
}, {
successMessage: `${taskName} completed`,
});
});
cli.standardCommand().subscribe(() => {
console.log('Usage: tasker <list|run|help>');
});
cli.startParse();
Sharp Edges Worth Knowing
- Register commands before calling
startParse(). startParse()dispatches synchronously; if a handler starts async work, manage that lifecycle in your application code.startParse()dispatches once; create a newSmartcliinstance for a second parse.addHelp()registers thehelpcommand, not a--helpoption.addVersion()prints only when no positional command is present; this early version path returns beforeparseCompletedresolves.- Unknown commands never trigger
standardCommand(). By default they fail with a named stderr error and exit code 1; registerunknownCommand()if your CLI accepts free positional arguments. getOption()reads the current runtime args. In command subscriptions, use the providedargvobject for the most testable code.- Alias metadata is stored, but aliases are not automatically dispatched unless you register the alias command name too.
- smartcli does not validate option schemas. Add validation in your own command handlers when inputs matter.
License and Legal Information
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the license file.
Please note: The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
Trademarks
This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH or third parties, and are not included within the scope of the MIT license granted herein.
Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines or the guidelines of the respective third-party owners, and any usage must be approved in writing. Third-party trademarks used herein are the property of their respective owners and used only in a descriptive manner, e.g. for an implementation of an API or similar.
Company Information
Task Venture Capital GmbH
Registered at District Court Bremen HRB 35230 HB, Germany
For any legal inquiries or further information, please contact us via email at hello@task.vc.
By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.