Skip to content

Latest commit

 

History

History
360 lines (286 loc) · 24 KB

File metadata and controls

360 lines (286 loc) · 24 KB

AGENTS.md - Ably CLI

Mandatory Workflow

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 needed

If you skip these steps, the work is NOT complete.

Project Context

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

Common Pitfalls - DO NOT DO THESE

  1. Skip tests - Only skip with documented valid reason
  2. Use _ prefix for unused variables - Remove the code instead
  3. Leave debug code - Remove ALL console.log, DEBUG_TEST, test-*.mjs
  4. Use // eslint-disable - Fix the root cause
  5. Remove tests without asking - Always get permission first
  6. NODE_ENV - To check if the CLI is in test mode, use the isTestMode() helper function.
  7. process.exit - When creating a command, use this.exit() for consistent test mode handling.
  8. console.log / console.error - In commands, always use this.log() (stdout) and this.logToStderr() (stderr). console.* bypasses oclif and can't be captured by tests.

Correct Practices

When Tests Fail

// 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

When Linting Fails

// WRONG - Workaround
let _unusedVar = getValue();

// CORRECT - Remove unused code
// Delete the line entirely

Debug Cleanup Checklist

# After debugging, ALWAYS check:
find . -name "test-*.mjs" -type f
grep -r "DEBUG_TEST" src/ test/
grep -r "console.log" src/  # Except legitimate output

Quick Reference

# 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 dev

Flag Architecture

Flags 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 via AblyBaseCommand.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 with parseTimestamp() from src/utils/time.ts. Accepts ISO 8601, Unix ms, or relative (e.g., "1h", "30m", "2d").
  • endpointFlag--endpoint. Hidden, only on accounts login and accounts 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.

Writing Tests

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:wait command tests — --duration is a required flag for that command
  • interactive-sigint.test.ts — needs a longer duration for SIGINT testing
  • Help output checks — testing that --help mentions --duration is fine

Test structure:

  • test/unit/ — Fast, mocked tests. Auth via MockConfigManager (automatic). Only set ABLY_API_KEY env 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). Uses node-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):

  1. "help" — verify --help shows USAGE
  2. "argument validation" — test required args or unknown flag rejection
  3. "functionality" — core happy-path behavior
  4. "flags" — verify flags exist and work
  5. "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 test

CLI Output & Flag Conventions

Output patterns (use helpers from src/utils/output.ts)

All 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 use formatProgress("Doing something").
  • Success: formatSuccess("Message published to channel " + formatResource(name) + ".") — green checkmark, must end with . (not !). Never use chalk.green(...) directly — always use formatSuccess().
  • Warnings: formatWarning("Message text here.") — yellow symbol. Never use chalk.yellow("Warning: ...") directly — always use formatWarning(). 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 a formatSuccess() call — use a separate formatListening() call.
  • Resource names: Always formatResource(name) (cyan), never quoted — including in logCliEvent messages.
  • 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 when hasMore === true.
  • Pagination collection: collectPaginatedResults(firstPage, limit) — walks cursor-based pages until limit items 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" when pagesConsumed > 1. Pass isBillable: true for 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 when hasMore is true. Pass lastTimestamp only 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 --json output. Only JSON payloads should be emitted when --json is active.
  • JSON envelope: Use this.logJsonResult(data, flags) for one-shot results, this.logJsonEvent(data, flags) for streaming events, and this.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-hoc success: true/false — the envelope handles it. --json produces compact single-line output (NDJSON for streaming). --pretty-json is unchanged.
  • JSON hold status: Long-running hold commands (e.g. spaces members enter, spaces locations set, spaces locks acquire, spaces cursors set) must emit a logJsonStatus("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. logJsonStatus has a built-in shouldOutputJson guard — no outer if needed.
  • JSON errors: Use this.fail(error, flags, component, context?) as the single error funnel in command run() methods. It logs the CLI event, preserves structured error data (Ably codes, HTTP status), emits JSON error envelope when --json is active, and calls this.error() for human-readable output. Returns never — no return; needed after calling it. Do NOT call this.error() directly — it is an internal implementation detail of fail.
  • 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. See references/patterns.md "History results" and "One-shot results" for both patterns.

Structured output format (non-JSON)

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.

JSON data nesting convention

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.

Command behavior semantics

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 call this.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.

Error handling architecture

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 returns never. TypeScript enforces no code runs after it. This eliminates the "forgotten return;" bug class.
  • Component strings are camelCase — both in this.fail() and logCliEvent(). 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: Use this.fail() for all errors. Wrap fallible calls in try-catch blocks.
  • Base class methods with flags (createControlApi, createAblyRealtimeClient, requireAppId, runControlCommand, etc.) also use this.fail() directly. Methods without flags pass {} as a fallback.
  • reject(new Error(...)) inside Promise callbacks (e.g., connection event handlers) is the one pattern that can't use this.fail() — the rejection propagates to await, where the command's catch block calls this.fail().
  • Never use this.error() directly — it is an internal implementation detail of this.fail().
  • requireAppId returns Promise<string> (not nullable) — calls this.fail() internally if no app found.
  • runControlCommand<T> returns Promise<T> (not nullable) — calls this.fail() internally on error.

Additional output patterns (direct chalk, not helpers)

  • No app error: 'No app specified. Use --app flag or select an app with "ably apps switch"'

Help output theme

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.

Flag conventions

  • All flags kebab-case: --my-flag (never camelCase)
  • --app: "The app ID or name (defaults to current app)" (for commands with resolveAppId), "The app ID (defaults to current app)" (for commands without)
  • --limit: "Maximum number of results to return" with min: 1 (oclif shows [default: N] automatically, don't duplicate in description)
  • --duration: Use durationFlag from src/flags.ts. "Automatically exit after N seconds", alias -D.
  • --rewind: Use rewindFlag from src/flags.ts. "Number of messages to rewind when subscribing (default: 0)". Apply with this.configureRewind(channelOptions, flags.rewind, flags, component, channelName).
  • --start/--end: Use timeRangeFlags from src/flags.ts and parse with parseTimestamp() from src/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")

Ably Knowledge

Development Standards

  • 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.

Before Marking Complete

  • pnpm prepare succeeds
  • pnpm exec eslint . shows 0 errors
  • pnpm test:unit passes
  • No debug artifacts remain
  • Docs updated if needed (especially docs/Project-Structure.md when adding/moving files, docs/Testing.md when changing test patterns)
  • Skills updated if needed (see below)
  • Followed oclif patterns

Keeping Skills Up to Date

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 commands
  • ably-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.