Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions .claude/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,8 @@ Flags are NOT global. Each command explicitly declares only the flags it needs v
- **`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 only to commands that create a realtime connection where client identity matters (presence, spaces members, cursors, locks, publish, etc.). Do NOT add globally.
- **`clientIdFlag`** — `--client-id`. Add to any command that creates a realtime connection (publish, subscribe, presence enter/subscribe, spaces enter/get/subscribe, locks acquire/get/subscribe, cursors set/get/subscribe, locations set/get/subscribe, etc.). The rule: if the command calls `space.enter()`, creates a realtime client, or joins a channel, include `clientIdFlag`. Do NOT add globally.
- **`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:**
Expand Down Expand Up @@ -214,6 +215,8 @@ But focus on THIS project unless specifically asked about others.
- **Resource names**: Always `resource(name)` (cyan), never quoted — including in `logCliEvent` messages.
- **Timestamps**: `formatTimestamp(ts)` — dim `[timestamp]` for event streams. Exported as `formatTimestamp` to avoid clashing with local `timestamp` variables.
- **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 errors**: In catch blocks, emit structured JSON when `--json` is active: `this.formatJsonOutput({ error: errorMsg, success: false }, flags)`. Never silently swallow errors in JSON mode — always emit a JSON error object or use `this.jsonError()`.
- **History output**: Use `[index] timestamp` ordering: `` `${chalk.dim(`[${index + 1}]`)} ${formatTimestamp(timestamp)}` ``. Consistent across all history commands (channels, logs, connection-lifecycle, push).

### Additional output patterns (direct chalk, not helpers)
- **Secondary labels**: `chalk.dim("Label:")` — for field names in structured output (e.g., `${chalk.dim("Profile:")} ${value}`)
Expand All @@ -240,8 +243,10 @@ When adding COMMANDS sections in `src/help.ts`, use `chalk.bold()` for headers,
- 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 (default: N)"`
- `--duration`: `"Automatically exit after N seconds (0 = run indefinitely)"`, alias `-D`
- `--duration`: `"Automatically exit after N seconds"`, alias `-D`
- `--rewind`: `"Number of messages to rewind when subscribing (default: 0)"`
- `--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"`)

Expand Down
6 changes: 5 additions & 1 deletion .cursor/rules/AI-Assistance.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -124,14 +124,18 @@ This document provides guidance for AI assistants working with the Ably CLI code
- **Event types**: `chalk.yellow(eventType)` — for action/event type labels
- **Warnings**: `chalk.yellow("Warning: ...")` — for non-fatal warnings
- **Errors**: Use `this.error()` (oclif standard) for fatal errors, not `this.log(chalk.red(...))`
- **JSON errors**: In catch blocks, emit structured JSON when `--json` is active: `this.formatJsonOutput({ error: errorMsg, success: false }, flags)`. Never silently swallow errors in JSON mode.
- **History output**: Use `[index] timestamp` ordering: `` `${chalk.dim(`[${index + 1}]`)} ${formatTimestamp(timestamp)}` ``
- **No app error**: `'No app specified. Use --app flag or select an app with "ably apps switch"'`

### 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 (default: N)"`
- `--duration`: `"Automatically exit after N seconds (0 = run indefinitely)"`, alias `-D`
- `--duration`: `"Automatically exit after N seconds"`, alias `-D`
- `--rewind`: `"Number of messages to rewind when subscribing (default: 0)"`
- `--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"`)

