Skip to content

Latest commit

 

History

History
402 lines (357 loc) · 22.3 KB

CODEBASE.md

File metadata and controls

402 lines (357 loc) · 22.3 KB

CODEBASE.md — Repo Map

Purpose: a ~2000-token orientation file so Claude (and humans) can navigate this repo without exploring. Describes what is where; AGENTS.md describes how to change things. Update when structure shifts, not on every new file.

What this project is

@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.

Top-level layout

/
├─ 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/ tree

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).

Architecture flow

  1. src/index.ts sets program.name('td'), registers global flags (--quiet, --accessible, --progress-jsonl, -v/--verbose, --no-spinner), and builds a lazy command registry — a Record<name, [description, loader]>.
  2. Placeholder subcommands are registered so --help lists everything without importing anything.
  3. 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.
  4. 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, so td task list skips 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.
  5. Otherwise only the token's loader runs (./commands/<name>.js for flat commands, ./commands/<name>/index.js for groups), then the real registerXxxCommand(program) replaces the placeholder.
  6. If output will be human-readable, preloadMarkdown() runs in parallel with the command import. startEarlySpinner() covers the import latency.
  7. td --help --json loads the command tree and serializes it with lib/command-schema.ts. After normal lazy registration, lib/mutation-json.ts adds JSON success envelopes to mutations that do not return an entity.
  8. program.parseAsync() runs the command's action handler. Uncaught CliError is rendered by reportFatal() via formatError() or formatErrorJson() depending on isJsonMode().

Command registration pattern

  • Flat command (e.g. today.ts): exports registerTodayCommand(program) that calls program.command('today') and attaches an action handler.
  • Group command (e.g. task/): index.ts exports registerTaskCommand(program), creates const task = program.command('task'), then calls task.command('<sub>') for each subcommand — each subcommand's logic lives in a sibling file (task/add.ts, task/list.ts, …) re-imported by index.ts. Shared helpers live in task/helpers.ts.
  • Pass-through command (extensions): registered by @doist/cli-core/extensions with .allowUnknownOption(), .allowExcessArguments(), .helpOption(false) and .helpGroup('Extensions:'), so commander touches nothing after the name and --help lists them under their own heading. Not .passThroughOptions(): commander requires enablePositionalOptions() on the parent for that, which makes every ordinary command reject the global flags it accepts today.
  • Implicit view subcommand: most group commands register .command('view [ref]', { isDefault: true }) so td project <ref> dispatches to td project view <ref>. Same for task, workspace, comment, notification, filter, label, folder, apps, attachment, hc.

Commands catalog (grouped domains)

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 add quick-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; aliases user/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.

