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
25 changes: 23 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ This is the Ably CLI npm package (`@ably/cli`), built with the [oclif framework]
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) for data/results and the logging helpers (`this.logProgress()`, `this.logSuccessMessage()`, `this.logListening()`, `this.logHolding()`, `this.logWarning()`) for status messages. `console.*` bypasses oclif and can't be captured by tests.
9. **Use `Args.string()` for primary entity identifiers** - If the value is "what is being acted on" (name, ID, channel), represent it as a positional `Args.string()`, not a `Flags.string()`. Primary entity identifiers should always use camelCase.

## Correct Practices

Expand Down Expand Up @@ -105,23 +106,43 @@ Flags are NOT global. Each command explicitly declares only the flags it needs v
- **`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`.

**Flags vs positional arguments (POSIX / docopt convention):**
- If a value answers **"what is being created/deleted/acted on?"** → **positional argument** (`Args.string()`)
- If a value answers **"how should the operation be performed?"** → **flag** (`Flags.string()`)
- The primary entity identifier (name, ID, channel) must always be a positional argument, never a `--flag`.
- Exceptions where required flags are correct: enum-constrained config values (e.g., `--rule-type` on `integrations create`), file path inputs (e.g., `--service-account` on `push config set-fcm`).

**When creating a new command:**
```typescript
// Product API command (channels, spaces, rooms, etc.)
import { productApiFlags, clientIdFlag, durationFlag, rewindFlag } from "../../flags.js";
static override args = {
// entityName should always be camelCase for `Args.*`.
entityName: Args.string({
description: "The primary entity being acted on",
required: true, // or false if interactive fallback exists
}),
};
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...
// command-specific flags (modifiers only, NOT primary entity identifiers)...
};

// Control API command (apps, keys, queues, etc.)
// controlApiFlags come from ControlBaseCommand.globalFlags automatically
static args = {
// entityName should always be camelCase for `Args.*`
entityName: Args.string({
description: "The primary entity being acted on",
required: true,
}),
};
static flags = {
...ControlBaseCommand.globalFlags,
// command-specific flags...
// command-specific flags (modifiers only, NOT primary entity identifiers)...
};
```

Expand Down
27 changes: 15 additions & 12 deletions src/commands/apps/create.ts
Original file line number Diff line number Diff line change
@@ -1,39 +1,42 @@
import { Flags } from "@oclif/core";
import { Args, Flags } from "@oclif/core";

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

