Run these IN ORDER for EVERY change:
pnpm prepare # 1. Build + update manifest
pnpm exec oclif readme # 2. Regenerate README.md from command metadata
pnpm exec eslint . # 3. Lint (MUST be 0 errors)
pnpm test:unit # 4. Test (at minimum)
pnpm test:tty # 5. TTY tests (local only, skip in CI)
# 6. Update docs if neededIf you skip these steps, the work is NOT complete.
This is the Ably CLI npm package (@ably/cli), built with the oclif framework.
.
├── src/
│ ├── commands/ # CLI commands (oclif)
│ ├── services/ # Business logic
│ ├── utils/ # Utilities
│ └── base-command.ts
├── test/
│ ├── unit/ # Fast, mocked
│ ├── integration/ # Multi-component, mocked external services
│ ├── e2e/ # Full scenarios against real Ably
│ └── helpers/ # runCommand(), MockConfigManager, etc.
├── docs/ # Project docs (Testing.md, Project-Structure.md, etc.)
└── package.json # Scripts defined here
- Skip tests - Only skip with documented valid reason
- Use
_prefix for unused variables - Remove the code instead - Leave debug code - Remove ALL console.log, DEBUG_TEST, test-*.mjs
- Use
// eslint-disable- Fix the root cause - Remove tests without asking - Always get permission first
- NODE_ENV - To check if the CLI is in test mode, use the
isTestMode()helper function. process.exit- When creating a command, usethis.exit()for consistent test mode handling.console.log/console.error- In commands, always usethis.log()(stdout) andthis.logToStderr()(stderr).console.*bypasses oclif and can't be captured by tests.
// WRONG
it.skip('test name', () => {
// CORRECT - Document why
it.skip('should handle Ctrl+C on empty prompt', function(done) {
// SKIPPED: This test is flaky in non-TTY environments
// The readline SIGINT handler doesn't work properly with piped stdio// WRONG - Workaround
let _unusedVar = getValue();
// CORRECT - Remove unused code
// Delete the line entirely# After debugging, ALWAYS check:
find . -name "test-*.mjs" -type f
grep -r "DEBUG_TEST" src/ test/
grep -r "console.log" src/ # Except legitimate output# Full validation
pnpm validate
# Run specific test
pnpm test test/unit/commands/interactive.test.ts
# Lint specific file
pnpm exec eslint src/commands/interactive.ts
# Dev mode
pnpm devFlags are NOT global. Each command explicitly declares only the flags it needs via composable flag sets defined in src/flags.ts:
coreGlobalFlags—--verbose,--json,--pretty-json,--web-cli-help(hidden) (on every command viaAblyBaseCommand.globalFlags)productApiFlags— core + hidden product API flags (port,tlsPort,tls). Use for commands that talk to the Ably product API.controlApiFlags— core + hidden control API flags (control-host,dashboard-host). Use for commands that talk to the Control API.clientIdFlag—--client-id. Add to any command where the user might want to control which client identity performs the operation. This includes: commands that create a realtime connection (subscribe, presence enter/subscribe, spaces, etc.), publish, and REST mutations where permissions may depend on the client (update, delete, append). Do NOT add globally.durationFlag—--duration/-D. Use for long-running subscribe/stream commands that auto-exit after N seconds.rewindFlag—--rewind. Use for subscribe commands that support message replay (default: 0).timeRangeFlags—--start,--end. Use for history and stats commands. Parse withparseTimestamp()fromsrc/utils/time.ts. Accepts ISO 8601, Unix ms, or relative (e.g.,"1h","30m","2d").endpointFlag—--endpoint. Hidden, only onaccounts loginandaccounts switch.
When creating a new command:
// Product API command (channels, spaces, rooms, etc.)
import { productApiFlags, clientIdFlag, durationFlag, rewindFlag } from "../../flags.js";
static override flags = {
...productApiFlags,
...clientIdFlag, // Only if command needs client identity
...durationFlag, // Only if long-running (subscribe/stream commands)
...rewindFlag, // Only if supports message replay
// command-specific flags...
};
// Control API command (apps, keys, queues, etc.)
// controlApiFlags come from ControlBaseCommand.globalFlags automatically
static flags = {
...ControlBaseCommand.globalFlags,
// command-specific flags...
};Auth is managed via ably login (stored config). Environment variables override stored config for CI, scripting, or testing:
ABLY_API_KEY,ABLY_TOKEN,ABLY_ACCESS_TOKEN
Do NOT add --api-key, --token, or --access-token flags to commands.
Auth in tests — do NOT use CLI flags (--api-key, --token, --access-token):
Unit tests — Auth is provided automatically by MockConfigManager (see test/helpers/mock-config-manager.ts). No env vars needed. Only set ABLY_API_KEY when specifically testing env var override behavior.
// WRONG — don't pass auth flags
runCommand(["channels", "publish", "my-channel", "hello", "--api-key", key]);
// CORRECT — MockConfigManager provides auth automatically
runCommand(["channels", "publish", "my-channel", "hello"]);
// CORRECT — use getMockConfigManager() to access test auth values
import { getMockConfigManager } from "../../helpers/mock-config-manager.js";
const mockConfig = getMockConfigManager();
const apiKey = mockConfig.getApiKey()!;
const appId = mockConfig.getCurrentAppId()!;E2E tests — Commands run as real subprocesses, so auth must be passed via env vars:
// CORRECT — pass auth via env vars for E2E
runCommand(["channels", "publish", "my-channel", "hello"], {
env: { ABLY_API_KEY: key },
});
// CORRECT — spawn with env vars
spawn("node", [cliPath, "channels", "subscribe", "my-channel"], {
env: { ...process.env, ABLY_API_KEY: key },
});
// Control API commands use ABLY_ACCESS_TOKEN
runCommand(["stats", "account"], {
env: { ABLY_ACCESS_TOKEN: token },
});Duration in tests — do NOT use --duration in unit/integration tests:
Unit and integration tests set ABLY_CLI_DEFAULT_DURATION: "0.25" in vitest.config.ts, which makes all subscribe/long-running commands auto-exit after 250ms. Do NOT pass --duration to runCommand() — it overrides the fast 250ms default with a slower explicit value.
Exceptions:
test:waitcommand tests —--durationis a required flag for that commandinteractive-sigint.test.ts— needs a longer duration for SIGINT testing- Help output checks — testing that
--helpmentions--durationis fine
Test structure:
test/unit/— Fast, mocked tests. Auth viaMockConfigManager(automatic). Only setABLY_API_KEYenv var when testing env var override behavior.test/integration/— Integration tests (e.g., interactive mode). Mocked external services but tests multi-component interaction.test/tty/— Interactive mode tests requiring a real pseudo-TTY (e.g., SIGINT/Ctrl+C with readline). Usesnode-pty. Local only — cannot run in CI.test/e2e/— Full scenarios against real Ably. Auth via env vars (ABLY_API_KEY,ABLY_ACCESS_TOKEN).- Helpers in
test/helpers/—runCommand(),runLongRunningBackgroundProcess(),e2e-test-helper.ts,mock-config-manager.ts.
Required test describe blocks (exact names — every unit test file must have all 5):
"help"— verify--helpshows USAGE"argument validation"— test required args or unknown flag rejection"functionality"— core happy-path behavior"flags"— verify flags exist and work"error handling"— API errors, network failures
Do NOT use variants like "command arguments and flags", "command flags", "flag options", or "parameter validation". Exempt: interactive.test.ts, interactive-sigint.test.ts, bench/*.test.ts.
Running tests:
pnpm test:unit # All unit tests
pnpm test:integration # Integration tests
pnpm test:tty # TTY tests (local only, needs real terminal)
pnpm test:e2e # All E2E tests
pnpm test test/unit/commands/foo.test.ts # Specific testAll output helpers use the format prefix and are exported from src/utils/output.ts:
- Progress:
formatProgress("Attaching to channel: " + formatResource(name))— no color on action text, appends...automatically. Never manually write"Doing something..."— always useformatProgress("Doing something"). - Success:
formatSuccess("Message published to channel " + formatResource(name) + ".")— green checkmark, must end with.(not!). Never usechalk.green(...)directly — always useformatSuccess(). - Warnings:
formatWarning("Message text here.")— yellow⚠symbol. Never usechalk.yellow("Warning: ...")directly — always useformatWarning(). Don't include "Warning:" prefix in the message — the symbol conveys it. - Listening:
formatListening("Listening for messages.")— dim, includes "Press Ctrl+C to exit." Don't combine listening text inside aformatSuccess()call — use a separateformatListening()call. - Resource names: Always
formatResource(name)(cyan), never quoted — including inlogCliEventmessages. - Timestamps:
formatTimestamp(ts)— dim[timestamp]for event streams.formatMessageTimestamp(message.timestamp)— converts Ably message timestamp (number|undefined) to ISO string. - Labels:
formatLabel("Field Name")— dim with colon appended, for field names in structured output. - Client IDs:
formatClientId(id)— blue, for user/client identifiers in events. - Event types:
formatEventType(type)— yellow, for action/event type labels. - Headings:
formatHeading("Record ID: " + id)— bold, for record headings in list output. - Index:
formatIndex(n)— dim bracketed number[n], for history/list ordering. - Count labels:
formatCountLabel(n, "message")— cyan count + pluralized label. - Limit warnings:
formatLimitWarning(count, limit, "items")— yellow warning if results truncated. Only show whenhasMore === true. - Pagination collection:
collectPaginatedResults(firstPage, limit)— walks cursor-based pages untillimititems are collected. Returns{ items, hasMore, pagesConsumed }. Use for both SDK and HTTP paginated commands. - Filtered pagination:
collectFilteredPaginatedResults(firstPage, limit, filter, maxPages?)— same as above but applies a client-side filter. Use for rooms/spaces list where channels need prefix filtering.maxPages(default: 20) prevents runaway requests. - Pagination warning:
formatPaginationLog(pagesConsumed, itemCount, isBillable?)— shows "Fetched N pages" whenpagesConsumed > 1. PassisBillable: truefor history commands (each message retrieved counts as a billable message). Guard with!this.shouldOutputJson(flags). - Pagination next hint:
buildPaginationNext(hasMore, lastTimestamp?)— returns{ hint, start? }for JSON output whenhasMoreis true. PasslastTimestamponly for history commands (which have--start). - JSON guard: All human-readable output (progress, success, listening messages) must be wrapped in
if (!this.shouldOutputJson(flags))so it doesn't pollute--jsonoutput. Only JSON payloads should be emitted when--jsonis active. - JSON envelope: Use
this.logJsonResult(data, flags)for one-shot results,this.logJsonEvent(data, flags)for streaming events, andthis.logJsonStatus(status, message, flags)for hold/status signals in long-running commands. The envelope adds top-level fields (type,command,success?). Nest domain data under a domain key (see "JSON data nesting convention" below). Do NOT add ad-hocsuccess: true/false— the envelope handles it.--jsonproduces compact single-line output (NDJSON for streaming).--pretty-jsonis unchanged. - JSON hold status: Long-running hold commands (e.g.
spaces members enter,spaces locations set,spaces locks acquire,spaces cursors set) must emit alogJsonStatus("holding", "Holding <thing>. Press Ctrl+C to exit.", flags)line after the result. This tells LLM agents and scripts that the command is alive and waiting.logJsonStatushas a built-inshouldOutputJsonguard — no outerifneeded. - JSON errors: Use
this.fail(error, flags, component, context?)as the single error funnel in commandrun()methods. It logs the CLI event, preserves structured error data (Ably codes, HTTP status), emits JSON error envelope when--jsonis active, and callsthis.error()for human-readable output. Returnsnever— noreturn;needed after calling it. Do NOT callthis.error()directly — it is an internal implementation detail offail. - History output: Use
[index] [timestamp]on the same line as a heading:`${formatIndex(index + 1)} ${formatTimestamp(timestamp)}`, then fields indented below. This is distinct from get-all output which uses[index]alone on its own line. Seereferences/patterns.md"History results" and "One-shot results" for both patterns.
All non-JSON output for data records must use multi-line labeled blocks — one block per record, separated by blank lines. Never use ASCII tables (┌─┬─┐, │, box-drawing characters) or custom grid layouts. Non-JSON output must expose the same fields as JSON output (omit only null/undefined/empty values). Use formatLabel() for field names, type-appropriate formatters for values (formatClientId, formatResource, formatEventType, formatTimestamp). Check SDK type definitions (see "Ably Knowledge" below) as the source of truth for available fields — import SDK types directly, never redefine them locally. See references/patterns.md "Human-Readable Output Format" in the ably-new-command skill for detailed examples.
The envelope provides three top-level fields: type, command, and success. All domain data must be nested under a domain key — never spread raw data fields at the top level alongside envelope fields.
- Events and single results: nest under a singular domain key (
message,cursor,lock) - Collection results: nest under a plural domain key (
cursors,rules,keys) - Metadata (
total,timestamp,hasMore,appId) may sit alongside the domain key
See references/patterns.md "JSON Data Nesting Convention" in the ably-new-command skill for detailed examples and domain key naming.
Each command type has strict rules about what side effects it may have. Remove unintended side effects (e.g., auto-entering presence) and support passive ("dumb") operations where applicable. Key principles:
- Subscribe = passive observer (no
space.enter(), no fetching initial state) - Get-all / get = one-shot query (no
space.enter(), no subscribing) - Set / enter / acquire = hold state until Ctrl+C /
--duration(enter, operate, hold — no subscribing after) - Call
space.enter()only when SDK requires it; always callthis.markAsEntered()after - Hold commands use manual entry (
enterSpace: false+space.enter()+markAsEntered()) for consistency
See references/patterns.md "Command behavior semantics" in the ably-new-command skill for full rules, side-effect table, and code examples.
Choose the right mechanism based on intent:
| Intent | Method | Behavior |
|---|---|---|
| Stop the command (fatal error) | this.fail(error, flags, component) |
Logs event, emits JSON error envelope if --json, exits. Returns never — execution stops, no return; needed. |
| Warn and continue (non-fatal) | this.warn() or this.logToStderr() |
Prints warning, execution continues normally. |
| Reject inside Promise callbacks | reject(new Error(...)) |
Propagates to await, where the catch block calls this.fail(). |
All fatal errors flow through this.fail() (src/base-command.ts), which uses CommandError (src/errors/command-error.ts) to preserve Ably error codes and HTTP status codes:
this.fail(): never ← the single funnel (logs event, emits JSON, exits)
↓ internally calls
this.error() ← oclif exit (ONLY inside fail, nowhere else)
this.fail()always exits — it returnsnever. TypeScript enforces no code runs after it. This eliminates the "forgottenreturn;" bug class.- Component strings are camelCase — both in
this.fail()andlogCliEvent(). Single-word:"room","auth". Multi-word:"channelPublish","roomPresenceSubscribe". These appear in verbose log output as[component]tags and in JSON envelopes. - In command
run()methods: Usethis.fail()for all errors. Wrap fallible calls in try-catch blocks. - Base class methods with
flags(createControlApi,createAblyRealtimeClient,requireAppId,runControlCommand, etc.) also usethis.fail()directly. Methods withoutflagspass{}as a fallback. reject(new Error(...))inside Promise callbacks (e.g., connection event handlers) is the one pattern that can't usethis.fail()— the rejection propagates toawait, where the command's catch block callsthis.fail().- Never use
this.error()directly — it is an internal implementation detail ofthis.fail(). requireAppIdreturnsPromise<string>(not nullable) — callsthis.fail()internally if no app found.runControlCommand<T>returnsPromise<T>(not nullable) — callsthis.fail()internally on error.
- No app error:
'No app specified. Use --app flag or select an app with "ably apps switch"'
Help colors are configured via package.json > oclif.theme (oclif's built-in theme system). The custom help class in src/help.ts also applies colors to COMMANDS sections it builds manually. Color scheme:
- Commands/bin/topics: cyan — primary actionable items
- Flags/args: whiteBright — bright but secondary to commands
- Section headers: bold — USAGE, FLAGS, COMMANDS, etc.
- Command summaries: whiteBright — descriptions in command listings
- Defaults/options: yellow —
[default: N],<options: ...> - Required flags: red —
(required)marker $prompt: green — shell prompt in examples/usage- Flag separator: dim — comma between
-c, --count
When adding COMMANDS sections in src/help.ts, use chalk.bold() for headers, chalk.cyan() for command names, and chalk.whiteBright() for descriptions to stay consistent.
- All flags kebab-case:
--my-flag(never camelCase) --app:"The app ID or name (defaults to current app)"(for commands withresolveAppId),"The app ID (defaults to current app)"(for commands without)--limit:"Maximum number of results to return"withmin: 1(oclif shows[default: N]automatically, don't duplicate in description)--duration: UsedurationFlagfromsrc/flags.ts."Automatically exit after N seconds", alias-D.--rewind: UserewindFlagfromsrc/flags.ts."Number of messages to rewind when subscribing (default: 0)". Apply withthis.configureRewind(channelOptions, flags.rewind, flags, component, channelName).--start/--end: UsetimeRangeFlagsfromsrc/flags.tsand parse withparseTimestamp()fromsrc/utils/time.ts. Accepts ISO 8601, Unix ms, or relative (e.g.,"1h","30m","2d").--direction:"Direction of message retrieval (default: backwards)"or"Direction of log retrieval", options["backwards", "forwards"].- Channels use "publish", Rooms use "send" (matches SDK terminology)
- Command descriptions: imperative mood, sentence case, no trailing period (e.g.,
"Subscribe to presence events on a channel")
- When in doubt about how Ably works, refer to the Ably docs at https://ably.com/docs.
- Key docs:
- Pub/Sub: https://ably.com/docs/basics and API ref at https://ably.com/docs/api/realtime-sdk (use https://ably.com/docs/sdk/js/v2.0/ when referenced)
- Chat: https://ably.com/docs/chat and API ref at https://sdk.ably.com/builds/ably/ably-chat-js/main/typedoc/modules/chat-js.html
- Spaces: https://ably.com/docs/spaces and API ref at https://sdk.ably.com/builds/ably/spaces/main/typedoc/index.html
- Control API: https://ably.com/docs/account/control-api and ref at https://ably.com/docs/api/control-api
- Platform: https://ably.com/docs/platform
- The CLI uses Ably SDKs for all data plane commands. When an API exists in the data plane REST API but has no corresponding SDK method, use the Pub/Sub SDK's request method.
- The Control API has no official SDK, so raw HTTP requests are used.
- SDK packages (
node_modules/ably/,node_modules/@ably/spaces/,node_modules/@ably/chat/) are the local source of truth for types and method behavior. Type definitions (e.g.,ably.d.ts,types.d.ts) tell you what fields exist; source code (e.g.,Space.js,Members.js) tells you how methods behave (side effects, prerequisites likespace.enter()). When in doubt, read the implementation — not just the types. Seereferences/patterns.md"Field display rules" in theably-new-commandskill for the full path table and import conventions.
- Use TypeScript and follow standard naming conventions.
- This project uses
pnpm(not npm or yarn). - When installing libraries, use
pnpm add(not manual package.json edits) to ensure latest versions. - Avoid unnecessary dependencies — don't write code when libraries solve common problems, but don't install a library for every problem either.
- Code quality matters. The target audience is experienced developers who will read this code.
-
pnpm preparesucceeds -
pnpm exec eslint .shows 0 errors -
pnpm test:unitpasses - No debug artifacts remain
- Docs updated if needed (especially
docs/Project-Structure.mdwhen adding/moving files,docs/Testing.mdwhen changing test patterns) - Skills updated if needed (see below)
- Followed oclif patterns
Skills in .claude/skills/ encode the project's conventions and patterns. When you change the source of truth (base classes, helpers, flags, error handling, test helpers), you must update the skills that reference those patterns. Stale skills cause Claude to generate incorrect code.
When to update skills:
- Changed a base class method signature or behavior (
base-command.ts,control-base-command.ts,chat-base-command.ts,spaces-base-command.ts,stats-base-command.ts) - Added, renamed, or removed output helpers in
src/utils/output.ts - Changed flag definitions in
src/flags.ts - Changed error handling patterns (e.g.,
fail(),CommandError) - Changed test helpers or mock patterns in
test/helpers/ - Added a new base class or removed an existing one
Which files to check:
ably-new-command/SKILL.md— the primary source of conventions for creating commandsably-new-command/references/patterns.md— implementation templates (must match actual code)ably-new-command/references/testing.md— test scaffolds (must match actual test helpers)ably-review/SKILL.md— branch review checks (must know current method names)ably-codebase-review/SKILL.md— codebase review checks (must know current method names)
How to verify: After updating skills, grep the skill files for the old method/pattern name to ensure no stale references remain.