TL;DR
listr2 is the full-featured task list runner — sequential and concurrent tasks, nested subtasks, multiple renderers (default, verbose, silent), input prompts, and rollback support. tasuku is the minimal task runner — simple API, concurrent tasks, zero config, looks like vitest output. cli-progress is the progress bar library — single and multi-bar progress indicators, customizable formats, ETA calculation. In 2026: listr2 for complex CLI workflows with subtasks, cli-progress for download/transfer progress bars, tasuku for simple task lists.
Key Takeaways
- listr2: ~10M weekly downloads — full task list runner, used by Angular CLI, Prisma
- tasuku: ~50K weekly downloads — minimal task runner, vitest-like output
- cli-progress: ~5M weekly downloads — progress bars, multi-bar, ETA, custom formats
- Different tools: listr2/tasuku are task list runners, cli-progress is a progress bar
- listr2 supports concurrent tasks, subtasks, rollback, prompts
- cli-progress supports single-bar, multi-bar, and custom token formats
listr2
listr2 — task list runner:
Basic task list
import { Listr } from "listr2"
const tasks = new Listr([
{
title: "Installing dependencies",
task: async () => {
await exec("npm install")
},
},
{
title: "Building project",
task: async () => {
await exec("npm run build")
},
},
{
title: "Running tests",
task: async () => {
await exec("npm test")
},
},
])
await tasks.run()
// ✔ Installing dependencies
// ✔ Building project
// ✔ Running tests
Concurrent tasks
import { Listr } from "listr2"
const tasks = new Listr([
{
title: "Lint and test",
task: () => new Listr([
{
title: "ESLint",
task: async () => await exec("eslint ."),
},
{
title: "TypeScript",
task: async () => await exec("tsc --noEmit"),
},
{
title: "Vitest",
task: async () => await exec("vitest run"),
},
], { concurrent: true }), // Run all three in parallel
},
{
title: "Build",
task: async () => await exec("npm run build"),
},
])
await tasks.run()
// ◼ Lint and test
// ✔ ESLint
// ✔ TypeScript
// ✔ Vitest
// ✔ Build
Subtasks and context
import { Listr } from "listr2"
interface Context {
packages: string[]
results: Map<string, boolean>
}
const tasks = new Listr<Context>([
{
title: "Discovering packages",
task: async (ctx) => {
ctx.packages = await findWorkspacePackages()
ctx.results = new Map()
},
},
{
title: "Building packages",
task: (ctx, task) => {
return task.newListr(
ctx.packages.map((pkg) => ({
title: `Building ${pkg}`,
task: async () => {
await buildPackage(pkg)
ctx.results.set(pkg, true)
},
})),
{ concurrent: 3 }, // Max 3 concurrent builds
)
},
},
{
title: "Summary",
task: (ctx, task) => {
const success = [...ctx.results.values()].filter(Boolean).length
task.title = `Built ${success}/${ctx.packages.length} packages`
},
},
])
await tasks.run()
Skip, retry, and rollback
import { Listr } from "listr2"
const tasks = new Listr([
{
title: "Check if migration needed",
task: async (ctx, task) => {
const needed = await checkMigrationStatus()
if (!needed) {
task.skip("Already up to date")
}
},
},
{
title: "Run database migration",
task: async () => {
await runMigration()
},
retry: 3, // Retry up to 3 times on failure
rollback: async () => {
await rollbackMigration() // Run if task fails after retries
},
},
{
title: "Seed data",
enabled: (ctx) => ctx.environment === "development", // Conditional
task: async () => {
await seedDatabase()
},
},
])
Renderers
import { Listr } from "listr2"
// Default renderer (interactive terminal):
new Listr(tasks, { renderer: "default" })
// Verbose renderer (for CI — no spinners):
new Listr(tasks, { renderer: "verbose" })
// Silent renderer (no output):
new Listr(tasks, { renderer: "silent" })
// Auto-detect: use verbose in CI, default in terminal:
new Listr(tasks, {
renderer: process.env.CI ? "verbose" : "default",
})
tasuku
tasuku — minimal task runner:
Basic usage
import task from "tasuku"
// Single task:
await task("Installing dependencies", async ({ setTitle }) => {
await exec("npm install")
setTitle("Dependencies installed")
})
// Sequential tasks:
await task("Building project", async () => {
await exec("npm run build")
})
await task("Running tests", async () => {
await exec("npm test")
})
// Output:
// ✔ Dependencies installed
// ✔ Building project
// ✔ Running tests
Grouped tasks
import task, { group } from "tasuku"
// Concurrent group:
await task.group((task) => [
task("Lint", async () => {
await exec("eslint .")
}),
task("Type check", async () => {
await exec("tsc --noEmit")
}),
task("Test", async () => {
await exec("vitest run")
}),
])
// All three run concurrently, output updates in place
Error handling
import task from "tasuku"
await task("Deploy", async ({ setError, setWarning, setOutput }) => {
try {
const result = await deploy()
setOutput(`Deployed to ${result.url}`)
} catch (err) {
setError(err.message)
throw err // Task shows as failed
}
})
// Warning (task still succeeds):
await task("Check config", async ({ setWarning }) => {
const config = await loadConfig()
if (!config.apiKey) {
setWarning("No API key configured — using defaults")
}
})
tasuku vs listr2
tasuku:
✅ Simple API — single function
✅ vitest-like output
✅ Concurrent groups
✅ Minimal (~3KB)
❌ No subtasks (nested tasks)
❌ No rollback
❌ No retry
❌ No skip
❌ No context passing
❌ No CI renderer
For complex workflows: use listr2
For simple task lists: tasuku is great
cli-progress
cli-progress — progress bars:
Single progress bar
import { SingleBar, Presets } from "cli-progress"
const bar = new SingleBar({
format: "Downloading | {bar} | {percentage}% | {value}/{total} files",
}, Presets.shades_classic)
bar.start(100, 0)
for (let i = 0; i < 100; i++) {
await downloadFile(files[i])
bar.increment()
}
bar.stop()
// Downloading | ████████████████████ | 100% | 100/100 files
Multi-bar progress
import { MultiBar, Presets } from "cli-progress"
const multibar = new MultiBar({
format: "{name} | {bar} | {percentage}% | {value}/{total}",
clearOnComplete: false,
}, Presets.shades_grey)
// Create multiple bars:
const bar1 = multibar.create(100, 0, { name: "Package A" })
const bar2 = multibar.create(200, 0, { name: "Package B" })
const bar3 = multibar.create(150, 0, { name: "Package C" })
// Update independently:
bar1.increment(10)
bar2.increment(25)
bar3.increment(15)
// When done:
multibar.stop()
// Package A | ████████░░░░░░░░░░░░ | 40% | 40/100
// Package B | ██████████░░░░░░░░░░ | 50% | 100/200
// Package C | ████████████░░░░░░░░ | 60% | 90/150
ETA and speed
import { SingleBar } from "cli-progress"
const bar = new SingleBar({
format: "{bar} | {percentage}% | ETA: {eta}s | {speed} files/s",
etaBuffer: 10,
etaAsynchronousUpdate: true,
})
bar.start(1000, 0, { speed: "N/A" })
for (let i = 0; i < 1000; i++) {
await processFile(files[i])
bar.update(i + 1, {
speed: calculateSpeed(i, startTime),
})
}
bar.stop()
// ████████░░░░░░░░░░░░ | 40% | ETA: 12s | 83 files/s
Custom format tokens
import { SingleBar } from "cli-progress"
const bar = new SingleBar({
format: "[{bar}] {percentage}% | {filename} | {size}",
barCompleteChar: "█",
barIncompleteChar: "░",
barsize: 30,
})
bar.start(totalFiles, 0)
for (const file of files) {
await processFile(file)
bar.increment(1, {
filename: file.name,
size: formatBytes(file.size),
})
}
bar.stop()
// [█████████████░░░░░░░░░░░░░░░░░] 45% | utils.ts | 2.4 KB
Download progress
import { SingleBar } from "cli-progress"
import https from "node:https"
function downloadWithProgress(url: string, dest: string) {
return new Promise<void>((resolve, reject) => {
https.get(url, (res) => {
const total = parseInt(res.headers["content-length"] ?? "0", 10)
const bar = new SingleBar({
format: "Downloading | {bar} | {percentage}% | {value}/{total} bytes",
})
bar.start(total, 0)
let received = 0
res.on("data", (chunk) => {
received += chunk.length
bar.update(received)
})
res.on("end", () => {
bar.stop()
resolve()
})
})
})
}
Feature Comparison
| Feature | listr2 | tasuku | cli-progress |
|---|---|---|---|
| Purpose | Task list runner | Task list runner | Progress bars |
| Concurrent tasks | ✅ | ✅ (groups) | ❌ |
| Subtasks | ✅ (nested) | ❌ | ❌ |
| Rollback | ✅ | ❌ | ❌ |
| Retry | ✅ | ❌ | ❌ |
| Skip tasks | ✅ | ❌ | ❌ |
| CI renderer | ✅ (verbose) | ❌ | ❌ |
| Multi-bar | ❌ | ❌ | ✅ |
| ETA calculation | ❌ | ❌ | ✅ |
| Custom formats | ❌ | ❌ | ✅ |
| TypeScript | ✅ | ✅ | ✅ |
| Weekly downloads | ~10M | ~50K | ~5M |
When to Use Each
Use listr2 if:
- Building complex CLI workflows (build, deploy, migrate)
- Need subtasks, rollback, retry, and skip
- Want different renderers for CI vs terminal
- Building tools like Angular CLI, Prisma, or Yeoman
Use tasuku if:
- Want a simple, minimal task list
- Need concurrent task groups with clean output
- Building scripts with sequential/parallel steps
- Prefer vitest-like visual output
Use cli-progress if:
- Need download or transfer progress bars
- Want multi-bar progress indicators
- Need ETA and speed calculations
- Building file processing or batch operation tools
CI and Non-Interactive Terminal Handling
One of the most important production considerations for CLI tools is how they behave in non-interactive environments like GitHub Actions, CircleCI, or any CI pipeline. listr2 handles this elegantly through its renderer system: when process.stdout.isTTY is false (the signal that the terminal is not interactive), you should switch to the verbose renderer which outputs each task title on its own line without spinners or ANSI escape codes. The verbose renderer produces clean, parseable CI log output that doesn't contain control characters that confuse log aggregation tools. cli-progress detects non-TTY environments and degrades gracefully — in non-interactive mode it suppresses bar rendering by default, which is usually the correct behavior. tasuku does not automatically detect CI environments, so scripts using tasuku should either add an explicit check (if (process.env.CI)) or accept that spinner output in CI logs will contain raw escape sequences that appear garbled in most CI UIs.
TypeScript Integration and Type-Safe Context
listr2's TypeScript generic support for context objects is one of its most valuable features for complex CLI workflows. By defining a context interface and passing it as a generic parameter to Listr<Context>, every task's ctx parameter is fully typed, preventing runtime errors from mismatched property names or incorrect types. This is particularly important in build tools and migration scripts where tasks pass computed data to downstream steps — typed context catches these integration errors at compile time rather than during a long-running deployment. tasuku does not support typed context passing between tasks, which makes it less suitable for complex multi-step workflows where data flows between steps. For simple sequential or parallel task lists where tasks are independent, this limitation is irrelevant. cli-progress has straightforward TypeScript types for its configuration options and the payload object passed to increment() and update(), with no complex generics required.
Building Production CLI Tools with listr2
When listr2 is used as the backbone of a production CLI tool (like a package manager, deployment tool, or migration script), several architectural patterns emerge from experience with tools like Prisma and the Angular CLI. Long-running tasks should emit progress updates via task.output to provide feedback without completing the task, which prevents the appearance of a hung CLI during operations that take more than a few seconds. For operations with uncertain duration, combining listr2 with cli-progress in a custom renderer or using task.output with a custom progress string gives users a meaningful progress indicator. Error messages from failed tasks should be added to task.output before throwing, so the verbose renderer captures the failure context. Finally, providing a --no-interactive flag that forces the verbose renderer makes it easier for users to pipe your tool's output to log files or other tools.
Progress Bar Accuracy and ETA Calculation
cli-progress's ETA calculation deserves special attention in production contexts where accuracy matters. The ETA is calculated using the etaBuffer value — the number of recent iterations used to calculate the moving average speed. A small etaBuffer (the default is 10) makes ETA estimates responsive to speed changes but volatile, which can make progress bars appear erratic when processing speed varies. For file transfer or download scenarios where speed varies due to network conditions, a larger etaBuffer (30-50) produces smoother ETA estimates. The etaAsynchronousUpdate option decouples ETA calculation from the main render loop, which is important when processing items concurrently because synchronous ETA calculation can block the render tick. For the most accurate ETA in batch processing scenarios, tracking the wall-clock time per item and feeding it to a custom token rather than relying on the built-in ETA calculation gives you full control over the smoothing algorithm.
Ecosystem Context and Alternative Libraries
Understanding where these tools fit in the broader CLI ecosystem helps with long-term maintenance decisions. listr2 is a maintained fork of the original listr package, which is no longer maintained. The upgrade from listr to listr2 requires API changes, but the conceptual model is identical. Ora (also by the Sindre Sorhus ecosystem) is a simpler spinner library that handles single-step async operations without the full task runner overhead, and it integrates naturally with code that doesn't fit the listr2 task list model. For download-heavy scripts, the progress-stream npm package wraps Node.js streams with progress event emission, which pairs naturally with cli-progress's stream-based update pattern. The combination of listr2 for the task structure and cli-progress for individual file downloads within a task represents a common pattern in complex deployment or migration scripts where you need both macro-level task status and micro-level progress within each task.
Renderer Strategy and Terminal Compatibility
Selecting the right listr2 renderer for your deployment target is more consequential than it might appear. The default DefaultRenderer uses ANSI escape codes to overwrite lines in place, creating the animated task list effect — but this requires a TTY-capable terminal. When your CLI tool is run in a GitHub Actions environment, a Docker container build, or piped through tee for log capture, the default renderer produces garbled output full of escape codes rather than clean log lines. listr2's VerboseRenderer outputs one line per task event without escape codes, making it suitable for any non-interactive context. The SilentRenderer suppresses all output entirely, which is useful when embedding listr2 inside another listr2 task where you want to control output at the parent level. For tools distributed to general audiences, automatically detecting the TTY capability with process.stdout.isTTY and selecting the renderer accordingly is standard practice. tasuku always uses its fixed renderer with no configuration options, which simplifies the decision but removes flexibility for non-interactive environments where its output may not render correctly.
Methodology
Download data from npm registry (weekly average, February 2026). Feature comparison based on listr2 v8.x, tasuku v2.x, and cli-progress v3.x.
Compare CLI tools and developer utilities on PkgPulse →
See also: Ink vs @clack/prompts vs Enquirer and cac vs meow vs arg 2026, acorn vs @babel/parser vs espree.