with one click
add-command
// Guide for adding new CLI commands or subcommands to twist-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 twist-cli. Use when implementing new SDK endpoints, adding subcommands to existing command groups, or extending CLI functionality.
Twist messaging CLI. View and respond to inbox threads, channel threads, direct messages, mentions, and group conversations; search, react, archive, mute, and manage workspaces. Use when the user mentions Twist, asks about their inbox, mentions, threads, DMs, channels, or wants to read or send Twist messages.
Guide for adding new CLI commands or subcommands to twist-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 twist-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/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)yellow — update/delete/archive mutations (e.g., muting, deleting, archiving)src/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 (tw auth login --read-only).
KNOWN_SAFE_API_METHODSsrc/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., channel.ts, inbox.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) |
resolveThreadId(ref) — resolve thread by numeric ID or Twist URLresolveChannelId(ref) — resolve channel by numeric ID, URL, or fuzzy nameresolveWorkspaceRef(ref) — resolve workspace by ID or fuzzy nameresolveConversationId(ref) — resolve conversation by numeric ID or URLparseRef(ref) — low-level parser: returns { type: 'id' | 'url' | 'name', ... }Add new resolver wrappers in refs.ts when needed.
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.
For entity commands with a view subcommand, mark it as the default so tw thread 123 maps to tw thread view 123:
thread
.command('view [thread-ref]', { isDefault: true })
.description('Display a thread with its comments')
.action((ref, options) => viewThread(ref, options))
Where commands accept positional [workspace-ref], also accept a --workspace flag. Error if both are provided:
if (workspaceRef && options.workspace) {
throw new Error('Cannot specify workspace both as argument and --workspace flag')
}
const ref = workspaceRef ?? options.workspace
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.
New top-level commands must be registered in src/index.ts using the lazy loading pattern:
const loadMyCommand = async () => (await import('./commands/my-entity/index.js')).registerMyCommand
const commands: Record<string, [string, () => Promise<(p: Command) => void>]> = {
// ... existing commands
'my-entity': ['My entity operations', loadMyCommand],
}
src/lib/output.ts)The CLI supports accessible mode via isAccessible() (checks TW_ACCESSIBLE=1 or --accessible flag). When adding output that uses color or visual elements, consider whether information is conveyed only by color or decoration.
archived, muted are self-explanatory.chalk.dim() for secondary info is fine — screen readers ignore styling.import { isAccessible } from '../lib/output.js'
const a11y = isAccessible()
const prefix = a11y ? '[!] ' : ''
console.log(chalk.yellow(`${prefix}Warning: thread is archived`))
src/__tests__/<entity>.test.ts)Tests mock the API layer directly using vi.mock and vi.hoisted. Follow the existing pattern in test files like thread.test.ts or conversation.test.ts.
const apiMocks = vi.hoisted(() => ({
getTwistClient: vi.fn(),
}))
vi.mock('../lib/api.js', async (importOriginal) => ({
...(await importOriginal<typeof import('../lib/api.js')>()),
getTwistClient: apiMocks.getTwistClient,
}))
vi.mock('../lib/markdown.js', () => ({
renderMarkdown: vi.fn((text: string) => text),
}))
vi.mock('chalk')
Build a mock client object that matches the SDK structure:
function createClient({ thread, comments, channel } = {}) {
return {
threads: {
getThread: vi.fn().mockResolvedValue(thread),
createThread: vi.fn().mockResolvedValue(thread),
},
channels: {
getChannel: vi.fn().mockResolvedValue(channel),
},
// ... add mock methods as needed
}
}
--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 build && npm run sync:skill
This builds the project and regenerates skills/twist-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 lint:check