Skip to content

Commit d61dc90

Browse files
authored
Merge pull request #249 from ably/jsonFixes
Clean up JSON output: logging helpers, stderr routing, and lifecycle signals
2 parents 6b72eef + 572225b commit d61dc90

237 files changed

Lines changed: 2701 additions & 2477 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.claude/skills/ably-codebase-review/SKILL.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -94,14 +94,14 @@ Launch these agents **in parallel**. Each agent gets a focused mandate and uses
9494
9595
### Agent 3: Output Formatting Sweep
9696
97-
**Goal:** Verify all human output uses the correct format helpers and is JSON-guarded.
97+
**Goal:** Verify all human output uses the correct format helpers and correct output method (stderr for status, stdout for data).
9898
9999
**Method (grep/read — text patterns):**
100100
1. **Grep** for `chalk\.cyan\(` in command files — should use `formatResource()` instead
101101
2. **Grep** for `formatProgress(` and check matches for manual `...` appended
102102
3. **Grep** for `formatSuccess(` and read the lines to check they end with `.`
103-
4. **Grep** for `shouldOutputJson` to find all JSON-aware commands
104-
5. **Read** command files and look for unguarded `this.log()` calls (not inside `if (!this.shouldOutputJson(flags))`)
103+
4. **Grep** for `this\.log(formatProgress\|this\.log(formatSuccess\|this\.log(formatListening\|this\.log(formatWarning` and `this\.logToStderr(formatProgress\|this\.logToStderr(formatSuccess\|this\.logToStderr(formatListening\|this\.logToStderr(formatWarning` — these must use the base command helpers instead: `this.logProgress(msg, flags)`, `this.logSuccessMessage(msg, flags)`, `this.logListening(msg, flags)`, `this.logHolding(msg, flags)`, `this.logWarning(msg, flags)`. In non-JSON mode all helpers emit to stderr. In JSON mode: `logProgress` and `logSuccessMessage` are **silent** (no-ops), while `logListening` (status: "listening"), `logHolding` (status: "holding"), and `logWarning` (status: "warning") emit structured JSON on stdout. `logSuccessMessage` should be inside the `else` block after `logJsonResult`. Also check these helpers are NOT inside `shouldOutputJson` guards — they don't need them.
104+
5. **Grep** for `shouldOutputJson` to find all JSON-aware commands and verify data output is properly branched
105105
6. **Grep** for quoted resource names patterns like `"${` or `'${` near `channel`, `name`, `app` variables — should use `formatResource()`
106106
107107
**Method (grep — structured output format):**
@@ -155,15 +155,15 @@ Launch these agents **in parallel**. Each agent gets a focused mandate and uses
155155
4. Cross-reference: every leaf command should appear in both the `logJsonResult`/`logJsonEvent` list and the `shouldOutputJson` list
156156
5. **Read** streaming commands to verify they use `logJsonEvent`, one-shot commands use `logJsonResult`
157157
6. **Read** each `logJsonResult`/`logJsonEvent` call and verify data is nested under a domain key — singular for events/single items (e.g., `{message: ...}`, `{cursor: ...}`), plural for collections (e.g., `{cursors: [...]}`, `{rules: [...]}`). Top-level envelope fields are `type`, `command`, `success` only. Metadata like `total`, `timestamp`, `appId` may sit alongside the domain key.
158-
7. **Check** hold commands (set, enter, acquire) emit `logJsonStatus("holding", ...)` after `logJsonResult` — this signals to JSON consumers that the command is alive and waiting for Ctrl+C / `--duration`
158+
7. **Check** hold commands (set, enter, acquire) emit `this.logHolding(...)` after `logJsonResult` — this emits `status: "holding"` in JSON mode, signaling to consumers that the command is alive and waiting for Ctrl+C / `--duration`. For passive subscribe commands, check for `this.logListening(...)` instead (emits `status: "listening"`)
159159
160160
**Reasoning guidance:**
161161
- Commands that ONLY have human output (no JSON path) are deviations
162162
- Direct `formatJsonRecord` usage in command files should use `logJsonResult`/`logJsonEvent` instead
163163
- Topic index commands (showing help) don't need JSON output
164164
- Data spread at the top level without a domain key is a deviation — nest under a singular or plural domain noun
165165
- Metadata fields (`total`, `timestamp`, `hasMore`, `appId`) alongside the domain key are acceptable — they describe the result, not the domain objects
166-
- Hold commands missing `logJsonStatus` after `logJsonResult` are deviations — JSON consumers need the hold signal
166+
- Hold commands missing `logHolding` after `logJsonResult` are deviations — JSON consumers need the hold signal
167167
168168
### Agent 6: Test Pattern Sweep
169169

