Skip to content

Architecture

Edit on GitHub

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

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:

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.

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

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.

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.

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.

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.

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

Section titled “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.

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.

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.

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.

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 for the JSON path and #373 for the text path (the hazard #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). 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.

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.

Every run writes a JSON Lines log through LogTape. 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.

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.

Cookies