A worked example: adding slackcli conversations members <channel-id>.
Before writing anything, satisfy the policy: open an issue with the feature
request template describing WHAT, WHY and optionally HOW, and wait for the
ready-for-pr label. See CONTRIBUTING.md.
1. Types first
Section titled “1. Types first”src/types/index.ts — model the data instead of passing any around.
export interface ChannelMember { id: string; name?: string; real_name?: string; is_bot?: boolean;}2. Add the API call to SlackClient
Section titled “2. Add the API call to SlackClient”src/lib/slack-client.ts. Go through this.request() so both auth types work
for free:
async listConversationMembers(channel: string, options: { cursor?: string; limit?: number;} = {}): Promise<any> { const params: Record<string, any> = { channel }; if (options.cursor) params.cursor = options.cursor; if (options.limit) params.limit = options.limit; return this.request('conversations.members', params);}Only branch on this.config.auth_type if Slack genuinely offers different
endpoints for the two. If it does, document the divergence in the method, in
--help, and in the user guide.
3. Put the logic in src/lib/
Section titled “3. Put the logic in src/lib/”If the command needs more than one call — here, resolving member IDs to names — that belongs in a lib module, not the command file. It keeps the command thin and makes the logic testable.
export async function fetchChannelMembers( client: SlackClient, channelId: string, options: { limit?: number; onProgress?: (message: string) => void } = {},): Promise<ChannelMember[]> { /* … */ }The onProgress callback is the house convention for feeding spinner text back
to the command layer without the lib knowing about ora — see unread.ts and
saved.ts.
For diagnostics, log through a LogTape category rather than printing:
import { getLogger } from '@logtape/logtape';
const logger = getLogger(['slackcli', 'members']);logger.debug('Resolved {count} members', { channel_id: channelId, count: members.length });Log IDs, counts and durations — never message text, file contents, search
queries or request params. Every Slack call made through SlackClient.request()
is already logged. See Logging. Do not define a -v
option on a subcommand: it is the global --verbose.
4. Add a formatter
Section titled “4. Add a formatter”src/lib/formatter.ts, next to its siblings:
export function formatChannelMembers(members: ChannelMember[]): string { /* … */ }5. Wire up the command
Section titled “5. Wire up the command”src/commands/conversations.ts. The help goes in the file’s HELP map, near
the top (see Help below):
const HELP = { // … members: { summary: 'List the members of a channel', description: 'List the members of a channel, private channel or DM, with their names.', examples: [ 'slackcli conversations members C0123456789', 'slackcli conversations members https://acme.slack.com/archives/C0123456789 --json', ], json: '{ channel_id, member_count, members[] }.', notes: ['<channel-id> accepts a channel ID (C…) or a Slack URL.'], },} satisfies Record<string, CommandHelp>;and the command chain refers to it:
describeCommand(conversations.command('members'), HELP.members) .argument('[channel-id]', 'Channel ID or Slack URL') .option('--limit <number>', 'Number of members to return', '100') .option('--workspace <id|name>', 'Workspace to use') .option('--json', 'Output in JSON format', false) .action(async (channelIdArg, options) => { const spinner = ora('Fetching members...').start(); try { const channelId = normalizeIdentifier(channelIdArg, 'channel', '<channel-id>'); const client = await getAuthenticatedClient(options.workspace); warnOnWorkspaceMismatch(client, workspaceOf(channelIdArg));
const members = await fetchChannelMembers(client, channelId, { limit: parseInt(options.limit), onProgress: (msg) => { spinner.text = msg; }, });
spinner.succeed(`Found ${members.length} members`);
if (options.json) { writeJson({ channel_id: channelId, member_count: members.length, members }); return; // never process.exit() after writeJson } console.log('\n' + formatChannelMembers(members)); } catch (err: any) { // Text: spinner fails with the context, then the error line. --json: one // {"error":{…}} line on stderr. Either way exit code 1, no process.exit(). failCommand(err, { json: options.json, spinner, context: 'Failed to fetch members' }); } });Every failure path of a command with --json goes through failCommand()
(src/lib/command-errors.ts), so that under --json it is reported as the
structured error object documented in
scripting. A failure the
command detects itself throws (or passes) a typed error from
src/lib/cli-errors.ts — InvalidInputError, NotFoundError,
UnsupportedAuthTypeError, ConfirmationRequiredError, or CliError with a
code — so classifyError() never has to read message text. Slack’s own codes
are mapped in SLACK_CODE_MAP; add a code there rather than matching a message.
A write gate is if (!(await confirmWrite(prompt, options.yes, options.json))) return;:
confirmWrite() has already reported the refusal and set the exit code.
A new group (rather than a subcommand) also needs a
create<Group>Command() factory, a describeCommand() call on the group, and
a program.addCommand() line in createProgram() (src/program.ts), above
the help and usage-error walks.
--help is often the only documentation an AI agent reads, so every command
uses describeCommand() from src/lib/help.ts, never a bare .description():
summary: one line, at most 48 characters, no trailing period. It is what the parent’s list and the root command tree show.description: one to three sentences, including when to use this command instead of a sibling.examples: two or three complete command lines starting withslackcli <command path>, using only flags the command defines. At least one uses--jsonwhen the command has it. Placeholder data only:C0123456789,U0123456789,acme.slack.com, and never a token value.json: the top-level shape of the--jsonoutput, including paging fields. Required exactly when the command has--json.confirms: trueon a command with--yes(adds the standard confirmation note);browserOnly: trueon a command that needs browser session tokens;standardOnly: trueon one that needs an app token (and add it toSTANDARD_ONLYinsrc/program.test.ts);dryRun: trueon a command with--dry-run(adds the standard dry-run note);fields: 'each item of <list key>'(or'the … record') on a command with--fields(adds the standard--fieldsnote).notes: accepted value formats, flags that replace or require each other, and auth-type differences. Keep the whole help to about one screen; depth belongs indocs/user-guide/.
Each command file keeps its help in one HELP map near the top
(satisfies Record<string, CommandHelp>), and the command chain only says
describeCommand(cmd, HELP.<name>). Keep it that way: SonarCloud’s duplication
check treats every string literal as the same token, so help objects written
inline next to similar commands (add/remove, enable/disable) extend the
duplicated blocks around them and fail the quality gate. A note repeated by
several commands becomes a named constant above the map.
src/program.test.ts enforces these rules for every command in the tree.
6. Tests
Section titled “6. Tests”src/lib/members.test.ts, using a SlackClient subclass that overrides
request() and records calls. Cover both auth types if the method branches. See
testing.
7. Documentation
Section titled “7. Documentation”Update the relevant page under docs/user-guide/, and the README if the feature
belongs in the overview. A command that ships undocumented is not finished.
Checklist
Section titled “Checklist”Conventions this codebase holds to — deviating from any of them will come up in review:
- Help through
describeCommand(): summary, description, examples, the--jsonshape, and the confirmation / browser-only notes where they apply (bun test src/program.test.ts). - Accept a Slack URL wherever an ID is accepted (
normalizeIdentifier), and warn on workspace mismatch. - Support
--workspace <id|name>. - Support
--jsonon any command that returns data. - A command that writes to Slack takes
--dry-run(DRY_RUN_FLAGfromsrc/lib/dry-run.ts) and is listed inSLACK_WRITESinsrc/program.test.ts. Branch after all resolution and validation and beforeconfirmWrite()and the write call: build the preview withbuildPreview(), print it withemitDryRun()and return. A dry run may read, never write, and never prompts. - A read command with
--jsontakes--fields(FIELDS_FLAGfromsrc/lib/json-fields.ts): add it toFIELDS_LIST_KEYSwith the key of its main list (nullfor a single record), validate withfieldsOption(options)before any Slack call, and print throughwriteJson(applyFields('<group> <command>', payload, fields)). - Never call
process.exit()afterwriteJson()— setprocess.exitCodeand return. See architecture. - Spinner via
ora; libs report progress through anonProgresscallback. - Errors:
failCommand(err, { json: options.json, spinner, context })and return; CLI-detected failures use a typed error fromcli-errors.ts. - Never print a token value.
-
bun run type-checkandbun testpass; commits are signed. - The PR links a
ready-for-prissue and follows the PR template.