Skip to content

Adding a command

Edit on GitHub

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.

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;
}

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.

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.

src/lib/members.ts
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.

src/lib/formatter.ts, next to its siblings:

export function formatChannelMembers(members: ChannelMember[]): string { /* … */ }

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 with slackcli <command path>, using only flags the command defines. At least one uses --json when the command has it. Placeholder data only: C0123456789, U0123456789, acme.slack.com, and never a token value.
  • json: the top-level shape of the --json output, including paging fields. Required exactly when the command has --json.
  • confirms: true on a command with --yes (adds the standard confirmation note); browserOnly: true on a command that needs browser session tokens; standardOnly: true on one that needs an app token (and add it to STANDARD_ONLY in src/program.test.ts); dryRun: true on 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 --fields note).
  • 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 in docs/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.

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.

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.

Conventions this codebase holds to — deviating from any of them will come up in review:

  • Help through describeCommand(): summary, description, examples, the --json shape, 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 --json on any command that returns data.
  • A command that writes to Slack takes --dry-run (DRY_RUN_FLAG from src/lib/dry-run.ts) and is listed in SLACK_WRITES in src/program.test.ts. Branch after all resolution and validation and before confirmWrite() and the write call: build the preview with buildPreview(), print it with emitDryRun() and return. A dry run may read, never write, and never prompts.
  • A read command with --json takes --fields (FIELDS_FLAG from src/lib/json-fields.ts): add it to FIELDS_LIST_KEYS with the key of its main list (null for a single record), validate with fieldsOption(options) before any Slack call, and print through writeJson(applyFields('<group> <command>', payload, fields)).
  • Never call process.exit() after writeJson() — set process.exitCode and return. See architecture.
  • Spinner via ora; libs report progress through an onProgress callback.
  • Errors: failCommand(err, { json: options.json, spinner, context }) and return; CLI-detected failures use a typed error from cli-errors.ts.
  • Never print a token value.
  • bun run type-check and bun test pass; commits are signed.
  • The PR links a ready-for-pr issue and follows the PR template.

Cookies