npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@doist/cli-core

v1.7.0

Published

Shared core utilities for Doist CLI projects

Readme

@doist/cli-core

Shared core utilities for Doist CLI projects (todoist-cli, twist-cli, outline-cli, comms-cli).

TypeScript, ESM-only, Node ≥ 24, npm ≥ 11.

Install

npm install @doist/cli-core

What's in it

| Module | Key exports | Purpose | | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | auth (subpath) | attachLoginCommand, attachLogoutCommand, attachStatusCommand, attachTokenViewCommand, attachRefreshTokenViewCommand, attachAccountListCommand, attachAccountUseCommand, attachAccountCurrentCommand, attachAccountRemoveCommand, runOAuthFlow, refreshAccessToken, createPkceProvider, createDcrProvider, createSecureStore, createKeyringTokenStore, migrateLegacyAuth, persistBundle, bundleFromExchange, PKCE helpers, AuthProvider / TokenStore / TokenBundle / ActiveBundleSnapshot / RefreshInput / RefreshHandshake / RefreshHandshakeContext / TokenRefreshOptions / AccountRef / ClearedAccount / SecureStore / UserRecordStore / CredentialStore types, AttachLogoutRevokeContext / AttachAccountListContext / AttachAccountCurrentContext / AttachAccountRemoveContext | OAuth runtime plus the Commander attachers for <cli> [auth] login / logout / status / token / refresh-token view and <cli> account list / use / current / remove. attachLogoutCommand accepts an optional revokeToken hook for best-effort server-side token revocation. Ships the standard public-client PKCE flow (createPkceProvider, with optional RFC 8707 resource indicators and client ID metadata document client IDs), the RFC 7591 Dynamic Client Registration flow (createDcrProvider, with optional RFC 8707 resource indicators, refresh-token support, and loadClient/saveClient client caching), a thin cross-platform OS-keyring wrapper (createSecureStore), and a multi-account keyring-backed TokenStore (createKeyringTokenStore) with strict system, explicit plaintext, and fallback storage policies. The store contract supports an optional setBundle(account, bundle) write method (required on KeyringTokenStore) so consumers that need refresh-token persistence can opt in via TokenBundle; active() stays narrow (access token + account only) so callers that don't need refresh state don't pay extra keyring IPC. AuthProvider and TokenStore remain the escape hatches for fully bespoke backends (device code, magic-link, …). logout / status / token / refresh-token view always attach --user <ref> and thread the parsed ref to the matching store read (store.active(ref), store.activeBundle(ref), or store.clear(ref)). status and token take an optional refresh (TokenRefreshOptions) so an expiring access token is rotated through refreshAccessToken before it is probed or printed. commander (when using the attachers), open (browser launch), @napi-rs/keyring (when using createSecureStore or the keyring TokenStore), and oauth4webapi (when a consumer opts into silent refresh or uses createDcrProvider) are optional peer/optional deps. | | command-token | findCommandToken, needsExtensionLookup, CommandToken | Find which argv entry names the command without parsing the rest, stepping over value-taking root flags (--user, --progress-jsonl by default), and decide whether an invocation needs the installed extensions looked up at all. | | commands (subpath) | registerChangelogCommand, registerUpdateCommand (+ semver helpers) | Commander wiring for cli-core's standard commands (e.g. <cli> changelog, <cli> update, <cli> update switch). Requires commander as an optional peer-dep. | | config | getConfigPath, readConfig, readConfigStrict, writeConfig, updateConfig, CoreConfig, UpdateChannel | Read / write a per-CLI JSON config file with typed error codes; CoreConfig is the shape of fields cli-core itself owns (extend it for per-CLI fields). | | empty | printEmpty | Print an empty-state message gated on --json / --ndjson / --ids-only so machine consumers never see human strings on stdout. | | errors | CliError | Typed CLI error class with code and exit-code mapping. | | extensions (subpath) | createExtensionManager, registerExtensionCommands, registerExtensionGroup, registerExtensionPassThrough, createExtension, listTemplates, defaultTemplatesDir, satisfiesRange, findManifestProblems, mapWithConcurrency, ExtensionManagerOptions / ExtensionManager / Extension / ExtensionListing / ExtensionCommands / ExtensionErrorCode types | A gh-style extension system for any CLI: discover <bin>-* directories under the data dir, install from GitHub releases, git clones or local paths, upgrade, remove, scaffold from shipped templates, and dispatch <bin> <name> … to the executable with the <PREFIX>_* environment contract. Requires commander and zod as optional peer-deps. | | global-args | parseGlobalArgs, stripUserFlag, createGlobalArgsStore, createAccessibleGate, createSpinnerGate, getProgressJsonlPath, isProgressJsonlEnabled | Parse well-known global flags (--json, --ndjson, --ids-only, --quiet, --verbose, --accessible, --no-spinner, --progress-jsonl, --user <ref>) and derive predicates from them. stripUserFlag removes --user tokens from argv so the cleaned array can be forwarded to Commander when the flag has no root-program attachment. | | ids | formatIds, outputIds | Format or emit one stable string or numeric ID per line. Empty results stay silent; optional pagination notices go to stderr. | | json | formatJson, formatNdjson | Stable JSON / newline-delimited JSON formatting for stdout. | | markdown (subpath) | preloadMarkdown, renderMarkdown, TerminalRendererOptions | Lazy-init terminal markdown renderer. Requires marked and marked-terminal-renderer as peer-deps — install only if your CLI uses this subpath. | | options | OUTPUT_MODES, OutputMode, ViewOptions, ListViewOptions, resolveOutputMode | Canonical output-mode contracts; ListViewOptions adds idsOnly?, and the resolver rejects conflicting machine-output flags. | | paths | getDataDir, getStateDir | Per-user data and state directories for an app name, XDG variables first and platform defaults second, alongside getConfigPath. | | spinner | createSpinner | Loading spinner factory wrapping yocto-spinner with disable gates. | | terminal | isCI, isStderrTTY, isStdinTTY, isStdoutTTY | TTY / CI detection helpers. | | testing (subpath) | describeEmptyMachineOutput, createTestProgram, captureConsole, captureStream, writeFixtureExtension, writeFakeGitRepo, buildTokenStore, buildSingleEntryStore, ingenEntries, alanGrant / ellieSattler / ianMalcolm, TestAccount / StoreEntry / TokenStoreHarness / MatchAccount types | Vitest helpers + fixtures reusable by consuming CLIs: a parametrised empty-state suite (--json / --ndjson / human modes); a Commander test-program builder (createTestProgram; the whole subpath requires commander since the barrel re-exports it); console / stdout-stderr spies that silence + auto-restore (captureConsole / captureStream, call inside a test or beforeEach); and a canonical stateful in-memory TokenStore mock plus shared account fixtures (buildTokenStore / buildSingleEntryStore) modelling createKeyringTokenStore's default-selection contract — pass matchAccount to mirror a consumer's own ref-matching (numeric-id / case-insensitive label); and writeFixtureExtension / writeFakeGitRepo, which put a runnable extension on disk so the extensions subpath can be exercised through a real process. |

