Skip to content
Closed
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
7 changes: 4 additions & 3 deletions .claude/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ ls -la .cursor/rules/ # Should show .mdc files
**MANDATORY - Read these .cursor/rules files before ANY work:**

1. `Workflow.mdc` - The mandatory development workflow
2. `Development.mdc` - Coding standards
2. `Development.mdc` - Coding standards
3. `AI-Assistance.mdc` - How to work with this codebase

**Finding the rules:**
Expand All @@ -51,6 +51,7 @@ cat .cursor/rules/AI-Assistance.mdc
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) and `this.logToStderr()` (stderr). `console.*` bypasses oclif and can't be captured by tests.
9. **Truncate markdown docs** - When fetching `.md` documentation (e.g., via `curl`), do NOT use `| head` or `| tail` to truncate. Fetch the complete markdown content. HTML or other doc formats may be truncated if needed.

## ✅ Correct Practices

Expand Down Expand Up @@ -253,10 +254,10 @@ When adding COMMANDS sections in `src/help.ts`, use `chalk.bold()` for headers,
## ✓ Before Marking Complete

- [ ] `pnpm prepare` succeeds
- [ ] `pnpm exec eslint .` shows 0 errors
- [ ] `pnpm exec eslint .` shows 0 errors
- [ ] `pnpm test:unit` passes
- [ ] No debug artifacts remain
- [ ] Docs updated if needed
- [ ] Followed oclif patterns

**Quality matters. This is read by developers.**
**Quality matters. This is read by developers.**
1,123 changes: 1,123 additions & 0 deletions ANNOTATIONS_IMPL.md

Large diffs are not rendered by default.

15 changes: 15 additions & 0 deletions src/commands/channels/annotations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { BaseTopicCommand } from "../../base-topic-command.js";

export default class ChannelsAnnotations extends BaseTopicCommand {
protected topicName = "channels:annotations";
protected commandGroup = "channel annotations";

static override description = "Manage annotations on Ably channel messages";

static override examples = [
"$ ably channels annotations publish my-channel msg-serial-123 reactions:flag.v1",
"$ ably channels annotations delete my-channel msg-serial-123 reactions:flag.v1",
"$ ably channels annotations get my-channel msg-serial-123",
"$ ably channels annotations subscribe my-channel",
];
}
110 changes: 110 additions & 0 deletions src/commands/channels/annotations/delete.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { Args, Flags } from "@oclif/core";
import * as Ably from "ably";

import { AblyBaseCommand } from "../../../base-command.js";
import { clientIdFlag, productApiFlags } from "../../../flags.js";
import { resource, success } from "../../../utils/output.js";
import {
extractSummarizationType,
validateAnnotationParams,
} from "../../../utils/annotation-validation.js";

export default class ChannelsAnnotationsDelete extends AblyBaseCommand {
static override args = {
channel: Args.string({
description: "Channel name",
required: true,
}),
msgSerial: Args.string({
description: "Message serial of the annotated message",
required: true,
}),
annotationType: Args.string({
description: "Annotation type (e.g., reactions:flag.v1)",
required: true,
}),
};

static override description = "Delete an annotation from a message";

static override examples = [
"$ ably channels annotations delete my-channel msg-serial-123 reactions:flag.v1",
'$ ably channels annotations delete my-channel msg-serial-123 reactions:distinct.v1 --name "thumbsup"',
"$ ably channels annotations delete my-channel msg-serial-123 reactions:flag.v1 --json",
];

static override flags = {
...productApiFlags,
...clientIdFlag,
name: Flags.string({
description:
"Annotation name (required for distinct/unique/multiple types)",
}),
data: Flags.string({
description: "Optional data payload (JSON string)",
}),
};

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

try {
// 1. Validate (same as publish, but count not needed for delete via isDelete flag)
const summarization = extractSummarizationType(args.annotationType);
const errors = validateAnnotationParams(summarization, {
name: flags.name,
isDelete: true,
});
if (errors.length > 0) {
this.error(errors.join("\n"));
}

// 2. Build OutboundAnnotation
const annotation: Ably.OutboundAnnotation = {
type: args.annotationType,
};
if (flags.name) annotation.name = flags.name;
if (flags.data) {
try {
annotation.data = JSON.parse(flags.data);
} catch {
this.error("Invalid JSON in --data flag. Please provide valid JSON.");
}
}

// 3. Create client and delete
const client = await this.createAblyRealtimeClient(flags);
if (!client) return;

const channel = client.channels.get(args.channel);
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

channels.get() is called without setting the Ably channel mode required for annotation publishing. The Ably SDK expects ANNOTATION_PUBLISH mode for annotation publish/delete; without it, channel.annotations.delete() can fail at runtime. Request the channel with { modes: ["ANNOTATION_PUBLISH"] }.

Suggested change
const channel = client.channels.get(args.channel);
const channel = client.channels.get(args.channel, {
modes: ["ANNOTATION_PUBLISH"],
});

Copilot uses AI. Check for mistakes.
await channel.annotations.delete(args.msgSerial, annotation);

// 4. Output success
if (this.shouldOutputJson(flags)) {
this.log(
this.formatJsonOutput(
{
success: true,
channel: args.channel,
messageSerial: args.msgSerial,
annotationType: args.annotationType,
name: flags.name || null,
},
flags,
),
);
} else {
this.log(
success(`Annotation deleted from channel ${resource(args.channel)}.`),
);
}
} catch (error) {
const errorMsg = `Error deleting annotation: ${error instanceof Error ? error.message : String(error)}`;
if (this.shouldOutputJson(flags)) {
this.jsonError({ error: errorMsg, success: false }, flags);
} else {
this.error(errorMsg);
}
}
}
}
131 changes: 131 additions & 0 deletions src/commands/channels/annotations/get.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import { Args, Flags } from "@oclif/core";
import * as Ably from "ably";
import chalk from "chalk";

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

