Purpose: a ~2000-token orientation file so Claude (and humans) can navigate this repo without exploring. Describes what is where;
AGENTS.mddescribes how to change things. Update when structure shifts, not on every new file.
@doist/twist-cli is a TypeScript CLI for Twist messaging. Binary name:
tw. It wraps @doist/twist-sdk and publishes a single executable
(dist/index.js).
ESM-only · Node >=24 · npm >=11 · Commander 14 · vitest · oxlint + oxfmt (no
eslint/prettier) · semantic-release on merge to main. Shared building blocks
(config I/O, output formatters, spinner, OAuth/keyring auth, command attachers)
come from @doist/cli-core.
/
├─ src/ # All source. See tree below.
├─ scripts/ # sync-skill.js, check-skill-sync.js, postinstall.js
├─ dist/ # Build output (tsc). Never edit.
├─ skills/twist-cli/ # Generated SKILL.md (from src/lib/skills/content.ts)
├─ .github/workflows/ # test, lint, release, check-skill-sync,
│ # check-semantic-pull-request, issue-automation,
│ # request-reviews, update-twist-sdk
├─ AGENTS.md # Prescriptive rules (build cmds, JSON flag, skill-sync, errors)
├─ CODEBASE.md # This file — descriptive map
├─ CLAUDE.md # One-liner forward to AGENTS.md
├─ tsconfig.json # Includes src + tests (type-check, IDE)
├─ tsconfig.build.json # Excludes *.test.ts/.spec.ts, __mocks__, __fixtures__
├─ vitest.config.ts # { globals, root: 'src', inlines @doist/cli-core }
├─ .oxlintrc.json / .oxfmtrc.json
├─ lefthook.yml # Pre-commit: type-check + oxlint + oxfmt; pre-push: tests
└─ release.config.js # semantic-release config
src/
├─ index.ts # Entry: Commander setup, lazy command registry, --user strip, early spinner
├─ commands/ # One file per flat command, one folder per group (+ colocated *.test.ts)
│ ├─ inbox.ts, mentions.ts, search.ts, react.ts, view.ts,
│ │ user.ts, workspace.ts, doctor.ts, changelog.ts
│ ├─ thread/, conversation/, msg/, comment/, channel/, groups/,
│ │ away/, account/, auth/, config/, skill/, completion/, update/
│ └─ <group>/index.ts # registerXxxCommand(program) + sibling files per subcommand
├─ lib/ # Shared utilities. See catalog — don't reimplement.
│ ├─ skills/ # content.ts (SKILL_CONTENT) + installer plumbing
│ └─ __fixtures__/ # accounts.ts, channels.ts — test fixtures (excluded from build)
└─ __mocks__/ # Manual vitest mocks for npm packages (chalk.ts)
# (program harness + console spies: @doist/cli-core/testing)
src/index.tssetsprogram.name('tw'), registers global flags (--no-spinner,--progress-jsonl [path],--include-private-channels,--accessible,--non-interactive,--interactive), and builds a lazy command registry —Record<name, [description, loader]>.- Lightweight placeholder subcommands are registered so
--helplists everything (with aliases) without importing any command module. - The global
--user <ref>has no commander root option, so the cache is warmed viagetRequestedUserRef()and thenstripUserFlag()rewritesprocess.argvbefore commander parses (see Auth below). - The invoked command name is resolved (aliases first); placeholders sharing
that loader are spliced out and only its
register*Command(program)runs. For human output,preloadMarkdown()runs in parallel with the import (skipped fornoMarkdownCommandsand under--json/--ndjson/--raw).startEarlySpinner()covers import latency. parseAsync().catch(...)renders an uncaughtBaseCliErrorviaformatError()/formatErrorJson()(perisJsonMode()); anything else becomes anINTERNAL_ERRORenvelope.finallyalways stops the spinner.
completion-server is a fast path: it loads only the completion module plus the
single command being completed (parsed from COMP_LINE).
- Flat command (e.g.
inbox.ts): exportsregisterInboxCommand(program)that callsprogram.command('inbox')and attaches an action. - Group command (e.g.
thread/):index.tsexportsregisterThreadCommand(program), createsconst thread = program.command('thread'), then wiresthread.command('<sub>')to sibling files (thread/view.ts,thread/reply.ts, …). Shared logic lives in<group>/helpers.ts. - Implicit
viewsubcommand:thread,conversation,msgregister.command('view <ref>', { isDefault: true })sotw thread <ref>→tw thread view <ref>. A ref colliding with a subcommand name loses to the subcommand. - Aliases:
channels→channel,convo→conversation,message→msg.
Subcommand enumeration lives in src/lib/skills/content.ts (SKILL_CONTENT) —
don't duplicate it here.
- Threads (
thread/) — view, create, reply, rename, update, mutate (move), mute, delete - Channels (
channel/) — list, threads, add, set, remove, members (membership control) - Conversations / DMs (
conversation/) — view, with, reply, unread, mute, unmute, done; messages (msg/) — view, update, delete - Comments (
comment/) — view, update, delete - Groups (
groups/) — list, view, create, rename, delete, members - Top-level reads —
inbox,mentions,search,view(URL router) - Reactions —
react/unreact(thread, comment, message) - Presence —
away(set/clear/status) - Identity & infra —
user/users,workspace/workspaces,auth(login/logout/token/status),account(list/current/use/remove),config,skill,completion,update,changelog,doctor
api.ts—getTwistClient()/createWrappedTwistClient()singletonTwistApi(workspace + user caching,API_SPINNER_MESSAGES), domain wrappers (fetchWorkspaces,getSessionUser,getWorkspaceUsers, group/channel-member mutators), and the batch helpersassertBatchData/getOptionalBatchData/buildBatchNameMap/buildOptionalBatchNameMap.refs.ts— flexible reference resolution:isIdRef,extractId,looksLikeRawId,parseRef,parseTwistUrl/classifyTwistUrl, and async resolversresolveWorkspaceRef,resolveChannelRef/Id,resolveThreadId,resolveConversationId,resolveMessageId,resolveCommentId,resolveGroupRef,resolveUserRefs,resolveChannelMemberRefs.output.ts—formatJson/formatNdjson(+ paginated variants) with per-EntityTypeessential-field filtering,formatError/formatErrorJson,printJson/printNdjson/printEmpty/printDryRun,colors,pluralize.options.ts—ViewOptions,PaginatedViewOptions,MutationOptions(extend these rather than addingjson/full/ndjsonad hoc).config.ts—~/.config/twist-cli/config.jsonI/O over cli-core (getConfig,readConfigStrict,setConfig,updateConfig),CONFIG_VERSION,validateConfigForDoctor,Config/StoredUsershapes.auth.ts— read-side token resolver:getApiToken,probeApiToken,getAuthMetadata,NoTokenError,TOKEN_ENV_VAR(TWIST_API_TOKEN).auth-provider.ts—createTwistAuthProvider()(cli-core DCR provider with a Twistvalidatehook),createTwistTokenStore()(cli-core keyring store wrapped with env-token/legacy fallbacks),matchTwistAccount,getScopes,isLegacyAuthActive,getActiveTokenSource.user-records.ts—UserRecordStore<TwistAccount>adapter overconfig.users[](REPLACE-not-mergeupsert).twist-account.ts—makeTwistAccount/toTwistAccountmappers.migrate-auth.ts—runMigrateLegacyAuth(thin wrapper over cli-core'smigrateLegacyAuth);auth-constants.ts— keyring service/slot names;auth-pages.ts— branded OAuth callback HTML.global-args.ts—isJsonMode,isNdjsonMode,getRequestedUserRef,isNonInteractive,includePrivateChannels,isAccessible,shouldDisableSpinner, progress-jsonl getters (layered on cli-core's parser).permissions.ts—ensureWriteAllowed,isMutatingMethod(read-only-scope guard).markdown.ts—preloadMarkdown/renderMarkdown(cli-core renderer + Twist mention preprocessing).search-api.ts/search-helpers.ts— extended search params/response + shared--search/options wiring and result printing.spinner.ts— re-exportsLoadingSpinner,withSpinner,startEarlySpinner,stopEarlySpinnerfrom cli-core.completion.ts— Commander tree-walker +parseCompLine,getCompletions,withCaseInsensitiveChoices,withUnvalidatedChoices.progress.ts—ProgressTrackerJSONL event writer (--progress-jsonl).dates.ts—formatRelativeDate,parseDate;input.ts—readStdin,openEditor;public-channels.ts— public-channel id cache +assertChannelIsPublic;update.ts—fetchLatestVersion,getConfiguredUpdateChannel;errors.ts—CliError(code, message, hints?),ErrorCodeunion,isInsufficientScope.skills/content.ts—SKILL_CONTENT(agent command reference, source of truth).
- Read with batch:
src/commands/inbox.ts— strict primary data + tolerant unread via theapi.tsbatch helpers, thenformatJson/formatNdjson/printEmpty. - Mutation with
--json:src/commands/away/set.ts— the reference impl forMutationOptions/ minimal status objects (per AGENTS.md). - Grouped command:
src/commands/thread/index.ts+ siblings — implicitviewdefault, one file per subcommand,thread/helpers.tsfor shared logic.
All in src/lib/refs.ts. A ref is one of: a bare numeric id (123), an
id:-prefixed id, a full Twist URL (parseTwistUrl → classifyTwistUrl routes
tw view <url>), or a fuzzy name (workspaces/users/channels/groups). Async
resolvers return the resolved id or entity and throw CliError (e.g.
AMBIGUOUS_*, *_NOT_FOUND) on miss. looksLikeRawId() decides when a string
is tried as an id vs a name.
@doist/cli-core/auth owns the keyring, multi-user TokenStore, OAuth flow,
and the login / logout / status / token view and account list/use/current/remove registrars. twist supplies (a) a
UserRecordStore<TwistAccount> adapter (user-records.ts) over its config file
and (b) a Twist DCR provider validate (auth-provider.ts) that probes
getSessionUser and records auth mode/scope.
Read path (auth.ts): env TWIST_API_TOKEN first, then the v2 keyring store
(createTwistTokenStore — wrapped with env-token + legacy-snapshot fallbacks),
then legacy plaintext config.token. Writes/clears/lists route through the
store; commands never touch the config directly.
twist uses a global --user <ref> (accepted before the subcommand). Because
commander has no root --user option, index.ts strips it from argv
(stripUserFlag) after warming getRequestedUserRef(). commands/auth/store-wrap.ts
withUserRefAware substitutes that ref into the cli-core attachers (which only
see a per-command --user). v1→v2 migration runs via migrate-auth.ts →
cli-core's migrateLegacyAuth.
Follow-up: once cli-core ships
getRequestedUserRefon the auth attachers (Doist/cli-core#30),store-wrap.ts/withUserRefAwarecan be deleted.
- Runner: vitest.
npm test(one-shot),npm run test:watch. Single file:npx vitest run src/lib/refs.test.ts. - Location: colocated
*.test.tsnext to the module under test. - Helpers:
createTestProgram(register)(Commander harness) andcaptureConsole(method)/captureStream(stream)(output spies) come from the shared@doist/cli-core/testingsubpath — not local files. Fixtures insrc/lib/__fixtures__/(accounts.ts,channels.ts) — don't hand-build account/channel objects. Manual npm-package mocks insrc/__mocks__/(chalk.ts). @doist/cli-coreinlining:vitest.config.tslists it inserver.deps.inlinesovi.mock('@doist/cli-core', …)/vi.doMock('node:fs/promises', …)reach its compiled imports — without it the auth/config/spinner suites break.
- Build:
tsc -p tsconfig.build.json→dist/(thenchmod +x). Two-tsconfig setup:tsconfig.jsonincludes tests (type-check/IDE);tsconfig.build.jsonexcludes*.test.ts/.spec.ts,__mocks__,__fixtures__. - Type-check:
npm run type-check. Lint/format:npm run lint(oxlint --fix + oxfmt),npm run lint:check(CI). No ESLint, no Prettier. - Pre-commit: lefthook (type-check + oxlint + oxfmt); pre-push: tests.
- Release: semantic-release on merge to
main; Conventional Commits required (enforced bycheck-semantic-pull-request.yml).
src/lib/skills/content.ts (SKILL_CONTENT) is the source of truth for the
agent command reference. When commands/flags change:
- Update
SKILL_CONTENT. npm run build && npm run sync:skill→ writesskills/twist-cli/SKILL.md.tw skill update claude-code(and other installed agents) propagates it.check-skill-sync.ymlrunsnpm run check:skill-syncon PRs — fails ifSKILL.mdis out of sync withcontent.ts.
node dist/index.js --help
node dist/index.js inbox
node dist/index.js <cmd> ...Uses the same token lookup as the installed tw binary — TWIST_API_TOKEN,
config file, or a token in the OS credential manager via tw auth login.
- Filenames: kebab-case; no barrels except per-group
index.tsCommander wiring. - User-facing errors:
throw new CliError(code, message, hints?)fromsrc/lib/errors.ts— neverprocess.exit(1)(strands the spinner). The global handler also catchesBaseCliErrorfrom cli-core helpers. - Machine output: support
--json/--ndjson(and--fullon commands that return an object); checkisJsonMode()/isNdjsonMode()before printing. Mutations extendMutationOptionsand emit viaformatJson(). - Every user-facing SDK call gets an
API_SPINNER_MESSAGESentry inapi.ts. - Never read
.dataoff aclient.batch(...)result — use theapi.tsbatch helpers. - Status glyphs (
✓/✗) allowed; otherwise no emojis.
src/index.ts— entry + lazy command registrysrc/commands/inbox.ts— canonical read (batch + pagination)src/commands/away/+src/commands/thread/index.ts— canonical mutation & group commandsrc/lib/refs.ts+src/lib/output.ts+src/lib/api.ts— what's already builtsrc/lib/auth-provider.ts— the cli-core auth wiringAGENTS.md— rules you must follow