@doist/cli-core

中文文档
1.10.1发布于 2026/11/11MITTypeScriptES Module构建来源已验证近 30 天下载 3.2万

Shared core utilities for Doist CLI projects

此软件包已被标记为弃用

Moved to @todoist/cli-core. Replace @doist/cli-core with @todoist/cli-core in your dependencies and imports.

原始使用文档
文档目录10 节
  1. Install
  2. What's in it
  3. Usage
  4. Global args + spinner gate
  5. Empty-state print
  6. Reading piped stdin
  7. Testing helpers (subpath)
  8. Markdown rendering (optional subpath)
  9. Standard commands (optional subpath)
  10. Extensions (optional subpath)

@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

ModuleKey exportsPurpose
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 / AttachAccountRemoveContextOAuth 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-tokenfindCommandToken, needsExtensionLookup, CommandTokenFind 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.
configgetConfigPath, readConfig, readConfigStrict, writeConfig, updateConfig, CoreConfig, UpdateChannelRead / 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).
emptyprintEmptyPrint an empty-state message gated on --json / --ndjson / --ids-only so machine consumers never see human strings on stdout.
errorsCliErrorTyped 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 typesA 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-argsparseGlobalArgs, stripUserFlag, createGlobalArgsStore, createAccessibleGate, createSpinnerGate, getProgressJsonlPath, isProgressJsonlEnabledParse 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.
idsformatIds, outputIdsFormat or emit one stable string or numeric ID per line. Empty results stay silent; optional pagination notices go to stderr.
jsonformatJson, formatNdjsonStable JSON / newline-delimited JSON formatting for stdout.
markdown (subpath)preloadMarkdown, renderMarkdown, TerminalRendererOptionsLazy-init terminal markdown renderer. Requires marked and marked-terminal-renderer as peer-deps — install only if your CLI uses this subpath.
optionsOUTPUT_MODES, OutputMode, ViewOptions, ListViewOptions, resolveOutputModeCanonical output-mode contracts; ListViewOptions adds idsOnly?, and the resolver rejects conflicting machine-output flags.
pathsgetDataDir, getStateDirPer-user data and state directories for an app name, XDG variables first and platform defaults second, alongside getConfigPath.
spinnercreateSpinnerLoading spinner factory wrapping yocto-spinner with disable gates.
stdinreadStdin, StdinErrorCodeReads piped stdin to the end for a --stdin flag; throws STDIN_IS_TTY on an interactive terminal instead of waiting for EOF.
terminalisCI, isStderrTTY, isStdinTTY, isStdoutTTYTTY / CI detection helpers.
testing (subpath)describeEmptyMachineOutput, createTestProgram, captureConsole, captureStream, mockStdin, writeFixtureExtension, writeFakeGitRepo, buildTokenStore, buildSingleEntryStore, ingenEntries, alanGrant / ellieSattler / ianMalcolm, TestAccount / StoreEntry / TokenStoreHarness / MatchAccount / MockStdinOptions typesVitest 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-trackingcreateUsageTracking, UsageTracking, UsageTrackingOptions, UsageHeadersOptions, CommandLikeThe 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

版本与发布记录

@doist/cli-core 共发布 48 个版本,首次发布于 2026/05/07;过去 12 个月发布了 48 个版本。最新版本附带 npm provenance 构建来源证明,可追溯到公开的 CI 构建记录。

版本发布日期距今
1.10.1latest2026/11/112 天前
1.10.02026/11/112 天前
1.9.02026/11/112 天前
1.8.02026/11/076 天前
1.7.22026/10/2420 天前
1.7.12026/10/2420 天前

关于 @doist/cli-core 的常见问题

@doist/cli-core 是什么?

Shared core utilities for Doist CLI projects注意:该包已被作者标记为弃用——Moved to @todoist/cli-core. Replace @doist/cli-core with @todoist/cli-core in your dependencies and imports.

如何安装 @doist/cli-core?

在项目目录运行 npm install @doist/cli-core,或使用 pnpm add / yarn add / bun add。

@doist/cli-core 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@doist/cli-core 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@doist/cli-core 需要什么版本的 Node.js?

package.json 的 engines 字段要求 Node.js >=24。版本不符时 npm 默认只给出警告,开启 engine-strict 后会拒绝安装。

@doist/cli-core 可以商用吗?

@doist/cli-core 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

@doist/cli-core 的下载量有多少?

根据 npm 官方下载统计,@doist/cli-core 在 2026/10/13 – 2026/11/11 这 30 天内被下载 31,651 次,日均约 1,055 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@doist/cli-core 还在维护吗?

最新版本 1.10.1 发布于 2026/11/11(2 天前);至今共发布 48 个版本,其中过去 12 个月发布了 48 个;首次发布于 2026/05/07。作者已将其标记为弃用,新项目建议寻找替代方案。