Usage

Global args + spinner gate

import { createGlobalArgsStore, createSpinnerGate, createSpinner } from '@doist/cli-core'

const store = createGlobalArgsStore()
export const isJsonMode = () => store.get().json

const shouldDisableSpinner = createSpinnerGate({
    envVar: 'TD_SPINNER',
    getArgs: store.get,
})
const { withSpinner } = createSpinner({ isDisabled: shouldDisableSpinner })

Empty-state print

import { printEmpty } from '@doist/cli-core'

if (tasks.length === 0) {
    printEmpty({ options, message: 'No tasks found.' })
    return
}

Testing helpers (subpath)

Reuse the shared vitest scaffolding instead of hand-rolling it per CLI:

import { attachStatusCommand } from '@doist/cli-core/auth'
import {
    alanGrant,
    buildSingleEntryStore,
    captureConsole,
    createTestProgram,
} from '@doist/cli-core/testing'

it('greets the active account', async () => {
    const logSpy = captureConsole() // silences console.log, auto-restores
    const { store } = buildSingleEntryStore({ token: 'tok', account: alanGrant })
    const program = createTestProgram((p) =>
        attachStatusCommand(p, { store, renderText: (ctx) => `Signed in as ${ctx.account.email}` }),
    )
    await program.parseAsync(['node', 'cli', 'status'])
    expect(logSpy).toHaveBeenCalledWith('Signed in as [email protected]')
})

