Purpose: a ~2000-token orientation file so Claude (and humans) can navigate this repo without exploring. Describes what is where;
AGENTS.mddescribes how to change things. Update when structure shifts, not on every new file.
@doist/todoist-cli is a TypeScript CLI for Todoist. Binary name: td. It
wraps @doist/todoist-sdk and publishes a single executable (dist/index.js).
ESM-only · Node ≥ 24 · npm ≥ 11 · Commander 14 · vitest · oxlint + oxfmt (no
eslint/prettier) · semantic-release on merge to main.
/
├─ src/ # All source. See tree below.
├─ scripts/ # sync-skill.js, check-skill-sync.js, postinstall.js
├─ dist/ # Build output (tsc). Never edit.
├─ skills/todoist-cli/ # Generated SKILL.md (from src/lib/skills/content.ts)
├─ .github/workflows/ # test.yml, lint.yml, release.yml, check-skill-sync.yml,
│ # check-semantic-pull-request.yml, update-todoist-sdk.yml,
│ # issue-automation.yml
├─ AGENTS.md # Prescriptive rules (build cmds, skill-sync, JSON flag)
├─ CODEBASE.md # This file — descriptive map
├─ CLAUDE.md # One-liner forward to AGENTS.md
├─ tsconfig.json # Includes src + tests (used by type-check, IDE)
├─ tsconfig.build.json # Excludes *.test.ts + test-support/ (used by build/dev)
├─ vitest.config.ts # Test runner config
├─ .oxlintrc.json / .oxfmtrc.json
├─ lefthook.yml # Pre-commit hooks
└─ release.config.js # semantic-release config
src/
├─ index.ts # Entry: Commander setup, lazy command registry, early spinner
├─ postinstall.ts # Runs after npm install (skill refresh, auth migration)
├─ commands/ # One file per flat command, one folder per group
│ ├─ add.ts, today.ts, upcoming.ts, inbox.ts, view.ts,
│ │ doctor.ts, changelog.ts, activity.ts, attachment.ts
│ ├─ task/, project/, label/, comment/, section/, filter/,
│ │ reminder/, workspace/, folder/, notification/, template/,
│ │ backup/, apps/, billing/, stats/, completed/, auth/, settings/, status/,
│ │ config/, skill/, hc/, completion/, update/, extension/
│ └─ *.test.ts # Co-located tests
├─ lib/ # Shared utilities. See catalog — don't reimplement.
│ ├─ api/ # SDK wrapper + typed helpers (core, filters, workspaces,
│ │ # notifications, reminders, stats, user-settings, user-status, uploads)
│ └─ skills/ # content.ts (SKILL_CONTENT), create-installer.ts
└─ test-support/
├─ mock-api.ts # createMockApi() — vitest mocks of every SDK method
├─ api-mock.ts # setupApiMock() — wires getApi() to a fresh mock API
└─ fixtures.ts # Sample task/project/label/section fixtures
# (program harness + console/stream spies now come
# from @doist/cli-core/testing)
The extension system itself lives in @doist/cli-core/extensions, scaffold
templates included; commands/extension/ is only the td-specific glue (binary
name, directories, first-party source, colours, doctor checks).
src/index.tssetsprogram.name('td'), registers global flags (--quiet,--accessible,--progress-jsonl,-v/--verbose,--no-spinner), and builds a lazy command registry — aRecord<name, [description, loader]>.- Placeholder subcommands are registered so
--helplists everything without importing anything. findCommandToken()(from@doist/cli-core) walks argv from the start, stepping over global flags and the values of the two that take one, and stops at the first remaining token. Nothing after it is inspected — that belongs to whatever the token names.- When the token does not name a built-in, the installed extensions are
discovered and registered as commands (
commands/extension/dispatch.ts). A built-in always wins, sotd task listskips this and never loads the extension system at all. If the token names an extension, it is spawned here, before commander parses, and the process exits with its exit code — which is what makes everything after the name reach it exactly as typed. - Otherwise only the token's loader runs (
./commands/<name>.jsfor flat commands,./commands/<name>/index.jsfor groups), then the realregisterXxxCommand(program)replaces the placeholder. - If output will be human-readable,
preloadMarkdown()runs in parallel with the command import.startEarlySpinner()covers the import latency. td --help --jsonloads the command tree and serializes it withlib/command-schema.ts. After normal lazy registration,lib/mutation-json.tsadds JSON success envelopes to mutations that do not return an entity.program.parseAsync()runs the command's action handler. UncaughtCliErroris rendered byreportFatal()viaformatError()orformatErrorJson()depending onisJsonMode().
- Flat command (e.g.
today.ts): exportsregisterTodayCommand(program)that callsprogram.command('today')and attaches an action handler. - Group command (e.g.
task/):index.tsexportsregisterTaskCommand(program), createsconst task = program.command('task'), then callstask.command('<sub>')for each subcommand — each subcommand's logic lives in a sibling file (task/add.ts,task/list.ts, …) re-imported byindex.ts. Shared helpers live intask/helpers.ts. - Pass-through command (extensions): registered by
@doist/cli-core/extensionswith.allowUnknownOption(),.allowExcessArguments(),.helpOption(false)and.helpGroup('Extensions:'), so commander touches nothing after the name and--helplists them under their own heading. Not.passThroughOptions(): commander requiresenablePositionalOptions()on the parent for that, which makes every ordinary command reject the global flags it accepts today. - Implicit
viewsubcommand: most group commands register.command('view [ref]', { isDefault: true })sotd project <ref>dispatches totd project view <ref>. Same for task, workspace, comment, notification, filter, label, folder, apps, attachment, hc.
Command files are flat/kebab-case in src/commands/. Subcommand enumeration
lives in src/lib/skills/content.ts (SKILL_CONTENT) — don't duplicate here.
- Tasks — add, list, view, complete, uncomplete, delete, move, reschedule,
quickadd, browse, update (+ top-level
addquick-task shortcut) - Projects — incl. archive/unarchive, join, collaborators, permissions, activity-stats, health, analyze-health
- Labels, Sections, Comments, Filters, Reminders, Folders, Templates — standard CRUD + browse
- Workspaces — list/view + workspace users + access management
- Notifications — list/view/accept/reject/dismiss
- Productivity & activity —
activity,stats,completed - Top-level views —
today,upcoming,inbox,view(URL router) - Infra —
auth(login/logout/token/status),accounts(multi-user list/use/current/remove; aliasesuser/users),settings,status,apps,backup,attachment,hc(Help Center),skill,completion,update,doctor,changelog
New subcommand? Copy a sibling in the target group, wire it in that group's
index.ts, update SKILL_CONTENT, run npm run sync:skill. See AGENTS.md.
api/core.ts—getApi()(SDK client factory), re-exportsTask,Project,Section,User. Paginated response shape{ results, nextCursor }lives inpagination.ts.api/siblings —filters.ts,workspaces.ts,notifications.ts,reminders.ts,stats.ts,user-settings.ts,user-status.ts,uploads.tsauth.ts— read-side resolver:resolveActiveUser,getApiToken,probeApiToken,getAuthMetadata,listStoredUsers,NoTokenError. All write/clear paths go throughauth-store.ts.users.ts— config-side account helpers shared across commands:matchUserRef/findUserByRef/requireUserByRef, the stored-user mutators (upsert/remove/set/clearDefaultUser),getDefaultUserId(raw pinned pointer) andgetEffectiveDefaultUserId(pinned-else-sole-account, used for the(default)marker inaccounts list/current,auth status,config view).- Extensions — the whole system (
createExtensionManager, theregisterExtension*registrars, discovery, install, upgrade, remove, dispatch, scaffolding) is@doist/cli-core/extensions. Everything td-specific — binary name,getDataDir(APP_NAME)/getStateDir(APP_NAME)from cli-core, first-party source,secretEnvVars, colours — is decided incommands/extension/index.ts;commands/extension/dispatch.tsadds the argv boundary and theTD_*flags to a dispatch.findCommandToken()andneedsExtensionLookup()also come from cli-core. Seedocs/specs/extensions.md. auth-flags.ts—buildReloginCommand()(rebuildstd auth loginwith--read-only/--additional-scopes=...preserved)config.ts—~/.config/todoist-cli/config.jsonread/write,stripLegacyAuthFields,AuthMode,UpdateChannel,AUTH_FLAG_ORDER.auth-provider.ts—createTodoistAuthProvider(): cli-core PKCE provider with TodoistvalidateToken(buildsauth_mode/auth_scope/auth_flagsfromgetUser).auth-store.ts—createTodoistTokenStore()(cli-corecreateKeyringTokenStorewired to theUserRecordStoreadapter), persisted identifier constants (SERVICE_NAME,LEGACY_ACCOUNT,accountForUser), andtoTodoistAccount()mapper.user-records.ts—UserRecordStore<TodoistAccount>adapter over the config file. REPLACE-not-mergeupsert;ensureV2on every write.migrate-auth.ts— postinstall v1 → v2 migration; thin wrapper around cli-core'smigrateLegacyAuthwith the Todoist callbacks.auth-html.ts— branded HTML pages for the cli-core OAuth callback (renderAuthSuccessPage/renderAuthErrorPage)oauth-scopes.ts— opt-in OAuth scope registry,parseScopesOption,extractAdditionalScopes,resolveAuthScope,formatScopesHelpoutput.ts—formatTaskRow,formatTaskView,formatJson,formatNdjson,formatPaginatedJson,processJsonItem(essential-field picker used by JSON callers that build composite payloads),formatChildrenBlock,processChildrenJson,formatDueDate,formatPriority,formatError,formatErrorJson,printDryRun. List commands import cli-core'soutputIdsandresolveOutputModedirectly to print IDs and validate human, JSON, NDJSON, and IDs-only output.children.ts—--include-childrensupport:getTaskChildren()(parentId query plus a nesting probe per child),buildProjectChildren()(pure; groups a pre-fetched personal-project list),resolveChildren()(never throws — reports viachildrenError),CHILDREN_MAXrefs.ts—isIdRef,extractId,looksLikeRawId,lenientIdRef,resolveTaskRef,resolveProjectRef,resolveProjectId,resolveSectionId,resolveParentTaskId,resolveWorkspaceRef,resolveFolderRef,resolveAppRef,resolveFromList,parseTodoistUrl,classifyTodoistUrlreorder.ts—validateReorderPlacement()for shared--before/--after/--positionvalidation.urls.ts—taskUrl,projectUrl,labelUrl,sectionUrl,commentUrl,filterUrltask-list.ts—fetchProjects,filterByWorkspaceOrPersonal,parsePriority,PRIORITY_CHOICES("p1"–"p4"; internally p1→4, p4→1)task-sort.ts— the CLI half of task ordering:TASK_SORT_FIELDS(the--sortvocabulary),taskSortFromViewOptions,buildProjectOrder, and asortTaskswrapper. The comparators live in the SDK; the API returns storage order, so every list view sorts locally. Whether a filter query is date-driven is the SDK'sisDateDrivenQuery, which needs the account language fromgetAccountLanguage(lib/api/core.ts).pagination.ts—paginate(),LIMITS(tasks: 300, projects: 50, …)completion.ts—parseCompLine,getCompletions,withCaseInsensitiveChoices,withUnvalidatedChoices(Commander tree-walker)command-schema.ts/mutation-json.ts— machine-readable help tree and JSON success envelopes for mutations without an entity resulttext.ts—truncateForDisplay: code-point-safe truncation. Use it instead oftext.slice(0, n)on user-supplied text to avoid splitting a surrogate pair. Name initials come from the SDK'sformatUserShortName.spinner.ts—startEarlySpinner,LoadingSpinnerclass (yocto-spinner wrapper)markdown.ts—preloadMarkdown, markdown → terminal renderererrors.ts—CliError(code, message, hints?),ErrorTypeunioncollaborators.ts—CollaboratorCache,formatAssignee,resolveAssigneeId,fetchCollaboratorsForProject,resolveNotifyIdscomment-recipients.ts—getDefaultCommentRecipients: who a new comment notifies when the caller does not sayglobal-args.ts— extends cli-core's canonical machine-output parsing withisJsonMode,isNdjsonMode,isIdsOnlyMode,isRawMode,isQuiet,isAccessible, and the Todoist progress-jsonl targetlogger.ts— verbose levels 0–4,initializeLoggerdates.ts/duration.ts— date filters,"2h30m"parsing/formatting;parseLocalDate(strict ISO, bare = local time) andparseDurationMs(30m/2h/1d) for thestatus setexpiry flagspermissions.ts— collaborator permission parsinghelp-center.ts— Help Center article search/fetchprogress.ts—--progress-jsonlJSONL event writerlocal-file.ts—openLocalFileAsBlob(): file-backedBlobfor multipart uploads (used bycomment add --file,template create --file,template import-file). Maps fs errors toFILE_NOT_FOUND/FILE_READ_ERRORCliErrors.usage-tracking.ts— request/session metadata headers, CLI command attribution, tracked fetch wrappersbrowser.ts/stdin.ts/update.ts— small single-purpose helpersskills/content.ts—SKILL_NAME,SKILL_DESCRIPTION,SKILL_CONTENTskills/refresh-on-upgrade.ts— refreshes installed skills once per upgrade from thepreActionhook (npm 11 can skippostinstall), keyed by askills-versionstamp in the state dir
- Simple read:
src/commands/today.ts—getApi()→ filter query →paginate()→formatTaskRow(). Supports--json,--ndjson,--workspace,--personal,--cursor,--limit. - Write with
--json:src/commands/task/add.ts— resolves project/section/parent refs viarefs.tsand the assignee viaresolveAssigneeId()fromsrc/lib/collaborators.ts, callsapi.addTask(), outputsformatJson(result, 'task')when--json, else human confirmation. - Grouped command:
src/commands/project/index.ts+ siblings — implicit-defaultview, sibling files per subcommand,project/helpers.tsfor shared logic.
All live in src/lib/refs.ts:
- Full name resolution (
resolveProjectRef,resolveTaskRef, …) — async, returns the full entity. Tries URL →id:prefix → raw ID (iflooksLikeRawId; a 404/400 falls through) → exact name → partial substring. The ID goes first so a bare ID never resolves to another entity whose name contains it. Use for entities with user-facing names. Add new wrappers inrefs.ts; the internalresolveRefis private. - ID-only validation (
lenientIdRef) — sync, no API call, returns an ID string. Triesid:prefix → URL → raw ID → error. Use for entities without afetchAllendpoint (comments, reminders). - Context-scoped (
resolveSectionId,resolveParentTaskId,resolveWorkspaceRef) — async, searches within a parent context.
looksLikeRawId() decides when a ref should be tried as an ID: pure-alpha
("Work") and spaced strings are names; mixed alphanumeric without spaces
("abc123") are potential IDs. IDs (raw or id:) must match
[A-Za-z0-9_-]+: they become URL path segments, so extractId rejects
anything else with INVALID_REF.
@doist/cli-core/auth owns the keyring, multi-user TokenStore, OAuth flow,
and the login / logout / status / token view registrars, plus the
account list / account use attachers that back td accounts list and
td accounts use (commands/user/{list,use}.ts). todoist-cli supplies a
UserRecordStore<TodoistAccount> adapter (user-records.ts) over its config
file plus a Todoist-specific validateToken (auth-provider.ts).
Read path (auth.ts — resolveActiveUser / getApiToken / probeApiToken):
env TODOIST_API_TOKEN first, then a config-derived target user, then either
StoredUser.api_token (plaintext keyring-offline fallback) or cli-core's
createSecureStore for the keyring slot.
Write/clear/list all route through createTodoistTokenStore(); commands never
write the config directly. auth logout and auth token view use
withUserRefAware (commands/auth/store-wrap.ts) to substitute the
global --user <ref> that index.ts strips from argv before commander runs.
v1 → v2 migration (migrate-auth.ts → cli-core's migrateLegacyAuth) runs
from postinstall.ts. Gate is config.config_version === CONFIG_VERSION,
which survives logout so a reinstall over a logged-out v2 install can't
re-migrate a stale legacy slot.
- Runner: vitest.
npm test(one-shot),npm run test:watch. - Location: co-located
*.test.tsnext to the module under test. - Mocks:
src/test-support/mock-api.ts—createMockApi()returns vitest-mocked versions of every SDK method. Use factories fromsrc/test-support/fixtures.ts— do NOT hand-build mock entities. - Helpers:
setupApiMock()(api-mock.ts) creates a mock API and wiresgetApi()to it. The Commander harnesscreateTestProgram(register)and the output spiescaptureConsole(method?)/captureStream(stream?)come from the shared@doist/cli-core/testingsubpath (not local files). - Pattern: still
vi.mock('…/api/core.js')(hoisted) per file, then inbeforeEachmockApi = setupApiMock(); build withcreateTestProgram(registerXCommand)and runprogram.parseAsync(['node','td','<cmd>',…]). - Auto-restore:
vitest.config.tssetsrestoreMocks: true, so spies are restored between tests automatically — do NOT add manual.mockRestore(). @doist/cli-coreinlining:vitest.config.tslists@doist/cli-coreinserver.deps.inlinesovi.doMock('node:fs/promises', …)/vi.doMock('node:os', …)reach cli-core's compiled imports. Without it vitest treats the package as external and Node's native resolver bypasses the mock substitution, breaking theauth/migrate-authsuites.
- Build:
tsc -p tsconfig.build.json(dist/). Two-tsconfig setup:tsconfig.jsonincludes tests for type-check/IDEs;tsconfig.build.jsonexcludes*.test.ts+src/test-support/so test-only code stays out ofdist/. - Dev:
npm run dev(watch mode). - Type-check:
npm run type-check(runstsc --noEmit). - Lint/format:
npm run check(oxlint src && oxfmt --check),npm run fix(oxlint src --fix && oxfmt). No ESLint, no Prettier. - Pre-commit: lefthook (
lefthook.yml). - Release: semantic-release on merge to
main(.github/workflows/release.yml). Commits must follow Conventional Commits — enforced bycheck-semantic-pull-request.yml.
src/lib/skills/content.ts is the source of truth for every command
reference shown to AI agents. The build/sync chain:
- Edit
SKILL_CONTENTwhen commands/flags change. npm run sync:skill→scripts/sync-skill.js→ writesskills/todoist-cli/SKILL.md.td skill update claude-code(and other installed agents) propagates the update to installed skill files..github/workflows/check-skill-sync.ymlrunsnpm run check:skill-syncon PRs — fails ifSKILL.mdis out of sync withcontent.ts.
See AGENTS.md for the exact update rule.
node dist/index.js --help
node dist/index.js today
node dist/index.js <cmd> ...Uses the same token lookup as the installed td binary — env var, config
file, or a token stored in the OS credential manager via td auth login.
- Filenames: kebab-case (
find-completed-tasks.ts) - No barrel files except per-group
index.tswiring Commander - Priority:
"p1"–"p4"strings in CLI; API uses 4=p1 (highest) → 1=p4 - API responses: always destructure
{ results, nextCursor }from the SDK - Mutating commands that return an entity (
add/create/update) support--jsonemittingformatJson(result, entityType); no-entity writes use the envelope frommutation-json.ts— see AGENTS.md - User-facing errors: throw
CliError(code, message, hints?)fromsrc/lib/errors.ts; the globalparseAsync().catchinsrc/index.tsrenders it. The same handler also catchesBaseCliError(re-exported fromsrc/lib/errors.ts) so errors thrown by@doist/cli-corehelpers route through the same path - Missing positional/required input: call
failWithUsage(command)fromsrc/lib/missing-argument.ts, never a barecommand.help()(which prints to stdout and exits 0). It exits 1 with help on stderr, or throwsMISSING_ARGUMENTunder--json/--ndjson - Global flags handled in
src/lib/global-args.ts— checkisJsonMode()etc. before printing
src/index.ts— entry + command registrysrc/commands/today.ts— canonical readsrc/commands/task/add.ts— canonical write with--jsonsrc/commands/project/index.ts— canonical group commandsrc/lib/refs.ts+src/lib/output.ts— what's already builtAGENTS.md— rules you must follow