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
1 change: 1 addition & 0 deletions .claude/skills/ably-codebase-review/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ Launch these agents **in parallel**. Each agent gets a focused mandate and uses
3. For any ambiguous matches, use **LSP** `hover` to confirm they're the oclif `this.error()` and not something else
4. **Grep** for `this\.fail\(` and check component strings are camelCase — single-word lowercase (`"room"`, `"auth"`), multi-word camelCase (`"channelPublish"`, `"roomPresenceSubscribe"`). Flag PascalCase like `"ChannelPublish"` or kebab-case like `"web-cli"`.
5. **Grep** for `this\.fail\(\s*new Error\(` — `this.fail()` accepts plain strings, so `new Error(...)` wrapping is unnecessary. Flag as a simplification opportunity.
6. **Check** that catch blocks calling `this.fail()` do NOT include manual oclif error re-throw guards (`if (error instanceof Error && "oclif" in error) throw error`). The base class `fail()` method handles this automatically — manual guards are unnecessary boilerplate.

**Reasoning guidance:**
- Base class files (`*-base-command.ts`) using `this.error()` are deviations — they should use `this.fail()` instead
Expand Down
4 changes: 3 additions & 1 deletion .claude/skills/ably-new-command/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,7 @@ 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
- `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
Expand Down Expand Up @@ -303,6 +303,8 @@ if (!appId) {

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

### 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.
Expand Down
1 change: 1 addition & 0 deletions .claude/skills/ably-review/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ For each changed command file, run the relevant checks. Spawn agents for paralle
2. If found, use **LSP** `hover` on the call to confirm it's the oclif `this.error()` and not something else
3. **Grep** for `this\.fail\(` and check component strings are camelCase — single-word lowercase (`"room"`, `"auth"`), multi-word camelCase (`"channelPublish"`, `"roomPresenceSubscribe"`). Flag PascalCase like `"ChannelPublish"` or kebab-case like `"web-cli"`.
4. **Grep** for `this\.fail\(\s*new Error\(` — `this.fail()` accepts plain strings, so `new Error(...)` wrapping is unnecessary. Flag as a simplification opportunity.
5. **Check** that catch blocks calling `this.fail()` do NOT include manual oclif error re-throw guards (`if (error instanceof Error && "oclif" in error) throw error`). The base class `fail()` method handles this automatically — manual guards are unnecessary boilerplate.

**Output formatting check (grep/read — text patterns):**
1. **Grep** for `chalk\.cyan\(` — should use `formatResource()` instead
Expand Down
340 changes: 169 additions & 171 deletions README.md

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion docs/Project-Structure.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,11 @@ This document outlines the directory structure of the Ably CLI project.
│ ├── commands/ # CLI commands (oclif)
│ │ ├── accounts/ # Account management (login, logout, list, switch, current)
│ │ ├── apps/ # App management (create, list, delete, switch, current, etc.)
│ │ │ ├── rules/ # Channel rules / namespaces (primary path)
│ │ │ └── channel-rules/ # Hidden alias → apps/rules/
│ │ ├── auth/ # Authentication (keys, tokens)
│ │ ├── bench/ # Benchmarking (publisher, subscriber)
│ │ ├── channel-rule/ # Channel rules / namespaces
│ │ ├── channel-rule/ # Hidden alias → apps/rules/
│ │ ├── channels/ # Pub/Sub channels (publish, subscribe, presence, history, etc.)
│ │ ├── config/ # CLI config management (show, path)
│ │ ├── connections/ # Client connections (test)
Expand Down
20 changes: 17 additions & 3 deletions src/base-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -918,8 +918,8 @@ export abstract class AblyBaseCommand extends InteractiveBaseCommand {
message,
timestamp: new Date().toISOString(),
};
// Log directly using console.error for SDK operational errors
console.error(this.formatJsonOutput(errorData, flags));
// Log to stderr with standard JSON envelope for consistency
this.logToStderr(this.formatJsonRecord("log", errorData, flags));
}
// If not verbose JSON and level > 1, suppress non-error SDK logs
} else {
Expand Down Expand Up @@ -1478,6 +1478,11 @@ export abstract class AblyBaseCommand extends InteractiveBaseCommand {
component: string,
context?: Record<string, unknown>,
): never {
// If error was already handled by a prior fail() call, re-throw it.
// This prevents double error output when fail() is called inside a try
// block (e.g., for validation) and the catch block also calls fail().
if (error instanceof Error && "oclif" in error) throw error;

const cmdError = CommandError.from(error, context);

this.logCliEvent(
Expand All @@ -1497,7 +1502,16 @@ export abstract class AblyBaseCommand extends InteractiveBaseCommand {
this.exit(1);
}

this.error(cmdError.message);
let humanMessage = cmdError.message;
const code = cmdError.code ?? cmdError.context.errorCode;
if (code !== undefined) {
const helpUrl = cmdError.context.helpUrl;
humanMessage += helpUrl
? `\nAbly error code: ${code} (${helpUrl})`
: `\nAbly error code: ${code}`;
}

this.error(humanMessage);
}

/**
Expand Down
191 changes: 13 additions & 178 deletions src/commands/apps/channel-rules/create.ts
Original file line number Diff line number Diff line change
@@ -1,184 +1,19 @@
import { Flags } from "@oclif/core";
import { Command } from "@oclif/core";

import { ControlBaseCommand } from "../../../control-base-command.js";
import { formatChannelRuleDetails } from "../../../utils/channel-rule-display.js";
import {
formatLabel,
formatSuccess,
formatWarning,
} from "../../../utils/output.js";
import RulesCreate from "../rules/create.js";

export default class ChannelRulesCreateCommand extends ControlBaseCommand {
static description = "Create a channel rule";

static examples = [
'$ ably apps channel-rules create --name "chat" --persisted',
'$ ably apps channel-rules create --name "chat" --mutable-messages',
'$ ably apps channel-rules create --name "events" --push-enabled',
'$ ably apps channel-rules create --name "notifications" --persisted --push-enabled --app "My App"',
'$ ably apps channel-rules create --name "chat" --persisted --json',
];

static flags = {
...ControlBaseCommand.globalFlags,
app: Flags.string({
description: "The app ID or name (defaults to current app)",
required: false,
}),
authenticated: Flags.boolean({
description:
"Whether channels matching this rule require clients to be authenticated",
required: false,
}),
"batching-enabled": Flags.boolean({
description:
"Whether to enable batching for messages on channels matching this rule",
required: false,
}),
"batching-interval": Flags.integer({
description:
"The batching interval for messages on channels matching this rule",
required: false,
}),
"conflation-enabled": Flags.boolean({
description:
"Whether to enable conflation for messages on channels matching this rule",
required: false,
}),
"conflation-interval": Flags.integer({
description:
"The conflation interval for messages on channels matching this rule",
required: false,
}),
"conflation-key": Flags.string({
description:
"The conflation key for messages on channels matching this rule",
required: false,
}),
"expose-time-serial": Flags.boolean({
description:
"Whether to expose the time serial for messages on channels matching this rule",
required: false,
}),
"mutable-messages": Flags.boolean({
description:
"Whether messages on channels matching this rule can be updated or deleted after publishing. Automatically enables message persistence.",
required: false,
}),
name: Flags.string({
description: "Name of the channel rule",
required: true,
}),
"persist-last": Flags.boolean({
description:
"Whether to persist only the last message on channels matching this rule",
required: false,
}),
persisted: Flags.boolean({
default: false,
description:
"Whether messages on channels matching this rule should be persisted",
required: false,
}),
"populate-channel-registry": Flags.boolean({
description:
"Whether to populate the channel registry for channels matching this rule",
required: false,
}),
"push-enabled": Flags.boolean({
default: false,
description:
"Whether push notifications should be enabled for channels matching this rule",
required: false,
}),
"tls-only": Flags.boolean({
description: "Whether to enforce TLS for channels matching this rule",
required: false,
}),
};
export default class ChannelRulesCreateCommand extends Command {
static override args = RulesCreate.args;
static override description = 'Alias for "ably apps rules create"';
static override flags = RulesCreate.flags;
static override hidden = true;
static isAlias = true;

async run(): Promise<void> {
const { flags } = await this.parse(ChannelRulesCreateCommand);

const appId = await this.requireAppId(flags);

try {
const controlApi = this.createControlApi(flags);

// When mutableMessages is enabled, persisted must also be enabled
const mutableMessages = flags["mutable-messages"];
let persisted = flags.persisted;

if (mutableMessages) {
persisted = true;
if (!this.shouldOutputJson(flags)) {
this.logToStderr(
formatWarning(
"Message persistence is automatically enabled when mutable messages is enabled.",
),
);
}
}

const namespaceData = {
authenticated: flags.authenticated,
batchingEnabled: flags["batching-enabled"],
batchingInterval: flags["batching-interval"],
id: flags.name,
conflationEnabled: flags["conflation-enabled"],
conflationInterval: flags["conflation-interval"],
conflationKey: flags["conflation-key"],
exposeTimeSerial: flags["expose-time-serial"],
mutableMessages,
persistLast: flags["persist-last"],
persisted,
populateChannelRegistry: flags["populate-channel-registry"],
pushEnabled: flags["push-enabled"],
tlsOnly: flags["tls-only"],
};

const createdNamespace = await controlApi.createNamespace(
appId,
namespaceData,
);

if (this.shouldOutputJson(flags)) {
this.logJsonResult(
{
appId,
rule: {
authenticated: createdNamespace.authenticated,
batchingEnabled: createdNamespace.batchingEnabled,
batchingInterval: createdNamespace.batchingInterval,
conflationEnabled: createdNamespace.conflationEnabled,
conflationInterval: createdNamespace.conflationInterval,
conflationKey: createdNamespace.conflationKey,
created: new Date(createdNamespace.created).toISOString(),
exposeTimeSerial: createdNamespace.exposeTimeSerial,
id: createdNamespace.id,
mutableMessages: createdNamespace.mutableMessages,
name: flags.name,
persistLast: createdNamespace.persistLast,
persisted: createdNamespace.persisted,
populateChannelRegistry: createdNamespace.populateChannelRegistry,
pushEnabled: createdNamespace.pushEnabled,
tlsOnly: createdNamespace.tlsOnly,
},
timestamp: new Date().toISOString(),
},
flags,
);
} else {
this.log(formatSuccess("Channel rule created."));
this.log(`${formatLabel("ID")} ${createdNamespace.id}`);
for (const line of formatChannelRuleDetails(createdNamespace, {
formatDate: (t) => this.formatDate(t),
})) {
this.log(line);
}
}
} catch (error) {
this.fail(error, flags, "channelRuleCreate", { appId });
}
this.warn(
'"apps channel-rules create" is deprecated. Use "apps rules create" instead.',
);
const command = new RulesCreate(this.argv, this.config);
await command.run();
}
}
Loading
Loading