buildTokenStore is a stateful in-memory TokenStore mock. For a CLI whose store matches refs differently from the default id/email/label rule, pass matchAccount so the mock resolves refs exactly like production:

import { buildTokenStore } from '@doist/cli-core/testing'
import { matchTwistAccount } from './lib/auth-provider.js'

const { store } = buildTokenStore<TwistAccount>({
    entries: [{ account: ACCOUNT_ALAN, isDefault: true }],
    matchAccount: matchTwistAccount,
})

The whole @doist/cli-core/testing entrypoint links commander (the barrel re-exports createTestProgram) and vitest at import time, so install both — consuming CLIs already have them — and only import this subpath from test files.

Markdown rendering (optional subpath)

Install the peer-deps in the consuming CLI:

npm install marked marked-terminal-renderer

Then:

import { preloadMarkdown, renderMarkdown } from '@doist/cli-core/markdown'

if (!options.json && !options.raw) {
    await preloadMarkdown()
}
console.log(await renderMarkdown(comment.body))

If the peer-deps are missing, preloadMarkdown throws a clear error pointing to the install command. TypeScript will also fail to resolve the subpath's types until the peers are installed.

Standard commands (optional subpath)

Install the peer-dep in the consuming CLI:

npm install commander

Then wire <cli> changelog in one call:

import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { registerChangelogCommand } from '@doist/cli-core/commands'
import packageJson from '../package.json' with { type: 'json' }

registerChangelogCommand(program, {
    path: join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'CHANGELOG.md'),
    repoUrl: 'https://github.com/Doist/todoist-cli',
    version: packageJson.version,
})