src/lib/ catalog — don't reimplement

  • api/core.ts — getApi() (SDK client factory), re-exports Task, Project, Section, User. Paginated response shape { results, nextCursor } lives in pagination.ts.
  • api/ siblings — filters.ts, workspaces.ts, notifications.ts, reminders.ts, stats.ts, user-settings.ts, user-status.ts, uploads.ts
  • auth.ts — read-side resolver: resolveActiveUser, getApiToken, probeApiToken, getAuthMetadata, listStoredUsers, NoTokenError. All write/clear paths go through auth-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) and getEffectiveDefaultUserId (pinned-else-sole-account, used for the (default) marker in accounts list/current, auth status, config view).
  • Extensions — the whole system (createExtensionManager, the registerExtension* 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 in commands/extension/index.ts; commands/extension/dispatch.ts adds the argv boundary and the TD_* flags to a dispatch. findCommandToken() and needsExtensionLookup() also come from cli-core. See docs/specs/extensions.md.
  • auth-flags.ts — buildReloginCommand() (rebuilds td auth login with --read-only / --additional-scopes=... preserved)
  • config.ts — ~/.config/todoist-cli/config.json read/write, stripLegacyAuthFields, AuthMode, UpdateChannel, AUTH_FLAG_ORDER.
  • auth-provider.ts — createTodoistAuthProvider(): cli-core PKCE provider with Todoist validateToken (builds auth_mode / auth_scope / auth_flags from getUser).
  • auth-store.ts — createTodoistTokenStore() (cli-core createKeyringTokenStore wired to the UserRecordStore adapter), persisted identifier constants (SERVICE_NAME, LEGACY_ACCOUNT, accountForUser), and toTodoistAccount() mapper.
  • user-records.ts — UserRecordStore<TodoistAccount> adapter over the config file. REPLACE-not-merge upsert; ensureV2 on every write.
  • migrate-auth.ts — postinstall v1 → v2 migration; thin wrapper around cli-core's migrateLegacyAuth with 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, formatScopesHelp
  • output.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's outputIds and resolveOutputMode directly to print IDs and validate human, JSON, NDJSON, and IDs-only output.
  • children.ts — --include-children support: getTaskChildren() (parentId query plus a nesting probe per child), buildProjectChildren() (pure; groups a pre-fetched personal-project list), resolveChildren() (never throws — reports via childrenError), CHILDREN_MAX
  • refs.ts — isIdRef, extractId, looksLikeRawId, lenientIdRef, resolveTaskRef, resolveProjectRef, resolveProjectId, resolveSectionId, resolveParentTaskId, resolveWorkspaceRef, resolveFolderRef, resolveAppRef, resolveFromList, parseTodoistUrl, classifyTodoistUrl
  • reorder.ts — validateReorderPlacement() for shared --before / --after / --position validation.
  • urls.ts — taskUrl, projectUrl, labelUrl, sectionUrl, commentUrl, filterUrl
  • task-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 --sort vocabulary), taskSortFromViewOptions, buildProjectOrder, and a sortTasks wrapper. 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's isDateDrivenQuery, which needs the account language from getAccountLanguage (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 result
  • text.ts — truncateForDisplay: code-point-safe truncation. Use it instead of text.slice(0, n) on user-supplied text to avoid splitting a surrogate pair. Name initials come from the SDK's formatUserShortName.
  • spinner.ts — startEarlySpinner, LoadingSpinner class (yocto-spinner wrapper)
  • markdown.ts — preloadMarkdown, markdown → terminal renderer
  • errors.ts — CliError(code, message, hints?), ErrorType union
  • collaborators.ts — CollaboratorCache, formatAssignee, resolveAssigneeId, fetchCollaboratorsForProject, resolveNotifyIds
  • comment-recipients.ts — getDefaultCommentRecipients: who a new comment notifies when the caller does not say
  • global-args.ts — extends cli-core's canonical machine-output parsing with isJsonMode, isNdjsonMode, isIdsOnlyMode, isRawMode, isQuiet, isAccessible, and the Todoist progress-jsonl target
  • logger.ts — verbose levels 0–4, initializeLogger
  • dates.ts / duration.ts — date filters, "2h30m" parsing/formatting; parseLocalDate (strict ISO, bare = local time) and parseDurationMs (30m/2h/1d) for the status set expiry flags
  • permissions.ts — collaborator permission parsing
  • help-center.ts — Help Center article search/fetch
  • progress.ts — --progress-jsonl JSONL event writer
  • local-file.ts — openLocalFileAsBlob(): file-backed Blob for multipart uploads (used by comment add --file, template create --file, template import-file). Maps fs errors to FILE_NOT_FOUND / FILE_READ_ERROR CliErrors.
  • usage-tracking.ts — request/session metadata headers, CLI command attribution, tracked fetch wrappers
  • browser.ts / stdin.ts / update.ts — small single-purpose helpers
  • skills/content.ts — SKILL_NAME, SKILL_DESCRIPTION, SKILL_CONTENT
  • skills/refresh-on-upgrade.ts — refreshes installed skills once per upgrade from the preAction hook (npm 11 can skip postinstall), keyed by a skills-version stamp in the state dir

Canonical examples

  • 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 via refs.ts and the assignee via resolveAssigneeId() from src/lib/collaborators.ts, calls api.addTask(), outputs formatJson(result, 'task') when --json, else human confirmation.
  • Grouped command: src/commands/project/index.ts + siblings — implicit-default view, sibling files per subcommand, project/helpers.ts for shared logic.