.claude/skills/ably-new-command/SKILL.md

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -210,19 +210,15 @@ export default class TopicAction extends AblyBaseCommand {
210210
211211
### Output patterns
212212
213-
The CLI has specific output helpers in `src/utils/output.ts`. All human-readable output must be wrapped in a JSON guard:
213+
The base command provides five logging helpers: `this.logProgress(msg, flags)`, `this.logSuccessMessage(msg, flags)`, `this.logListening(msg, flags)`, `this.logHolding(msg, flags)`, `this.logWarning(msg, flags)`. These do NOT need `shouldOutputJson` guards. In non-JSON mode they all emit formatted text on stderr. In JSON mode: `logProgress` and `logSuccessMessage` are **silent** (no-ops — the JSON result/event records already convey progress and success), while `logListening` (status: "listening"), `logHolding` (status: "holding"), and `logWarning` (status: "warning") emit structured JSON on stdout. `logSuccessMessage` should be placed inside the `else` block after `logJsonResult` for readability. Only human-readable **data output** (field labels, headings, record blocks) needs the `if/else` guard:
214214
215215
```typescript
216-
// JSON guard — all human output goes through this
217-
if (!this.shouldOutputJson(flags)) {
218-
this.log(formatProgress("Attaching to channel: " + formatResource(channelName)));
219-
}
216+
// Progress/success/listening — no guard needed, helpers handle both modes
217+
this.logProgress("Attaching to channel: " + formatResource(channelName), flags);
220218

221219
// After success:
222-
if (!this.shouldOutputJson(flags)) {
223-
this.log(formatSuccess("Subscribed to channel: " + formatResource(channelName) + "."));
224-
this.log(formatListening("Listening for messages."));
225-
}
220+
this.logSuccessMessage("Subscribed to channel: " + formatResource(channelName) + ".", flags);
221+
this.logListening("Listening for messages.", flags);
226222

227223
// JSON output — nest data under a domain key, not spread at top level.
228224
// Envelope provides top-level: type, command, success.
@@ -276,7 +272,11 @@ Rules:
276272
- `formatHeading(text)` — bold, for record headings in lists
277273
- `formatIndex(n)`dim bracketed number, for history ordering
278274
- Use `this.fail()` for all errors (see Error handling below), never `this.log(chalk.red(...))`
279-
- Never use `console.log` or `console.error`always `this.log()` or `this.logToStderr()`
275+
- Never use `console.log` or `console.error`always `this.log()` for data or the logging helpers for status messages
276+
- Use `this.logProgress(msg, flags)`, `this.logSuccessMessage(msg, flags)`, `this.logListening(msg, flags)`, `this.logHolding(msg, flags)`, `this.logWarning(msg, flags)` for status messagesno `shouldOutputJson` guard needed. In non-JSON mode all emit to stderr. In JSON mode: `logProgress` and `logSuccessMessage` are silent; `logListening` (status: "listening"), `logHolding` (status: "holding"), and `logWarning` (status: "warning") emit structured JSON on stdout
277+
- Do NOT use the old pattern `this.logToStderr(formatProgress/Success/Listening/Warning(...))`use the helpers instead
278+
- `formatPaginationLog()` output still uses `this.logToStderr()` directly (not a helper yet)
279+
- `formatLabel()`, `formatHeading()`, `formatIndex()`, `formatTimestamp()`, `formatClientId()`, `formatEventType()``this.log()` inside the non-JSON branch
280280

281281
### JSON envelopereserved keys
282282

@@ -454,9 +454,10 @@ See the "Keeping Skills Up to Date" section in `CLAUDE.md` for the full list of
454454
- [ ] Correct base class (`AblyBaseCommand`, `ControlBaseCommand`, `ChatBaseCommand`, `SpacesBaseCommand`, or `StatsBaseCommand`)
455455
- [ ] Correct flag set (`productApiFlags` vs `ControlBaseCommand.globalFlags`)
456456
- [ ] `clientIdFlag` only if command needs client identity
457-
- [ ] All human output wrapped in `if (!this.shouldOutputJson(flags))`
458-
- [ ] Output helpers used correctly (`formatProgress`, `formatSuccess`, `formatWarning`, `formatListening`, `formatResource`, `formatTimestamp`, `formatClientId`, `formatEventType`, `formatLabel`, `formatHeading`, `formatIndex`)
459-
- [ ] `formatSuccess()` messages end with `.` (period)
457+
- [ ] Human data output wrapped in `if (!this.shouldOutputJson(flags))`but `logProgress`, `logSuccessMessage`, `logListening`, `logWarning` helpers do NOT need guards
458+
- [ ] Status messages use base command helpers (`this.logProgress`, `this.logSuccessMessage`, `this.logListening`, `this.logWarning`) — NOT `this.logToStderr(formatProgress/Success/Listening/Warning(...))`
459+
- [ ] Output formatters used correctly for data display (`formatResource`, `formatTimestamp`, `formatClientId`, `formatEventType`, `formatLabel`, `formatHeading`, `formatIndex`)
460+
- [ ] `logSuccessMessage()` messages end with `.` (period)
460461
- [ ] Non-JSON data output uses multi-line labeled blocks (see `patterns.md` "Human-Readable Output Format"), not tables or custom grids
461462
- [ ] Non-JSON output exposes all available SDK fields (same data as JSON mode, omitting only null/empty values)
462463
- [ ] SDK types imported directly (`import type { CursorUpdate } from "@ably/spaces"`) — no local interface redefinitions of SDK types (display interfaces in `src/utils/` are fine)

.claude/skills/ably-new-command/references/patterns.md

Lines changed: 43 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -44,15 +44,11 @@ async run(): Promise<void> {
4444
// Returns a cleanup function, but cleanup is handled automatically by base command.
4545
this.setupChannelStateLogging(channel, flags);
4646

47-
if (!this.shouldOutputJson(flags)) {
48-
this.log(formatProgress("Attaching to channel: " + formatResource(args.channel)));
49-
}
47+
this.logProgress("Attaching to channel: " + formatResource(args.channel), flags);
5048

5149
channel.once("attached", () => {
52-
if (!this.shouldOutputJson(flags)) {
53-
this.log(formatSuccess("Attached to channel: " + formatResource(args.channel) + "."));
54-
this.log(formatListening("Listening for events."));
55-
}
50+
this.logSuccessMessage("Attached to channel: " + formatResource(args.channel) + ".", flags);
51+
this.logListening("Listening for events.", flags);
5652
});
5753

5854
let sequenceCounter = 0;
@@ -115,9 +111,7 @@ async run(): Promise<void> {
115111

116112
const channel = rest.channels.get(args.channel);
117113

118-
if (!this.shouldOutputJson(flags)) {
119-
this.log(formatProgress("Publishing to channel: " + formatResource(args.channel)));
120-
}
114+
this.logProgress("Publishing to channel: " + formatResource(args.channel), flags);
121115

122116
try {
123117
const message: Partial<Ably.Message> = {
@@ -131,9 +125,9 @@ async run(): Promise<void> {
131125
// Nest data under a domain key. Don't use "success" as a data key
132126
// for batch summaries — it overrides the envelope's success field. Use "allSucceeded".
133127
this.logJsonResult({ message: { channel: args.channel, name: message.name, data: message.data } }, flags);
134-
} else {
135-
this.log(formatSuccess("Message published to channel: " + formatResource(args.channel) + "."));
136128
}
129+
130+
this.logSuccessMessage("Message published to channel: " + formatResource(args.channel) + ".", flags);
137131
} catch (error) {
138132
this.fail(error, flags, "publish", { channel: args.channel });
139133
}
@@ -179,17 +173,15 @@ async run(): Promise<void> {
179173
// NO room.attach() — update/delete/annotate are REST calls
180174
const room = await chatClient.rooms.get(args.room);
181175

182-
if (!this.shouldOutputJson(flags)) {
183-
this.log(formatProgress("Updating message " + formatResource(args.serial) + " in room " + formatResource(args.room)));
184-
}
176+
this.logProgress("Updating message " + formatResource(args.serial) + " in room " + formatResource(args.room), flags);
185177

186178
const result = await room.messages.update(args.serial, updateParams, details);
187179

188180
if (this.shouldOutputJson(flags)) {
189181
this.logJsonResult({ room: args.room, serial: args.serial, versionSerial: result.version.serial }, flags);
190-
} else {
191-
this.log(formatSuccess(`Message ${formatResource(args.serial)} updated in room ${formatResource(args.room)}.`));
192182
}
183+
184+
this.logSuccessMessage(`Message ${formatResource(args.serial)} updated in room ${formatResource(args.room)}.`, flags);
193185
} catch (error) {
194186
this.fail(error, flags, "roomMessageUpdate", { room: args.room, serial: args.serial });
195187
}
@@ -227,15 +219,18 @@ async run(): Promise<void> {
227219
const { items: messages, hasMore, pagesConsumed } = await collectPaginatedResults(history, flags.limit);
228220

229221
const paginationWarning = formatPaginationLog(pagesConsumed, messages.length);
230-
if (paginationWarning && !this.shouldOutputJson(flags)) {
231-
this.log(paginationWarning);
222+
if (paginationWarning) {
223+
this.logToStderr(paginationWarning);
232224
}
233225

234226
if (this.shouldOutputJson(flags)) {
235227
// Plural domain key for collections, optional metadata alongside
236228
this.logJsonResult({ messages, hasMore, total: messages.length }, flags);
237-
} else {
238-
this.log(formatSuccess(`Found ${messages.length} messages.`));
229+
}
230+
231+
this.logSuccessMessage(`Found ${messages.length} messages.`, flags);
232+
233+
if (!this.shouldOutputJson(flags)) {
239234
// Display each message using multi-line labeled blocks
240235

241236
if (hasMore) {
@@ -328,9 +323,7 @@ async run(): Promise<void> {
328323
}
329324
}
330325

331-
if (!this.shouldOutputJson(flags)) {
332-
this.log(formatProgress("Entering presence on channel: " + formatResource(args.channel)));
333-
}
326+
this.logProgress("Entering presence on channel: " + formatResource(args.channel), flags);
334327

335328
// Optionally subscribe to other members' events before entering
336329
if (flags["show-others"]) {
@@ -342,10 +335,8 @@ async run(): Promise<void> {
342335

343336
await channel.presence.enter(presenceData);
344337

345-
if (!this.shouldOutputJson(flags)) {
346-
this.log(formatSuccess("Entered presence on channel: " + formatResource(args.channel) + "."));
347-
this.log(formatListening("Present on channel."));
348-
}
338+
this.logSuccessMessage("Entered presence on channel: " + formatResource(args.channel) + ".", flags);
339+
this.logListening("Present on channel.", flags);
349340

350341
await this.waitAndTrackCleanup(flags, "presence", flags.duration);
351342
}
@@ -434,8 +425,8 @@ async run(): Promise<void> {
434425
const { items, hasMore, pagesConsumed } = await collectPaginatedResults(firstPage, flags.limit);
435426

436427
const paginationWarning = formatPaginationLog(pagesConsumed, items.length);
437-
if (paginationWarning && !this.shouldOutputJson(flags)) {
438-
this.log(paginationWarning);
428+
if (paginationWarning) {
429+
this.logToStderr(paginationWarning);
439430
}
440431

441432
if (this.shouldOutputJson(flags)) {
@@ -486,8 +477,11 @@ async run(): Promise<void> {
486477
if (this.shouldOutputJson(flags)) {
487478
// Singular domain key for single-item results
488479
this.logJsonResult({ resource: result }, flags);
489-
} else {
490-
this.log(formatSuccess("Resource created: " + formatResource(result.id) + "."));
480+
}
481+
482+
this.logSuccessMessage("Resource created: " + formatResource(result.id) + ".", flags);
483+
484+
if (!this.shouldOutputJson(flags)) {
491485
// Display additional fields using formatLabel
492486
this.log(`${formatLabel("Status")} ${result.status}`);
493487
this.log(`${formatLabel("Created")} ${new Date(result.createdAt).toISOString()}`);
@@ -669,7 +663,7 @@ Commands must behave strictly according to their documented purpose — no unint
669663
670664
**Set / enter / acquire commands** — hold state until Ctrl+C / `--duration`:
671665
- Enter space (manual: `enterSpace: false` + `space.enter()` + `markAsEntered()`), perform operation, output confirmation, then hold with `waitAndTrackCleanup`
672-
- Emit `formatListening("Holding <thing>.")` (human) and `logJsonStatus("holding", ...)` (JSON)
666+
- Emit `this.logHolding("Holding <thing>. Press Ctrl+C to exit.", flags)` — in non-JSON mode this shows dim text on stderr; in JSON mode it emits `status: "holding"`
673667
- **NOT subscribe** to other events — that is what subscribe commands are for
674668
675669
**Side-effect rules:**
@@ -706,8 +700,7 @@ await this.space!.enter();
706700
this.markAsEntered();
707701
await this.space!.locations.set(location);
708702
// output result...
709-
this.log(formatListening("Holding location."));
710-
this.logJsonStatus("holding", "Holding location. Press Ctrl+C to exit.", flags);
703+
this.logHolding("Holding location. Press Ctrl+C to exit.", flags);
711704
await this.waitAndTrackCleanup(flags, "location", flags.duration);
712705
```
713706
@@ -752,22 +745,21 @@ this.logJsonResult({ channels: items, total, hasMore }, flags); // channel
752745
753746
Metadata fields (`total`, `timestamp`, `hasMore`, `appId`) may sit alongside the collection key since they describe the result, not the domain objects.
754747
755-
### Hold status for long-running commands (logJsonStatus)
748+
### Hold status for long-running commands (logHolding)
756749
757-
Long-running commands that hold state (e.g. `spaces members enter`, `spaces locations set`, `spaces locks acquire`, `spaces cursors set`) must emit a status line after the result so JSON consumers know the command is alive and waiting:
750+
Long-running commands that hold state (e.g. `spaces members enter`, `spaces locations set`, `spaces locks acquire`, `spaces cursors set`) must call `this.logHolding(...)` after the result so JSON consumers know the command is alive and waiting. For passive subscribe/stream commands, use `this.logListening(...)` instead.
758751
759752
```typescript
760753
// After the result output:
761754
if (this.shouldOutputJson(flags)) {
762755
this.logJsonResult({ member: formatMemberOutput(self!) }, flags);
763756
} else {
764-
this.log(formatSuccess(`Entered space: ${formatResource(spaceName)}.`));
757+
this.logSuccessMessage(`Entered space: ${formatResource(spaceName)}.`, flags);
765758
// ... labels ...
766-
this.log(formatListening("Holding presence."));
767759
}
768760
769-
// logJsonStatus has built-in shouldOutputJson guard — no outer if needed
770-
this.logJsonStatus("holding", "Holding presence. Press Ctrl+C to exit.", flags);
761+
// logHolding: in non-JSON mode emits dim text on stderr; in JSON mode emits status: "holding"
762+
this.logHolding("Holding presence. Press Ctrl+C to exit.", flags);
771763
772764
await this.waitAndTrackCleanup(flags, "member", flags.duration);
773765
```
@@ -778,6 +770,16 @@ This emits two NDJSON lines in `--json` mode:
778770
{"type":"status","command":"spaces:members:enter","status":"holding","message":"Holding presence. Press Ctrl+C to exit."}
779771
```
780772
773+
**Behavior matrix for logging helpers:**
774+
775+
| Helper | Non-JSON | JSON |
776+
|--------|----------|------|
777+
| `logProgress` | stderr | silent (no-op) |
778+
| `logSuccessMessage` | stderr | silent (no-op) |
779+
| `logWarning` | stderr | stdout (status: "warning") |
780+
| `logListening` | stderr | stdout (status: "listening") |
781+
| `logHolding` | stderr | stdout (status: "holding") |
782+
781783
### Choosing the domain key name
782784
783785
| Scenario | Key | Example |

0 commit comments

Comments
 (0)