# 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](https://github.com/shaharia-lab/slackcli/blob/main/CONTRIBUTING.md).

## 1. Types first

`src/types/index.ts` — model the data instead of passing `any` around.

```ts
export interface ChannelMember {
  id: string;
  name?: string;
  real_name?: string;
  is_bot?: boolean;
}
```

## 2. Add the API call to `SlackClient`

`src/lib/slack-client.ts`. Go through `this.request()` so both auth types work
for free:

```ts
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](/docs/user-guide/).

## 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.

```ts
// 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:

```ts
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](/docs/development/architecture/#logging). Do not define a `-v`
option on a subcommand: it is the global `--verbose`.

## 4. Add a formatter

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

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

## 5. Wire up the command

`src/commands/conversations.ts`. The help goes in the file's `HELP` map, near
the top (see [Help](#help) below):

```ts
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:

```ts
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](/docs/user-guide/scripting/#errors-under---json). 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

`--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.

## 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](/docs/development/testing/).

## 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

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](/docs/development/architecture/#output).
- [ ] 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.