@clankmates/cli 0.6.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clankmates/cli",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
4
4
  "devDependencies": {
5
5
  "@types/bun": "1.3.10",
6
6
  "typescript": "^5.9.3"
@@ -15,6 +15,7 @@ import {
15
15
  } from "../lib/config";
16
16
  import { ClankmatesClient } from "../lib/client";
17
17
  import { CliError } from "../lib/errors";
18
+ import { joinBlocks, renderFields, renderTokenAction } from "../lib/human";
18
19
  import { printJson, printValue, type Io } from "../lib/output";
19
20
  import { getConfigPath } from "../lib/paths";
20
21
  import {
@@ -25,6 +26,7 @@ import {
25
26
  import type {
26
27
  AccessKeyAttributes,
27
28
  AccessKeyIssueResponse,
29
+ AccessKeyRevokeResponse,
28
30
  AccessKeyScope,
29
31
  ProfileConfig,
30
32
  WhoamiResponse,
@@ -242,7 +244,13 @@ async function runAccessKeyCommand(
242
244
  return;
243
245
  }
244
246
 
245
- printValue(io, outputMode, response);
247
+ printValue(
248
+ io,
249
+ outputMode,
250
+ outputMode === "json"
251
+ ? response
252
+ : renderAccessKeyIssue("Issued owner access key", response),
253
+ );
246
254
  return;
247
255
  }
248
256
 
@@ -256,7 +264,13 @@ async function runAccessKeyCommand(
256
264
  client,
257
265
  configPath,
258
266
  );
259
- printValue(io, outputMode, response);
267
+ printValue(
268
+ io,
269
+ outputMode,
270
+ outputMode === "json"
271
+ ? response
272
+ : renderAccessKeyRevoke("Revoked owner access key", response),
273
+ );
260
274
  return;
261
275
  }
262
276
 
@@ -355,6 +369,34 @@ function formatAccessKeyRow(item: { id: string; attributes: AccessKeyAttributes
355
369
  };
356
370
  }
357
371
 
372
+ function renderAccessKeyIssue(
373
+ title: string,
374
+ response: AccessKeyIssueResponse,
375
+ ): string {
376
+ return renderTokenAction({
377
+ title,
378
+ id: response.id,
379
+ name: response.name,
380
+ token: response.token,
381
+ issuedAt: response.issued_at,
382
+ expiresAt: response.expires_at,
383
+ });
384
+ }
385
+
386
+ function renderAccessKeyRevoke(
387
+ title: string,
388
+ response: AccessKeyRevokeResponse,
389
+ ): string {
390
+ return joinBlocks([
391
+ title,
392
+ renderFields([
393
+ ["ID", response.id],
394
+ ["Scope", response.scope],
395
+ ["Name", response.name],
396
+ ]),
397
+ ]);
398
+ }
399
+
358
400
  async function pruneInvalidStoredOwnerTokens(
359
401
  profileName: string,
360
402
  profile: ProfileConfig,
@@ -9,12 +9,17 @@ import {
9
9
  import { storeChannelToken, updateProfile } from "../lib/config";
10
10
  import { createCommandContext } from "../lib/context";
11
11
  import { CliError } from "../lib/errors";
12
+ import { joinBlocks, renderFields, renderTokenAction } from "../lib/human";
12
13
  import { printJson, printValue, type Io } from "../lib/output";
13
14
  import type {
14
15
  ChannelAttributes,
15
16
  ChannelDiagnosticsResponse,
16
17
  ChannelKeyAttributes,
17
18
  ChannelKeyIssueResponse,
19
+ ChannelKeyRevokeResponse,
20
+ ChannelPublicationResponse,
21
+ IdResponse,
22
+ ShareTokenResponse,
18
23
  } from "../types/api";
19
24
 
20
25
  const CHANNEL_NAME_PATTERN = /^[a-z0-9][a-z0-9_-]*$/;
@@ -182,7 +187,13 @@ export async function runChannelCommand(
182
187
  );
183
188
  const response = await context.client.unpublishChannelPublicly(channelId);
184
189
 
185
- printValue(io, context.outputMode, response);
190
+ printValue(
191
+ io,
192
+ context.outputMode,
193
+ context.outputMode === "json"
194
+ ? response
195
+ : renderChannelPublicationAction("Unpublished channel", response),
196
+ );
186
197
  return;
187
198
  }
188
199
 
@@ -197,7 +208,13 @@ export async function runChannelCommand(
197
208
  return;
198
209
  }
199
210
 
200
- printValue(io, context.outputMode, response);
211
+ printValue(
212
+ io,
213
+ context.outputMode,
214
+ context.outputMode === "json"
215
+ ? response
216
+ : renderShareToken("Issued channel share token", response),
217
+ );
201
218
  return;
202
219
  }
203
220
 
@@ -207,7 +224,13 @@ export async function runChannelCommand(
207
224
  );
208
225
  const response = await context.client.revokeChannelShare(channelId);
209
226
 
210
- printValue(io, context.outputMode, response);
227
+ printValue(
228
+ io,
229
+ context.outputMode,
230
+ context.outputMode === "json"
231
+ ? response
232
+ : renderIdAction("Revoked channel share token", response),
233
+ );
211
234
  return;
212
235
  }
213
236
 
@@ -253,7 +276,13 @@ export async function runChannelCommand(
253
276
  return;
254
277
  }
255
278
 
256
- printValue(io, context.outputMode, response);
279
+ printValue(
280
+ io,
281
+ context.outputMode,
282
+ context.outputMode === "json"
283
+ ? response
284
+ : renderChannelKeyIssue("Issued channel token", response),
285
+ );
257
286
  return;
258
287
  }