export default class ChannelsAnnotationsGet extends AblyBaseCommand {
static override args = {
channel: Args.string({
description: "Channel name",
required: true,
}),
msgSerial: Args.string({
description: "Message serial to get annotations for",
required: true,
}),
};

static override description = "Get annotations for a message";

static override examples = [
"$ ably channels annotations get my-channel msg-serial-123",
"$ ably channels annotations get my-channel msg-serial-123 --limit 50",
"$ ably channels annotations get my-channel msg-serial-123 --json",
];

static override flags = {
...productApiFlags,
limit: Flags.integer({
default: 100,
description: "Maximum number of results to return (default: 100)",
}),
};

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

try {
// 1. Create REST client (get is a REST operation)
const client = await this.createAblyRestClient(flags);
if (!client) return;

// 2. Get channel and fetch annotations
const channel = client.channels.get(args.channel);
const params: Ably.GetAnnotationsParams = {};
if (flags.limit !== undefined) {
params.limit = flags.limit;
}

const result = await channel.annotations.get(args.msgSerial, params);
const annotations = result.items;

// 3. Output results
if (this.shouldOutputJson(flags)) {
this.log(
this.formatJsonOutput(
annotations.map((annotation, index) => ({
index: index + 1,
id: annotation.id,
action: annotation.action,
type: annotation.type,
name: annotation.name || null,
clientId: annotation.clientId || null,
count: annotation.count ?? null,
data: annotation.data ?? null,
messageSerial: annotation.messageSerial,
serial: annotation.serial,
timestamp: annotation.timestamp
? new Date(annotation.timestamp).toISOString()
: null,
})),
flags,
),
);
} else {
if (annotations.length === 0) {
this.log(
`No annotations found for message ${resource(args.msgSerial)} on channel ${resource(args.channel)}.`,
);
return;
}

this.log(
`Annotations for message ${resource(args.msgSerial)} on channel ${resource(args.channel)}:\n`,
);

for (const [index, annotation] of annotations.entries()) {
const timestamp = annotation.timestamp
? new Date(annotation.timestamp).toISOString()
: new Date().toISOString();

const actionLabel =
annotation.action === "annotation.create"
? chalk.green("CREATE")
: chalk.red("DELETE");

this.log(
`${chalk.dim(`[${index + 1}]`)} ${formatTimestamp(timestamp)} ${actionLabel} | ${chalk.dim("Type:")} ${annotation.type} | ${chalk.dim("Name:")} ${annotation.name || "(none)"} | ${chalk.dim("Client:")} ${annotation.clientId ? chalk.blue(annotation.clientId) : "(none)"}`,
);
if (annotation.count !== undefined) {
this.log(` ${chalk.dim("Count:")} ${annotation.count}`);
}

if (annotation.data) {
this.log(
` ${chalk.dim("Data:")} ${JSON.stringify(annotation.data)}`,
);
}
}

if (annotations.length === flags.limit) {
this.log("");
this.log(
chalk.yellow(
`Showing maximum of ${flags.limit} annotations. Use --limit to show more.`,
),
);
}
}
} catch (error) {
const errorMsg = `Error retrieving annotations: ${error instanceof Error ? error.message : String(error)}`;
if (this.shouldOutputJson(flags)) {
this.jsonError({ error: errorMsg, success: false }, flags);
} else {
this.error(errorMsg);
}
}
}
}
Loading