jkunz 4ada0ab692
Default (tags) / security (push) Failing after 3s
Default (tags) / test (push) Failing after 1s
Default (tags) / metadata (push) Skipped
v5.0.1
2026-09-29 08:55:27 +00:00
…
…
…
…
…
…

@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, and ts-node.
  • 🎛️ yargs-parser integration for positional arguments and flags.
  • 🧯 Standard fallback command, named unknown-command failures, version output, help command, parse-completion hooks, and manual command triggering.
  • 🖥️ SmartcliTerminal for 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 new Smartcli instance for a second parse.
  • addHelp() registers the help command, not a --help option.
  • addVersion() prints only when no positional command is present; this early version path returns before parseCompleted resolves.
  • Unknown commands never trigger standardCommand(). By default they fail with a named stderr error and exit code 1; register unknownCommand() if your CLI accepts free positional arguments.
  • getOption() reads the current runtime args. In command subscriptions, use the provided argv object 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.

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.

S
Description
A library for easily creating observable CLI tasks with support for commands, arguments, and options.
Readme
2.1 MiB
Languages
TypeScript 100%