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/comms-cli is a TypeScript CLI for Comms messaging. Binary name:
tdc. It wraps @doist/comms-sdk and publishes a single executable
(dist/index.js).
ESM-only · Node ^20.19 || >=22.12 · 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/comms-cli/ # Generated SKILL.md (from src/lib/skills/content.ts)
├─ docs/ # SPEC.md, comms-search.md (design notes)
├─ icons/ # OAuth callback / skill logo assets
├─ .github/workflows/ # test, lint, release, check-skill-sync,
│ # check-semantic-pull-request, issue-automation,
│ # request-reviews, update-comms-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
├─ README.md / CONTRIBUTING.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
├─ renovate.json # Dependency automation
└─ release.config.js # semantic-release config
src/
├─ index.ts # Entry: Commander setup, lazy command registry, --user strip, early spinner
├─ postinstall.ts # Post-install notice logic (+ colocated test); scripts/postinstall.js calls it
├─ 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/,
│ │ 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 — test fixtures (excluded from build)
└─ __mocks__/ # Manual vitest mocks for npm packages (chalk.ts)
src/index.tssetsprogram.name('tdc'), 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()(from cli-core) 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 })sotdc thread <ref>→tdc 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, create, update - 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) - Identity & infra —
user/users,workspace/workspaces,auth(login/logout/token/status),account(list/current/use/remove),config,skill,completion,update,changelog,doctor
api.ts—getCommsClient()/createWrappedCommsClient()singletonCommsApi(workspace + user caching,API_SPINNER_MESSAGES), and domain wrappers (fetchWorkspaces,getSessionUser,getWorkspaceUsers,buildUserNameMap, group CRUD + member mutators). Commands callclient.<domain>.<method>()directly off the wrapped client.refs.ts— flexible reference resolution:isIdRef,extractId,looksLikeRawId,parseRef,parseCommsUrl/classifyCommsUrl(routestdc view <url>), and async resolversresolveWorkspaceRef,resolveChannelRef/Id,getDirectChannelId,resolveThreadId,resolveConversationId,resolveMessageId,resolveCommentId,resolveGroupRef,resolveUserRefs,parseNotifyIdRefs.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/comms-cli/config.jsonI/O over cli-core (getConfig,readConfigStrict,setConfig,updateConfig),validateConfigForDoctor,Config/StoredUser/UserSettingsshapes,UPDATE_CHANNELS.auth.ts— read-side token resolver:getApiToken,probeApiToken,getAuthMetadata,NoTokenError,TOKEN_ENV_VAR(COMMS_API_TOKEN).auth-provider.ts—createCommsAuthProvider()(cli-core DCR provider with a Commsvalidatehook),createCommsTokenStore()(cli-core keyring store wrapped with env-token + manual-token fallbacks),matchCommsAccount,getScopes,MANUAL_TOKEN_ACCOUNT/isManualTokenAccount,findAccountInStore,getActiveTokenSource. No legacy/v1 migration — this CLI shipped multi-account from the start.comms-account.ts—makeCommsAccount/toCommsAccountmappers.user-records.ts—UserRecordStore<CommsAccount>adapter overconfig.users[],getDefaultUserRecord.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).search-api.ts/search-helpers.ts— extended search params/response (extendedSearch) + shared--search/options wiring (addSharedSearchOptions,runSearch,printSearchResults). Seedocs/comms-search.md.threads.ts—fetchUnreadThreadIds;public-channels.ts— public-channel id cache +assertChannelIsPublic.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;validation.ts—validateNonEmptyName;update.ts—fetchLatestVersion,getConfiguredUpdateChannel;errors.ts—CliError(code, message, hints?),ErrorCodeunion,isInsufficientScope.skills/content.ts—SKILL_CONTENT(agent command reference, source of truth).
- Read:
src/commands/inbox.ts—getCommsClient(), parallel fetches (getInbox+fetchUnreadThreadIds), public-channel handling, thenformatJson/formatNdjson/printEmpty. - Mutation with
--json:src/commands/channel/create.ts— the reference impl forMutationOptions,ensureWriteAllowed,printDryRun, andformatJson(entity, 'channel', options.full). - 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 Comms URL (parseCommsUrl → classifyCommsUrl routes
tdc 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 attachers. comms supplies (a) a
UserRecordStore<CommsAccount> adapter (user-records.ts) over its config file
and (b) a Comms DCR provider validate (auth-provider.ts) that probes
getSessionUser and records auth mode/scope (derived from handshake.readOnly).
Read path (auth.ts): env COMMS_API_TOKEN first, then the keyring store
(createCommsTokenStore — wrapped so active / activeBundle / activeAccount
all honour the env token). A raw token saved via tdc auth token is stored as
MANUAL_TOKEN_ACCOUNT (empty id/label); account list hides it and account current reports it as source: token-only. Writes/clears/lists route through
the store; commands never touch the config directly.
comms 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).
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. - Fixtures:
src/lib/__fixtures__/accounts.ts(ACCOUNT_ALAN/ACCOUNT_ELLIE) — don't hand-build account 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.tsand__mocks__. - 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).update-comms-sdk.ymlkeeps@doist/comms-sdkcurrent.
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/comms-cli/SKILL.md.tdc 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 tdc binary — COMMS_API_TOKEN,
config file, or a token in the OS credential manager via tdc 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. - Status glyphs (
✓/✗) allowed; otherwise no emojis.
src/index.ts— entry + lazy command registrysrc/commands/inbox.ts— canonical readsrc/commands/channel/create.ts+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