export default class AppsCreateCommand extends ControlBaseCommand {
static description = "Create a new app";

static args = {
appName: Args.string({
description: "Name of the app",
required: true,
}),
};
Comment thread
sacOO7 marked this conversation as resolved.

static examples = [
'$ ably apps create --name "My New App"',
'$ ably apps create --name "My New App" --tls-only',
'$ ably apps create --name "My New App" --json',
'$ ABLY_ACCESS_TOKEN="YOUR_ACCESS_TOKEN" ably apps create --name "My New App"',
'$ ably apps create "My New App"',
'$ ably apps create "My New App" --tls-only',
'$ ably apps create "My New App" --json',
'$ ABLY_ACCESS_TOKEN="YOUR_ACCESS_TOKEN" ably apps create "My New App"',
];

static flags = {
...ControlBaseCommand.globalFlags,
name: Flags.string({
description: "Name of the app",
required: true,
}),
"tls-only": Flags.boolean({
default: false,
description: "Whether the app should accept TLS connections only",
}),
};

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

try {
const controlApi = this.createControlApi(flags);
this.logProgress(`Creating app ${formatResource(flags.name)}`, flags);
this.logProgress(`Creating app ${formatResource(args.appName)}`, flags);

const app = await controlApi.createApp({
name: flags.name,
name: args.appName,
tlsOnly: flags["tls-only"],
});

Expand Down
27 changes: 15 additions & 12 deletions src/commands/apps/rules/create.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Flags } from "@oclif/core";
import { Args, Flags } from "@oclif/core";

import { ControlBaseCommand } from "../../../control-base-command.js";
import { formatChannelRuleDetails } from "../../../utils/channel-rule-display.js";
Expand All @@ -7,12 +7,19 @@ import { formatLabel, formatResource } from "../../../utils/output.js";
export default class RulesCreateCommand extends ControlBaseCommand {
static description = "Create a rule";

static args = {
ruleName: Args.string({
description: "Name of the rule",
required: true,
}),
};

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

static flags = {
Expand Down Expand Up @@ -56,10 +63,6 @@ export default class RulesCreateCommand extends ControlBaseCommand {
"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 rule",
required: true,
}),
"persist-last": Flags.boolean({
description:
"Whether to persist only the last message on channels matching this rule",
Expand Down Expand Up @@ -89,7 +92,7 @@ export default class RulesCreateCommand extends ControlBaseCommand {
};

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

const appId = await this.requireAppId(flags);

Expand All @@ -112,7 +115,7 @@ export default class RulesCreateCommand extends ControlBaseCommand {
authenticated: flags.authenticated,
batchingEnabled: flags["batching-enabled"],
batchingInterval: flags["batching-interval"],
id: flags.name,
id: args.ruleName,
conflationEnabled: flags["conflation-enabled"],
conflationInterval: flags["conflation-interval"],
conflationKey: flags["conflation-key"],
Expand Down
6 changes: 3 additions & 3 deletions src/commands/apps/rules/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ export default class RulesIndexCommand extends BaseTopicCommand {

static examples = [
"$ ably apps rules list",
'$ ably apps rules create --name "chat" --persisted',
"$ ably apps rules update chat --push-enabled",
"$ ably apps rules delete chat",
'$ ably apps rules create "chat" --persisted',
'$ ably apps rules update "chat" --push-enabled',
'$ ably apps rules delete "chat"',
];
}
37 changes: 20 additions & 17 deletions src/commands/auth/keys/create.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Flags } from "@oclif/core";
import { Args, Flags } from "@oclif/core";

import { ControlBaseCommand } from "../../../control-base-command.js";
import { formatCapabilities } from "../../../utils/key-display.js";
Expand All @@ -8,16 +8,23 @@ import { formatLabel, formatResource } from "../../../utils/output.js";
export default class KeysCreateCommand extends ControlBaseCommand {
static description = "Create a new API key for an app";

static args = {
keyName: Args.string({
description: "Name of the key",
required: true,
}),
};

static examples = [
`$ ably auth keys create --name "My New Key"`,
`$ ably auth keys create --name "My New Key" --app APP_ID`,
`$ ably auth keys create --name "My New Key" --capabilities '{"*":["*"]}'`,
`$ ably auth keys create --name "My New Key" --capabilities '{"channel1":["publish","subscribe"],"channel2":["history"]}'`,
`$ ably auth keys create --name "My New Key" --capabilities "publish,subscribe"`,
`$ ably auth keys create --name "My New Key" --json`,
`$ ably auth keys create --name "My New Key" --pretty-json`,
`$ ably auth keys create --app APP_ID --name "MyKey" --capabilities '{"channel:*":["publish"]}'`,
`$ ably auth keys create --app APP_ID --name "MyOtherKey" --capabilities '{"channel:chat-*":["subscribe"],"channel:updates":["publish"]}'`,
`$ ably auth keys create "My New Key"`,
`$ ably auth keys create "My New Key" --app APP_ID`,
`$ ably auth keys create "My New Key" --capabilities '{"*":["*"]}'`,
`$ ably auth keys create "My New Key" --capabilities '{"channel1":["publish","subscribe"],"channel2":["history"]}'`,
`$ ably auth keys create "My New Key" --capabilities "publish,subscribe"`,
`$ ably auth keys create "My New Key" --json`,
`$ ably auth keys create "My New Key" --pretty-json`,
`$ ably auth keys create "MyKey" --app APP_ID --capabilities '{"channel:*":["publish"]}'`,
`$ ably auth keys create "MyOtherKey" --app APP_ID --capabilities '{"channel:chat-*":["subscribe"],"channel:updates":["publish"]}'`,
];

static flags = {
Expand All @@ -31,14 +38,10 @@ export default class KeysCreateCommand extends ControlBaseCommand {
description:
"Capabilities as JSON object (per-channel) or comma-separated list (all channels)",
}),
name: Flags.string({
description: "Name of the key",
required: true,
}),
};

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

const appId = await this.requireAppId(flags);

Expand All @@ -52,13 +55,13 @@ export default class KeysCreateCommand extends ControlBaseCommand {
try {
const controlApi = this.createControlApi(flags);
this.logProgress(
`Creating key ${formatResource(flags.name)} for app ${formatResource(appId)}`,
`Creating key ${formatResource(args.keyName)} for app ${formatResource(appId)}`,
flags,
);

const key = await controlApi.createKey(appId, {
capability: capabilities,
name: flags.name,
name: args.keyName,
});

if (this.shouldOutputJson(flags)) {
Expand Down
2 changes: 1 addition & 1 deletion src/commands/auth/keys/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export default class AuthKeys extends BaseTopicCommand {

static examples = [
"$ ably auth keys list",
'$ ably auth keys create --name "My New Key"',
'$ ably auth keys create "My New Key"',
"$ ably auth keys get KEY_ID",
"$ ably auth keys revoke KEY_ID",
'$ ably auth keys update KEY_ID --name "New Name"',
Expand Down
4 changes: 2 additions & 2 deletions src/commands/push/channels/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ export default class PushChannels extends BaseTopicCommand {
"Manage push notification channel subscriptions";

static override examples = [
"<%= config.bin %> <%= command.id %> list --channel my-channel",
"<%= config.bin %> <%= command.id %> save --channel my-channel --device-id device-123",
'<%= config.bin %> <%= command.id %> list "my-channel"',
'<%= config.bin %> <%= command.id %> save "my-channel" --device-id device-123',
"<%= config.bin %> <%= command.id %> list-channels",
];
}
25 changes: 14 additions & 11 deletions src/commands/push/channels/list.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Flags } from "@oclif/core";
import { Args, Flags } from "@oclif/core";

import { AblyBaseCommand } from "../../../base-command.js";
import { productApiFlags } from "../../../flags.js";
Expand All @@ -20,18 +20,21 @@ import {
export default class PushChannelsList extends AblyBaseCommand {
static override description = "List push channel subscriptions";

static override args = {
channelName: Args.string({
description: "Channel name to list subscriptions for",
required: true,
}),
};

static override examples = [
"<%= config.bin %> <%= command.id %> --channel my-channel",
"<%= config.bin %> <%= command.id %> --channel my-channel --device-id device-123",
"<%= config.bin %> <%= command.id %> --channel my-channel --json",
'<%= config.bin %> <%= command.id %> "my-channel"',
'<%= config.bin %> <%= command.id %> "my-channel" --device-id device-123',
'<%= config.bin %> <%= command.id %> "my-channel" --json',
];

static override flags = {
...productApiFlags,
channel: Flags.string({
description: "Channel name to list subscriptions for",
required: true,
}),
"device-id": Flags.string({
description: "Filter by device ID",
}),
Expand All @@ -46,19 +49,19 @@ export default class PushChannelsList extends AblyBaseCommand {
};

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

try {
const rest = await this.createAblyRestClient(flags as BaseFlags);
if (!rest) return;

this.logProgress(
`Fetching subscriptions for channel ${formatResource(flags.channel)}`,
`Fetching subscriptions for channel ${formatResource(args.channelName)}`,
flags,
);

const params: Record<string, string | number> = {
channel: flags.channel,
channel: args.channelName,
limit: flags.limit,
};
if (flags["device-id"]) params.deviceId = flags["device-id"];
Expand Down
25 changes: 14 additions & 11 deletions src/commands/push/channels/remove-where.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Flags } from "@oclif/core";
import { Args, Flags } from "@oclif/core";

import { AblyBaseCommand } from "../../../base-command.js";
import { forceFlag, productApiFlags } from "../../../flags.js";
Expand All @@ -10,18 +10,21 @@ export default class PushChannelsRemoveWhere extends AblyBaseCommand {
static override description =
"Remove push channel subscriptions matching filter criteria";

static override args = {
channelName: Args.string({
description: "Channel name to filter by",
required: true,
}),
};

static override examples = [
"<%= config.bin %> <%= command.id %> --channel my-channel",
"<%= config.bin %> <%= command.id %> --channel my-channel --device-id device-123 --force",
"<%= config.bin %> <%= command.id %> --channel my-channel --json",
'<%= config.bin %> <%= command.id %> "my-channel"',
'<%= config.bin %> <%= command.id %> "my-channel" --device-id device-123 --force',
'<%= config.bin %> <%= command.id %> "my-channel" --json',
];

static override flags = {
...productApiFlags,
channel: Flags.string({
description: "Channel name to filter by",
required: true,
}),
"device-id": Flags.string({
description: "Filter by device ID",
}),
Expand All @@ -32,14 +35,14 @@ export default class PushChannelsRemoveWhere extends AblyBaseCommand {
};

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

try {
const rest = await this.createAblyRestClient(flags as BaseFlags);
if (!rest) return;

const params: Record<string, string> = {
channel: flags.channel,
channel: args.channelName,
};
if (flags["device-id"]) params.deviceId = flags["device-id"];
if (flags["client-id"]) params.clientId = flags["client-id"];
Expand Down Expand Up @@ -69,7 +72,7 @@ export default class PushChannelsRemoveWhere extends AblyBaseCommand {
}

this.logProgress(
`Removing matching subscriptions from channel ${formatResource(flags.channel)}`,
`Removing matching subscriptions from channel ${formatResource(args.channelName)}`,
flags,
);

Expand Down
Loading
Loading