@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 / SetBundleOptions / 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. |
stdin | readStdin, StdinErrorCode | Reads piped stdin to the end for a --stdin flag; throws STDIN_IS_TTY on an interactive terminal instead of waiting for EOF. |
terminal | isCI, isStderrTTY, isStdinTTY, isStdoutTTY | TTY / CI detection helpers. |
testing (subpath) | describeEmptyMachineOutput, createTestProgram, captureConsole, captureStream, mockStdin, writeFixtureExtension, writeFakeGitRepo, buildTokenStore, buildSingleEntryStore, ingenEntries, alanGrant / ellieSattler / ianMalcolm, TestAccount / StoreEntry / TokenStoreHarness / MatchAccount / MockStdinOptions 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); mockStdin, which swaps process.stdin for a pipe (or a terminal with isTTY: true) and restores it after the test; 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-tracking | createUsageTracking, UsageTracking, UsageTrackingOptions, UsageHeadersOptions, CommandLike | The request headers every Doist CLI sends so backends can attribute API traffic: User-Agent: <cli>/<version>, doist-platform: cli, doist-version, doist-os (macos/linux/windows/unknown), a per-request request-id UUID, a session-id UUID per tracker (create one tracker per process so it groups one CLI run), cli-command (the active command as a low-cardinality label such as task:add), and cli-agent (the AI coding agent running the CLI, read once from the environment when the tracker is created: claude-code, codex, cursor, gemini, copilot, opencode, other for an agent outside that list, or none). Call setCommand(actionCommand) from a preAction hook (it takes any CommandLike, so the root entry needs no commander), then pass headers() to the SDK client or mergeHeaders(init) into a raw fetch. request-id is separate from the SDKs' X-Request-Id, which is an idempotency key sent only on some writes. |
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
}
Reading piped stdin
readStdin backs a command's --stdin flag. When nothing is piped in (stdin is a terminal) it throws STDIN_IS_TTY instead of waiting for an EOF that never comes. It returns the input untrimmed, so the command decides what empty input means.
import { CliError, readStdin } from '@doist/cli-core'
if (options.stdin && options.description !== undefined) {
throw new CliError('CONFLICTING_OPTIONS', 'Cannot use both --description and --stdin')
}
const description = options.stdin ? await readStdin() : options.description
In tests, mockStdin from @doist/cli-core/testing stands in for the pipe:
mockStdin('# Notes\n\nBody.\n') // or mockStdin('', { isTTY: true }) for a terminal
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/todoist/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 { getConfigPath, getDataDir, getStateDir } from '@doist/cli-core'
import { createExtensionManager, registerExtensionCommands } from '@doist/cli-core/extensions'
import chalk from 'chalk'
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: 'todoist' },
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 discov