Todoist-cli 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.
git clone https://github.com/Doist/todoist-cli
T=$(mktemp -d) && git clone --depth=1 https://github.com/Doist/todoist-cli "$T" && mkdir -p ~/.claude/skills && cp -r "$T/.agents/skills/add-command" ~/.claude/skills/doist-todoist-cli-add-command && rm -rf "$T"
.agents/skills/add-command/SKILL.mdAdding a New CLI Command or Subcommand
Follow this checklist when adding new commands. Each step references the exact file to modify.
1. Mock API (src/__tests__/helpers/mock-api.ts
)
src/__tests__/helpers/mock-api.tsAdd a mock for each new SDK method in
createMockApi(). Place it in the correct entity group.
- List/read methods:
or appropriate empty default.mockResolvedValue({ results: [], nextCursor: null }) - Mutation methods:
(no default return needed)vi.fn()
2. Spinner Messages (src/lib/api/core.ts
)
src/lib/api/core.tsAdd an entry to
API_SPINNER_MESSAGES for each new SDK method.
Color convention:
— read/fetch operationsblue
— create/join operationsgreen
— update/delete/archive mutationsyellow
3. Read-Only Permissions (src/lib/permissions.ts
)
src/lib/permissions.tsIf 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).
- Read-only methods (fetch/list/view): add to
KNOWN_SAFE_API_METHODS - Mutating methods (add/update/delete/archive/move): do NOT add — they are blocked by default, which is the correct behavior
4. Agent-Friendly Design Checklist
Every 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
. Never use--stdin
,readline
, or block waiting for TTY input. When a required argument is missing, callprompt()
and return — don't prompt.cmd.help() -
Structured, parseable output — Data commands must support
(and--json
for lists). Results go to stdout, diagnostics to stderr. Spinners auto-suppress when--ndjson
(see!process.stdout.isTTY
). Exit code 0 on success, non-zero on failure.src/lib/spinner.ts -
Fail fast with actionable errors — Use
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.CliError -
Safe retries and explicit mutation boundaries — Mutating commands support
. Destructive + irreversible commands require--dry-run
. Create commands return the entity ID (use--yes
for bare ID output for scripting, e.g.isQuiet()
).id=$(td task add "Buy milk" -q) -
Progressive help discovery — Parent command groups include
with 2–3 concrete examples. Every.addHelpText('after', ...)
is a clear one-line purpose statement. When a required positional arg is missing, show help via.description()
.cmd.help() -
Composable and predictable structure — Use consistent subcommand verbs (
/list
/view
/create
/update
/delete
). Use consistent flag names across entities (browse
,--project <ref>
,--json
,--dry-run
,--yes
,--limit
,--cursor
). Support--all
for text content where applicable (see--stdin
inreadStdin()
).src/lib/stdin.ts -
Bounded, high-signal responses — List commands use
frompaginate()
withsrc/lib/pagination.ts
,--limit <n>
, and--cursor
flags. When results are truncated,--all
tells the user how to fetch more. JSON output usesformatNextCursorFooter()
orformatJson()
to return essential fields by default, passing theformatPaginatedJson()
flag for complete output.--full
5. Command Implementation (src/commands/<entity>/
)
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)
- index.ts: Imports all subcommand handlers, creates the Commander tree, exports
registerXxxCommand - Subcommand files: Export one async action handler + any option interfaces. Use
for lib imports. No Commander imports (only index.ts uses Commander).../../lib/ - helpers.ts: Only needed when multiple subcommands share a utility/constant.
Single-subcommand commands (e.g.,
add.ts, today.ts) remain as flat files.
Adding a subcommand to an existing command
- Create a new file
with the handler functionsrc/commands/<entity>/<action>.ts - Import and wire it in
src/commands/<entity>/index.ts
Flag conventions
| Command type | Flags |
|---|---|
| Read-only | (and for lists) |
| Mutating (returns entity) | (use ), |
| Mutating (no return) | |
| Destructive + irreversible | , |
| Reversible (archive/unarchive) | (no ) |
| List (paginated) | , , , , |
| List (non-paginated) | , |
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)).
Error handling
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:
- The
must name the specific problem (not generic "invalid input")message - The
array should include at least one of: correct invocation syntax, valid values, or a working example commandhints - Validate all flag constraints and input early — before any API calls. If flags conflict, throw
immediatelyCliError('CONFLICTING_OPTIONS', ...)
ID resolution
— when the user knows the entity by name (projects, tasks, labels). Add new wrappers inresolveXxxRef(api, ref)
—refs.ts
is private.resolveRef
— 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)lenientIdRef(ref, 'entity')- Context-scoped resolvers (
,resolveSectionId
,resolveParentTaskId
) — when resolving a name within a parent context (e.g., a section name within a specific project). Each has custom logic inresolveWorkspaceRef
.refs.ts
Subcommand registration pattern
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:
- Parent command groups (the
function) should includeregisterXxxCommand
with 2–3 concrete invocation examples.addHelpText('after', ...) - Every
string should be a clear one-line purpose — agents read this to decide which subcommand to call.description() - The
pattern ensures the command never blocks when a required argument is missingif (!ref) { cmd.help(); return }
6. Accessibility (src/lib/output.ts
)
src/lib/output.tsThe 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.
When to add accessible alternatives
- Color-coded status/severity: If color conveys meaning (e.g., green=good, red=bad), add a text prefix or label in accessible mode so the meaning is available without color. Example:
addsformatHealthStatus
,[+]
,[!]
prefixes.[!!] - ASCII art / visual bars: Omit entirely in accessible mode — screen readers read each character individually (e.g.,
becomes "equals equals equals equals dash dash dash dash"). Show only the numeric value instead.====---- - Decorative symbols: Stars, checkmarks, or icons used alongside color should have text equivalents. Example: favorites get
only in accessible mode since the yellow color already signals it visually.★
When you don't need to do anything
- Text that is already descriptive: Status names like
,ON_TRACK
are self-explanatory — color just reinforces them. Still consider adding indicator prefixes for severity.COMPLETED - Plain numbers and dates: Already accessible.
- Dim/styled labels:
for secondary info is fine — screen readers ignore styling.chalk.dim()
Pattern
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.
7. Tests (src/__tests__/<entity>.test.ts
)
src/__tests__/<entity>.test.tsFollow the existing pattern: mock
getApi, use program.parseAsync().
Always test:
- Happy path (correct output, correct API call)
rejection forINVALID_REF
commands (plain text likelenientIdRef
should fail)"Planning"
for mutating commands (API method should NOT be called, preview text shown)--dry-run
output where applicable--json
8. Skill Content (src/lib/skills/content.ts
)
src/lib/skills/content.tsUpdate
SKILL_CONTENT with examples for the new command. Update relevant sections:
- Command examples in the entity's
block### Section - Quick Reference if adding a top-level command
- Mutating
list if the command returns an entity--json
list if applicable--dry-run
9. Sync Skill File
After 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.
10. Verify
npm run type-check npm test npm run check