mrkeyoor.com_
Thu 06 Aug 02:03 UTC
npmCLI & Toolingupdated 06 Aug 2026

listr2

listr2 renders a live, updating task list in the terminal. You describe your work as an array of objects with a title and an async task function, call run(), and it draws spinners, checkmarks, timers, nested subtasks and streaming per-task output in place. It is a fork of the original listr, which stopped being maintained, and it is what npx create-* wizards, Angular CLI and Nx style tools use to make a long build look like progress rather than a wall of logs. Beyond the drawing it gives you a shared context object passed to every task, per-task skip and enable predicates, retries with backoff, rollbacks, concurrency control, and interactive prompts through adapter packages. Five renderers ship with it, and it swaps to a plain-text one automatically when the output is not a TTY.

Verdict

The best option available for a rich, interactive multi-step CLI, and the only one that handles nested tasks, prompts and rollback in one model. Pay attention to the aggressive Node floor, the ESM-only packaging, and the fact that your CI users see a completely different renderer from the one you built.

API stability3/5The task object shape has been recognizable since v3, but there are eleven majors with migration guides for v6 through v11, and v11 changed Listr.errors from an array to array-or-null, deleted ListrError.ctx and altered Ctrl+C semantics
Docs5/5listr2.kilic.dev covers every option and every renderer with runnable examples pulled from files in the repo, plus a per-version migration guide and generated API reference. The GitHub README is only a signpost to it
Maintenance4/5Zero open issues and zero open PRs, pushes within the day, and v11.0.0 shipped in July 2026 with a proper beta cycle. It is essentially one maintainer, so the bus factor is the risk rather than the activity level
Ecosystem4/544M weekly downloads because major CLI toolchains depend on it, and it has first-party prompt adapters and a task manager extension. Only 680 stars though: almost nobody arrives here directly, so community examples outside the official docs are thin

Use it if

  • You are building an interactive CLI where a human watches a multi-step process (scaffolding, deploys, migrations) and you want per-step state rather than a scrolling log
  • Your steps have real structure: nested subtasks, some concurrent and some sequential, with per-task skip conditions, retries and rollback on failure
  • You need prompts in the middle of a running task list, which plain prompt libraries cannot do because listr2 owns the terminal's cursor and needs an adapter to hand it over
  • You want a shared typed context object threaded through every task instead of passing state around with closures and module-level variables
Skip it if

Setup reality

npm i listr2 pulls three small dependencies (cli-truncate, log-update, wrap-ansi) and nothing else, with TypeScript types included. Two things bite immediately. First, v11 requires Node >=22.13.0 and is ESM only, so an older runner or a CommonJS entry point stops you before you write a task. Second, prompts are not in the box: you install @listr2/prompt-adapter-inquirer plus @inquirer/prompts as separate packages and call task.prompt(ListrInquirerPromptAdapter).run(...). The older enquirer adapter is documented as lifeline support only, is unmaintained upstream since 2021, and is no longer guaranteed on Node 26 and above because of a readline change. After that, budget time for output discipline: nothing may write to stdout directly during a run, subtasks must be created with task.newListr() rather than new Listr() so they share the renderer, and you should test with the output piped to a file to see what the fallback renderer actually prints.

Patterns

Run a sequential task listbasic-task-list

import { Listr } from 'listr2'

const tasks = new Listr([
  {
    title: 'Installing dependencies',
    task: async () => install()
  },
  {
    title: 'Building',
    task: async () => build()
  }
], { concurrent: false })

try {
  await tasks.run()
} catch (e) {
  console.error(e)
}

run() rejects on the first failure unless you set exitOnError: false, so wrap it or your CLI exits with an unhandled rejection.

Thread typed state through tasksshared-context

import { Listr } from 'listr2'

interface Ctx {
  version?: string
  artifacts: string[]
}

const tasks = new Listr<Ctx>([
  {
    title: 'Read version',
    task: async (ctx) => { ctx.version = await readVersion() }
  },
  {
    title: 'Package',
    task: async (ctx) => { ctx.artifacts = await pack(ctx.version) }
  }
], { ctx: { artifacts: [] } })

const ctx = await tasks.run()
console.log(ctx.artifacts)

run() resolves with the context, and the context is also readable as tasks.ctx after the run; subtasks inherit the parent context automatically.

Nest a subtask listnested-subtasks

import { Listr } from 'listr2'

const tasks = new Listr([
  {
    title: 'Deploy',
    task: (ctx, task) => task.newListr([
      { title: 'Upload bundle', task: async () => upload() },
      { title: 'Invalidate cache', task: async () => invalidate() }
    ], { concurrent: true, rendererOptions: { collapseSubtasks: false } })
  }
])

await tasks.run()

