-
Notifications
You must be signed in to change notification settings - Fork 0
[DX-936] Add new-command skill and adopt output helpers across CLI #155
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
9a17ff0
Adds a skill to assist in the upcoming work of adding new commands to…
umair-ably 78987fa
improve new command skill
umair-ably 92c9495
update helpers to prefix "format" to reduce import clashes. Also alig…
umair-ably 831b59c
makes changes to align with new helpers (prepends "format" to helpers)
umair-ably 52cfa66
Fix flaky test
umair-ably 27aa28b
PR comments
umair-ably 9a7978f
update skill around errors
umair-ably File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,335 @@ | ||
| # Command Implementation Patterns | ||
|
|
||
| Pick the pattern that matches your command from Step 1 of the skill, then follow the template below. | ||
|
|
||
| ## Table of Contents | ||
| - [Subscribe Pattern](#subscribe-pattern) | ||
| - [Publish/Send Pattern](#publishsend-pattern) | ||
| - [History Pattern](#history-pattern) | ||
| - [Enter/Presence Pattern](#enterpresence-pattern) | ||
| - [List Pattern](#list-pattern) | ||
| - [CRUD / Control API Pattern](#crud--control-api-pattern) | ||
|
|
||
| --- | ||
|
|
||
| ## Subscribe Pattern | ||
|
|
||
| Flags for subscribe commands: | ||
| ```typescript | ||
| static override flags = { | ||
| ...productApiFlags, | ||
| ...clientIdFlag, | ||
| ...durationFlag, | ||
| ...rewindFlag, | ||
| // command-specific flags here | ||
| }; | ||
| ``` | ||
|
|
||
| ```typescript | ||
| async run(): Promise<void> { | ||
| const { args, flags } = await this.parse(MySubscribeCommand); | ||
|
|
||
| const client = await this.createAblyRealtimeClient(flags); | ||
| if (!client) return; | ||
|
|
||
| this.setupConnectionStateLogging(client, flags); | ||
|
|
||
| const channelOptions: Ably.ChannelOptions = {}; | ||
| this.configureRewind(channelOptions, flags.rewind, flags, "MySubscribe", args.channel); | ||
|
|
||
| const channel = client.channels.get(args.channel, channelOptions); | ||
| // Shared helper that monitors channel state changes and logs them (verbose mode). | ||
| // Returns a cleanup function, but cleanup is handled automatically by base command. | ||
| this.setupChannelStateLogging(channel, flags); | ||
|
|
||
| if (!this.shouldOutputJson(flags)) { | ||
| this.log(formatProgress("Attaching to channel: " + formatResource(args.channel))); | ||
| } | ||
|
|
||
| channel.once("attached", () => { | ||
| if (!this.shouldOutputJson(flags)) { | ||
| this.log(formatSuccess("Attached to channel: " + formatResource(args.channel) + ".")); | ||
| this.log(formatListening("Listening for events.")); | ||
| } | ||
| }); | ||
|
|
||
| let sequenceCounter = 0; | ||
| await channel.subscribe((message) => { | ||
| sequenceCounter++; | ||
| // Format and output the message | ||
| if (this.shouldOutputJson(flags)) { | ||
| this.log(this.formatJsonOutput({ /* message data */ }, flags)); | ||
| } else { | ||
| // Human-readable output with formatTimestamp, formatResource, chalk colors | ||
| } | ||
| }); | ||
|
|
||
| await waitUntilInterruptedOrTimeout(flags); | ||
| } | ||
| ``` | ||
|
|
||
| Import `waitUntilInterruptedOrTimeout` from `../../utils/long-running.js`. | ||
|
|
||
| --- | ||
|
|
||
| ## Publish/Send Pattern | ||
|
|
||
| Flags for publish commands: | ||
| ```typescript | ||
| static override flags = { | ||
| ...productApiFlags, | ||
| ...clientIdFlag, | ||
| // command-specific flags (e.g., --name, --encoding, --count, --delay) | ||
| }; | ||
| ``` | ||
|
|
||
| ```typescript | ||
| async run(): Promise<void> { | ||
| const { args, flags } = await this.parse(MyPublishCommand); | ||
|
|
||
| const rest = await this.createAblyRestClient(flags); | ||
umair-ably marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| if (!rest) return; | ||
|
|
||
| const channel = rest.channels.get(args.channel); | ||
|
|
||
| if (!this.shouldOutputJson(flags)) { | ||
| this.log(formatProgress("Publishing to channel: " + formatResource(args.channel))); | ||
| } | ||
|
|
||
| try { | ||
| const message: Partial<Ably.Message> = { | ||
| name: flags.name || args.eventName, | ||
| data: args.data, | ||
| }; | ||
|
|
||
| await channel.publish(message as Ably.Message); | ||
|
|
||
| if (this.shouldOutputJson(flags)) { | ||
| this.log(this.formatJsonOutput({ success: true, channel: args.channel }, flags)); | ||
| } else { | ||
| this.log(formatSuccess("Message published to channel: " + formatResource(args.channel) + ".")); | ||
| } | ||
| } catch (error) { | ||
| this.handleCommandError(error, flags, "Publish", { channel: args.channel }); | ||
| } | ||
| } | ||
| ``` | ||
|
|
||
| For multi-message publish or realtime transport, see `src/commands/channels/publish.ts` as a reference. | ||
|
|
||
| **When to use Realtime instead of REST for publishing:** | ||
| - When publishing multiple messages with a count/repeat loop (continuous publishing with delays between messages) | ||
| - When the command also subscribes to the same channel (publish + subscribe in one command) | ||
| - When the command needs to maintain a persistent connection for other reasons | ||
|
|
||
| For single-shot publish, REST is preferred (simpler, no connection overhead). See `src/commands/channels/publish.ts` which supports both via a `--transport` flag. | ||
|
|
||
| --- | ||
|
|
||
| ## History Pattern | ||
|
|
||
| ```typescript | ||
| async run(): Promise<void> { | ||
| const { args, flags } = await this.parse(MyHistoryCommand); | ||
|
|
||
| const rest = await this.createAblyRestClient(flags); | ||
| if (!rest) return; | ||
|
|
||
| const channel = rest.channels.get(args.channel); | ||
|
|
||
| const historyParams = { | ||
| direction: flags.direction, | ||
| limit: flags.limit, | ||
| ...(flags.start && { start: parseTimestamp(flags.start) }), | ||
| ...(flags.end && { end: parseTimestamp(flags.end) }), | ||
| }; | ||
|
|
||
| const history = await channel.history(historyParams); | ||
| const messages = history.items; | ||
|
|
||
| if (this.shouldOutputJson(flags)) { | ||
| this.log(this.formatJsonOutput({ messages }, flags)); | ||
| } else { | ||
| this.log(formatSuccess(`Found ${messages.length} messages.`)); | ||
| // Display each message | ||
| } | ||
| } | ||
| ``` | ||
|
|
||
| --- | ||
|
|
||
| ## Enter/Presence Pattern | ||
|
|
||
| Flags for enter commands: | ||
| ```typescript | ||
| static override flags = { | ||
| ...productApiFlags, | ||
| ...clientIdFlag, | ||
| ...durationFlag, | ||
| data: Flags.string({ description: "Optional JSON data to associate with the presence" }), | ||
| "show-others": Flags.boolean({ default: false, description: "Show other presence events while present (default: false)" }), | ||
| }; | ||
| ``` | ||
|
|
||
| ```typescript | ||
| async run(): Promise<void> { | ||
| const { args, flags } = await this.parse(MyEnterCommand); | ||
|
|
||
| const client = await this.createAblyRealtimeClient(flags); | ||
| if (!client) return; | ||
|
|
||
| this.setupConnectionStateLogging(client, flags); | ||
|
|
||
| const channel = client.channels.get(args.channel); | ||
| this.setupChannelStateLogging(channel, flags); | ||
|
|
||
| // Parse optional JSON data (handle shell quote stripping) | ||
| let presenceData; | ||
| if (flags.data) { | ||
| try { | ||
| presenceData = JSON.parse(flags.data); | ||
| } catch { | ||
| this.handleCommandError("Invalid JSON data provided", flags, "PresenceEnter"); | ||
| return; | ||
| } | ||
| } | ||
|
|
||
| if (!this.shouldOutputJson(flags)) { | ||
| this.log(formatProgress("Entering presence on channel: " + formatResource(args.channel))); | ||
| } | ||
|
|
||
| // Optionally subscribe to other members' events before entering | ||
| if (flags["show-others"]) { | ||
| await channel.presence.subscribe((msg) => { | ||
| if (msg.clientId === client.auth.clientId) return; // filter self | ||
| // Display presence event | ||
| }); | ||
| } | ||
|
|
||
| await channel.presence.enter(presenceData); | ||
|
|
||
| if (!this.shouldOutputJson(flags)) { | ||
| this.log(formatSuccess("Entered presence on channel: " + formatResource(args.channel) + ".")); | ||
| this.log(formatListening("Present on channel.")); | ||
| } | ||
|
|
||
| await waitUntilInterruptedOrTimeout(flags); | ||
| } | ||
|
|
||
| // Clean up in finally — leave presence before closing connection | ||
| async finally(err: Error | undefined): Promise<void> { | ||
| if (this.channel) { | ||
| await this.channel.presence.leave(); | ||
| } | ||
| return super.finally(err); | ||
| } | ||
| ``` | ||
|
|
||
| --- | ||
|
|
||
| ## List Pattern | ||
|
|
||
| List commands query a collection and display results. They don't use `formatSuccess()` because there's no action to confirm — they just display data. | ||
|
|
||
| **Simple identifier lists** (e.g., `channels list`, `rooms list`) — use `formatResource()` for each item: | ||
| ```typescript | ||
| if (!this.shouldOutputJson(flags)) { | ||
| this.log(`Found ${chalk.cyan(items.length.toString())} active channels:`); | ||
| for (const item of items) { | ||
| this.log(`${formatResource(item.id)}`); | ||
| } | ||
| } | ||
| ``` | ||
|
|
||
| **Structured record lists** (e.g., `queues list`, `integrations list`, `push devices list`) — use `formatHeading()` and `formatLabel()` helpers: | ||
| ```typescript | ||
| if (!this.shouldOutputJson(flags)) { | ||
| this.log(`Found ${items.length} devices:\n`); | ||
| for (const item of items) { | ||
| this.log(formatHeading(`Device ID: ${item.id}`)); | ||
| this.log(` ${formatLabel("Platform")} ${item.platform}`); | ||
| this.log(` ${formatLabel("Push State")} ${item.pushState}`); | ||
| this.log(` ${formatLabel("Client ID")} ${item.clientId || "N/A"}`); | ||
| this.log(""); | ||
| } | ||
| } | ||
| ``` | ||
|
|
||
| Full Control API list command template: | ||
| ```typescript | ||
| async run(): Promise<void> { | ||
| const { flags } = await this.parse(MyListCommand); | ||
|
|
||
| const controlApi = this.createControlApi(flags); | ||
| const appId = await this.resolveAppId(flags); | ||
|
|
||
| if (!appId) { | ||
| this.handleCommandError( | ||
| 'No app specified. Use --app flag or select an app with "ably apps switch"', | ||
| flags, | ||
| "ListItems", | ||
| ); | ||
| return; | ||
| } | ||
|
|
||
| try { | ||
| const items = await controlApi.listThings(appId); | ||
| const limited = flags.limit ? items.slice(0, flags.limit) : items; | ||
|
|
||
| if (this.shouldOutputJson(flags)) { | ||
| this.log(this.formatJsonOutput({ items: limited, total: limited.length, appId }, flags)); | ||
| } else { | ||
| this.log(`Found ${limited.length} item${limited.length !== 1 ? "s" : ""}:\n`); | ||
| for (const item of limited) { | ||
| this.log(formatHeading(`Item ID: ${item.id}`)); | ||
| this.log(` ${formatLabel("Type")} ${item.type}`); | ||
| this.log(` ${formatLabel("Status")} ${item.status}`); | ||
| this.log(""); | ||
| } | ||
| } | ||
| } catch (error) { | ||
| this.handleCommandError(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 `:`) | ||
umair-ably marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| - `formatSuccess()` is not used in list commands — it's for confirming an action completed | ||
|
|
||
| --- | ||
|
|
||
| ## CRUD / Control API Pattern | ||
|
|
||
| ```typescript | ||
| async run(): Promise<void> { | ||
| const { args, flags } = await this.parse(MyControlCommand); | ||
|
|
||
| const controlApi = this.createControlApi(flags); | ||
| const appId = await this.resolveAppId(flags); | ||
|
|
||
| if (!appId) { | ||
| this.handleCommandError( | ||
| 'No app specified. Use --app flag or select an app with "ably apps switch"', | ||
| flags, | ||
| "CreateResource", | ||
| ); | ||
| return; | ||
| } | ||
|
|
||
| try { | ||
| const result = await controlApi.someMethod(appId, data); | ||
|
|
||
| if (this.shouldOutputJson(flags)) { | ||
| this.log(this.formatJsonOutput({ result }, flags)); | ||
| } else { | ||
| this.log(formatSuccess("Resource created: " + formatResource(result.id) + ".")); | ||
| // Display additional fields | ||
| } | ||
| } catch (error) { | ||
| this.handleCommandError(error, flags, "CreateResource"); | ||
| } | ||
| } | ||
| ``` | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.