The helper throws CliError (INVALID_TYPE for a bad --count, FILE_READ_ERROR if the file can't be read) so the CLI's top-level error handler formats and exits.

Wire <cli> update and <cli> update switch similarly:

import { createSpinner, getConfigPath } from '@doist/cli-core'
import { registerUpdateCommand } from '@doist/cli-core/commands'
import packageJson from '../package.json' with { type: 'json' }

const { withSpinner } = createSpinner()
registerUpdateCommand(program, {
    packageName: '@doist/todoist-cli',
    currentVersion: packageJson.version,
    configPath: getConfigPath('todoist-cli'),
    changelogCommandName: 'td changelog',
    brewFormula: 'doist/tap/todoist-cli',
    withSpinner,
})

A CLI published under a dist-tag of its own pins it instead of choosing a channel:

registerUpdateCommand(program, {
    packageName: '@doist/automations-cli',
    currentVersion: packageJson.version,
    configPath: getConfigPath('tda'),
    distTag: 'internal',
    withSpinner,
})

update checks the configured channel's npm dist-tag (stable → latest, pre-release → next), compares against currentVersion, and shells out to npm i -g (or pnpm add -g if npm_execpath indicates pnpm). When the CLI was installed via Homebrew (its binary resolves into a brew Cellar), it runs brew upgrade <brewFormula> instead — set brewFormula on brew-distributed CLIs (the brew formula may lag the npm publish, so an upgrade can be a no-op until the formula is bumped). update switch --stable | --pre-release flips the persisted update_channel field via updateConfig, preserving any sibling keys. Both subcommands accept --json / --ndjson. Errors are CliError (INVALID_FLAGS, UPDATE_CHECK_FAILED, UPDATE_INSTALL_FAILED, or the canonical CONFIG_* codes if the config file is broken).

distTag replaces that channel mapping for a CLI published under a tag of its own (an invite-only CLI on internal, say). It installs from the pinned tag, update switch and --channel are not registered, the config file is never read, and both the human output and the machine record carry distTag where they would otherwise carry channel.

Both actions read --json / --ndjson from the command and its ancestors, so they work whether the consumer declares them on its root program or lets update own them. Nothing else is inherited: a root option that happens to be named --check or --channel has no effect on update.

The semver helpers (parseVersion, compareVersions, isNewer, getInstallTag, fetchLatestVersion, getConfiguredUpdateChannel) are also exported for ad-hoc use outside the registered command.

Extensions (optional subpath)

Give a CLI a gh-style extension system: <bin> extension list | create | install | upgrade | remove | exec, plus <bin> <name> … for every installed extension. An extension is a directory named <bin>-<name> holding an executable of the same name; the host spawns it with stdio inherited and exits with its exit code. Install the peer-deps in the consuming CLI:

npm install commander zod

Everything host-specific arrives through ExtensionManagerOptions:

import { realpathSync } from 'node:fs'
import { dirname } from 'node:path'
import chalk from 'chalk'
import { getConfigPath, getDataDir, getStateDir } from '@doist/cli-core'
import { createExtensionManager, registerExtensionCommands } from '@doist/cli-core/extensions'
import packageJson from '../package.json' with { type: 'json' }

const APP_NAME = 'todoist-cli'

function buildExtensionManager(program: Command) {
    // Snapshot the built-in names before any extension is registered, so an
    // extension can never be measured as shadowing itself.
    const reserved = new Set([
        ...program.commands.flatMap((command) => [command.name(), ...command.aliases()]),
        'extension',
        'ext',
        'completion-server',
    ])

    return createExtensionManager({
        binName: 'td',
        envPrefix: 'TD',
        version: packageJson.version,
        dataDir: getDataDir(APP_NAME),
        stateDir: getStateDir(APP_NAME),
        configDir: dirname(getConfigPath(APP_NAME)),
        hostPath: realpathSync(process.argv[1] ?? ''),
        reservedNames: () => reserved,
        officialSource: { host: 'github.com', owner: 'Doist' },
        officialLabel: 'Todoist',
        // Kept away from npm lifecycle scripts when an extension's
        // dependencies are installed, on top of GH_TOKEN and GITHUB_TOKEN.
        secretEnvVars: ['TODOIST_API_TOKEN'],
        isAccessible,
        theme: { dim: chalk.dim, bold: chalk.bold, green: chalk.green, yellow: chalk.yellow },
    })
}

registerExtensionCommands(program, buildExtensionManager(program))

registerExtensionCommands registers the extension group (alias ext) and one pass-through command per installed extension under an Extensions: help group. Extension names that collide with a built-in are reported as shadowed in list and stay reachable through extension exec.

A CLI that lazy-loads its commands usually wants the two halves separately. registerExtensionGroup is the extension group, loaded only for <bin> extension …. registerExtensionPassThrough discovers what is installed and registers the pass-through commands; it is needed whenever the command token is not a built-in, which is what --help, completion and the unknown-command hint all rely on. The host then dispatches before commander parses, so everything after the extension's name reaches it exactly as typed:

import { findCommandToken, needsExtensionLookup } from '@doist/cli-core'
import { registerExtensionPassThrough } from '@doist/cli-core/extensions'

const rawArgs = process.argv.slice(2)
const { token, index } = findCommandToken(rawArgs)
const builtIn = token ? resolveBuiltInCommand(token) : undefined

let extensions: ExtensionCommands | undefined
if (needsExtensionLookup(rawArgs, builtIn, index)) {
    const manager = buildExtensionManager(program)
    extensions = await registerExtensionPassThrough(program, manager, {
        // One definition of what running an extension means, used whether the
        // entry point dispatches here or commander reaches the registered command.
        dispatch: (name, args, hostArgvLength) =>
            manager.dispatch(name, args, { user: requestedUserRef(hostArgvLength) }),
    })
}

if (token && extensions?.names.has(token)) {
    process.exit(await extensions.dispatch(token, rawArgs.slice(index + 1), index))
}

await program.parseAsync(process.argv)

hostArgvLength is where the host's own arguments stop: td goals --json is --json for goals, not the host's output mode, so a host that reads global flags from process.argv has to stop at that boundary before deciding what to pass on.

The environment an extension receives is the contract extension authors write against: <PREFIX>_EXTENSION=1, <PREFIX>_EXTENSION_NAME, <PREFIX>_EXTENSION_DIR, <PREFIX>_NODE, <PREFIX>_PATH, <PREFIX>_VERSION, <PREFIX>_CONFIG_DIR, <PREFIX>_USER (set or explicitly cleared from DispatchOptions.user), <PREFIX>_ACCESSIBLE (from isAccessible), plus anything the host adds through DispatchOptions.env. Credentials are never injected: an extension calls the host back with "$<PREFIX>_NODE" "$<PREFIX>_PATH" ….

extension create scaffolds from the bash and node templates this package ships (defaultTemplatesDir()), rendered with {{NAME}}, {{DIRNAME}}, {{BIN}}, {{ENV_PREFIX}}, {{VERSION}}, {{DESCRIPTION}} and {{OWNER}}. A host with its own set passes templatesDir to registerExtensionCommands / registerExtensionGroup; inside a template directory, executable becomes <bin>-<name>, extension.json becomes <bin>-extension.json and gitignore becomes .gitignore.

Manifests and the per-extension state file are validated with zod, loaded lazily so it never sits on the --version path. A host that has not installed it gets a CliError-style message naming the fix the first time a manifest is read. Every failure is a CliError with an ExtensionErrorCode (EXTENSION_NOT_FOUND, EXTENSION_NAME_RESERVED, EXTENSION_CHECKSUM_MISMATCH, EXTENSION_NPM_MISSING, …), all folded into CliErrorCode.

For tests, @doist/cli-core/testing exports writeFixtureExtension and writeFakeGitRepo, which put a runnable extension on disk that reports its arguments and <PREFIX>_* environment as JSON.

Auth (optional subpath)

Wire <cli> [auth] login and the supporting OAuth runtime. cli-core ships the standard public-client PKCE flow (createPkceProvider), the RFC 7591 Dynamic Client Registration flow (createDcrProvider), and the attachLoginCommand Commander helper that drives runOAuthFlow end-to-end. Other bespoke flows (device code, magic link, username / password) implement the AuthProvider interface directly — no cli-core release needed. Token storage is a TokenStore the consumer provides; cli-core does not ship a default.

Install

npm install commander open

commander is required when using attachLoginCommand. open is optional. The authorize URL is always surfaced via onAuthorizeUrl (or printed to stdout in human mode, stderr in --json / --ndjson mode) — even when the browser launch succeeds — because the launch can resolve cleanly yet open no actual browser (WSL silent no-op, headless Linux, locked-down corporate envs). WSL hosts get routed through cmd.exe directly so the user's real Windows browser opens. Headless Linux skips the launch entirely and relies on the URL print.

Quick start (PKCE)

import { attachLoginCommand, createPkceProvider } from '@doist/cli-core/auth'
import type { TokenStore } from '@doist/cli-core/auth'

type Account = { id: string; label?: string; email: string }

const store: TokenStore<Account> = createTokenStore() // see "Implementing TokenStore" below

const provider = createPkceProvider<Account>({
    authorizeUrl: ({ handshake }) => `${handshake.baseUrl as string}/oauth/authorize`,
    tokenUrl: ({ handshake }) => `${handshake.baseUrl as string}/oauth/token`,
    clientId: ({ flags }) => flags.clientId as string,
    // Optional: add extra form parameters to the authorization-code token
    // request (e.g. Zendesk's `expires_in` / `refresh_token_expires_in`).
    tokenRequestParams: async ({ handshake, flags }) => ({
        // `handshake` carries the authorize-time state; `flags` carries any
        // runtime CLI flags that shaped the flow.
        expires_in: 172800,
        refresh_token_expires_in: 7776000,
    }),
    validate: async ({ token, handshake }) => probeUser(token, handshake.baseUrl as string),
})

const auth = program.command('auth')
attachLoginCommand<Account>(auth, {
    provider,
    store,
    preferredPort: 54969,
    portFallbackCount: 5,
    resolveScopes: ({ readOnly }) => (readOnly ? ['read'] : ['read', 'write']),
    renderSuccess: () => `<html>...</html>`,
    renderError: (message) => `<html>${message}</html>`,
    onSuccess: ({ account, view }) => {
        if (view.json) console.log(JSON.stringify({ account }))
        else console.log(`Signed in as ${account.label ?? account.id}`)
    },
}).description('Authenticate via OAuth')

attachLoginCommand returns the new Command so you can chain .description(...) / .option(...) / .addHelpText(...). Any consumer-attached options land in the flags object passed to resolveScopes, onSuccess, and the provider hooks.

The authorizeUrl / tokenUrl / clientId resolvers may return string or Promise<string> — so a consumer can resolve the base URL or client id asynchronously (reading config, prompting the user) without abandoning createPkceProvider. tokenRequestParams is the equivalent escape hatch for authorization-code token requests: it is optional, receives the same handshake + flags context as the