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
--jsonon any command that returns data. - Never call
process.exit()afterwriteJson()— setprocess.exitCodeand return. See architecture. - Spinner via
ora; libs report progress through anonProgresscallback. - Errors:
spinner.fail(...),error(message),process.exit(1). - 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.