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.
npx skills add https://github.com/Doist/todoist-cli --skill add-command
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.
--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.--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.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.--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))..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().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).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
Guide for creating high-quality MCP (Model Context Protocol) servers that enable LLMs to interact with external services through well-designed tools. Use when building MCP servers to integrate external APIs or services, whether in Python (FastMCP) or Node/TypeScript (MCP SDK).
Automatically creates user-facing changelogs from git commits by analyzing commit history, categorizing changes, and transforming technical commits into clear, customer-friendly release notes. Turns hours of manual changelog writing into minutes of automated generation.
Use when implementation is complete, all tests pass, and you need to decide how to integrate the work - guides completion of development work by presenting structured options for merge, PR, or cleanup
Guide for creating high-quality MCP (Model Context Protocol) servers that enable LLMs to interact with external services through well-designed tools. Use when building MCP servers to integrate external APIs or services, whether in Python (FastMCP) or Node/TypeScript (MCP SDK).
React Native and Expo best practices for building performant mobile apps. Use when building React Native components, optimizing list performance, implementing animations, or working with native modules. Triggers on tasks involving React Native, Expo, mobile performance, or native platform APIs.
React and Next.js performance optimization guidelines from Vercel Engineering. This skill should be used when writing, reviewing, or refactoring React/Next.js code to ensure optimal performance patterns. Triggers on tasks involving React components, Next.js pages, data fetching, bundle optimization, or performance improvements.
Next.js best practices - file conventions, RSC boundaries, data patterns, async APIs, metadata, error handling, route handlers, image/font optimization, bundling
Use when starting feature work that needs isolation from current workspace or before executing implementation plans - creates isolated git worktrees with smart directory selection and safety verification
Take doist/add-command from the repository into ~/.claude/skills for personal
use, or into .claude/skills inside a project.
The agent identifies a skill by the name field in its header. Two skills with the
same name cannot sit side by side — one of them will be ignored.