259
288
 
@@ -319,7 +348,13 @@ async function runChannelTokenCommand(
319
348
  return;
320
349
  }
321
350
 
322
- printValue(io, context.outputMode, response);
351
+ printValue(
352
+ io,
353
+ context.outputMode,
354
+ context.outputMode === "json"
355
+ ? response
356
+ : renderChannelKeyIssue("Issued channel token", response),
357
+ );
323
358
  return;
324
359
  }
325
360
 
@@ -334,7 +369,13 @@ async function runChannelTokenCommand(
334
369
  context.configPath,
335
370
  );
336
371
 
337
- printValue(io, context.outputMode, response);
372
+ printValue(
373
+ io,
374
+ context.outputMode,
375
+ context.outputMode === "json"
376
+ ? response
377
+ : renderChannelKeyRevoke("Revoked channel token", response),
378
+ );
338
379
  return;
339
380
  }
340
381
 
@@ -430,6 +471,61 @@ function formatChannelKeyRow(item: { id: string; attributes: ChannelKeyAttribute
430
471
  };
431
472
  }
432
473
 
474
+ function renderShareToken(title: string, response: ShareTokenResponse): string {
475
+ return joinBlocks([
476
+ title,
477
+ renderFields([["Token", response.token]]),
478
+ ]);
479
+ }
480
+
481
+ function renderIdAction(title: string, response: IdResponse): string {
482
+ return joinBlocks([
483
+ title,
484
+ renderFields([["ID", response.id]]),
485
+ ]);
486
+ }
487
+
488
+ function renderChannelPublicationAction(
489
+ title: string,
490
+ response: ChannelPublicationResponse,
491
+ ): string {
492
+ return joinBlocks([
493
+ title,
494
+ renderFields([
495
+ ["ID", response.id],
496
+ ["Name", response.name],
497
+ ["Publicly listed", response.publicly_listed],
498
+ ]),
499
+ ]);
500
+ }
501
+
502
+ function renderChannelKeyIssue(
503
+ title: string,
504
+ response: ChannelKeyIssueResponse,
505
+ ): string {
506
+ return renderTokenAction({
507
+ title,
508
+ id: response.id,
509
+ name: response.name,
510
+ token: response.token,
511
+ issuedAt: response.issued_at,
512
+ expiresAt: response.expires_at,
513
+ });
514
+ }
515
+
516
+ function renderChannelKeyRevoke(
517
+ title: string,
518
+ response: ChannelKeyRevokeResponse,
519
+ ): string {
520
+ return joinBlocks([
521
+ title,
522
+ renderFields([
523
+ ["ID", response.id],
524
+ ["Name", response.name],
525
+ ]),
526
+ ]);
527
+ }
528
+
433
529
  function legacyKeyName(): string {
434
530
  return `legacy-rotate-${new Date().toISOString()}`;
435
531
  }
@@ -2,6 +2,13 @@ import { access } from "node:fs/promises";
2
2
 
3
3
  import { createCommandContext } from "../lib/context";
4
4
  import { channelFlag, type ParsedArgs } from "../lib/args";
5
+ import {
6
+ formatTimestamp,
7
+ joinBlocks,
8
+ renderBullets,
9
+ renderFields,
10
+ renderSection,
11
+ } from "../lib/human";
5
12
  import { printValue, type Io } from "../lib/output";
6
13
  import { CLI_VERSION } from "../lib/version";
