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 Commander program; registers 7 command groups │ ▼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 interfacesThe 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.
Dual authentication
Section titled “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:
async request(method: string, params = {}) { return this.rateLimiter.run(() => this.config.auth_type === 'standard' ? this.standardRequest(method, params) : this.browserRequest(method, params));}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
Section titled “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
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.
Retry/backoff on HTTP 429 is deliberately not here — it is a separate concern
from pacing, and the raw fetch() calls used for file upload/download are
single-shot per invocation, so they bypass the limiter.
Where the two genuinely diverge
Section titled “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 |
listSavedItems |
saved.list |
stars.list |
searchModules |
search.modules |
list + client-side filter (capped at 1000) |
getUnreadCounts |
client.counts |
conversations.list unread fields |
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.
Workspace storage
Section titled “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 → explicitprofilefield → workspace ID → workspace name. A selector matching more than one record throwsAmbiguousWorkspaceErrorinstead of guessing.deriveStorageKey()keeps backward compatibility: the first identity for a team is stored under its bareteam_id, exactly as before profiles existed. Re-authenticating the same identity (team + auth type +user_id) refreshes in place; a different identity getsT123-2rather 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.
Authentication flows
Section titled “Authentication flows”src/lib/auth.ts orchestrates login and is the only place that decides a token
is valid.
authenticateStandard()andauthenticateBrowser()build a temporary config, callauth.test, then persist the realteam_id/user_idfrom the response.authenticateAuto()— thelogin-autoflow — deliberately routes verification and persistence back throughauthenticateBrowser(), so a workspace enrolled by the browser is indistinguishable from one added by hand.getAuthenticatedClient(identifier?)is what every command calls to get a readySlackClient.
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.
Browser token capture (login-auto)
Section titled “Browser token capture (login-auto)”Three modules, in order:
browser-launcher.ts— finds a local Chrome/Edge/Chromium/Brave (SLACKCLI_BROWSERoverrides), launches it against SlackCLI’s own profile directory with remote debugging on loopback, and cleans it up.cdp-client.ts— a ~200-line Chrome DevTools Protocol client over Bun’s WebSocket. Playwright would be the obvious alternative, but SlackCLI ships as abun build --compilebinary: 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 increateCdpSession, behind aCdpSocketseam so it unit-tests without a browser.browser-auth.ts— captures the pair. Thexoxc-*token comes from intercepted API requests (which proves it is live); thexoxd-*value comes from the browser’s cookie store, because thedcookie isHttpOnlyand page JavaScript cannot read it. It also readslocalConfig_v2from 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
Section titled “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
Section titled “Output”src/lib/formatter.ts holds every chalk-coloured renderer plus success(),
error(), info(), warning().
One rule with teeth — read the comment above writeJson() before changing
anything about output:
Never call
process.exit()afterwriteJson().oramaterialises Bun’s Node-compatWriteStreamat import time, which routes stdout through an async path. Exiting immediately drops everything past the 64 KiB pipe buffer, giving you silently truncated JSON with exit code 0. Setprocess.exitCodeand return instead.
That is issue #73, and #77 tracks the same hazard on the non-JSON paths.
Adding to the client
Section titled “Adding to the client”To wire up a new Slack API method:
- Add a method to
SlackClientthat callsthis.request(...). - Branch on
this.config.auth_typeonly if Slack genuinely offers different endpoints — and document the divergence. - Add types to
src/types/index.tsrather than passinganyaround. - Add a formatter if the output is human-facing, and a
--jsonshape if it is script-facing.
Worked example: adding a command.