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 argvsrc/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 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.
--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
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 = {}) { 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
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.
The raw fetch() calls used for file upload/download are single-shot per
invocation, so they bypass the limiter.
Retry (browser auth)
Section titled “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-Afterseconds (exponential backoff with jitter when the header is missing,0or 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:falseSlack 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
Section titled “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
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 |
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.
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.
Credentials go through a SecretStore
Section titled “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()).
FileSecretStorekeeps secrets inline on the record under their legacy field names — so an all-fileworkspaces.jsonis byte-for-byte unchanged. It mutates the loaded document;saveWorkspaces()still does the one write.MacOSKeychainSecretStore(macOS only) shells out to thesecurityCLI (serviceslackcli, account<profile key>:<token|xoxc|xoxd>) rather than an FFI binding — thecdp-client.tstrade-off again. ItsSecurityCliRunneris injectable, the same seamcdp-client.test.tsuses for an untestable transport, so it is fully unit-tested without a real Mac or keychain.getreturnsnullonly when a secret does not exist. A backend that is unavailable or refuses access throwsSecretStoreError(reasonunavailable/access_denied); a record whose secret is missing surfaces asMissingCredentialError. Keep those three cases distinct.- Only
getWorkspace()andgetWorkspaceEntry()(the same lookup, keeping the profile key forauth whoamiandauth list --check) resolve secrets. Plainauth listandauth set-defaultwork from metadata;auth removeandauth logoutdelete 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:
- Read + validate credentials on the current backend.
- Write them to the target backend; a write failure cleans up the target and leaves everything else untouched.
- Read them back from the target and compare byte-for-byte
(
credentialsMatch()); a mismatch cleans up the target the same way. - Flip
secret_backendon 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
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. It is also the one place the workspace is chosen:effectiveWorkspaceSelector(flag, env)— pure — picks the--workspacevalue, thenSLACKCLI_WORKSPACE(trimmed; empty counts as unset), then the stored default, andselectWorkspace()resolves that throughgetWorkspace()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 passingoptions.workspace; they never read the variable themselves. Theauthmanagement 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)
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 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()afterwriteJson()orwriteText().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 output with exit code 0 — invalid JSON on the--jsonpath, a short list on the text path. Setprocess.exitCodeand 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.
Failures
Section titled “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
Section titled “Logging”Every run writes a JSON Lines log through LogTape. The pieces:
src/lib/logger.tsis configuration only.startLogging()runs once, in the root command’spreActionhook insrc/program.ts(or, for a rejected command line, from the exit override described below), so--helpand--versionstay side-effect free. It resolves the level and directory, configures LogTape, and writes asession_startrecord.- Usage errors (unknown option, missing argument, invalid value, …) are
rejected by Commander before
preActionruns, so they are logged from an exit override instead.installUsageErrorLogging()walks the whole command tree after the lastaddCommand()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 codeisUsageError()accepts, the override callsstartLogging()for the failing command (unlesspreActionalready did) and writes ausage_errorrecord inslackcli.cliwith the Commandercode,exit_codeand command path, then exits with Commander’s exit code. Commander’s message is not logged, sinceinvalidArgumentechoes the user’s value. Help and version exits skip logging entirely. - Libraries log through categories,
getLogger(['slackcli', '<area>']), imported straight from@logtape/logtape(not fromlogger.ts, which importsupdater.tsand 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 throughtildify()(src/lib/tildify.ts), every loggederrorfield througherrorMessageForLog(), and stack traces throughtildifyText(), 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) withbufferSize: 0, so every record is on disk before the next line of code runs and survives anyprocess.exit(). A stderr sink is added only with-v/--verbose. stdout is never a sink, so--jsonoutput 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?-…, includingxoxe.xoxp-and URL-encoded values), thed=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, attrace. The SDK adapter drops@slack/web-api’s debug output, which serialises full request bodies. Thelogin-autopath logs outcomes and typed failure reasons only:cdpnever logs commandparams/resultor event payloads,browser-authlogs “found / not found” and team IDs, never a token, cookie orpostData, and the launcher logs the start URL’s origin only. A corruptworkspaces.jsonis logged by its parse error, with quoted fragments (which can be file contents) replaced by<redacted>. - Unhandled errors:
src/index.tsrunsprogram.parse(), so a rejection from an async action is not awaited.installProcessErrorHandlers()(src/lib/process-errors.ts), registered inpreActionafter logging is configured, logsunhandledRejection/uncaughtExceptionaterrorwith the stack, prints the message withformatter.error, and exits 1. - Location:
SLACKCLI_LOG_DIR, else$XDG_STATE_HOME/slackcli/logs(default~/.local/state/slackcli/logs) on Linux,~/Library/Logs/slackclion macOS,%LOCALAPPDATA%\slackcli\logson Windows. The directory is created0o700and the file kept0o600; the rotating sink opens files itself, so the process umask is tightened to077around its calls to cover files created by rotation. An existing directory’s mode is left alone. - Level:
-v(debug) >SLACKCLI_LOG_LEVEL>info;-vnever lowers an explicittrace.offconfigures no sinks and creates no file. slackcli logsdoes not write the file (shouldWriteLogFile()):logs showwould otherwise show its own run, andlogs clearwould delete a file the process holds open (which fails on Windows). Reading is insrc/lib/logs.ts, which re-appliesSLACK_REDACTION_PATTERNSto 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
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.
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.