Always build subtasks with task.newListr(); a bare new Listr() inside a task starts a second renderer and the output tears.

Run tasks in parallel with a capconcurrency-limit

const tasks = new Listr(
  services.map((svc) => ({
    title: `Publishing ${svc.name}`,
    task: async () => publish(svc)
  })),
  { concurrent: 4, exitOnError: false }
)

await tasks.run()

concurrent accepts true for unlimited or a number for a cap; with exitOnError: false the run continues past failures and reports every failed task at the end.

Stream output from inside a tasktask-output

{
  title: 'Compiling',
  task: async (ctx, task) => {
    for await (const line of compiler()) {
      task.output = line
    }
  },
  rendererOptions: { persistentOutput: true, bottomBar: 5 }
}

Assigning to task.output replaces the previous line by default; persistentOutput keeps the last line after the task finishes, bottomBar keeps a scrolling window of N lines.

Skip or hide tasks conditionallyskip-and-enable

const tasks = new Listr<{ hasTests: boolean }>([
  {
    title: 'Detect tests',
    task: async (ctx) => { ctx.hasTests = await detectTests() }
  },
  {
    title: 'Run tests',
    enabled: (ctx) => ctx.hasTests,   // not rendered at all
    skip: (ctx) => ctx.ci && 'skipped in CI',
    task: async () => runTests()
  }
])

enabled removes the task from the list entirely; skip renders it greyed out with the returned string as the reason. You can also call task.skip('reason') from inside a running task.

Retry a flaky taskretry-with-delay

{
  title: 'Fetch release metadata',
  retry: { tries: 3, delay: 2000 },
  task: async (ctx, task) => {
    const attempt = task.isRetrying()
    if (attempt.count > 0) {
      task.title = `Fetch release metadata (attempt ${attempt.count + 1})`
    }
    return fetchMetadata()
  }
}

While waiting between attempts the task is paused and the default renderer shows a countdown; task.isRetrying().error holds the previous failure.

Undo work when a task failsrollback-on-failure

{
  title: 'Create database',
  task: async (ctx) => { ctx.dbId = await createDb() },
  rollback: async (ctx, task) => {
    task.title = 'Removing partially created database'
    await dropDb(ctx.dbId)
  }
}

Since v11 rollback also runs when the user hits Ctrl+C, and the process waits for in-flight rollbacks before exiting with code 127.

Read every failure after the runcollect-errors

const tasks = new Listr(steps, {
  concurrent: true,
  exitOnError: false,
  collectErrors: true
})

await tasks.run()

if (tasks.errors?.length) {
  for (const err of tasks.errors) {
    console.error(err.path.join(' > '), err.error.message)
  }
  process.exitCode = 1
}

In v11 collectErrors is a boolean and defaults to false, which leaves tasks.errors as null; guard with ?. and note that ListrError.ctx was removed.

Abort your own work on Ctrl+Ccooperative-cancellation

{
  title: 'Downloading release',
  task: async (ctx, task) => {
    const res = await fetch(url, { signal: task.signal })
    await writeFile(dest, res.body)
  },
  rollback: async () => rm(dest, { force: true })
}

// abort the whole run from inside a task:
// task.cancel()   or   listr.cancel()

task.signal is a standard AbortSignal; without it the interrupted promise keeps running in the background while its rollback executes.

Ask a question mid-runprompt-inside-task

// npm i @listr2/prompt-adapter-inquirer @inquirer/prompts
import { input } from '@inquirer/prompts'
import { ListrInquirerPromptAdapter } from '@listr2/prompt-adapter-inquirer'
import { Listr } from 'listr2'

const tasks = new Listr<{ name: string }>([
  {
    task: async (ctx, task) => {
      ctx.name = await task
        .prompt(ListrInquirerPromptAdapter)
        .run(input, { message: 'Project name?' })
    }
  }
])

Prompt adapters are separate packages with their own peer dependencies; the enquirer adapter is on lifeline support and is not guaranteed on Node 26 and above.

Control what CI seesrenderer-selection

import { Listr, PRESET_TIMER } from 'listr2'

const tasks = new Listr(steps, {
  renderer: process.env.CI ? 'simple' : 'default',
  fallbackRenderer: 'simple',
  rendererOptions: { collapseSubtasks: false, timer: PRESET_TIMER },
  fallbackRendererOptions: { timer: PRESET_TIMER }
})

listr2 already falls back on a non-TTY stream, but setting it explicitly means you can reproduce the CI output locally instead of discovering it in a pipeline log.

Alternatives

PackageRegistryPick it when
oranpmOne operation, one spinner, no task tree or context to manage
@clack/promptsnpmThe CLI is mostly a question-and-answer wizard with a few spinners rather than a long task pipeline
tasukunpmYou want nested task lists with a much smaller API and no renderer configuration