# Architecture

SlackCLI is a thin, layered CLI. The shape is deliberately boring: commands
parse and print, libraries do the work, and exactly one class knows how to talk
to Slack.

```
src/index.ts                 Entry point: builds the program, parses argv
src/program.ts               createProgram(): registers the groups, hooks, help
        │
        ▼
src/commands/*.ts            Parse flags, call lib, format output, set exit code
        │
        ▼
src/lib/*.ts                 All logic: client, auth, storage, parsers, formatting
        │
        ▼
src/types/index.ts           Shared interfaces
```

**The rule that keeps it navigable:** command files contain no Slack knowledge
and no business logic beyond argument handling. Anything testable lives in
`src/lib/`, which is why the lib modules have thorough unit tests and the
command files have very few.

## Help

`--help` is written for an agent that has nothing else to go on (#324).
Every command calls `describeCommand(cmd, HELP.<name>)` from `src/lib/help.ts`
instead of a bare `.description()`. It sets `.summary()` (the one-liner in the
parent's list), `.description()`, and appends `Examples` and `Notes` after the
options. Standard notes come from fields, so the wording stays the same
everywhere: `json` (the `--json` output shape), `browserOnly`, `standardOnly`
and `confirms` (the `--yes` / TTY / non-TTY rule).

The root help replaces Commander's list of groups with a tree of every
command, rendered from `program.commands` (`renderCommandTree()`), and a
footer on workspace selection, `SLACKCLI_*` variables and `--json`.
`installUsageErrorHint()` makes every command print
`(run "slackcli <command> --help" for usage and examples)` after a usage
error. Like the usage-error logging below, it walks the tree after the last
`addCommand()`, because `addCommand()` does not pass settings down.

`src/program.test.ts` builds the real tree with `createProgram()` and fails
for any command that lacks a description, an example, an option or argument
description, a `--json` example and shape, the confirmation note on a `--yes`
command, or the browser-only note. Examples must start with the command's own
path and use only flags it defines.

## Dual authentication

Two credential kinds coexist everywhere, and the split is the single most
important thing to understand about this codebase.

| | Standard | Browser |
|---|---|---|
| Tokens | `xoxb-*` / `xoxp-*` | `xoxd-*` cookie + `xoxc-*` token |
| Transport | `@slack/web-api` `WebClient` | raw `fetch` to `<workspace_url>/api/<method>` |
| Auth carried by | SDK bearer token | `Cookie: d=<urlencoded xoxd>` + `token` form field |
| Discriminated by | `config.auth_type === 'standard'` | `config.auth_type === 'browser'` |

`SlackClient.request()` in `src/lib/slack-client.ts` is the fork:

```ts
async request(method: string, params = {}) {
  if (this.config.auth_type === 'standard') {
    return this.rateLimiter.run(() => this.attempt(method, params, 1));  // → standardRequest()
  }
  // browser: retry loop around rateLimiter.run(() => this.attempt(...)) → browserRequest()
}
```

Everything above it — every `listConversations`, `postMessage`, `searchMessages`
— is auth-agnostic and goes through `request()`. **New API calls belong there,
not in a command file.**

### Throttling

Because `request()` is a complete funnel, it is also where outgoing traffic is
paced. `src/lib/rate-limiter.ts` holds a hand-rolled `RateLimiter` — a
concurrency cap plus a minimum delay between call *starts* — and
`slackRateLimiter`, the process-wide instance every `SlackClient` shares by
default (`SLACK_MAX_CONCURRENT_REQUESTS`, `SLACK_MIN_REQUEST_INTERVAL_MS`).

It exists because Slack's Enterprise Grid anomaly detection raises
[`unexpected_api_call_volume`](https://docs.slack.dev/reference/audit-logs-api/anomalous-events-reference/)
when a client outpaces what a browser would do, and it can log the session out.
Several paths fan out one call per entity — `getUsersInfo()`,
`enrichSavedItems()` in `saved.ts`, and the unread resolver in `unread.ts` — so
the gate sits in `request()` and those modules need no throttling of their own.

Two consequences worth knowing:

- The pacing applies to **both** auth types. The anomaly is about volume, not
  about which transport produced it.
- Commands that resolve many names (`saved list`, `conversations unread`) are
  measurably slower on large workspaces. That is the trade; keep the spinner
  running so it does not look hung.

The raw `fetch()` calls used for file upload/download are single-shot per
invocation, so they bypass the limiter.

### Retry (browser auth)

Pacing keeps traffic low; retry reacts when Slack pushes back anyway. The
standard path gets it from `@slack/web-api`, which retries by itself. The browser
path gets it from `request()`, driven by the pure policy in `src/lib/retry.ts`
(`decideRetry()`):

- **HTTP 429 → retried for every method**, reads and writes, after the
  `Retry-After` seconds (exponential backoff with jitter when the header is
  missing, `0` or not whole seconds). Slack rejected the request, so a retry
  cannot apply it twice.
- **5xx and network errors → retried for read methods only**, with exponential
  backoff and jitter. A write that failed that way may still have been applied,
  so retrying could double-post; the error is surfaced instead. Reads are an
  explicit allowlist (`READ_METHODS`): a method not on it — including any new
  one — is treated as a write. Add a new read method to it.
- **`ok:false` Slack errors are never retried**, nor is an unparseable body.
- Caps: 3 retries, 60 s per wait, 120 s of waiting per call. A wait that would
  overrun the total ends the call with the last error, unchanged.

Each attempt takes its own limiter slot, and the wait happens *between*
`rateLimiter.run()` calls, so a call sitting out `Retry-After: 30` holds no slot
and every other call keeps flowing. Retries are logged at `info` with the
method, attempt, wait and HTTP status only. `SlackClientOptions` takes `sleep`
and `retry` overrides for tests.

### Authentication failures

Both transports report Slack's refusal of the credentials as an `ok:false`
payload, and `request()` reads its code the same way for each. When the code is
one of `invalid_auth`, `token_expired`, `token_revoked`, `not_authed` or
`account_inactive`, the attempt rethrows it as a `SlackAuthError`
(`src/lib/auth-errors.ts`) whose `message` already holds the three lines a user
needs: the profile and the verbatim code, what the code means, and a `To fix:`
line that depends on the profile's auth type. Commands print `err.message` as
they do for any failure, so no command handles it specially, and the original
payload stays on `slackData`. Every other code passes through unchanged.

The classifier is pure and takes profile metadata only (key, name, auth type,
workspace URL), never a credential. `auth.ts` catches the same error on the
login paths, where the refused token is the one just supplied, and reports a
rejected token rather than advice to log in again.

### Where the two genuinely diverge

A handful of methods branch on `authType` because Slack itself offers different
endpoints. Each divergence is one method, and each is a deliberate trade:

| Method | Browser | Standard |
|---|---|---|
| `createDraft` | `drafts.create` | throws — no public API exists |
| `listDrafts` | `drafts.list` | throws — no public API exists |
| `addEmoji`, `addEmojiAlias`, `removeEmoji` | `emoji.add` (multipart for an image), `emoji.remove` | throws — only the Enterprise Grid `admin.emoji.*` API exists |
| `scheduleMessage`, `listScheduledMessages`, `deleteScheduledMessage` | throws — Slack answers `not_allowed_token_type` (`requireStandardAuth()`) | `chat.scheduleMessage`, `chat.scheduledMessages.list`, `chat.deleteScheduledMessage` |
| `listSavedItems` | `saved.list` | `stars.list` |
| `addSavedItem`, `updateSavedItem`, `deleteSavedItem` | `saved.add`, `saved.update`, `saved.delete` (one item: `item_type=message`, `item_id`, `ts`) | throws — `stars.*` is a different list |
| `searchModules` | `search.modules` | list + client-side filter (capped at 1000) |
| `getUnreadCounts` | `client.counts` (channels, DMs and the `threads` summary) | `conversations.list` unread fields — no thread equivalent |
| `getUnreadThreadView` | `subscriptions.thread.getView` (undocumented; one page of the Threads view, paged with `current_ts`) | throws — no public API exists |
| `fetchMessage` (`src/lib/message.ts`) | `messages.list` — resolves replies too | `conversations.history` — top-level only |

When you add a feature that only one auth type can support, follow this shape:
implement the capable path, degrade or fail loudly on the other, and say so in
the command's `--help` text and in the [user guide](/docs/user-guide/).

## Workspace storage

`src/lib/workspaces.ts` owns `~/.config/slackcli/workspaces.json` (file `0600`
inside a `0700` directory).

The map key is a **profile key**. Resolution and key derivation are pure
functions with no I/O — `resolveWorkspace()` and `deriveStorageKey()` — which is
why they are heavily unit-tested.

- `resolveWorkspace()` tries, in order: exact profile key → explicit `profile`
  field → workspace ID → workspace name. A selector matching more than one
  record throws `AmbiguousWorkspaceError` instead of guessing.
- `deriveStorageKey()` keeps backward compatibility: the first identity for a
  team is stored under its bare `team_id`, exactly as before profiles existed.
  Re-authenticating the same identity (team + auth type + `user_id`) refreshes in
  place; a *different* identity gets `T123-2` rather than overwriting the first.

If you touch either function, assume there are legacy config files in the wild
that predate the `profile` and `user_id` fields — the tests encode that.

### Credentials go through a `SecretStore`

Secrets (`token`, `xoxc_token`, `xoxd_token`) are never read or written on a
record directly. `src/lib/secret-store.ts` defines a small `SecretStore`
interface (`get`/`set`/`delete` on string keys of the form
`<profile key>:<token|xoxc|xoxd>`), and `workspaces.ts` splits every config into
metadata (`metadataOf()`) plus secrets (`storeCredentials()` /
`loadCredentials()` / `deleteCredentials()`).

- `FileSecretStore` keeps secrets inline on the record under their legacy field
  names — so an all-file `workspaces.json` is byte-for-byte unchanged. It
  mutates the loaded document; `saveWorkspaces()` still does the one write.
- `MacOSKeychainSecretStore` (macOS only) shells out to the `security` CLI
  (service `slackcli`, account `<profile key>:<token|xoxc|xoxd>`) rather than
  an FFI binding — the `cdp-client.ts` trade-off again. Its `SecurityCliRunner`
  is injectable, the same seam `cdp-client.test.ts` uses for an untestable
  transport, so it is fully unit-tested without a real Mac or keychain.
- `get` returns `null` only when a secret does not exist. A backend that is
  unavailable or refuses access throws `SecretStoreError` (`reason`
  `unavailable` / `access_denied`); a record whose secret is missing surfaces as
  `MissingCredentialError`. Keep those three cases distinct.
- Only `getWorkspace()` and `getWorkspaceEntry()` (the same lookup, keeping the
  profile key for `auth whoami` and `auth list --check`) resolve secrets. Plain
  `auth list` and `auth set-default` work from metadata; `auth remove` and `auth logout` delete credentials before
  the record, so a failing backend leaves the profile listed and retryable.
- The document-level operations (`putWorkspace`, `readWorkspace`,
  `dropWorkspace`, `dropAllWorkspaces`) take the document and a store and do no
  I/O — test new credential behaviour there, with an in-memory store, never
  against the real `~/.config/slackcli`.

### Multiple backends in one document: `RoutingSecretStore` and migration

A profile's `secret_backend` field (absent = `'file'`) says which backend its
credentials are on. `workspaces.ts`'s `secretStoreFor()` wraps every document
in a `RoutingSecretStore`, which re-reads that field on every call and
dispatches to `FileSecretStore` or the Keychain store accordingly — so one
document can hold some profiles on each backend at once, and a profile mid
re-tag is never read from the wrong place.

`putWorkspace()` only applies its `backend` argument to a **brand-new**
profile; refreshing an existing one (a plain token rotation via `auth login`)
always keeps whatever backend it is already tagged with, ignoring the
argument. Otherwise an ordinary refresh — which passes Commander's `'file'`
default, not the user's original choice — would silently migrate a Keychain
profile back to plaintext. The only sanctioned way to change an existing
profile's backend is `migrateWorkspaceCredentials()` /
`auth migrate-secrets`.

Migration takes explicit `{ file, keychain }` backend instances
(`secretBackendsFor()`), never the routing store — the routing store starts
reading the *new* backend the instant the metadata flips mid-migration, so the
function needs a reference to the *old* backend that survives that flip.
Order, all covered by `workspaces.test.ts`'s failure-injection tests:

1. Read + validate credentials on the current backend.
2. Write them to the target backend; a write failure cleans up the target and
   leaves everything else untouched.
3. Read them back from the target and compare byte-for-byte
   (`credentialsMatch()`); a mismatch cleans up the target the same way.
4. Flip `secret_backend` on the in-memory record — replacing it with fresh
   metadata, which also clears an old *file* backend's inline secret as a side
   effect, not a separate step.

The **IO wrapper** (`migrateSecrets()`) then does two things the document-level
function deliberately does not: `saveWorkspaces()` to persist the flip
durably, and only *after* that succeeds, deletes the old backend's copy. That
ordering — durably commit the new copy before removing the last old one — is
the same rule `putWorkspace()` follows for an auth-type switch; breaking it
either way risks a state with neither backend holding a valid credential.
Migrating "every profile" loops one profile at a time through this same
sequence rather than batching, so an interruption mid-batch leaves the
document in a valid, resumable state and a re-run only touches what is left.

## Authentication flows

`src/lib/auth.ts` orchestrates login and is the only place that decides a token
is valid.

- `authenticateStandard()` and `authenticateBrowser()` build a temporary config,
  call `auth.test`, then persist the real `team_id` / `user_id` from the
  response.
- `authenticateAuto()` — the `login-auto` flow — deliberately routes
  verification and persistence back through `authenticateBrowser()`, so a
  workspace enrolled by the browser is indistinguishable from one added by hand.
- `getAuthenticatedClient(identifier?)` is what every command calls to get a
  ready `SlackClient`. It is also the one place the workspace is chosen:
  `effectiveWorkspaceSelector(flag, env)` — pure — picks the `--workspace` value,
  then `SLACKCLI_WORKSPACE` (trimmed; empty counts as unset), then the stored
  default, and `selectWorkspace()` resolves that through `getWorkspace()` or
  throws. A selector that matches nothing never falls back to the default, and
  the error names the variable when the value came from it. Commands keep
  passing `options.workspace`; they never read the variable themselves. The
  `auth` management commands (`list`, `set-default`, `remove`, logins) do not go
  through this seam and so ignore the variable.

Per-workspace failures in `authenticateAuto()` are collected rather than thrown:
when several workspaces are captured at once, one stale token must not discard
the rest.

The browser profile outlives the Slack session it holds, and capture can return
a dead token from `localConfig_v2` before Slack redirects to sign-in. So when an
interactive run saves nothing and every failure is Slack refusing the
credentials (`authenticateBrowser()` keeps the `SlackAuthError` as the thrown
error's `cause`), `authenticateAuto()` keeps the browser open and calls
`captureSlackTokens()` again with the refused tokens in `rejectedTokens` and the
rest of the one `--timeout` budget. Both capture sources skip those tokens, so
the loop ends on a fresh sign-in or a `capture_timeout`. Headless runs do not
retry.

## Browser token capture (`login-auto`)

Three modules, in order:

1. **`browser-launcher.ts`** — finds a local Chrome/Edge/Chromium/Brave
   (`SLACKCLI_BROWSER` overrides), launches it against SlackCLI's own profile
   directory with remote debugging on loopback, and cleans it up.
2. **`cdp-client.ts`** — a ~200-line Chrome DevTools Protocol client over Bun's
   WebSocket. Playwright would be the obvious alternative, but SlackCLI ships as
   a `bun build --compile` binary: bundling Playwright blows the 150 MB CI
   budget, and an external one cannot be resolved from a downloaded binary at
   all. The transport edge (`connectCdpSocket`) is kept a few lines long;
   everything decidable lives in `createCdpSession`, behind a `CdpSocket` seam so
   it unit-tests without a browser.
3. **`browser-auth.ts`** — captures the pair. The `xoxc-*` token comes from
   intercepted API requests (which proves it is live); the `xoxd-*` value comes
   from the browser's cookie store, because the `d` cookie is `HttpOnly` and page
   JavaScript cannot read it. It also reads `localConfig_v2` from localStorage to
   enumerate workspaces the user is signed into but has not opened — that union
   is why one sign-in enrols every workspace. localStorage is Slack client
   internals and may change shape without notice, so a failure to read it
   degrades to whatever interception found rather than failing the run.

Security invariants worth preserving if you work here: only `https://` URLs on a
`slack.com` host are ever paired with the session cookie, and that check is
re-done at the point the credential is used rather than being delegated; the
browser is closed whether or not capture succeeded; and `auth logout` deletes the
browser profile, because while it exists it can re-mint working tokens with no
prompt.

## Parsers

Each is dependency-free and pure, so each is directly testable:

| Module | Job |
|---|---|
| `curl-parser.ts` | Pull `xoxd`/`xoxc` out of a DevTools cURL command. Handles URL-encoded tokens, `-b` / `--cookie` / `-H 'Cookie:'`, and enterprise Slack URLs. The most thoroughly tested file in the repo — use it as the model for new tests. |
| `slack-url-parser.ts` | Normalise Slack URLs, permalinks, and timestamps into IDs. Also produces the workspace-mismatch warning. |
| `mrkdwn.ts` | Slack mrkdwn → `rich_text` blocks, for drafts. |
| `canvas-parser.ts` | Slack canvas (Quip-based) HTML → Markdown, zero dependencies. |

## Output

`src/lib/formatter.ts` holds every chalk-coloured renderer plus `writeText()`,
`writeJson()`, `success()`, `error()`, `info()`, `warning()`.

Every command result print goes through one of two stdout sinks —
`writeJson()` for `--json` and `writeText()` for text — never `console.log`.
Both use `process.stdout.write`; `success()` and `info()` are built on
`writeText()`. Diagnostics stay off stdout: `error()` and `warning()` write to
stderr (so `--json` stdout carries exactly one object), and an error hint that
accompanies them uses `console.error`.

One rule with teeth — read the comment above `writeJson()`/`writeText()` before
changing anything about output:

> **Never call `process.exit()` after `writeJson()` or `writeText()`.** `ora`
> materialises Bun's Node-compat `WriteStream` at import time, which routes
> stdout through an async path. Exiting immediately drops everything past the
> 64 KiB pipe buffer, giving you silently truncated output *with exit code 0* —
> invalid JSON on the `--json` path, a short list on the text path. Set
> `process.exitCode` and return instead.

That is [issue #73](https://github.com/shaharia-lab/slackcli/issues/73) for the
JSON path and [#373](https://github.com/shaharia-lab/slackcli/issues/373) for the
text path (the hazard [#77](https://github.com/shaharia-lab/slackcli/issues/77)
tracked). `console.log` is the trap both sinks exist to avoid, so a guard test
(`src/output-sink.test.ts`) fails the build if any non-test file in
`src/commands/` or `src/lib/` calls it — covering shared result-output helpers
such as `emitDryRun()` in `src/lib/dry-run.ts`
([#377](https://github.com/shaharia-lab/slackcli/issues/377)). The scan is
default-on with a by-name exemption for `interactive-input.ts` (prompts, not a
command result), so a new helper is covered the moment it lands.

### Failures

A command reports a failure through `failCommand()` in
`src/lib/command-errors.ts` and returns; it never exits the process itself.
Without `--json` it prints what commands always printed (`spinner.fail(context)`
then `error(message, hint)`). With `--json` it stops the spinner silently and
writes one `{"error":{code,message,hint?,retryable,slack_error?}}` line to
stderr, so stdout stays empty (#326). `classifyError()` picks the `code` from
the error's type (`SlackAuthError`, the `CliError` family in `cli-errors.ts`,
`SlackUrlParseError`, `SlackTransportError`) or from Slack's own code
(`slackData.error`, attached by both transports) through `SLACK_CODE_MAP`, never
from message text, and redacts credentials from `message` and `hint` with the
log patterns. An error no command caught reaches `handleFatalError()`
(`process-errors.ts`), which writes the same object when the `preAction` hook
recorded `--json` (`setJsonErrorMode()`). The update notice is skipped once the
exit code is non-zero, so the error object stays the last line of stderr.

## Logging

Every run writes a JSON Lines log through [LogTape](https://logtape.org). The
pieces:

- **`src/lib/logger.ts`** is configuration only. `startLogging()` runs once, in
  the root command's `preAction` hook in `src/program.ts` (or, for a rejected
  command line, from the exit override described below), so `--help` and
  `--version` stay side-effect free. It resolves the level and directory,
  configures LogTape, and writes a `session_start` record.
- **Usage errors** (unknown option, missing argument, invalid value, …) are
  rejected by Commander before `preAction` runs, so they are logged from an
  exit override instead. `installUsageErrorLogging()` walks the whole command
  tree after the last `addCommand()` and installs one on every command:
  `addCommand()` does not copy the parent's override, and Commander only calls
  the failing command's own. On a code `isUsageError()` accepts, the override
  calls `startLogging()` for the failing command (unless `preAction` already
  did) and writes a `usage_error` record in `slackcli.cli` with the Commander
  `code`, `exit_code` and command path, then exits with Commander's exit code.
  Commander's message is not logged, since `invalidArgument` echoes the user's
  value. Help and version exits skip logging entirely.
- **Libraries log through categories**, `getLogger(['slackcli', '<area>'])`,
  imported straight from `@logtape/logtape` (not from `logger.ts`, which imports
  `updater.ts` and would invite an import cycle). A logger is a no-op until
  configured, so lib code and its tests need no setup. Current categories:
  `session`, `slack-client`, `slack-web-api` (the SDK's own messages),
  `rate-limiter`, `auth`, `browser-launcher`, `cdp`, `browser-auth`,
  `workspaces`, `secret-store`, `updater`, `process`. Paths go through
  `tildify()` (`src/lib/tildify.ts`), every logged `error` field through
  `errorMessageForLog()`, and stack traces through `tildifyText()`, so the home
  directory (and with it the username) is written as `~`.
- **Sinks.** A rotating file sink (`@logtape/file`, 5 MiB per file, the live
  file plus 5 rotated ones, so about 30 MB at most) with `bufferSize: 0`, so
  every record is on disk before the next line of code runs
  and survives any `process.exit()`. A stderr sink is added only with
  `-v/--verbose`. **stdout is never a sink**, so `--json` output is unaffected.
  The `["logtape", "meta"]` category is configured explicitly, because an
  unconfigured meta logger gets LogTape's default console sink, which prints to
  stdout.
- **Every record carries `run_id`**, added by the sinks, so one run's lines can
  be pulled out of a shared file.
- **Redaction** (`src/lib/log-redaction.ts`, `@logtape/redaction`) is applied to
  the formatted line in every sink: Slack tokens (`xox?-…`, including
  `xoxe.xoxp-` and URL-encoded values), the `d=` cookie, and JWTs. It is the
  safety net. The policy is that call sites **never log request params, message
  text, file contents or search queries** — log IDs, method names, counts,
  statuses and durations. `SlackClient.request()` logs only param *names*, at
  `trace`. The SDK adapter drops `@slack/web-api`'s debug output, which
  serialises full request bodies. The `login-auto` path logs outcomes and typed
  failure reasons only: `cdp` never logs command `params`/`result` or event
  payloads, `browser-auth` logs "found / not found" and team IDs, never a
  token, cookie or `postData`, and the launcher logs the start URL's origin
  only. A corrupt `workspaces.json` is logged by its parse error, with quoted
  fragments (which can be file contents) replaced by `<redacted>`.
- **Unhandled errors**: `src/index.ts` runs `program.parse()`, so a rejection
  from an async action is not awaited. `installProcessErrorHandlers()`
  (`src/lib/process-errors.ts`), registered in `preAction` after logging is
  configured, logs `unhandledRejection` / `uncaughtException` at `error` with
  the stack, prints the message with `formatter.error`, and exits 1.
- **Location**: `SLACKCLI_LOG_DIR`, else `$XDG_STATE_HOME/slackcli/logs` (default
  `~/.local/state/slackcli/logs`) on Linux, `~/Library/Logs/slackcli` on macOS,
  `%LOCALAPPDATA%\slackcli\logs` on Windows. The directory is created `0o700`
  and the file kept `0o600`; the rotating sink opens files itself, so the
  process umask is tightened to `077` around its calls to cover files created by
  rotation. An existing directory's mode is left alone.
- **Level**: `-v` (debug) > `SLACKCLI_LOG_LEVEL` > `info`; `-v` never lowers an
  explicit `trace`. `off` configures no sinks and creates no file.
- **`slackcli logs` does not write the file** (`shouldWriteLogFile()`): `logs
  show` would otherwise show its own run, and `logs clear` would delete a file
  the process holds open (which fails on Windows). Reading is in
  `src/lib/logs.ts`, which re-applies `SLACK_REDACTION_PATTERNS` to every line.
- **Fail-safe**: an unusable directory, or a write failing mid-run, disables the
  file sink with exactly one stderr warning. Logging never fails a command.

`session_start` records `version`, `os`, `os_release`, `arch`, `runtime`,
`install_method` (`homebrew` | `source` | `binary`, reusing
`isRunningUnderBun()` then `isInstalledViaHomebrew()`, so a source run under a
Homebrew-installed Bun reports `source`), `exec_path` (home as
`~`), `command` (the subcommand path), `options` (option **names** given on the
command line, never values or positional arguments) and `stdout_tty`.

## Adding to the client

To wire up a new Slack API method:

1. Add a method to `SlackClient` that calls `this.request(...)`.
2. Branch on `this.config.auth_type` **only** if Slack genuinely offers
   different endpoints — and document the divergence.
3. Add types to `src/types/index.ts` rather than passing `any` around.
4. Add a formatter if the output is human-facing, and a `--json` shape if it is
   script-facing.

The call is logged for free by `request()` (method, auth type, duration,
outcome, Slack error code; the failure line's `reason` is that code, else
`HTTP <status>`, else `request error`). Do not add logging that includes `params`.

Worked example: [adding a command](/docs/development/adding-a-command/).