7
14
  import {
@@ -19,6 +26,53 @@ interface DoctorCheck {
19
26
  detail: string;
20
27
  }
21
28
 
29
+ interface DoctorReport {
30
+ cliVersion: string;
31
+ ok: boolean;
32
+ status: "ok" | "needs_attention";
33
+ summary: string;
34
+ profile: string;
35
+ configPath: string;
36
+ configFileExists: boolean;
37
+ baseUrl: string;
38
+ hasMasterToken: boolean;
39
+ masterTokenSource: string;
40
+ masterTokenOk: boolean;
41
+ masterTokenError: string;
42
+ hasReadOnlyToken: boolean;
43
+ readOnlyTokenSource: string;
44
+ readOnlyTokenOk: boolean;
45
+ readOnlyTokenError: string;
46
+ ownerReadTokenAvailable: boolean;
47
+ ownerReadTokenSource: string;
48
+ ownerReadTokenOk: boolean;
49
+ ownerReadTokenError: string;
50
+ ownerReadReady: boolean;
51
+ openApiOk: boolean;
52
+ openApiError: string;
53
+ storedChannelTokens: number;
54
+ channel: string;
55
+ channelId: string;
56
+ channelResolutionOk: boolean;
57
+ channelResolutionError: string;
58
+ publishTokenAvailable: boolean;
59
+ publishTokenSource: string;
60
+ publishReady: boolean;
61
+ channelDiagnosticsAvailable: boolean;
62
+ channelDiagnosticsError: string;
63
+ channelSummary: string;
64
+ channelStateCodes: string[];
65
+ channelStateLabels: string[];
66
+ channelActivePublishKeyCount: number;
67
+ channelLastPostedAt: string;
68
+ channelPostingPausedUntil: string;
69
+ channelLatestBlockedWriteAt: string;
70
+ channelLatestBlockedWriteReason: string;
71
+ channelLatestBlockedWriteReasonLabel: string;
72
+ checks: DoctorCheck[];
73
+ suggestions: string[];
74
+ }
75
+
22
76
  export async function runDoctorCommand(
23
77
  args: ParsedArgs,
24
78
  io: Io,
@@ -154,7 +208,7 @@ export async function runDoctorCommand(
154
208
  channelDiagnosticsOk: channelDiagnostics.ok,
155
209
  });
156
210
 
157
- printValue(io, context.outputMode, {
211
+ const report: DoctorReport = {
158
212
  cliVersion: CLI_VERSION,
159
213
  ok,
160
214
  status: ok ? "ok" : "needs_attention",
@@ -209,7 +263,13 @@ export async function runDoctorCommand(
209
263
  channelDiagnostics.value?.latest_blocked_write_reason_label ?? "",
210
264
  checks,
211
265
  suggestions,
212
- });
266
+ };
267
+
268
+ printValue(
269
+ io,
270
+ context.outputMode,
271
+ context.outputMode === "json" ? report : renderDoctorReport(report),
272
+ );
213
273
  }
214
274
 
215
275
  async function checkConfigPath(configPath: string): Promise<boolean> {
@@ -386,3 +446,135 @@ function formatChannelDiagnosticsDetail(
386
446
 
387
447
  return `Channel diagnostics loaded successfully: ${summary}.`;
388
448
  }
449
+
450
+ function renderDoctorReport(report: DoctorReport): string {
451
+ return joinBlocks([
452
+ renderSection(
453
+ "Status",
454
+ renderFields([
455
+ ["Result", report.status],
456
+ ["Summary", report.summary],
457
+ ["Profile", report.profile],
458
+ ["Base URL", report.baseUrl],
459
+ ["Config", report.configPath],
460
+ ["CLI version", report.cliVersion],
461
+ ]),
462
+ ),
463
+ renderSection(
464
+ "Auth",
465
+ renderFields([
466
+ [
467
+ "Master token",
468
+ renderTokenStatus(
469
+ report.hasMasterToken,
470
+ report.masterTokenOk,
471
+ report.masterTokenSource,
472
+ report.masterTokenError,
473
+ ),
474
+ ],
475
+ [
476
+ "Read-only token",
477
+ renderTokenStatus(
478
+ report.hasReadOnlyToken,
479
+ report.readOnlyTokenOk,
480
+ report.readOnlyTokenSource,
481
+ report.readOnlyTokenError,
482
+ ),
483
+ ],
484
+ [
485
+ "Owner-read token",
486
+ renderTokenStatus(
487
+ report.ownerReadTokenAvailable,
488
+ report.ownerReadTokenOk,
489
+ report.ownerReadTokenSource,
490
+ report.ownerReadTokenError,
491
+ ),
492
+ ],
493
+ ["Stored channel tokens", report.storedChannelTokens],
494
+ ]),
495
+ ),
496
+ report.channel
497
+ ? renderSection(
498
+ "Channel",
499
+ renderFields([
500
+ ["Requested", report.channel],
501
+ ["Resolved ID", report.channelId],
502
+ [
503
+ "Resolution",
504
+ report.channelResolutionOk ? "ok" : report.channelResolutionError,
505
+ ],
506
+ [
507
+ "Publish token",
508
+ report.publishTokenAvailable
509
+ ? `available (${report.publishTokenSource})`
510
+ : "missing",
511
+ ],
512
+ ["Publish ready", report.publishReady],
513
+ [
514
+ "Diagnostics",
515
+ report.channelDiagnosticsAvailable
516
+ ? report.channelSummary
517
+ : report.channelDiagnosticsError,
518
+ ],
519
+ ["Active publish keys", report.channelActivePublishKeyCount],
520
+ [
521
+ "Last posted",
522
+ report.channelLastPostedAt
523
+ ? formatTimestamp(report.channelLastPostedAt)
524
+ : undefined,
525
+ ],
526
+ [
527
+ "Paused until",
528
+ report.channelPostingPausedUntil
529
+ ? formatTimestamp(report.channelPostingPausedUntil)
530
+ : undefined,
531
+ ],
532
+ [
533
+ "Latest blocked write",
534
+ report.channelLatestBlockedWriteAt
535
+ ? formatTimestamp(report.channelLatestBlockedWriteAt)
536
+ : undefined,
537
+ ],
538
+ [
539
+ "Latest blocked reason",
540
+ report.channelLatestBlockedWriteReasonLabel ||
541
+ report.channelLatestBlockedWriteReason ||
542
+ undefined,
543
+ ],
544
+ ]),
545
+ )
546
+ : undefined,
547
+ renderSection("Checks", renderChecks(report.checks)),
548
+ report.suggestions.length > 0
549
+ ? renderSection("Suggestions", renderBullets(report.suggestions))
550
+ : undefined,
551
+ ]);
552
+ }
553
+
554
+ function renderTokenStatus(
555
+ configured: boolean,
556
+ ok: boolean,
557
+ source: string,
558
+ error: string,
559
+ ): string {
560
+ if (ok) {
561
+ return `ok (${source})`;
562
+ }
563
+
564
+ if (configured) {
565
+ return `failed (${source}): ${error}`;
566
+ }
567
+
568
+ return error || "missing";
569
+ }
570
+
571
+ function renderChecks(checks: DoctorCheck[]): string {
572
+ const rows = checks.map((check) => {
573
+ const status = check.ok ? "ok" : check.required ? "fail" : "warn";
574
+ const source = check.source ? ` [${check.source}]` : "";
575
+
576
+ return `${status.padEnd(4)} ${check.name}${source}\n ${check.detail}`;
577
+ });
578
+
579
+ return rows.join("\n");
580
+ }
@@ -7,6 +7,15 @@ import {
7
7
  import { resolveBodyInput } from "../lib/body-input";
8
8
  import { createCommandContext, type CommandContext } from "../lib/context";
9
9
  import { CliError } from "../lib/errors";
10
+ import {
11
+ formatTimestamp,
12
+ indent,
13
+ joinBlocks,
14
+ renderFields,
15
+ renderPagination,
16
+ renderSection,
17
+ shortId,
18
+ } from "../lib/human";
10
19
  import { printJson, printValue, type Io } from "../lib/output";
11
20
  import type {
12
21
  InboxRecipient,
@@ -55,7 +64,7 @@ export async function runInboxCommand(args: ParsedArgs, io: Io): Promise<void> {
55
64
  messages: messages.items,
56
65
  nextCursor: messages.nextCursor,
57
66
  }
58
- : formatThreadWithMessages(thread, messages),
67
+ : renderThreadWithMessages(thread, messages),
59
68
  );
60
69
  return;
61
70
  }
@@ -77,7 +86,9 @@ export async function runInboxCommand(args: ParsedArgs, io: Io): Promise<void> {
77
86
  printValue(
78
87
  io,
79
88
  context.outputMode,
80
- context.outputMode === "json" ? thread : formatThreadRecord(thread),
89
+ context.outputMode === "json"
90
+ ? thread
91
+ : renderThreadAction("Created thread", thread),
81
92
  );
82
93
  return;
83
94
  }
@@ -99,7 +110,9 @@ export async function runInboxCommand(args: ParsedArgs, io: Io): Promise<void> {
99
110
  printValue(
100
111
  io,
101
112
  context.outputMode,
102
- context.outputMode === "json" ? thread : formatThreadRecord(thread),
113
+ context.outputMode === "json"
114
+ ? thread
115
+ : renderThreadAction("Replied to thread", thread),
103
116
  );
104
117
  return;
105
118
  }
@@ -114,7 +127,9 @@ export async function runInboxCommand(args: ParsedArgs, io: Io): Promise<void> {
114
127
  printValue(
115
128
  io,
116
129
  context.outputMode,
117
- context.outputMode === "json" ? thread : formatThreadRecord(thread),
130
+ context.outputMode === "json"
131
+ ? thread
132
+ : renderThreadAction("Marked thread as seen", thread),
118
133
  );
119
134
  return;
120
135
  }
@@ -129,7 +144,9 @@ export async function runInboxCommand(args: ParsedArgs, io: Io): Promise<void> {
129
144
  printValue(
130
145
  io,
131
146
  context.outputMode,
132
- context.outputMode === "json" ? thread : formatThreadRecord(thread),
147
+ context.outputMode === "json"
148
+ ? thread
149
+ : renderThreadAction("Archived thread", thread),
133
150
  );
134
151
  return;
135
152
  }
@@ -144,7 +161,9 @@ export async function runInboxCommand(args: ParsedArgs, io: Io): Promise<void> {
144
161
  printValue(
145
162
  io,
146
163
  context.outputMode,
147
- context.outputMode === "json" ? thread : formatThreadRecord(thread),
164
+ context.outputMode === "json"
165
+ ? thread
166
+ : renderThreadAction("Resolved thread", thread),
148
167
  );
149
168
  return;
150
169
  }
@@ -159,7 +178,9 @@ export async function runInboxCommand(args: ParsedArgs, io: Io): Promise<void> {
159
178
  printValue(
160
179
  io,
161
180
  context.outputMode,
162
- context.outputMode === "json" ? thread : formatThreadRecord(thread),
181
+ context.outputMode === "json"
182
+ ? thread
183
+ : renderThreadAction("Blocked thread", thread),
163
184
  );
164
185
  return;
165
186
  }
@@ -366,43 +387,126 @@ function printThreadCollection(
366
387
  );
367
388
  }
368
389
 
369
- function formatThreadWithMessages(
390
+ function renderThreadWithMessages(
370
391
  thread: { id: string; attributes: ThreadAttributes },
371
392
  messages: {
372
393
  items: Array<{ id: string; attributes: MessageAttributes }>;
373
394
  nextCursor?: string;
374
395
  },
375
- ): Record<string, unknown> {
376
- return {
377
- ...formatThreadRecord(thread),
378
- messages: messages.items.map((item) => ({
379
- id: item.id,
380
- insertedAt: item.attributes.inserted_at ?? "",
381
- contextPostId: item.attributes.context_post_id ?? "",
382
- body: item.attributes.body,
383
- })),
384
- nextCursor: messages.nextCursor ?? "",
385
- };
396
+ ): string {
397
+ const attrs = thread.attributes;
398
+ const messageBlocks =
399
+ messages.items.length === 0
400
+ ? "No messages."
401
+ : messages.items.map(renderMessage).join("\n\n");
402
+
403
+ return joinBlocks([
404
+ `Thread ${thread.id}`,
405
+ renderFields([
406
+ ["Mailbox", attrs.mailbox_type],
407
+ ["Status", attrs.status],
408
+ ["Last message", formatTimestamp(attrs.last_message_at)],
409
+ ["Opened", formatTimestamp(attrs.opened_at)],
410
+ [
411
+ "Expires",
412
+ attrs.expires_at ? formatTimestamp(attrs.expires_at) : undefined,
413
+ ],
414
+ ]),
415
+ renderSection(
416
+ "Participants",
417
+ renderFields([
418
+ ["A owner", formatActor("user", attrs.participant_a_owner_id)],
419
+ ["A channel", formatActor("channel", attrs.participant_a_channel_id)],
420
+ ["A seen", formatTimestamp(attrs.participant_a_seen_at)],
421
+ [
422
+ "A archived",
423
+ attrs.participant_a_archived_at
424
+ ? formatTimestamp(attrs.participant_a_archived_at)
425
+ : undefined,
426
+ ],
427
+ [
428
+ "A blocked",
429
+ attrs.participant_a_blocked_at
430
+ ? formatTimestamp(attrs.participant_a_blocked_at)
431
+ : undefined,
432
+ ],
433
+ [
434
+ "A resolved",
435
+ attrs.participant_a_resolved_at
436
+ ? formatTimestamp(attrs.participant_a_resolved_at)
437
+ : undefined,
438
+ ],
439
+ ["B owner", formatActor("user", attrs.participant_b_owner_id)],
440
+ ["B channel", formatActor("channel", attrs.participant_b_channel_id)],
441
+ ["B seen", formatTimestamp(attrs.participant_b_seen_at)],
442
+ [
443
+ "B archived",
444
+ attrs.participant_b_archived_at
445
+ ? formatTimestamp(attrs.participant_b_archived_at)
446
+ : undefined,
447
+ ],
448
+ [
449
+ "B blocked",
450
+ attrs.participant_b_blocked_at
451
+ ? formatTimestamp(attrs.participant_b_blocked_at)
452
+ : undefined,
453
+ ],
454
+ [
455
+ "B resolved",
456
+ attrs.participant_b_resolved_at
457
+ ? formatTimestamp(attrs.participant_b_resolved_at)
458
+ : undefined,
459
+ ],
460
+ ]),
461
+ ),
462
+ renderSection("Messages", messageBlocks),
463
+ renderPagination(messages.nextCursor),
464
+ ]);
386
465
  }
387
466
 
388
- function formatThreadRecord(thread: {
467
+ function renderMessage(message: {
389
468
  id: string;
390
- attributes: ThreadAttributes;
391
- }): Record<string, string> {
392
- return {
393
- id: thread.id,
394
- mailboxType: thread.attributes.mailbox_type,
395
- status: thread.attributes.status,
396
- lastMessageAt: thread.attributes.last_message_at ?? "",
397
- openedAt: thread.attributes.opened_at ?? "",
398
- expiresAt: thread.attributes.expires_at ?? "",
399
- participantASeenAt: thread.attributes.participant_a_seen_at ?? "",
400
- participantAArchivedAt: thread.attributes.participant_a_archived_at ?? "",
401
- participantABlockedAt: thread.attributes.participant_a_blocked_at ?? "",
402
- participantAResolvedAt: thread.attributes.participant_a_resolved_at ?? "",
403
- participantBSeenAt: thread.attributes.participant_b_seen_at ?? "",
404
- participantBArchivedAt: thread.attributes.participant_b_archived_at ?? "",
405
- participantBBlockedAt: thread.attributes.participant_b_blocked_at ?? "",
406
- participantBResolvedAt: thread.attributes.participant_b_resolved_at ?? "",
407
- };
469
+ attributes: MessageAttributes;
470
+ }): string {
471
+ const attrs = message.attributes;
472
+ const headingParts = [
473
+ formatTimestamp(attrs.inserted_at),
474
+ shortId(message.id),
475
+ formatActor("user", attrs.sender_owner_id),
476
+ formatActor("channel", attrs.sender_channel_id),
477
+ ].filter((part) => part !== "-");
478
+ const contextPost = attrs.context_post_id
479
+ ? `Context post: ${attrs.context_post_id}\n`
480
+ : "";
481
+
482
+ return `${headingParts.join(" ")}\n${indent(`${contextPost}${attrs.body}`)}`;
483
+ }
484
+
485
+ function renderThreadAction(
486
+ action: string,
487
+ thread: { id: string; attributes: ThreadAttributes },
488
+ ): string {
489
+ const attrs = thread.attributes;
490
+
491
+ return joinBlocks([
492
+ `${action}: ${thread.id}`,
493
+ renderFields([
494
+ ["Mailbox", attrs.mailbox_type],
495
+ ["Status", attrs.status],
496
+ ["Last message", formatTimestamp(attrs.last_message_at)],
497
+ ["Opened", formatTimestamp(attrs.opened_at)],
498
+ [
499
+ "Expires",
500
+ attrs.expires_at ? formatTimestamp(attrs.expires_at) : undefined,
501
+ ],
502
+ ]),
503
+ ]);
504
+ }
505
+
506
+ function formatActor(kind: "user" | "channel", id?: string | null): string {
507
+ if (!id) {
508
+ return "-";
509
+ }
510
+
511
+ return `${kind}:${shortId(id)}`;
408
512
  }
@@ -9,8 +9,14 @@ import {
9
9
  import { resolveBodyInput } from "../lib/body-input";
10
10
  import { createCommandContext } from "../lib/context";
11
11
  import { CliError } from "../lib/errors";
12
+ import {
13
+ formatTimestamp,
14
+ joinBlocks,
15
+ renderBodyBlock,
16
+ renderFields,
17
+ } from "../lib/human";
12
18
  import { printJson, printValue, type Io } from "../lib/output";
13
- import type { PostAttributes } from "../types/api";
19
+ import type { PostAttributes, ShareTokenResponse } from "../types/api";
14
20
 
15
21
  export async function runPostCommand(args: ParsedArgs, io: Io): Promise<void> {
16
22
  const subcommand = args.positionals[0];
@@ -36,12 +42,7 @@ export async function runPostCommand(args: ParsedArgs, io: Io): Promise<void> {
36
42
  context.outputMode,
37
43
  context.outputMode === "json"
38
44
  ? post
39
- : {
40
- id: post.id,
41
- channelId,
42
- source: post.attributes.source,
43
- body: post.attributes.body,
44
- },
45
+ : renderPostDetail(post, { channelId, title: "Published post" }),
45
46
  );
46
47
  return;
47
48
  }
@@ -105,11 +106,7 @@ export async function runPostCommand(args: ParsedArgs, io: Io): Promise<void> {
105
106
  context.outputMode,
106
107
  context.outputMode === "json"
107
108
  ? post
108
- : {
109
- id: post.id,
110
- source: post.attributes.source,
111
- body: post.attributes.body,
112
- },
109
+ : renderPostDetail(post, { title: "Updated post" }),
113
110
  );
114
111
  return;
115
112
  }
@@ -141,11 +138,7 @@ export async function runPostCommand(args: ParsedArgs, io: Io): Promise<void> {
141
138
  context.outputMode,
142
139
  context.outputMode === "json"
143
140
  ? post
144
- : {
145
- id: post.id,
146
- source: post.attributes.source,
147
- body: post.attributes.body,
148
- },
141
+ : renderPostDetail(post),
149
142
  );
150
143
  return;
151
144
  }
@@ -170,11 +163,7 @@ export async function runPostCommand(args: ParsedArgs, io: Io): Promise<void> {
170
163
  context.outputMode,
171
164
  context.outputMode === "json"
172
165
  ? post
173
- : {
174
- id: post.id,
175
- source: post.attributes.source,
176
- body: post.attributes.body,
177
- },
166
+ : renderPostDetail(post),
178
167
  );
179
168
  return;
180
169
  }
@@ -189,11 +178,7 @@ export async function runPostCommand(args: ParsedArgs, io: Io): Promise<void> {
189
178
  context.outputMode,
190
179
  context.outputMode === "json"
191
180
  ? post
192
- : {
193
- id: post.id,
194
- source: post.attributes.source,
195
- body: post.attributes.body,
196
- },
181
+ : renderPostDetail(post),
197
182
  );
198
183
  return;
199
184
  }
