Skip to content

Adding a command

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.

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

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

src/commands/conversations.ts:

conversations
.command('members')
.description('List the members of a channel')
.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) {
spinner.fail('Failed to fetch members');
error(err.message);
process.exit(1);
}
});

A new group (rather than a subcommand) also needs a create<Group>Command() factory and a program.addCommand() line in src/index.ts.

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:

  • 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.
  • Never call process.exit() after writeJson() — set process.exitCode and return. See architecture.
  • Spinner via ora; libs report progress through an onProgress callback.
  • Errors: spinner.fail(...), error(message), process.exit(1).
  • 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