Guide for adding new CLI commands or subcommands to comms-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 comms-cli. Use when implementing new SDK endpoints, adding subcommands to existing command groups, or extending CLI functionality.
Adding a New CLI Command or Subcommand
Follow this checklist when adding new commands. Each step references the exact file to modify.
1. Spinner Messages (src/lib/api.ts)
Add an entry to API_SPINNER_MESSAGES for each new SDK method.
Color convention:
blue — read/fetch operations (e.g., loading threads, listing channels)
green — create/join operations (e.g., creating a thread, starting a conversation)
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 (tdc auth login --read-only).
Read-only methods (fetch/list/view): add to KNOWN_SAFE_API_METHODS
Mutating methods (create/update/delete/archive/mute): do NOT add — they are blocked by default, which is the correct behavior
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 ../../lib/ for lib imports. No Commander imports (only index.ts uses Commander).
helpers.ts: Only needed when multiple subcommands share a utility/constant.
Single-subcommand commands (e.g., channel.ts, inbox.ts) remain as flat files.
Adding a subcommand to an existing command
Create a new file src/commands/<entity>/<action>.ts with the handler function
Import and wire it in src/commands/<entity>/index.ts
Flag conventions
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)
ID resolution
resolveThreadId(ref) — resolve thread by numeric ID or Comms URL
resolveChannelId(ref) — resolve channel by numeric ID, URL, or fuzzy name
resolveWorkspaceRef(ref) — resolve workspace by ID or fuzzy name
resolveConversationId(ref) — resolve conversation by numeric ID or URL
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
}
returnmyAction(ref, options)
})
The variable assignment (const myCmd = ...) is needed so the .action() callback can call myCmd.help() when the argument is missing.
Implicit view subcommand
For entity commands with a view subcommand, mark it as the default so tdc thread 123 maps to tdc thread view 123:
thread
.command('view [thread-ref]', { isDefault: true })
.description('Display a thread with its comments')
.action((ref, options) =>viewThread(ref, options))
Named flag aliases
Where commands accept positional [workspace-ref], also accept a --workspace flag. Error if both are provided:
if (workspaceRef && options.workspace) {
thrownewError('Cannot specify workspace both as argument and --workspace flag')
}
const ref = workspaceRef ?? options.workspace
Error handling
Never use process.exit(1) in command handlers. It terminates immediately without running finally blocks, leaving the spinner stuck. Use process.exitCode = 1 followed by return instead.
Lazy loading
New top-level commands must be registered in src/index.ts using the lazy loading pattern:
The CLI supports accessible mode via isAccessible() (checks TDC_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.
ASCII art / visual bars: Omit entirely in accessible mode — screen readers read each character individually. Show only the numeric value instead.
Decorative symbols: Stars, checkmarks, or icons used alongside color should have text equivalents.
When you don't need to do anything
Text that is already descriptive: Status names like archived, muted are self-explanatory.
Plain numbers and dates: Already accessible.
Dim/styled labels: chalk.dim() for secondary info is fine — screen readers ignore styling.
Update SKILL_CONTENT with examples for the new command. Update relevant sections:
Command examples in the entity's ### Section block
Quick Reference if adding a top-level command
Mutating --json list if the command returns an entity
--dry-run list if applicable
7. Sync Skill File
After all code changes are complete:
npm run build && npm run sync:skill
This builds the project and regenerates skills/comms-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.