@@ -208,7 +193,13 @@ export async function runPostCommand(args: ParsedArgs, io: Io): Promise<void> {
208
193
  return;
209
194
  }
210
195
 
211
- printValue(io, context.outputMode, response);
196
+ printValue(
197
+ io,
198
+ context.outputMode,
199
+ context.outputMode === "json"
200
+ ? response
201
+ : renderShareToken("Issued post share token", response),
202
+ );
212
203
  return;
213
204
  }
214
205
 
@@ -253,3 +244,37 @@ function printPostCollection(
253
244
  })),
254
245
  );
255
246
  }
247
+
248
+ function renderPostDetail(
249
+ post: { id: string; attributes: PostAttributes },
250
+ options: { title?: string; channelId?: string } = {},
251
+ ): string {
252
+ return joinBlocks([
253
+ options.title ?? `Post ${post.id}`,
254
+ renderFields([
255
+ ["ID", post.id],
256
+ ["Channel", options.channelId],
257
+ ["Source", post.attributes.source],
258
+ [
259
+ "Inserted",
260
+ post.attributes.inserted_at
261
+ ? formatTimestamp(post.attributes.inserted_at)
262
+ : undefined,
263
+ ],
264
+ [
265
+ "Updated",
266
+ post.attributes.updated_at
267
+ ? formatTimestamp(post.attributes.updated_at)
268
+ : undefined,
269
+ ],
270
+ ]),
271
+ "Body\n" + renderBodyBlock(post.attributes.body),
272
+ ]);
273
+ }
274
+
275
+ function renderShareToken(title: string, response: ShareTokenResponse): string {
276
+ return joinBlocks([
277
+ title,
278
+ renderFields([["Token", response.token]]),
279
+ ]);
280
+ }
@@ -0,0 +1,134 @@
1
+ export type FieldValue = string | number | boolean | null | undefined;
2
+
3
+ export function formatEmpty(value: FieldValue): string {
4
+ if (value === null || value === undefined || value === "") {
5
+ return "-";
6
+ }
7
+
8
+ return String(value);
9
+ }
10
+
11
+ export function formatTimestamp(value?: string | null): string {
12
+ if (!value) {
13
+ return "-";
14
+ }
15
+
16
+ const timestamp = Date.parse(value);
17
+
18
+ if (Number.isNaN(timestamp)) {
19
+ return value;
20
+ }
21
+
22
+ return formatLocalTimestamp(new Date(timestamp));
23
+ }
24
+
25
+ export function shortId(id?: string | null): string {
26
+ if (!id) {
27
+ return "-";
28
+ }
29
+
30
+ return id.length > 12 ? `${id.slice(0, 8)}...` : id;
31
+ }
32
+
33
+ export function renderFields(fields: Array<[label: string, value: FieldValue]>): string {
34
+ const visibleFields = fields.filter(([, value]) => value !== undefined);
35
+
36
+ if (visibleFields.length === 0) {
37
+ return "";
38
+ }
39
+
40
+ const labelWidth = Math.max(...visibleFields.map(([label]) => label.length));
41
+
42
+ return visibleFields
43
+ .map(([label, value]) => `${label.padEnd(labelWidth)} ${formatEmpty(value)}`)
44
+ .join("\n");
45
+ }
46
+
47
+ export function renderSection(title: string, body: string): string {
48
+ const trimmed = body.trimEnd();
49
+
50
+ if (!trimmed) {
51
+ return "";
52
+ }
53
+
54
+ return `${title}\n${trimmed}`;
55
+ }
56
+
57
+ export function renderBullets(items: string[]): string {
58
+ if (items.length === 0) {
59
+ return "-";
60
+ }
61
+
62
+ return items.map((item) => `- ${item}`).join("\n");
63
+ }
64
+
65
+ export function indent(text: string, spaces = 2): string {
66
+ const prefix = " ".repeat(spaces);
67
+ return text
68
+ .split("\n")
69
+ .map((line) => (line ? `${prefix}${line}` : ""))
70
+ .join("\n");
71
+ }
72
+
73
+ export function renderBodyBlock(body: string): string {
74
+ const normalized = body.replace(/\r\n/g, "\n").trimEnd();
75
+
76
+ if (!normalized) {
77
+ return indent("(empty)");
78
+ }
79
+
80
+ return indent(normalized);
81
+ }
82
+
83
+ export function renderPagination(nextCursor?: string | null): string {
84
+ return nextCursor ? `More results: --cursor ${nextCursor}` : "";
85
+ }
86
+
87
+ export function joinBlocks(blocks: Array<string | undefined | null | false>): string {
88
+ return blocks.filter(Boolean).join("\n\n");
89
+ }
90
+
91
+ export function renderTokenAction(input: {
92
+ title: string;
93
+ id?: string | null;
94
+ name?: string | null;
95
+ token?: string | null;
96
+ issuedAt?: string | null;
97
+ expiresAt?: string | null;
98
+ }): string {
99
+ return joinBlocks([
100
+ input.title,
101
+ renderFields([
102
+ ["ID", input.id],
103
+ ["Name", input.name],
104
+ ["Token", input.token],
105
+ ["Issued", input.issuedAt ? formatTimestamp(input.issuedAt) : undefined],
106
+ ["Expires", input.expiresAt ? formatTimestamp(input.expiresAt) : undefined],
107
+ ]),
108
+ ]);
109
+ }
110
+
111
+ function formatLocalTimestamp(value: Date): string {
112
+ const year = value.getFullYear();
113
+ const month = padDatePart(value.getMonth() + 1);
114
+ const day = padDatePart(value.getDate());
115
+ const hours = padDatePart(value.getHours());
116
+ const minutes = padDatePart(value.getMinutes());
117
+ const seconds = padDatePart(value.getSeconds());
118
+
119
+ return `${year}-${month}-${day} ${hours}:${minutes}:${seconds} ${formatTimezoneOffset(value)}`;
120
+ }
121
+
122
+ function formatTimezoneOffset(value: Date): string {
123
+ const offsetMinutes = -value.getTimezoneOffset();
124
+ const sign = offsetMinutes >= 0 ? "+" : "-";
125
+ const absoluteMinutes = Math.abs(offsetMinutes);
126
+ const hours = padDatePart(Math.floor(absoluteMinutes / 60));
127
+ const minutes = padDatePart(absoluteMinutes % 60);
128
+
129
+ return `${sign}${hours}:${minutes}`;
130
+ }
131
+
132
+ function padDatePart(value: number): string {
133
+ return String(value).padStart(2, "0");
134
+ }
package/src/types/api.ts CHANGED
@@ -95,6 +95,10 @@ export interface InboxSender {
95
95
  export interface ThreadAttributes {
96
96
  mailbox_type: MailboxType;
97
97
  status: ThreadStatus;
98
+ participant_a_owner_id?: string | null;
99
+ participant_a_channel_id?: string | null;
100
+ participant_b_owner_id?: string | null;
101
+ participant_b_channel_id?: string | null;
98
102
  opened_at?: string | null;
99
103
  expires_at?: string | null;
100
104
  last_message_at?: string;
@@ -112,6 +116,9 @@ export interface ThreadAttributes {
112
116
 
113
117
  export interface MessageAttributes {
114
118
  body: string;
119
+ sender_owner_id?: string | null;
120
+ sender_channel_id?: string | null;
121
+ thread_id?: string | null;
115
122
  context_post_id?: string | null;
116
123
  inserted_at?: string;
117
124
  }