Scaffold new CLI commands with tests for the Ably CLI (oclif + TypeScript). Use this skill whenever creating a new command, adding a subcommand, migrating a command, or scaffolding a command with its test file — even if described casually (e.g., 'I need an ably X Y command', 'can you build ably rooms typing subscribe', 'we should add a purge command to queues'). Do NOT use for modifying existing commands, fixing bugs, or adding tests to existing commands.
Installation
Installer avec Codex ou Claude Copiez ce prompt, collez-le dans Codex, Claude ou un autre assistant, puis laissez-le vérifier la page du skill et l'installer pour vous.
Scaffold new CLI commands with tests for the Ably CLI (oclif + TypeScript). Use this skill whenever creating a new command, adding a subcommand, migrating a command, or scaffolding a command with its test file — even if described casually (e.g., 'I need an ably X Y command', 'can you build ably rooms typing subscribe', 'we should add a purge command to queues'). Do NOT use for modifying existing commands, fixing bugs, or adding tests to existing commands.
Ably CLI New Command
This skill helps you create new commands for the Ably CLI, including the command file, test file, and any needed index/topic files. The Ably CLI is built on oclif (TypeScript) and has strict conventions that every command must follow.
Step 1: Identify the command pattern
Every command in this CLI falls into one of these patterns. Pick the right one based on what the command does:
Pattern
When to use
Base class
Client
Example
Subscribe
Long-running event listener
AblyBaseCommand
Realtime
channels subscribe, rooms messages subscribe
Publish/Send
Send messages
AblyBaseCommand
REST or Realtime
channels publish, rooms messages send
History
Query past messages/events
AblyBaseCommand
REST
channels history, rooms messages history
Get
One-shot query for current state
AblyBaseCommand
REST
channels occupancy get, rooms occupancy get
List
Enumerate resources via REST API
AblyBaseCommand
REST
channels list, rooms list
Enter
Join presence/space then optionally listen
AblyBaseCommand
Realtime
channels presence enter, spaces members enter
REST Mutation
One-shot REST mutation (no subscription)
AblyBaseCommand
REST
rooms messages update, rooms messages delete
CRUD
Create/read/update/delete via Control API
ControlBaseCommand
Control API (HTTP)
integrations create, queues delete
Specialized base classes — some command groups have dedicated base classes that handle common setup (client lifecycle, cleanup, shared flags):
If your command falls into one of these groups, extend the specialized base class instead of AblyBaseCommand or ControlBaseCommand directly. Exception: if your command only needs a REST client (e.g., history queries that don't enter a space or join a room), you may use AblyBaseCommand directly — the specialized base class is most valuable when the command needs realtime connections, cleanup lifecycle, or shared setup like room.attach() / space.enter().
When to call room.attach() / space.enter()
Not every Chat/Spaces command needs room.attach() or space.enter(). Before adding attachment, check whether the SDK method you're calling requires an active realtime connection or is a pure REST call:
Needs room.attach()
Does NOT need room.attach()
Subscribe (realtime listener)
Send (REST via chatApi.sendMessage)
Presence enter/get/update/leave
Update (REST via chatApi.updateMessage)
Occupancy subscribe
Delete (REST via chatApi.deleteMessage)
Typing keystroke/stop
Annotate/append (REST mutation)
Reactions send (realtime publish)
History queries (REST via chatApi.history)
Reactions subscribe
Occupancy get (REST via chatApi.getOccupancy)
How it works in the SDK: Methods that go through this._chatApi.* are REST calls and don't need attachment. Methods that use this._channel.publish(), this._channel.presence.*, or subscribe to channel events require the realtime channel to be attached. Room-level reactions use this._channel.publish() (realtime), but messages send/update/delete use this._chatApi.* (REST).
Rule of thumb: If the SDK method is a one-shot REST call (returns a Promise with a result, no callback/listener), you do NOT need room.attach(). Just call chatClient.rooms.get(roomId) to get the room handle and invoke the method directly. Attaching unnecessarily adds latency and creates a realtime connection that isn't needed.
How to verify: Check the Chat SDK source or typedoc — methods that are REST-based don't require the room to be in an attached state. When in doubt, test without room.attach() — if the SDK method works, attachment isn't needed.
Step 2: Create the command file
File location
Commands map to the filesystem: ably <topic> <subtopic> <action> lives at src/commands/<topic>/<subtopic>/<action>.ts.
If the topic/subtopic doesn't exist yet, you also need an index file at src/commands/<topic>/<subtopic>/index.ts (or src/commands/<topic>/index.ts for top-level topics). The index file is a simple topic descriptor — read an existing one like src/commands/channels/index.ts for the pattern.
Imports and base class
Product API commands (channels, rooms, spaces, presence, pub/sub):
Import depth: These examples use ../../ which is correct for src/commands/<topic>/<action>.ts. For deeper nesting like src/commands/<topic>/<subtopic>/<action>.ts, add one more ../ per level (e.g., ../../../base-command.js). Always count the directory levels from your command file back to src/.
Flag sets
Choose the right combination — never add flags a command doesn't need:
// Product API commandstaticoverride flags = {
...productApiFlags, // Always for product API commands
...clientIdFlag, // See below for when to include this// command-specific flags here
};
// Control API command — flags come from ControlBaseCommand.globalFlags automaticallystatic flags = {
...ControlBaseCommand.globalFlags,
app: Flags.string({
description: "The app ID or name (defaults to current app)",
required: false,
}),
// command-specific flags here
};
Control API helper methods:
await this.requireAppId(flags) — resolves and validates the app ID, returns Promise<string> (non-nullable). Calls this.fail() internally if no app found — no manual null check needed.
await this.runControlCommand(flags, api => api.method(appId)) — creates the Control API client, executes the call, and handles errors in one step. Returns Promise<T> (non-nullable). Useful for single API calls; for multi-step flows, use this.createControlApi(flags) directly.
When to include clientIdFlag: Add ...clientIdFlag to commands where client identity affects the operation: subscribe, publish, enter, set, acquire, update, delete, append, annotate. The reason is that users may want to test auth scenarios — e.g., "can client B update client A's message?" — so they need the ability to set their client ID. Do NOT add to read-only queries (get, get-all, history, occupancy get) — Ably capabilities are operation-based, not clientId-based, so client identity is irrelevant for pure reads.
--app: "The app ID or name (defaults to current app)"
--limit: "Maximum number of results to return (default: N)"
--duration: "Automatically exit after N seconds", alias -D
--rewind: "Number of messages to rewind when subscribing (default: 0)"
Channels use "publish", Rooms use "send" (matches SDK terminology)
Output patterns
The base command provides five logging helpers: this.logProgress(msg, flags), this.logSuccessMessage(msg, flags), this.logListening(msg, flags), this.logHolding(msg, flags), this.logWarning(msg, flags). These do NOT need shouldOutputJson guards. In non-JSON mode they all emit formatted text on stderr. In JSON mode: logProgress and logSuccessMessage are silent (no-ops — the JSON result/event records already convey progress and success), while logListening (status: "listening"), logHolding (status: "holding"), and logWarning (status: "warning") emit structured JSON on stdout. logSuccessMessage should be placed inside the else block after logJsonResult for readability. Only human-readable data output (field labels, headings, record blocks) needs the if/else guard:
// Progress/success/listening — no guard needed, helpers handle both modesthis.logProgress("Attaching to channel: " + formatResource(channelName), flags);
// After success:this.logSuccessMessage("Subscribed to channel: " + formatResource(channelName) + ".", flags);
this.logListening("Listening for messages.", flags);
// JSON output — nest data under a domain key, not spread at top level.// Envelope provides top-level: type, command, success.// One-shot single result — singular domain key:if (this.shouldOutputJson(flags)) {
this.logJsonResult({ message: messageData }, flags);
// → {"type":"result","command":"...","success":true,"message":{...}}
}
// One-shot collection result — plural domain key + optional metadata:if (this.shouldOutputJson(flags)) {
this.logJsonResult({ messages: items, total: items.length }, flags);
}
// Streaming events — singular domain key:if (this.shouldOutputJson(flags)) {
this.logJsonEvent({ message: messageData }, flags);
// → {"type":"event","command":"...","message":{...}}
}
Output helper reference — all exported from src/utils/output.ts:
Helper
Usage
Example
formatProgress(msg)
Action in progress — appends ... automatically
formatProgress("Attaching to channel")
formatSuccess(msg)
Green checkmark — always end with . (period, not !)
formatSuccess("Subscribed to channel " + formatResource(name) + ".")
formatWarning(msg)
Yellow ⚠ — for non-fatal warnings. Don't prefix with "Warning:"
formatWarning("Persistence is automatically enabled.")
formatListening(msg)
Dim text — auto-appends "Press Ctrl+C to exit."
formatListening("Listening for messages.")
formatResource(name)
Cyan — for resource names, never use quotes
formatResource(channelName)
formatTimestamp(ts)
Dim [timestamp] — for event streams
formatTimestamp(isoString)
formatMessageTimestamp(ts)
Converts Ably message timestamp to ISO string
formatMessageTimestamp(message.timestamp)
formatCountLabel(n, singular, plural?)
Cyan count + pluralized label
formatCountLabel(3, "message") → "3 messages"
formatLimitWarning(count, limit, name)
Yellow warning if results truncated, null otherwise
Dim with colon — for field labels in structured output
formatLabel("Platform") → dim "Platform:"
formatHeading(text)
Bold — for record headings in list output
formatHeading("Device ID: " + id)
formatIndex(n)
Dim bracketed number — for history ordering
formatIndex(1) → dim "[1]"
Rules:
formatProgress("Action text") — appends ... automatically, never add it manually
formatSuccess("Completed action.") — green checkmark, always end with . (period, not !)
formatListening("Listening for X.") — dim text, automatically appends "Press Ctrl+C to exit."
formatResource(name) — cyan colored, never use quotes around resource names. Exception: do not use formatResource() or any ANSI-producing helper inside this.fail() message strings — fail() passes the message into the JSON error envelope, where ANSI codes would corrupt the output. Use plain quoted strings in error messages instead.
formatTimestamp(ts) — dim [timestamp] for event streams
formatClientId(id) — blue, for client identity in events
formatEventType(type) — yellow, for event/action labels
formatLabel(text) — dim with colon, for field labels
formatHeading(text) — bold, for record headings in lists
formatIndex(n) — dim bracketed number, for history ordering
Use this.fail() for all errors (see Error handling below), never this.log(chalk.red(...))
Never use console.log or console.error — always this.log() for data or the logging helpers for status messages
Use this.logProgress(msg, flags), this.logSuccessMessage(msg, flags), this.logListening(msg, flags), this.logHolding(msg, flags), this.logWarning(msg, flags) for status messages — no shouldOutputJson guard needed. In non-JSON mode all emit to stderr. In JSON mode: logProgress and logSuccessMessage are silent; logListening (status: "listening"), logHolding (status: "holding"), and logWarning (status: "warning") emit structured JSON on stdout
Do NOT use the old pattern this.logToStderr(formatProgress/Success/Listening/Warning(...)) — use the helpers instead
formatPaginationLog() output still uses this.logToStderr() directly (not a helper yet)
logJsonResult(data, flags) and logJsonEvent(data, flags) are shorthand for this.log(this.formatJsonRecord("result"|"event", data, flags)). The envelope wraps data in {type, command, success?, ...data} and silently strips these reserved keys from your data to prevent collisions:
type — always stripped (envelope's own type field)
command — always stripped (envelope's own command field)
success — stripped from "error" records (always false); for "result" records, data's successoverrides the envelope's default true
Pitfall: If your event data has a type field (e.g., from an SDK event object), it will be silently dropped. Use a different key name:
// WRONG — event.type is silently stripped by the envelopethis.logJsonEvent({ type: event.type, message, room }, flags);
// CORRECT — use "eventType" to avoid collision with envelope's "type"this.logJsonEvent({ eventType: event.type, message, room }, flags);
Similarly, for batch results with a success/failure summary, don't use success as the key — it collides with the envelope's success: true:
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(), 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)
In command run() methods, use this.fail() for all errors. It always exits — returns never, so no return; is needed after calling it. It logs the CLI event, preserves structured error data, emits JSON error envelope when --json is active, and calls this.error() for human-readable output. It accepts an Error object or a plain string message.
Component name casing: All component strings use camelCase — both in this.fail() and logCliEvent(). Single-word components are plain lowercase ("room", "auth"). Multi-word components use camelCase ("channelPublish", "roomPresenceSubscribe"). This matches CLI conventions for log tags and keeps output like [channelPublish] Error: ... readable.
// In catch blocks — pass the error objecttry {
// All fallible calls go inside try-catch, including base class methods// like createControlApi, createAblyRealtimeClient, etc.const controlApi = this.createControlApi(flags);
const result = await controlApi.someMethod(appId, data);
// ...
} catch (error) {
this.fail(
error,
flags,
"channelPublish", // camelCase — e.g., "channelPublish", "presenceEnter"
{ channel: args.channel }, // optional context for logging
);
}
// logCliEvent uses the same camelCase conventionthis.logCliEvent(flags, "room", "attaching", `Attaching to room ${roomName}`);
this.logCliEvent(flags, "presence", "subscribed", "Subscribed to presence events");
// For validation / early exit — pass a string message (no return; needed)if (!appId) {
this.fail(
'No app specified. Use --app flag or select an app with "ably apps switch"',
flags,
"app",
);
}
In base class utility methods (e.g., createControlApi, createAblyRealtimeClient, parseJsonFlag), use this.fail() for fatal errors — the same pattern as in command run() methods. The fail() method returns never, so TypeScript knows execution stops and won't require a return value after the call.
Do NOT use this.error() directly — it is an internal implementation detail of fail. Calling this.error() directly skips event logging and doesn't respect --json mode.
Safe to use this.fail() in both try and catch — this.fail() automatically detects if the error was already processed by a prior this.fail() call (by checking for the oclif property) and re-throws it instead of double-processing. This means you can freely call this.fail() for validation inside a try block without worrying about the catch block calling this.fail() again.
Error hints — fail() appends a CLI-specific hint from src/utils/errors.ts if one exists for the Ably error code. If your command may surface an error code not yet in the hints map, fetchhttps://ably.com/docs/platform/errors/codes using WebFetch to get the official description before adding a hint. Do NOT rely on memory or assumptions about what an error code means — always fetch the doc. Hints must only contain actionable CLI advice, not restate the upstream error message.
JSON error envelope structure — CommandError.toJsonData(hint?) nests error details under an error object: { error: { message, code?, statusCode?, hint? }, ...context }. Context fields (e.g., channel, room) sit alongside the error key, not inside it.
Inline error extraction — For commands that report per-item errors inline (batch publish, connections test), use extractErrorInfo(error) from src/utils/errors.ts instead of this.fail(). It returns { message, code?, statusCode?, href? } — suitable for embedding in result objects:
import { extractErrorInfo } from"../../utils/errors.js";
// In a batch loop:const result = { error: extractErrorInfo(error), index, success: false };
Pattern-specific implementation
Read references/patterns.md for the full implementation template matching your pattern (Subscribe, Publish/Send, History, Enter/Presence, List, CRUD/Control API). Each template includes the correct flags, run() method structure, and output conventions.
Step 3: Create the test file
Read references/testing.md for the full test scaffold matching your command type (Realtime mock, REST mock, Control API with nock, E2E subscribe, E2E CRUD). Test files go at test/unit/commands/<path-matching-command>.test.ts.
Step 4: Create index files if needed
If you're adding a new topic or subtopic, create an index file using BaseTopicCommand:
The topicName must match the oclif command ID prefix (colons for nesting). The commandGroup is used in the help display as "Ably {commandGroup} commands:".
Step 5: Web CLI restrictions
If the new command shouldn't be available in the web CLI, add it to the appropriate array in src/base-command.ts:
WEB_CLI_RESTRICTED_COMMANDS — for commands that don't make sense in a web context
WEB_CLI_ANONYMOUS_RESTRICTED_COMMANDS — for commands that expose account/app data
INTERACTIVE_UNSUITABLE_COMMANDS — for commands that don't work in interactive mode
Security rule: The web CLI runs on a shared server — any command that reads files from the filesystem reads the server's files, not the user's local machine, which can expose server contents. There is no file-upload mechanism in the web CLI to transfer local files. So for any command that reads files, choose one of:
Block the command — add it to WEB_CLI_RESTRICTED_COMMANDS when the entire command is meaningless without local files (e.g. push config set-fcm/set-apns exist only to upload a credentials file).
Disable just the file read — when the command is still useful with inline input (e.g. push publish/batch-publish/devices save accept JSON inline), load the input through the this.resolveJsonInput(input, label, flags, component) base-command helper. It reads @file/path inputs from disk in local mode but, in web CLI mode, treats the input as literal data and rejects @file references — so the command keeps working without a server-side file-read sink. Never call fs.readFileSync directly on flag/arg values; always go through resolveJsonInput so the web-CLI restriction is inherited rather than re-implemented (and re-missed) per command.
Step 6: Validate
After creating command and test files, always run:
This skill is the source of truth for command conventions. The review skills (ably-review, ably-codebase-review) and CLAUDE.md reference the same patterns. If you change conventions here — or if the underlying source code changes (base classes, helpers, flags, error handling) — update all three locations:
This file (SKILL.md) and its references/ templates
ably-review/SKILL.md and ably-codebase-review/SKILL.md — review checks must match current patterns
.claude/CLAUDE.md — project-level docs that all agents see
See the "Keeping Skills Up to Date" section in CLAUDE.md for the full list of triggers.
Checklist
Command file at correct path under src/commands/
Correct base class (AblyBaseCommand, ControlBaseCommand, ChatBaseCommand, SpacesBaseCommand, or StatsBaseCommand)
Correct flag set (productApiFlags vs ControlBaseCommand.globalFlags)
clientIdFlag only if command needs client identity
Human data output wrapped in if (!this.shouldOutputJson(flags)) — but logProgress, logSuccessMessage, logListening, logWarning helpers do NOT need guards
Status messages use base command helpers (this.logProgress, this.logSuccessMessage, this.logListening, this.logWarning) — NOT this.logToStderr(formatProgress/Success/Listening/Warning(...))
Output formatters used correctly for data display (formatResource, formatTimestamp, formatClientId, formatEventType, formatLabel, formatHeading, formatIndex)
logSuccessMessage() messages end with . (period)
Non-JSON data output uses multi-line labeled blocks (see patterns.md "Human-Readable Output Format"), not tables or custom grids
Non-JSON output exposes all available SDK fields (same data as JSON mode, omitting only null/empty values)
SDK types imported directly (import type { CursorUpdate } from "@ably/spaces") — no local interface redefinitions of SDK types (display interfaces in src/utils/ are fine)
Field coverage checked against SDK type definitions (node_modules/ably/ably.d.ts, node_modules/@ably/spaces/dist/mjs/types.d.ts)
Subscribe commands do NOT fetch initial state — they only listen for new events (use get-all for current state)
Resource names use formatResource(name), never quoted
JSON output uses logJsonResult() (one-shot) or logJsonEvent() (streaming), not direct formatJsonRecord()
JSON data nested under a domain key (singular for events/single items, plural for collections) — not spread at top level (see patterns.md "JSON Data Nesting Convention")
room.attach() / space.enter() only called when the SDK method requires a realtime connection (subscribe, send, presence) — NOT for REST mutations (update, delete, annotate, history)
Subscribe/enter commands use this.waitAndTrackCleanup(flags, component, flags.duration) (not waitUntilInterruptedOrTimeout)
Error handling uses this.fail() exclusively, not this.error() or this.log(chalk.red(...))
Subscribe tests auto-exit via env var (ABLY_CLI_DEFAULT_DURATION: "0.25" in vitest.config.ts) — do NOT pass --duration to runCommand()
Tests use standardHelpTests(), standardArgValidationTests(), standardFlagTests() from test/helpers/standard-tests.ts
Control API tests use nockControl(), controlApiCleanup() from test/helpers/control-api-test-helpers.ts
Control API tests use standardControlApiErrorTests() for 401/500/network error tests in the describe("error handling", ...) block
Control API response bodies use mockApp(), mockKey(), mockRule(), mockQueue(), mockNamespace(), mockStats() from test/fixtures/control-api.ts where applicable