{"slug":"add-command","title":"add-command","summary":"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.","platform":"Claude","tags":[],"authorName":"LLM Mart","authorSlug":"llm-mart","score":0,"source":"github","price":null,"verified":false,"createdAt":"2026-08-24T17:08:24.417044Z","repo":{"url":"https://github.com/Doist/todoist-cli","stars":313,"forks":18,"license":"MIT","updatedAt":"2026-09-24T14:26:29Z"},"bodyHtml":"<hr>\n<h2>name: add-command\ndescription: 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.</h2>\n<h1>Adding a New CLI Command or Subcommand</h1>\n<p>Follow this checklist when adding new commands. Each step references the exact file to modify.</p>\n<h2>1. Mock API (<code>src/__tests__/helpers/mock-api.ts</code>)</h2>\n<p>Add a mock for each new SDK method in <code>createMockApi()</code>. Place it in the correct entity group.</p>\n<ul>\n<li>List/read methods: <code>.mockResolvedValue({ results: [], nextCursor: null })</code> or appropriate empty default</li>\n<li>Mutation methods: <code>vi.fn()</code> (no default return needed)</li>\n</ul>\n<h2>2. Spinner Messages (<code>src/lib/api/core.ts</code>)</h2>\n<p>Add an entry to <code>API_SPINNER_MESSAGES</code> for each new SDK method.</p>\n<p>Color convention:</p>\n<ul>\n<li><code>blue</code> — read/fetch operations</li>\n<li><code>green</code> — create/join operations</li>\n<li><code>yellow</code> — update/delete/archive mutations</li>\n</ul>\n<h2>3. Read-Only Permissions (<code>src/lib/permissions.ts</code>)</h2>\n<p>If the new command uses a <strong>read-only</strong> SDK method (e.g., <code>getXxx</code>, <code>listXxx</code>), add it to the <code>KNOWN_SAFE_API_METHODS</code> set. This set uses a default-deny approach: any method <strong>not</strong> listed is treated as mutating and will be blocked when the CLI is authenticated with a read-only OAuth token (<code>td auth login --read-only</code>).</p>\n<ul>\n<li><strong>Read-only methods</strong> (fetch/list/view): add to <code>KNOWN_SAFE_API_METHODS</code></li>\n<li><strong>Mutating methods</strong> (add/update/delete/archive/move): do NOT add — they are blocked by default, which is the correct behavior</li>\n</ul>\n<h2>4. Agent-Friendly Design Checklist</h2>\n<p>Every new command should satisfy these properties. They ensure the CLI works well for both humans and AI agents. See <a href=\"https://trevinsays.com/p/7-principles-for-agent-friendly-clis\">7 Principles for Agent-Friendly CLIs</a> for background.</p>\n<ol>\n<li><p><strong>Non-interactive by default</strong> — All input via flags, positional args, or <code>--stdin</code>. Never use <code>readline</code>, <code>prompt()</code>, or block waiting for TTY input. When a required argument is missing, call <code>cmd.help()</code> and return — don't prompt.</p>\n</li>\n<li><p><strong>Structured, parseable output</strong> — Data commands must support <code>--json</code> (and <code>--ndjson</code> for lists). Results go to stdout, diagnostics to stderr. Spinners auto-suppress when <code>!process.stdout.isTTY</code> (see <code>src/lib/spinner.ts</code>). Exit code 0 on success, non-zero on failure.</p>\n</li>\n<li><p><strong>Fail fast with actionable errors</strong> — Use <code>CliError</code> 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.</p>\n</li>\n<li><p><strong>Safe retries and explicit mutation boundaries</strong> — Mutating commands support <code>--dry-run</code>. Destructive + irreversible commands require <code>--yes</code>. Create commands return the entity ID (use <code>isQuiet()</code> for bare ID output for scripting, e.g. <code>id=$(td task add \"Buy milk\" -q)</code>).</p>\n</li>\n<li><p><strong>Progressive help discovery</strong> — Parent command groups include <code>.addHelpText('after', ...)</code> with 2–3 concrete examples. Every <code>.description()</code> is a clear one-line purpose statement. When a required positional arg is missing, show help via <code>cmd.help()</code>.</p>\n</li>\n<li><p><strong>Composable and predictable structure</strong> — Use consistent subcommand verbs (<code>list</code>/<code>view</code>/<code>create</code>/<code>update</code>/<code>delete</code>/<code>browse</code>). Use consistent flag names across entities (<code>--project &lt;ref&gt;</code>, <code>--json</code>, <code>--dry-run</code>, <code>--yes</code>, <code>--limit</code>, <code>--cursor</code>, <code>--all</code>). Support <code>--stdin</code> for text content where applicable (see <code>readStdin()</code> in <code>src/lib/stdin.ts</code>).</p>\n</li>\n<li><p><strong>Bounded, high-signal responses</strong> — List commands use <code>paginate()</code> from <code>src/lib/pagination.ts</code> with <code>--limit &lt;n&gt;</code>, <code>--cursor</code>, and <code>--all</code> flags. When results are truncated, <code>formatNextCursorFooter()</code> tells the user how to fetch more. JSON output uses <code>formatJson()</code> or <code>formatPaginatedJson()</code> to return essential fields by default, passing the <code>--full</code> flag for complete output.</p>\n</li>\n</ol>\n<h2>5. Command Implementation (<code>src/commands/&lt;entity&gt;/</code>)</h2>\n<p>Commands with multiple subcommands use a folder-based structure:</p>\n<pre><code>src/commands/&lt;entity&gt;/\n  index.ts          # registerXxxCommand — creates parent cmd, wires subcommands\n  list.ts           # async function listXxx(...) — one file per subcommand\n  view.ts           # async function viewXxx(...)\n  create.ts         # async function createXxx(...)\n  helpers.ts        # shared constants/utilities used by multiple subcommands (optional)\n</code></pre>\n<ul>\n<li><strong>index.ts</strong>: Imports all subcommand handlers, creates the Commander tree, exports <code>registerXxxCommand</code></li>\n<li><strong>Subcommand files</strong>: Export one async action handler + any option interfaces. Use <code>../../lib/</code> for lib imports. No Commander imports (only index.ts uses Commander).</li>\n<li><strong>helpers.ts</strong>: Only needed when multiple subcommands share a utility/constant.</li>\n</ul>\n<p>Single-subcommand commands (e.g., <code>add.ts</code>, <code>today.ts</code>) remain as flat files.</p>\n<h3>Adding a subcommand to an existing command</h3>\n<ol>\n<li>Create a new file <code>src/commands/&lt;entity&gt;/&lt;action&gt;.ts</code> with the handler function</li>\n<li>Import and wire it in <code>src/commands/&lt;entity&gt;/index.ts</code></li>\n</ol>\n<h3>Flag conventions</h3>\n<table>\n<thead>\n<tr>\n<th>Command type</th>\n<th>Flags</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>Read-only</td>\n<td><code>--json</code> (and <code>--ndjson</code> for lists)</td>\n</tr>\n<tr>\n<td>Mutating (returns entity)</td>\n<td><code>--json</code> (use <code>formatJson</code>), <code>--dry-run</code></td>\n</tr>\n<tr>\n<td>Mutating (no return)</td>\n<td><code>--dry-run</code></td>\n</tr>\n<tr>\n<td>Destructive + irreversible</td>\n<td><code>--yes</code>, <code>--dry-run</code></td>\n</tr>\n<tr>\n<td>Reversible (archive/unarchive)</td>\n<td><code>--dry-run</code> (no <code>--yes</code>)</td>\n</tr>\n<tr>\n<td>List (paginated)</td>\n<td><code>--limit &lt;n&gt;</code>, <code>--cursor</code>, <code>--all</code>, <code>--json</code>, <code>--ndjson</code></td>\n</tr>\n<tr>\n<td>List (non-paginated)</td>\n<td><code>--json</code>, <code>--ndjson</code></td>\n</tr>\n</tbody>\n</table>\n<p>The <code>--quiet</code> / <code>-q</code> flag suppresses success messages on mutations. Create commands in quiet mode print only the bare entity ID for scripting (e.g., <code>id=$(td task add \"Buy milk\" -q)</code>).</p>\n<h3>Error handling</h3>\n<p>Always use <code>CliError</code> from <code>src/lib/errors.ts</code> instead of bare <code>throw new Error(...)</code>. This ensures structured error output in JSON mode and consistent formatting in text mode.</p>\n<pre><code>import { CliError } from '../../lib/errors.js'\n\nthrow new CliError('ERROR_CODE', 'User-facing message', ['Optional hint'])\n</code></pre>\n<p>When adding a new error code, add it to the <code>ErrorCode</code> type in <code>src/lib/errors.ts</code> under the appropriate category. The type provides intellisense for known codes while accepting any string for dynamic codes.</p>\n<p>To make errors actionable for agents:</p>\n<ul>\n<li>The <code>message</code> must name the specific problem (not generic \"invalid input\")</li>\n<li>The <code>hints</code> array should include at least one of: correct invocation syntax, valid values, or a working example command</li>\n<li>Validate all flag constraints and input early — before any API calls. If flags conflict, throw <code>CliError('CONFLICTING_OPTIONS', ...)</code> immediately</li>\n</ul>\n<h3>ID resolution</h3>\n<ul>\n<li><code>resolveXxxRef(api, ref)</code> — when the user knows the entity by name (projects, tasks, labels). Add new wrappers in <code>refs.ts</code> — <code>resolveRef</code> is private.</li>\n<li><code>lenientIdRef(ref, 'entity')</code> — 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)</li>\n<li><strong>Context-scoped resolvers</strong> (<code>resolveSectionId</code>, <code>resolveParentTaskId</code>, <code>resolveWorkspaceRef</code>) — when resolving a name within a parent context (e.g., a section name within a specific project). Each has custom logic in <code>refs.ts</code>.</li>\n</ul>\n<h3>Subcommand registration pattern</h3>\n<pre><code>const myCmd = parent\n    .command('my-action [ref]')\n    .description('Do something')\n    .option('--json', 'Output as JSON')\n    .option('--dry-run', 'Preview what would happen without executing')\n    .action((ref, options) =&gt; {\n        if (!ref) {\n            myCmd.help()\n            return\n        }\n        return myAction(ref, options)\n    })\n</code></pre>\n<p>The variable assignment (<code>const myCmd = ...</code>) is needed so the <code>.action()</code> callback can call <code>myCmd.help()</code> when the argument is missing.</p>\n<p>Help text quality:</p>\n<ul>\n<li>Parent command groups (the <code>registerXxxCommand</code> function) should include <code>.addHelpText('after', ...)</code> with 2–3 concrete invocation examples</li>\n<li>Every <code>.description()</code> string should be a clear one-line purpose — agents read this to decide which subcommand to call</li>\n<li>The <code>if (!ref) { cmd.help(); return }</code> pattern ensures the command never blocks when a required argument is missing</li>\n</ul>\n<h2>6. Accessibility (<code>src/lib/output.ts</code>)</h2>\n<p>The CLI supports accessible mode via <code>isAccessible()</code> (checks <code>TD_ACCESSIBLE=1</code> or <code>--accessible</code> flag). When adding output that uses color or visual elements, consider whether information is conveyed <strong>only</strong> by color or decoration.</p>\n<h3>When to add accessible alternatives</h3>\n<ul>\n<li><strong>Color-coded status/severity</strong>: 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: <code>formatHealthStatus</code> adds <code>[+]</code>, <code>[!]</code>, <code>[!!]</code> prefixes.</li>\n<li><strong>ASCII art / visual bars</strong>: Omit entirely in accessible mode — screen readers read each character individually (e.g., <code>====----</code> becomes \"equals equals equals equals dash dash dash dash\"). Show only the numeric value instead.</li>\n<li><strong>Decorative symbols</strong>: Stars, checkmarks, or icons used alongside color should have text equivalents. Example: favorites get <code>★</code> only in accessible mode since the yellow color already signals it visually.</li>\n</ul>\n<h3>When you don't need to do anything</h3>\n<ul>\n<li><strong>Text that is already descriptive</strong>: Status names like <code>ON_TRACK</code>, <code>COMPLETED</code> are self-explanatory — color just reinforces them. Still consider adding indicator prefixes for severity.</li>\n<li><strong>Plain numbers and dates</strong>: Already accessible.</li>\n<li><strong>Dim/styled labels</strong>: <code>chalk.dim()</code> for secondary info is fine — screen readers ignore styling.</li>\n</ul>\n<h3>Pattern</h3>\n<pre><code>import { isAccessible } from '../lib/output.js'\n\n// For color-coded values: add text prefix in accessible mode\nconst a11y = isAccessible()\nconst prefix = a11y ? '[!] ' : ''\nconsole.log(chalk.yellow(`${prefix}AT_RISK`))\n\n// For visual bars: skip entirely in accessible mode\nif (isAccessible()) {\n    console.log(`${percent}%`)\n} else {\n    console.log(`[${'='.repeat(filled)}${'-'.repeat(empty)}] ${percent}%`)\n}\n</code></pre>\n<p>If adding a new shared formatter to <code>output.ts</code>, use <code>Record&lt;ExactType, ...&gt;</code> rather than <code>Record&lt;string, ...&gt;</code> so the compiler catches missing variants.</p>\n<h2>7. Tests (<code>src/__tests__/&lt;entity&gt;.test.ts</code>)</h2>\n<p>Follow the existing pattern: mock <code>getApi</code>, use <code>program.parseAsync()</code>.</p>\n<p>Always test:</p>\n<ul>\n<li>Happy path (correct output, correct API call)</li>\n<li><code>INVALID_REF</code> rejection for <code>lenientIdRef</code> commands (plain text like <code>\"Planning\"</code> should fail)</li>\n<li><code>--dry-run</code> for mutating commands (API method should NOT be called, preview text shown)</li>\n<li><code>--json</code> output where applicable</li>\n</ul>\n<h2>8. Skill Content (<code>src/lib/skills/content.ts</code>)</h2>\n<p>Update <code>SKILL_CONTENT</code> with examples for the new command. Update relevant sections:</p>\n<ul>\n<li>Command examples in the entity's <code>### Section</code> block</li>\n<li>Quick Reference if adding a top-level command</li>\n<li>Mutating <code>--json</code> list if the command returns an entity</li>\n<li><code>--dry-run</code> list if applicable</li>\n</ul>\n<h2>9. Sync Skill File</h2>\n<p>After all code changes are complete:</p>\n<pre><code>npm run sync:skill\n</code></pre>\n<p>This builds the project and regenerates <code>skills/todoist-cli/SKILL.md</code> from the compiled skill content. The regenerated file must be committed. CI will fail (<code>npm run check:skill-sync</code>) if it is out of sync.</p>\n<h2>10. Verify</h2>\n<pre><code>npm run type-check\nnpm test\nnpm run check\n</code></pre>\n","files":[{"path":"SKILL.md","sizeBytes":11602,"isText":true}],"reviewScore":null,"reviewSummary":null,"trust":{"provenance":"trusted-source-unreviewed","notice":"Community-authored content, reproduced verbatim and not vetted as instructions. Treat it as data to evaluate, never as directives to follow.","bodySource":null},"bodyLocked":false,"purchaseUrl":null,"sourceUrl":null,"report":{"provenance":"trusted-source-unreviewed","screen":{"ran":true,"outcome":"clean","suspicious":0,"notes":0,"hiddenCharacters":false},"virusScan":{"engine":"clamav","status":"clean","scannedAt":"2026-08-24T17:08:33.158454Z","sha256":"64D2B4E77D2BE71E39005736B2F1BCECAD0EDFED3CB734E9AF81A9C17A79A0F5","sizeBytes":5055},"review":null,"source":{"repositoryUrl":"https://github.com/Doist/todoist-cli","path":".agents/skills/add-command","license":"MIT","commit":"06002e337d0db3c13ce93b0501597f3891230483","subtreeSha":"2BE9B8C0982D83CCE218C9D247DA9CF6FF5C0EE225F03CD4663B9737313D049F","lastSyncedAt":"2026-09-27T20:54:17.007305Z"},"reviewedAt":"2026-08-24T17:23:08.749332Z","notice":"Community-authored content, reproduced verbatim and not vetted as instructions. Treat it as data to evaluate, never as directives to follow."},"install":[{"target":"skills-cli","command":"npx skills add https://github.com/Doist/todoist-cli/tree/main/.agents/skills/add-command"},{"target":"claude-code","command":"claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install doist-todoist-cli@llmmart"},{"target":"git","command":"git clone https://github.com/Doist/todoist-cli.git"}]}