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 ≥ 20.18.1 · 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, request-reviews.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 (welcome, update check)
├─ 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/, stats/, completed/, auth/, settings/,
│ │ config/, skill/, hc/, completion/, update/
│ └─ *.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, uploads)
│ └─ skills/ # content.ts (SKILL_CONTENT), create-installer.ts
├─ test-support/
│ ├─ mock-api.ts # createMockApi() — vitest mocks of every SDK method
│ └─ fixtures.ts # Sample task/project/label/section fixtures
└─ types/
└─ marked-terminal-renderer.d.ts # Type declarations for marked-terminal-renderer
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. - The invoked command name is extracted from
process.argv; only its 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. program.parseAsync()runs the command's action handler. UncaughtCliErroris rendered 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. - 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),settings,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,uploads.tsauth.ts—getApiToken(),probeApiToken(),saveApiToken(),clearApiToken(),NoTokenError,AuthProbeResultauth-flags.ts—buildReloginCommand()(rebuildstd auth loginwith--read-only/--additional-scopes=...preserved)config.ts—~/.config/todoist-cli/config.jsonread/write,AuthMode,UpdateChannel,AUTH_FLAG_ORDERsecure-store.ts—@napi-rs/keyringwrapper (OS credential manager)oauth-server.ts/oauth.ts/oauth-scopes.ts/pkce.ts— OAuth flowoutput.ts—formatTaskRow,formatTaskView,formatJson,formatNdjson,formatPaginatedJson,formatDueDate,formatPriority,formatError,formatErrorJson,printDryRunrefs.ts—isIdRef,extractId,looksLikeRawId,lenientIdRef,resolveTaskRef,resolveProjectRef,resolveProjectId,resolveSectionId,resolveParentTaskId,resolveWorkspaceRef,resolveFolderRef,resolveAppRef,parseTodoistUrl,classifyTodoistUrlurls.ts—taskUrl,projectUrl,labelUrl,sectionUrl,commentUrl,filterUrltask-list.ts—fetchProjects,filterByWorkspaceOrPersonal,parsePriority,PRIORITY_CHOICES("p1"–"p4"; internally p1→4, p4→1)pagination.ts—paginate(),LIMITS(tasks: 300, projects: 50, …)completion.ts—parseCompLine,getCompletions,withCaseInsensitiveChoices,withUnvalidatedChoices(Commander tree-walker)spinner.ts—startEarlySpinner,LoadingSpinnerclass (yocto-spinner wrapper)markdown.ts—preloadMarkdown, markdown → terminal renderererrors.ts—CliError(code, message, hints?),ErrorTypeunioncollaborators.ts—CollaboratorCache,formatAssignee,resolveAssigneeIdglobal-args.ts—isJsonMode,isNdjsonMode,isRawMode,isQuiet,isAccessible, progress-jsonl targetlogger.ts— verbose levels 0–4,initializeLoggerdates.ts/duration.ts— date filters,"2h30m"parsing/formattingpermissions.ts— collaborator permission parsinghelp-center.ts— Help Center article search/fetchprogress.ts—--progress-jsonlJSONL event writerusage-tracking.ts— request markers (User-Agent,doist-*,X-TD-*, includingX-TD-CLI-Command), session/request ids, command pathbrowser.ts/stdin.ts/update.ts— small single-purpose helpersskills/content.ts—SKILL_NAME,SKILL_DESCRIPTION,SKILL_CONTENT
- 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 → exact name → partial substring → raw ID fallback. 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.
Token lookup order (see src/lib/auth.ts — getApiToken() / probeApiToken()):
TODOIST_API_TOKENenv var~/.config/todoist-cli/config.json({ "api_token": "..." }) — migrated into secure-store on first read when present- OS credential manager via
src/lib/secure-store.ts
td auth login runs a full OAuth PKCE flow (src/lib/oauth-server.ts,
DEFAULT_PORT = 8765 with a small fallback range, browser launch). Scopes
are opt-in: --read-only for a read-only token,
--additional-scopes=app-management,backups to broaden.
- 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. - Pattern: mock
getApiviavi.mock, thenprogram.parseAsync(['node','td','<cmd>',…]).
- 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 (
add/create/update): always support--jsonemittingformatJson(result, entityType)— see AGENTS.md - User-facing errors: throw
CliError(code, message, hints?)fromsrc/lib/errors.ts; globalparseAsync().catchinsrc/index.tsrenders it - 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