Expand Down
4 changes: 3 additions & 1 deletion eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,9 @@ export default [
"packages/react-web-cli/dist/index.mjs",
"bin/", // Added from .eslintrc.cjs
"playwright-report/**", // Ignore Playwright report files
"vitest.config.ts"
"vitest.config.ts",
".claude/worktrees/**",
".claude/skills/**"
], // Updated to match all ignorePatterns from .eslintrc.json
},
{
Expand Down
6 changes: 3 additions & 3 deletions src/commands/accounts/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { ControlBaseCommand } from "../../control-base-command.js";
import { endpointFlag } from "../../flags.js";
import { ControlApi } from "../../services/control-api.js";
import { displayLogo } from "../../utils/logo.js";
import { resource, success } from "../../utils/output.js";
import { progress, resource, success } from "../../utils/output.js";
import { promptForConfirmation } from "../../utils/prompt-confirmation.js";

// Moved function definition outside the class
Expand Down Expand Up @@ -96,7 +96,7 @@ export default class AccountsLogin extends ControlBaseCommand {
// Prompt the user to get a token
if (!flags["no-browser"]) {
if (!this.shouldOutputJson(flags)) {
this.log("Opening browser to get an access token...");
this.log(progress("Opening browser to get an access token"));
}

await this.openBrowser(obtainTokenPath);
Expand Down Expand Up @@ -227,7 +227,7 @@ export default class AccountsLogin extends ControlBaseCommand {
const appName = await this.promptForAppName();

try {
this.log(`\nCreating app "${appName}"...`);
this.log(`\n${progress(`Creating app ${resource(appName)}`)}`);

const app = await controlApi.createApp({
name: appName,
Expand Down
4 changes: 2 additions & 2 deletions src/commands/apps/channel-rules/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,10 +90,10 @@ export default class ChannelRulesCreateCommand extends ControlBaseCommand {
const { flags } = await this.parse(ChannelRulesCreateCommand);

const controlApi = this.createControlApi(flags);
let appId: string | undefined;

let appId: string | undefined = flags.app;

try {
appId = flags.app;
if (!appId) {
appId = await this.resolveAppId(flags);
}
Expand Down
4 changes: 2 additions & 2 deletions src/commands/apps/channel-rules/delete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,10 @@ export default class ChannelRulesDeleteCommand extends ControlBaseCommand {
const { args, flags } = await this.parse(ChannelRulesDeleteCommand);

const controlApi = this.createControlApi(flags);
let appId: string | undefined;

let appId: string | undefined = flags.app;

try {
appId = flags.app;
if (!appId) {
appId = await this.resolveAppId(flags);
}
Expand Down
2 changes: 1 addition & 1 deletion src/commands/apps/channel-rules/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export default class ChannelRulesListCommand extends ControlBaseCommand {
];

static flags = {
...ControlBaseCommand.flags,
...ControlBaseCommand.globalFlags,
app: Flags.string({
description: "The app ID or name (defaults to current app)",
required: false,
Expand Down
4 changes: 2 additions & 2 deletions src/commands/apps/create.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Flags } from "@oclif/core";

import { ControlBaseCommand } from "../../control-base-command.js";
import { resource, success } from "../../utils/output.js";
import { progress, resource, success } from "../../utils/output.js";

export default class AppsCreateCommand extends ControlBaseCommand {
static description = "Create a new app";
Expand Down Expand Up @@ -31,7 +31,7 @@ export default class AppsCreateCommand extends ControlBaseCommand {

try {
if (!this.shouldOutputJson(flags)) {
this.log(`Creating app "${flags.name}"...`);
this.log(progress(`Creating app ${resource(flags.name)}`));
}

const app = await controlApi.createApp({
Expand Down
3 changes: 2 additions & 1 deletion src/commands/apps/delete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Args, Flags } from "@oclif/core";
import * as readline from "node:readline";

import { ControlBaseCommand } from "../../control-base-command.js";
import { progress, resource } from "../../utils/output.js";
import { promptForConfirmation } from "../../utils/prompt-confirmation.js";
import AppsSwitch from "./switch.js";

Expand Down Expand Up @@ -131,7 +132,7 @@ export default class AppsDeleteCommand extends ControlBaseCommand {
}

if (!this.shouldOutputJson(flags)) {
this.log(`Deleting app ${appIdToDelete}...`);
this.log(progress(`Deleting app ${resource(appIdToDelete)}`));
}

await controlApi.deleteApp(appIdToDelete);
Expand Down
16 changes: 11 additions & 5 deletions src/commands/apps/set-apns-p12.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import * as fs from "node:fs";
import * as path from "node:path";

import { ControlBaseCommand } from "../../control-base-command.js";
import { progress, resource, success } from "../../utils/output.js";

export default class AppsSetApnsP12Command extends ControlBaseCommand {
static args = {
Expand Down Expand Up @@ -53,7 +54,9 @@ export default class AppsSetApnsP12Command extends ControlBaseCommand {
return;
}

this.log(`Uploading APNS P12 certificate for app ${args.id}...`);
this.log(
progress(`Uploading APNS P12 certificate for app ${resource(args.id)}`),
);

// Read certificate file and encode as base64
const certificateData = fs
Expand All @@ -68,7 +71,7 @@ export default class AppsSetApnsP12Command extends ControlBaseCommand {
if (this.shouldOutputJson(flags)) {
this.log(this.formatJsonOutput(result, flags));
} else {
this.log(`\nAPNS P12 certificate uploaded successfully!`);
this.log(success("APNS P12 certificate uploaded."));
this.log(`Certificate ID: ${result.id}`);
if (flags["use-for-sandbox"]) {
this.log(`Environment: Sandbox`);
Expand All @@ -77,9 +80,12 @@ export default class AppsSetApnsP12Command extends ControlBaseCommand {
}
}
} catch (error) {
this.error(
`Error uploading APNS P12 certificate: ${error instanceof Error ? error.message : String(error)}`,
);
const errorMsg = `Error uploading APNS P12 certificate: ${error instanceof Error ? error.message : String(error)}`;
if (this.shouldOutputJson(flags)) {
this.jsonError({ error: errorMsg, success: false }, flags);
} else {
this.error(errorMsg);
}
}
}
}
3 changes: 2 additions & 1 deletion src/commands/apps/update.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Args, Flags } from "@oclif/core";

import { ControlBaseCommand } from "../../control-base-command.js";
import { progress, resource } from "../../utils/output.js";

export default class AppsUpdateCommand extends ControlBaseCommand {
static args = {
Expand Down Expand Up @@ -59,7 +60,7 @@ export default class AppsUpdateCommand extends ControlBaseCommand {

try {
if (!this.shouldOutputJson(flags)) {
this.log(`Updating app ${args.id}...`);
this.log(progress(`Updating app ${resource(args.id)}`));
}

const updateData: { name?: string; tlsOnly?: boolean } = {};
Expand Down
8 changes: 6 additions & 2 deletions src/commands/auth/keys/create.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Flags } from "@oclif/core";

import { ControlBaseCommand } from "../../../control-base-command.js";
import { resource, success } from "../../../utils/output.js";
import { progress, resource, success } from "../../../utils/output.js";

export default class KeysCreateCommand extends ControlBaseCommand {
static description = "Create a new API key for an app";
Expand Down Expand Up @@ -84,7 +84,11 @@ export default class KeysCreateCommand extends ControlBaseCommand {

try {
if (!this.shouldOutputJson(flags)) {
this.log(`Creating key "${flags.name}" for app ${appId}...`);
this.log(
progress(
`Creating key ${resource(flags.name)} for app ${resource(appId)}`,
),
);
}

const key = await controlApi.createKey(appId, {
Expand Down
2 changes: 1 addition & 1 deletion src/commands/bench/subscriber.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export default class BenchSubscriber extends AblyBaseCommand {
...productApiFlags,
duration: Flags.integer({
char: "D",
description: "Automatically exit after N seconds (0 = run indefinitely)",
description: "Automatically exit after N seconds",
}),
};

Expand Down
15 changes: 10 additions & 5 deletions src/commands/channels/batch-publish.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { Args, Flags } from "@oclif/core";
import chalk from "chalk";

import { AblyBaseCommand } from "../../base-command.js";
import { productApiFlags } from "../../flags.js";
import { progress, resource, success } from "../../utils/output.js";

// Define interfaces for the batch-publish command
interface BatchMessage {
Expand Down Expand Up @@ -245,8 +248,8 @@ export default class ChannelsBatchPublish extends AblyBaseCommand {
} as BatchContent;
}

if (!this.shouldSuppressOutput(flags)) {
this.log("Sending batch publish request...");
if (!this.shouldOutputJson(flags) && !this.shouldSuppressOutput(flags)) {
this.log(progress("Sending batch publish request"));
}

// Make the batch publish request using the REST client's request method
Expand Down Expand Up @@ -280,7 +283,7 @@ export default class ChannelsBatchPublish extends AblyBaseCommand {
),
);
} else {
this.log("Batch publish successful!");
this.log(success("Batch publish successful."));
this.log(
`Response: ${this.formatJsonOutput({ responses: responseItems }, flags)}`,
);
Expand Down Expand Up @@ -329,11 +332,13 @@ export default class ChannelsBatchPublish extends AblyBaseCommand {
batchResponses.forEach((item: BatchResponseItem) => {
if (item.error) {
this.log(
`Failed to publish to channel '${item.channel}': ${item.error.message} (${item.error.code})`,
`${chalk.red("✗")} Failed to publish to channel ${resource(item.channel)}: ${item.error.message} (${item.error.code})`,
);
} else {
this.log(
`Published to channel '${item.channel}' with messageId: ${item.messageId}`,
success(
`Published to channel ${resource(item.channel)} with messageId: ${item.messageId}.`,
),
);
}
});
Expand Down
43 changes: 25 additions & 18 deletions src/commands/channels/history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@ import * as Ably from "ably";
import chalk from "chalk";

import { AblyBaseCommand } from "../../base-command.js";
import { productApiFlags } from "../../flags.js";
import { productApiFlags, timeRangeFlags } from "../../flags.js";
import { formatJson, isJsonData } from "../../utils/json-formatter.js";
import { resource } from "../../utils/output.js";
import { formatTimestamp, resource } from "../../utils/output.js";
import { parseTimestamp } from "../../utils/time.js";

export default class ChannelsHistory extends AblyBaseCommand {
static override args = {
Expand All @@ -22,6 +23,7 @@ export default class ChannelsHistory extends AblyBaseCommand {
"$ ably channels history my-channel --json",
"$ ably channels history my-channel --pretty-json",
'$ ably channels history my-channel --start "2023-01-01T00:00:00Z" --end "2023-01-02T00:00:00Z"',
"$ ably channels history my-channel --start 1h",
"$ ably channels history my-channel --limit 100",
"$ ably channels history my-channel --direction forward",
];
Expand All @@ -37,16 +39,11 @@ export default class ChannelsHistory extends AblyBaseCommand {
options: ["backwards", "forwards"],
}),

end: Flags.string({
description: "End time for the history query (ISO 8601 format)",
}),
...timeRangeFlags,
limit: Flags.integer({
default: 50,
description: "Maximum number of results to return (default: 50)",
}),
start: Flags.string({
description: "Start time for the history query (ISO 8601 format)",
}),
};

async run(): Promise<void> {
Expand Down Expand Up @@ -82,11 +79,19 @@ export default class ChannelsHistory extends AblyBaseCommand {

// Add time range if specified
if (flags.start) {
historyParams.start = new Date(flags.start).getTime();
historyParams.start = parseTimestamp(flags.start, "start");
}

if (flags.end) {
historyParams.end = new Date(flags.end).getTime();
historyParams.end = parseTimestamp(flags.end, "end");
}

if (
historyParams.start !== undefined &&
historyParams.end !== undefined &&
historyParams.start > historyParams.end
) {
this.error("--start must be earlier than or equal to --end");
}

// Get history
Expand All @@ -108,11 +113,11 @@ export default class ChannelsHistory extends AblyBaseCommand {
this.log("");

for (const [index, message] of messages.entries()) {
const timestamp = message.timestamp
? new Date(message.timestamp).toISOString()
: "Unknown timestamp";
const timestampDisplay = message.timestamp
? formatTimestamp(new Date(message.timestamp).toISOString())
: chalk.dim("[Unknown timestamp]");

this.log(chalk.dim(`[${index + 1}] ${timestamp}`));
this.log(`${chalk.dim(`[${index + 1}]`)} ${timestampDisplay}`);
this.log(
`${chalk.dim("Event:")} ${chalk.yellow(message.name || "(none)")}`,
);
Expand Down Expand Up @@ -142,10 +147,12 @@ export default class ChannelsHistory extends AblyBaseCommand {
}
}
} catch (error) {
// Restore standard error handling
this.error(
`Error retrieving channel history: ${error instanceof Error ? error.message : String(error)}`,
);
const errorMsg = `Error retrieving channel history: ${error instanceof Error ? error.message : String(error)}`;
if (this.shouldOutputJson(flags)) {
this.jsonError({ error: errorMsg, success: false }, flags);
} else {
this.error(errorMsg);
}
}
}
}
Loading
Loading