diff --git a/.claude/skills/ably-new-command/SKILL.md b/.claude/skills/ably-new-command/SKILL.md index c638cdfa..d996418c 100644 --- a/.claude/skills/ably-new-command/SKILL.md +++ b/.claude/skills/ably-new-command/SKILL.md @@ -151,6 +151,34 @@ static override flags = { }; ``` +For history and list commands, use pagination utilities: +```typescript +import { + buildPaginationNext, + collectPaginatedResults, + formatPaginationWarning, +} from "../../utils/pagination.js"; + +// In run(): +const { items, hasMore, pagesConsumed } = await collectPaginatedResults(firstPage, flags.limit); +const paginationWarning = formatPaginationWarning(pagesConsumed, items.length, true); // true for history (billable) +if (paginationWarning && !this.shouldOutputJson(flags)) { + this.log(paginationWarning); +} +// For JSON output: +const next = buildPaginationNext(hasMore, lastTimestamp); // lastTimestamp only for history commands +this.logJsonResult({ [domainKey]: items, hasMore, ...(next && { next }) }, flags); +``` + +For list commands that need client-side filtering (e.g., rooms/spaces with prefix), use `collectFilteredPaginatedResults`: +```typescript +import { collectFilteredPaginatedResults } from "../../utils/pagination.js"; + +const { items, hasMore, pagesConsumed } = await collectFilteredPaginatedResults( + firstPage, flags.limit, (item) => item.name.startsWith(prefix), +); +``` + ### Command metadata ```typescript diff --git a/.claude/skills/ably-new-command/references/patterns.md b/.claude/skills/ably-new-command/references/patterns.md index 6ff9d0bc..0a5434c1 100644 --- a/.claude/skills/ably-new-command/references/patterns.md +++ b/.claude/skills/ably-new-command/references/patterns.md @@ -205,6 +205,8 @@ See `src/commands/rooms/messages/update.ts` and `src/commands/rooms/messages/del ## History Pattern ```typescript +import { collectPaginatedResults, formatPaginationWarning } from "../../utils/pagination.js"; + async run(): Promise { const { args, flags } = await this.parse(MyHistoryCommand); @@ -222,14 +224,24 @@ async run(): Promise { }; const history = await channel.history(historyParams); - const messages = history.items; + const { items: messages, hasMore, pagesConsumed } = await collectPaginatedResults(history, flags.limit); + + const paginationWarning = formatPaginationWarning(pagesConsumed, messages.length); + if (paginationWarning && !this.shouldOutputJson(flags)) { + this.log(paginationWarning); + } if (this.shouldOutputJson(flags)) { // Plural domain key for collections, optional metadata alongside - this.logJsonResult({ messages, total: messages.length }, flags); + this.logJsonResult({ messages, hasMore, total: messages.length }, flags); } else { this.log(formatSuccess(`Found ${messages.length} messages.`)); // Display each message using multi-line labeled blocks + + if (hasMore) { + const warning = formatLimitWarning(messages.length, flags.limit, "messages"); + if (warning) this.log(warning); + } } } catch (error) { this.fail(error, flags, "history", { channel: args.channel }); @@ -407,11 +419,55 @@ async run(): Promise { } ``` +**Product API list with pagination** (e.g., `push devices list`, `channels list`) — use `collectPaginatedResults`: +```typescript +import { buildPaginationNext, collectPaginatedResults, formatPaginationWarning } from "../../utils/pagination.js"; + +async run(): Promise { + const { flags } = await this.parse(MyListCommand); + + try { + const rest = await this.createAblyRestClient(flags); + if (!rest) return; + + const firstPage = await rest.someResource.list({ limit: flags.limit }); + const { items, hasMore, pagesConsumed } = await collectPaginatedResults(firstPage, flags.limit); + + const paginationWarning = formatPaginationWarning(pagesConsumed, items.length); + if (paginationWarning && !this.shouldOutputJson(flags)) { + this.log(paginationWarning); + } + + if (this.shouldOutputJson(flags)) { + const next = buildPaginationNext(hasMore); + this.logJsonResult({ items, hasMore, ...(next && { next }) }, flags); + } else { + this.log(`Found ${items.length} items:\n`); + for (const item of items) { + this.log(formatHeading(`Item ID: ${item.id}`)); + this.log(` ${formatLabel("Type")} ${item.type}`); + this.log(""); + } + + if (hasMore) { + const warning = formatLimitWarning(items.length, flags.limit, "items"); + if (warning) this.log(warning); + } + } + } catch (error) { + this.fail(error, flags, "listItems"); + } +} +``` + Key conventions for list output: - `formatResource()` is for inline resource name references, not for record headings - `formatHeading()` is for record heading lines that act as visual separators between multi-field records - `formatLabel(text)` for field labels in detail lines (automatically appends `:`) - `formatSuccess()` is not used in list commands — it's for confirming an action completed +- `formatLimitWarning()` should only be shown when `hasMore` is true — it means there are more results beyond the limit +- Always include `hasMore` and `next` in JSON output for paginated commands. `next` provides continuation hints (and `start` timestamp for history commands) +- Use `collectPaginatedResults()` for SDK paginated results and `collectFilteredPaginatedResults()` when a client-side filter is applied across pages --- diff --git a/AGENTS.md b/AGENTS.md index 608f65fa..ffed2739 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -217,7 +217,11 @@ All output helpers use the `format` prefix and are exported from `src/utils/outp - **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. +- **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**: `formatPaginationWarning(pagesConsumed, itemCount, isBillable?)` — shows "Fetched N pages" when `pagesConsumed > 1`. Pass `isBillable: true` for history commands (billable API calls). 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 and `this.logJsonEvent(data, flags)` for streaming events. The envelope adds three 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 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`. @@ -294,7 +298,7 @@ When adding COMMANDS sections in `src/help.ts`, use `chalk.bold()` for headers, ### 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)"` +- `--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"`). diff --git a/README.md b/README.md index e35b9df5..a358cfa0 100644 --- a/README.md +++ b/README.md @@ -543,12 +543,13 @@ List all apps in the current account ``` USAGE - $ ably apps list [-v] [--json | --pretty-json] + $ ably apps list [-v] [--json | --pretty-json] [--limit ] FLAGS - -v, --verbose Output verbose logs - --json Output in JSON format - --pretty-json Output in colorized JSON format + -v, --verbose Output verbose logs + --json Output in JSON format + --limit= [default: 100] Maximum number of results to return + --pretty-json Output in colorized JSON format DESCRIPTION List all apps in the current account @@ -676,13 +677,14 @@ List channel rules for an app ``` USAGE - $ ably apps rules list [-v] [--json | --pretty-json] [--app ] + $ ably apps rules list [-v] [--json | --pretty-json] [--app ] [--limit ] FLAGS - -v, --verbose Output verbose logs - --app= The app ID or name (defaults to current app) - --json Output in JSON format - --pretty-json Output in colorized JSON format + -v, --verbose Output verbose logs + --app= The app ID or name (defaults to current app) + --json Output in JSON format + --limit= [default: 100] Maximum number of results to return + --pretty-json Output in colorized JSON format DESCRIPTION List channel rules for an app @@ -1063,13 +1065,14 @@ List all keys in the app ``` USAGE - $ ably auth keys list [-v] [--json | --pretty-json] [--app ] + $ ably auth keys list [-v] [--json | --pretty-json] [--app ] [--limit ] FLAGS - -v, --verbose Output verbose logs - --app= The app ID or name (defaults to current app) - --json Output in JSON format - --pretty-json Output in colorized JSON format + -v, --verbose Output verbose logs + --app= The app ID or name (defaults to current app) + --json Output in JSON format + --limit= [default: 100] Maximum number of results to return + --pretty-json Output in colorized JSON format DESCRIPTION List all keys in the app @@ -1453,7 +1456,7 @@ ARGUMENTS FLAGS -v, --verbose Output verbose logs --json Output in JSON format - --limit= [default: 100] Maximum number of results to return (default: 100) + --limit= [default: 100] Maximum number of results to return --pretty-json Output in colorized JSON format DESCRIPTION @@ -1694,7 +1697,7 @@ FLAGS --end= End time as ISO 8601, Unix ms, or relative (e.g., "1h", "30m", "2d") --json Output in JSON format - --limit= [default: 50] Maximum number of results to return (default: 50) + --limit= [default: 50] Maximum number of results to return --pretty-json Output in colorized JSON format --start= Start time as ISO 8601, Unix ms, or relative (e.g., "1h", "30m", "2d") @@ -1757,7 +1760,7 @@ FLAGS -p, --prefix= Filter channels by prefix -v, --verbose Output verbose logs --json Output in JSON format - --limit= [default: 100] Maximum number of results to return (default: 100) + --limit= [default: 100] Maximum number of results to return --pretty-json Output in colorized JSON format DESCRIPTION @@ -2407,13 +2410,14 @@ List all integrations ``` USAGE - $ ably integrations list [-v] [--json | --pretty-json] [--app ] + $ ably integrations list [-v] [--json | --pretty-json] [--app ] [--limit ] FLAGS - -v, --verbose Output verbose logs - --app= The app ID or name (defaults to current app) - --json Output in JSON format - --pretty-json Output in colorized JSON format + -v, --verbose Output verbose logs + --app= The app ID or name (defaults to current app) + --json Output in JSON format + --limit= [default: 100] Maximum number of results to return + --pretty-json Output in colorized JSON format DESCRIPTION List all integrations @@ -2609,7 +2613,7 @@ FLAGS --end= End time as ISO 8601, Unix ms, or relative (e.g., "1h", "30m", "2d") --json Output in JSON format - --limit= [default: 100] Maximum number of results to return (default: 100) + --limit= [default: 100] Maximum number of results to return --pretty-json Output in colorized JSON format --start= Start time as ISO 8601, Unix ms, or relative (e.g., "1h", "30m", "2d") @@ -2682,7 +2686,7 @@ FLAGS --end= End time as ISO 8601, Unix ms, or relative (e.g., "1h", "30m", "2d") --json Output in JSON format - --limit= [default: 100] Maximum number of results to return (default: 100) + --limit= [default: 100] Maximum number of results to return --pretty-json Output in colorized JSON format --start= Start time as ISO 8601, Unix ms, or relative (e.g., "1h", "30m", "2d") @@ -2743,7 +2747,7 @@ FLAGS --end= End time as ISO 8601, Unix ms, or relative (e.g., "1h", "30m", "2d") --json Output in JSON format - --limit= [default: 100] Maximum number of results to return (default: 100) + --limit= [default: 100] Maximum number of results to return --pretty-json Output in colorized JSON format --start= Start time as ISO 8601, Unix ms, or relative (e.g., "1h", "30m", "2d") @@ -2933,7 +2937,7 @@ FLAGS --client-id= Filter by client ID --device-id= Filter by device ID --json Output in JSON format - --limit= [default: 100] Maximum number of results to return (default: 100) + --limit= [default: 100] Maximum number of results to return --pretty-json Output in colorized JSON format DESCRIPTION @@ -2960,7 +2964,7 @@ USAGE FLAGS -v, --verbose Output verbose logs --json Output in JSON format - --limit= [default: 100] Maximum number of results to return (default: 100) + --limit= [default: 100] Maximum number of results to return --pretty-json Output in colorized JSON format DESCRIPTION @@ -3298,7 +3302,7 @@ FLAGS --client-id= Filter by client ID --device-id= Filter by device ID --json Output in JSON format - --limit= [default: 100] Maximum number of results to return (default: 100) + --limit= [default: 100] Maximum number of results to return --pretty-json Output in colorized JSON format --state=