Ref resolution (three strategies)

All live in src/lib/refs.ts:

  1. Full name resolution (resolveProjectRef, resolveTaskRef, …) — async, returns the full entity. Tries URL → id: prefix → raw ID (if looksLikeRawId; 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 in refs.ts; the internal resolveRef is private.
  2. ID-only validation (lenientIdRef) — sync, no API call, returns an ID string. Tries id: prefix → URL → raw ID → error. Use for entities without a fetchAll endpoint (comments, reminders).
  3. 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.

Auth & token storage

@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.

Testing

  • Runner: vitest. npm test (one-shot), npm run test:watch.
  • Location: co-located *.test.ts next to the module under test.
  • Mocks: src/test-support/mock-api.ts — createMockApi() returns vitest-mocked versions of every SDK method. Use factories from src/test-support/fixtures.ts — do NOT hand-build mock entities.
  • Helpers: setupApiMock() (api-mock.ts) creates a mock API and wires getApi() to it. The Commander harness createTestProgram(register) and the output spies captureConsole(method?) / captureStream(stream?) come from the shared @doist/cli-core/testing subpath (not local files).
  • Pattern: still vi.mock('…/api/core.js') (hoisted) per file, then in beforeEach mockApi = setupApiMock(); build with createTestProgram(registerXCommand) and run program.parseAsync(['node','td','<cmd>',…]).
  • Auto-restore: vitest.config.ts sets restoreMocks: true, so spies are restored between tests automatically — do NOT add manual .mockRestore().
  • @doist/cli-core inlining: vitest.config.ts lists @doist/cli-core in server.deps.inline so vi.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 the auth / migrate-auth suites.

Build & release

  • Build: tsc -p tsconfig.build.json (dist/). Two-tsconfig setup: tsconfig.json includes tests for type-check/IDEs; tsconfig.build.json excludes *.test.ts + src/test-support/ so test-only code stays out of dist/.
  • Dev: npm run dev (watch mode).
  • Type-check: npm run type-check (runs tsc --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 by check-semantic-pull-request.yml.

Skill content flow

src/lib/skills/content.ts is the source of truth for every command reference shown to AI agents. The build/sync chain:

  1. Edit SKILL_CONTENT when commands/flags change.
  2. npm run sync:skill → scripts/sync-skill.js → writes skills/todoist-cli/SKILL.md.
  3. td skill update claude-code (and other installed agents) propagates the update to installed skill files.
  4. .github/workflows/check-skill-sync.yml runs npm run check:skill-sync on PRs — fails if SKILL.md is out of sync with content.ts.

See AGENTS.md for the exact update rule.

Running the CLI directly (no install)

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.

Conventions (quick)

  • Filenames: kebab-case (find-completed-tasks.ts)
  • No barrel files except per-group index.ts wiring 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 --json emitting formatJson(result, entityType); no-entity writes use the envelope from mutation-json.ts — see AGENTS.md
  • User-facing errors: throw CliError(code, message, hints?) from src/lib/errors.ts; the global parseAsync().catch in src/index.ts renders it. The same handler also catches BaseCliError (re-exported from src/lib/errors.ts) so errors thrown by @doist/cli-core helpers route through the same path
  • Missing positional/required input: call failWithUsage(command) from src/lib/missing-argument.ts, never a bare command.help() (which prints to stdout and exits 0). It exits 1 with help on stderr, or throws MISSING_ARGUMENT under --json / --ndjson
  • Global flags handled in src/lib/global-args.ts — check isJsonMode() etc. before printing

Start here if new

  1. src/index.ts — entry + command registry
  2. src/commands/today.ts — canonical read
  3. src/commands/task/add.ts — canonical write with --json
  4. src/commands/project/index.ts — canonical group command
  5. src/lib/refs.ts + src/lib/output.ts — what's already built
  6. AGENTS.md — rules you must follow