with one click
add-command
// Guide for adding new CLI commands or subcommands to todoist-cli. Use when implementing new SDK endpoints, adding subcommands to existing command groups, or extending CLI functionality.
// Guide for adding new CLI commands or subcommands to todoist-cli. Use when implementing new SDK endpoints, adding subcommands to existing command groups, or extending CLI functionality.
Manage Todoist tasks, projects, labels, filters, sections, comments, reminders, and workspaces via the `td` CLI. Use when the user wants to view, create, update, complete, or organize Todoist items, or mentions tasks, inbox, today, upcoming, projects, labels, or filters.
Guide for adding new CLI commands or subcommands to todoist-cli. Use when implementing new SDK endpoints, adding subcommands to existing command groups, or extending CLI functionality.
| name | add-command |
| description | Guide for adding new CLI commands or subcommands to todoist-cli. Use when implementing new SDK endpoints, adding subcommands to existing command groups, or extending CLI functionality. |
Follow this checklist when adding new commands. Each step references the exact file to modify.
src/__tests__/helpers/mock-api.ts)Add a mock for each new SDK method in createMockApi(). Place it in the correct entity group.
.mockResolvedValue({ results: [], nextCursor: null }) or appropriate empty defaultvi.fn() (no default return needed)src/lib/api/core.ts)Add an entry to API_SPINNER_MESSAGES for each new SDK method.
Color convention:
blue — read/fetch operationsgreen — create/join operationsyellow — update/delete/archive mutationssrc/lib/permissions.ts)If the new command uses a read-only SDK method (e.g., getXxx, listXxx), add it to the KNOWN_SAFE_API_METHODS set. This set uses a default-deny approach: any method not listed is treated as mutating and will be blocked when the CLI is authenticated with a read-only OAuth token (td auth login --read-only).
KNOWN_SAFE_API_METHODSEvery new command should satisfy these properties. They ensure the CLI works well for both humans and AI agents. See 7 Principles for Agent-Friendly CLIs for background.
Non-interactive by default — All input via flags, positional args, or --stdin. Never use readline, prompt(), or block waiting for TTY input. When a required argument is missing, call cmd.help() and return — don't prompt.
Structured, parseable output — Data commands must support --json (and --ndjson for lists). Results go to stdout, diagnostics to stderr. Spinners auto-suppress when !process.stdout.isTTY (see src/lib/spinner.ts). Exit code 0 on success, non-zero on failure.
Fail fast with actionable errors — Use CliError with a specific error code, a message naming the exact problem, and hints that include correct invocation syntax, valid values, or example commands. Validate all inputs before making API calls.
Safe retries and explicit mutation boundaries — Mutating commands support --dry-run. Destructive + irreversible commands require --yes. Create commands return the entity ID (use isQuiet() for bare ID output for scripting, e.g. id=$(td task add "Buy milk" -q)).
Progressive help discovery — Parent command groups include .addHelpText('after', ...) with 2–3 concrete examples. Every .description() is a clear one-line purpose statement. When a required positional arg is missing, show help via cmd.help().
Composable and predictable structure — Use consistent subcommand verbs (list/view/create/update/delete/browse). Use consistent flag names across entities (--project <ref>, --json, --dry-run, --yes, --limit, --cursor, --all). Support --stdin for text content where applicable (see readStdin() in src/lib/stdin.ts).
Bounded, high-signal responses — List commands use paginate() from src/lib/pagination.ts with --limit <n>, --cursor, and --all flags. When results are truncated, formatNextCursorFooter() tells the user how to fetch more. JSON output uses formatJson() or formatPaginatedJson() to return essential fields by default, passing the --full flag for complete output.
src/commands/<entity>/)Commands with multiple subcommands use a folder-based structure:
src/commands/<entity>/
index.ts # registerXxxCommand — creates parent cmd, wires subcommands
list.ts # async function listXxx(...) — one file per subcommand
view.ts # async function viewXxx(...)
create.ts # async function createXxx(...)
helpers.ts # shared constants/utilities used by multiple subcommands (optional)
registerXxxCommand../../lib/ for lib imports. No Commander imports (only index.ts uses Commander).Single-subcommand commands (e.g., add.ts, today.ts) remain as flat files.
src/commands/<entity>/<action>.ts with the handler functionsrc/commands/<entity>/index.ts| Command type | Flags |
|---|---|
| Read-only | --json (and --ndjson for lists) |
| Mutating (returns entity) | --json (use formatJson), --dry-run |
| Mutating (no return) | --dry-run |
| Destructive + irreversible | --yes, --dry-run |
| Reversible (archive/unarchive) | --dry-run (no --yes) |
| List (paginated) | --limit <n>, --cursor, --all, --json, --ndjson |
| List (non-paginated) | --json, --ndjson |
The --quiet / -q flag suppresses success messages on mutations. Create commands in quiet mode print only the bare entity ID for scripting (e.g., id=$(td task add "Buy milk" -q)).
Always use CliError from src/lib/errors.ts instead of bare throw new Error(...). This ensures structured error output in JSON mode and consistent formatting in text mode.
import { CliError } from '../../lib/errors.js'
throw new CliError('ERROR_CODE', 'User-facing message', ['Optional hint'])
When adding a new error code, add it to the ErrorCode type in src/lib/errors.ts under the appropriate category. The type provides intellisense for known codes while accepting any string for dynamic codes.
To make errors actionable for agents:
message must name the specific problem (not generic "invalid input")hints array should include at least one of: correct invocation syntax, valid values, or a working example commandCliError('CONFLICTING_OPTIONS', ...) immediatelyresolveXxxRef(api, ref) — when the user knows the entity by name (projects, tasks, labels). Add new wrappers in refs.ts — resolveRef is private.lenientIdRef(ref, 'entity') — when there is no list endpoint for lookup, or the user can't access the entity yet (e.g., comments, reminders, joining an unjoined project)resolveSectionId, resolveParentTaskId, resolveWorkspaceRef) — when resolving a name within a parent context (e.g., a section name within a specific project). Each has custom logic in refs.ts.const myCmd = parent
.command('my-action [ref]')
.description('Do something')
.option('--json', 'Output as JSON')
.option('--dry-run', 'Preview what would happen without executing')
.action((ref, options) => {
if (!ref) {
myCmd.help()
return
}
return myAction(ref, options)
})
The variable assignment (const myCmd = ...) is needed so the .action() callback can call myCmd.help() when the argument is missing.
Help text quality:
registerXxxCommand function) should include .addHelpText('after', ...) with 2–3 concrete invocation examples.description() string should be a clear one-line purpose — agents read this to decide which subcommand to callif (!ref) { cmd.help(); return } pattern ensures the command never blocks when a required argument is missingsrc/lib/output.ts)The CLI supports accessible mode via isAccessible() (checks TD_ACCESSIBLE=1 or --accessible flag). When adding output that uses color or visual elements, consider whether information is conveyed only by color or decoration.
formatHealthStatus adds [+], [!], [!!] prefixes.====---- becomes "equals equals equals equals dash dash dash dash"). Show only the numeric value instead.★ only in accessible mode since the yellow color already signals it visually.ON_TRACK, COMPLETED are self-explanatory — color just reinforces them. Still consider adding indicator prefixes for severity.chalk.dim() for secondary info is fine — screen readers ignore styling.import { isAccessible } from '../lib/output.js'
// For color-coded values: add text prefix in accessible mode
const a11y = isAccessible()
const prefix = a11y ? '[!] ' : ''
console.log(chalk.yellow(`${prefix}AT_RISK`))
// For visual bars: skip entirely in accessible mode
if (isAccessible()) {
console.log(`${percent}%`)
} else {
console.log(`[${'='.repeat(filled)}${'-'.repeat(empty)}] ${percent}%`)
}
If adding a new shared formatter to output.ts, use Record<ExactType, ...> rather than Record<string, ...> so the compiler catches missing variants.
src/__tests__/<entity>.test.ts)Follow the existing pattern: mock getApi, use program.parseAsync().
Always test:
INVALID_REF rejection for lenientIdRef commands (plain text like "Planning" should fail)--dry-run for mutating commands (API method should NOT be called, preview text shown)--json output where applicablesrc/lib/skills/content.ts)Update SKILL_CONTENT with examples for the new command. Update relevant sections:
### Section block--json list if the command returns an entity--dry-run list if applicableAfter all code changes are complete:
npm run sync:skill
This builds the project and regenerates skills/todoist-cli/SKILL.md from the compiled skill content. The regenerated file must be committed. CI will fail (npm run check:skill-sync) if it is out of sync.
npm run type-check
npm test
npm run check