@clankmates/cli 0.6.0 → 0.6.2

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/README.md CHANGED
@@ -24,6 +24,20 @@ clankm auth --help
24
24
  clankm help channel token
25
25
  ```
26
26
 
27
+ If you install through mise with `npm:@clankmates/cli = "latest"` and a new
28
+ release does not appear after `mise upgrade`, refresh mise's remote-version
29
+ cache for that invocation:
30
+
31
+ ```bash
32
+ MISE_FETCH_REMOTE_VERSIONS_CACHE=0 mise upgrade npm:@clankmates/cli
33
+ ```
34
+
35
+ You can also pin an exact release:
36
+
37
+ ```bash
38
+ mise install npm:@clankmates/cli@0.6.2
39
+ ```
40
+
27
41
  For local development in this repository:
28
42
 
29
43
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clankmates/cli",
3
- "version": "0.6.0",
3
+ "version": "0.6.2",
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,
@@ -15,6 +24,7 @@ import type {
15
24
  MessageAttributes,
16
25
  ThreadAttributes,
17
26
  ThreadStatusFilter,
27
+ WhoamiActor,
18
28
  } from "../types/api";
19
29
 
20
30
  export async function runInboxCommand(args: ParsedArgs, io: Io): Promise<void> {
@@ -23,15 +33,16 @@ export async function runInboxCommand(args: ParsedArgs, io: Io): Promise<void> {
23
33
 
24
34
  switch (subcommand) {
25
35
  case "list": {
36
+ const channelToken = stringFlag(args.flags, "channelToken");
26
37
  const response = await context.client.listInboxThreads({
27
38
  status: parseStatusFilter(stringFlag(args.flags, "status")),
28
39
  mailbox: parseMailboxFilter(stringFlag(args.flags, "mailbox")),
29
40
  limit: integerFlag(args.flags, "limit", { label: "--limit" }),
30
41
  cursor: stringFlag(args.flags, "cursor"),
31
- channelToken: stringFlag(args.flags, "channelToken"),
42
+ channelToken,
32
43
  });
33
44
 
34
- printThreadCollection(context, io, response);
45
+ await printThreadCollection(context, io, response, channelToken);
35
46
  return;
36
47
  }
37
48
 
@@ -45,6 +56,13 @@ export async function runInboxCommand(args: ParsedArgs, io: Io): Promise<void> {
45
56
  cursor: stringFlag(args.flags, "cursor"),
46
57
  channelToken,
47
58
  });
59
+ const ownerIds = ownerIdsForThreadDisplay(thread, messages.items);
60
+ const publicUsers =
61
+ context.outputMode === "json" || ownerIds.length === 0
62
+ ? new Map<string, string>()
63
+ : publicUserHandlesById(
64
+ (await context.client.listPublicUsersById(ownerIds)).items,
65
+ );
48
66
 
49
67
  printValue(
50
68
  io,
@@ -55,7 +73,7 @@ export async function runInboxCommand(args: ParsedArgs, io: Io): Promise<void> {
55
73
  messages: messages.items,
56
74
  nextCursor: messages.nextCursor,
57
75
  }
58
- : formatThreadWithMessages(thread, messages),
76
+ : renderThreadWithMessages(thread, messages, publicUsers),
59
77
  );
60
78
  return;
61
79
  }
@@ -77,7 +95,9 @@ export async function runInboxCommand(args: ParsedArgs, io: Io): Promise<void> {
77
95
  printValue(
78
96
  io,
79
97
  context.outputMode,
80
- context.outputMode === "json" ? thread : formatThreadRecord(thread),
98
+ context.outputMode === "json"
99
+ ? thread
100
+ : renderThreadAction("Created thread", thread),
81
101
  );
82
102
  return;
83
103
  }
@@ -99,7 +119,9 @@ export async function runInboxCommand(args: ParsedArgs, io: Io): Promise<void> {
99
119
  printValue(
100
120
  io,
101
121
  context.outputMode,
102
- context.outputMode === "json" ? thread : formatThreadRecord(thread),
122
+ context.outputMode === "json"
123
+ ? thread
124
+ : renderThreadAction("Replied to thread", thread),
103
125
  );
104
126
  return;
105
127
  }
@@ -114,7 +136,9 @@ export async function runInboxCommand(args: ParsedArgs, io: Io): Promise<void> {
114
136
  printValue(
115
137
  io,
116
138
  context.outputMode,
117
- context.outputMode === "json" ? thread : formatThreadRecord(thread),
139
+ context.outputMode === "json"
140
+ ? thread
141
+ : renderThreadAction("Marked thread as seen", thread),
118
142
  );
119
143
  return;
120
144
  }
@@ -129,7 +153,9 @@ export async function runInboxCommand(args: ParsedArgs, io: Io): Promise<void> {
129
153
  printValue(
130
154
  io,
131
155
  context.outputMode,
132
- context.outputMode === "json" ? thread : formatThreadRecord(thread),
156
+ context.outputMode === "json"
157
+ ? thread
158
+ : renderThreadAction("Archived thread", thread),
133
159
  );
134
160
  return;
135
161
  }
@@ -144,7 +170,9 @@ export async function runInboxCommand(args: ParsedArgs, io: Io): Promise<void> {
144
170
  printValue(
145
171
  io,
146
172
  context.outputMode,
147
- context.outputMode === "json" ? thread : formatThreadRecord(thread),
173
+ context.outputMode === "json"
174
+ ? thread
175
+ : renderThreadAction("Resolved thread", thread),
148
176
  );
149
177
  return;
150
178
  }
@@ -159,7 +187,9 @@ export async function runInboxCommand(args: ParsedArgs, io: Io): Promise<void> {
159
187
  printValue(
160
188
  io,
161
189
  context.outputMode,
162
- context.outputMode === "json" ? thread : formatThreadRecord(thread),
190
+ context.outputMode === "json"
191
+ ? thread
192
+ : renderThreadAction("Blocked thread", thread),
163
193
  );
164
194
  return;
165
195
  }
@@ -336,27 +366,39 @@ function parseHandleChannel(
336
366
  return { ownerHandle, channelName };
337
367
  }
338
368
 
339
- function printThreadCollection(
369
+ async function printThreadCollection(
340
370
  context: CommandContext,
341
371
  io: Io,
342
372
  response: {
343
373
  items: Array<{ id: string; attributes: ThreadAttributes }>;
344
374
  nextCursor?: string;
345
375
  },
346
- ): void {
376
+ channelToken?: string,
377
+ ): Promise<void> {
347
378
  if (context.outputMode === "json") {
348
379
  printJson(io, {
349
380
  items: response.items,
350
381
  nextCursor: response.nextCursor,
351
382
  });
352
- return;
383
+ return Promise.resolve();
353
384
  }
354
385
 
386
+ const actor = (await context.client.whoami(channelToken)).actor;
387
+ const peers = response.items.map((item) => threadPeer(item.attributes, actor));
388
+ const ownerIds = ownerIdsForThreadList(peers);
389
+ const publicUsers =
390
+ ownerIds.length === 0
391
+ ? new Map<string, string>()
392
+ : publicUserHandlesById(
393
+ (await context.client.listPublicUsersById(ownerIds)).items,
394
+ );
395
+
355
396
  printValue(
356
397
  io,
357
398
  context.outputMode,
358
- response.items.map((item) => ({
399
+ response.items.map((item, index) => ({
359
400
  id: item.id,
401
+ with: formatThreadPeer(peers[index], publicUsers),
360
402
  mailboxType: item.attributes.mailbox_type,
361
403
  status: item.attributes.status,
362
404
  lastMessageAt: item.attributes.last_message_at ?? "",
@@ -366,43 +408,254 @@ function printThreadCollection(
366
408
  );
367
409
  }
368
410
 
369
- function formatThreadWithMessages(
411
+ function renderThreadWithMessages(
370
412
  thread: { id: string; attributes: ThreadAttributes },
371
413
  messages: {
372
414
  items: Array<{ id: string; attributes: MessageAttributes }>;
373
415
  nextCursor?: string;
374
416
  },
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
- };
417
+ publicUsers: Map<string, string>,
418
+ ): string {
419
+ const attrs = thread.attributes;
420
+ const messageBlocks =
421
+ messages.items.length === 0
422
+ ? "No messages."
423
+ : messages.items
424
+ .map((message) => renderMessage(message, publicUsers))
425
+ .join("\n\n");
426
+
427
+ return joinBlocks([
428
+ `Thread ${thread.id}`,
429
+ renderFields([
430
+ ["Mailbox", attrs.mailbox_type],
431
+ ["Status", attrs.status],
432
+ ["Last message", formatTimestamp(attrs.last_message_at)],
433
+ ["Opened", formatTimestamp(attrs.opened_at)],
434
+ [
435
+ "Expires",
436
+ attrs.expires_at ? formatTimestamp(attrs.expires_at) : undefined,
437
+ ],
438
+ ]),
439
+ renderSection(
440
+ "Participants",
441
+ renderFields([
442
+ ["A owner", formatUserActor(attrs.participant_a_owner_id, publicUsers)],
443
+ ["A channel", formatActor("channel", attrs.participant_a_channel_id)],
444
+ ["A seen", formatTimestamp(attrs.participant_a_seen_at)],
445
+ [
446
+ "A archived",
447
+ attrs.participant_a_archived_at
448
+ ? formatTimestamp(attrs.participant_a_archived_at)
449
+ : undefined,
450
+ ],
451
+ [
452
+ "A blocked",
453
+ attrs.participant_a_blocked_at
454
+ ? formatTimestamp(attrs.participant_a_blocked_at)
455
+ : undefined,
456
+ ],
457
+ [
458
+ "A resolved",
459
+ attrs.participant_a_resolved_at
460
+ ? formatTimestamp(attrs.participant_a_resolved_at)
461
+ : undefined,
462
+ ],
463
+ ["B owner", formatUserActor(attrs.participant_b_owner_id, publicUsers)],
464
+ ["B channel", formatActor("channel", attrs.participant_b_channel_id)],
465
+ ["B seen", formatTimestamp(attrs.participant_b_seen_at)],
466
+ [
467
+ "B archived",
468
+ attrs.participant_b_archived_at
469
+ ? formatTimestamp(attrs.participant_b_archived_at)
470
+ : undefined,
471
+ ],
472
+ [
473
+ "B blocked",
474
+ attrs.participant_b_blocked_at
475
+ ? formatTimestamp(attrs.participant_b_blocked_at)
476
+ : undefined,
477
+ ],
478
+ [
479
+ "B resolved",
480
+ attrs.participant_b_resolved_at
481
+ ? formatTimestamp(attrs.participant_b_resolved_at)
482
+ : undefined,
483
+ ],
484
+ ]),
485
+ ),
486
+ renderSection("Messages", messageBlocks),
487
+ renderPagination(messages.nextCursor),
488
+ ]);
489
+ }
490
+
491
+ function renderMessage(
492
+ message: {
493
+ id: string;
494
+ attributes: MessageAttributes;
495
+ },
496
+ publicUsers: Map<string, string>,
497
+ ): string {
498
+ const attrs = message.attributes;
499
+ const headingParts = [
500
+ formatTimestamp(attrs.inserted_at),
501
+ shortId(message.id),
502
+ formatUserActor(attrs.sender_owner_id, publicUsers),
503
+ formatActor("channel", attrs.sender_channel_id),
504
+ ].filter((part) => part !== "-");
505
+ const contextPost = attrs.context_post_id
506
+ ? `Context post: ${attrs.context_post_id}\n`
507
+ : "";
508
+
509
+ return `${headingParts.join(" ")}\n${indent(`${contextPost}${attrs.body}`)}`;
510
+ }
511
+
512
+ function renderThreadAction(
513
+ action: string,
514
+ thread: { id: string; attributes: ThreadAttributes },
515
+ ): string {
516
+ const attrs = thread.attributes;
517
+
518
+ return joinBlocks([
519
+ `${action}: ${thread.id}`,
520
+ renderFields([
521
+ ["Mailbox", attrs.mailbox_type],
522
+ ["Status", attrs.status],
523
+ ["Last message", formatTimestamp(attrs.last_message_at)],
524
+ ["Opened", formatTimestamp(attrs.opened_at)],
525
+ [
526
+ "Expires",
527
+ attrs.expires_at ? formatTimestamp(attrs.expires_at) : undefined,
528
+ ],
529
+ ]),
530
+ ]);
531
+ }
532
+
533
+ function formatActor(kind: "user" | "channel", id?: string | null): string {
534
+ if (!id) {
535
+ return "-";
536
+ }
537
+
538
+ return `${kind}:${shortId(id)}`;
539
+ }
540
+
541
+ function formatUserActor(
542
+ id: string | null | undefined,
543
+ publicUsers: Map<string, string>,
544
+ ): string {
545
+ if (!id) {
546
+ return "-";
547
+ }
548
+
549
+ return publicUsers.get(id) ?? formatActor("user", id);
550
+ }
551
+
552
+ function ownerIdsForThreadDisplay(
553
+ thread: { attributes: ThreadAttributes },
554
+ messages: Array<{ attributes: MessageAttributes }>,
555
+ ): string[] {
556
+ const ids = [
557
+ thread.attributes.participant_a_owner_id,
558
+ thread.attributes.participant_b_owner_id,
559
+ ...messages.map((message) => message.attributes.sender_owner_id),
560
+ ];
561
+
562
+ return Array.from(new Set(ids.filter((id): id is string => Boolean(id))));
563
+ }
564
+
565
+ function ownerIdsForThreadList(
566
+ peers: Array<ThreadPeer>,
567
+ ): string[] {
568
+ return Array.from(
569
+ new Set(
570
+ peers
571
+ .map((peer) => peer.ownerId)
572
+ .filter((id): id is string => Boolean(id)),
573
+ ),
574
+ );
575
+ }
576
+
577
+ interface ThreadPeer {
578
+ ownerId?: string | null;
579
+ channelId?: string | null;
386
580
  }
387
581
 
388
- function formatThreadRecord(thread: {
389
- id: string;
390
- attributes: ThreadAttributes;
391
- }): Record<string, string> {
582
+ function threadPeer(attrs: ThreadAttributes, actor: WhoamiActor): ThreadPeer {
583
+ const side = threadActorSide(attrs, actor);
584
+
585
+ if (side === "a") {
586
+ return {
587
+ ownerId: attrs.participant_b_owner_id,
588
+ channelId: attrs.participant_b_channel_id,
589
+ };
590
+ }
591
+
592
+ if (side === "b") {
593
+ return {
594
+ ownerId: attrs.participant_a_owner_id,
595
+ channelId: attrs.participant_a_channel_id,
596
+ };
597
+ }
598
+
392
599
  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 ?? "",
600
+ ownerId: attrs.participant_a_owner_id,
601
+ channelId: attrs.participant_a_channel_id,
407
602
  };
408
603
  }
604
+
605
+ function threadActorSide(
606
+ attrs: ThreadAttributes,
607
+ actor: WhoamiActor,
608
+ ): "a" | "b" | undefined {
609
+ if (actor.type === "channel") {
610
+ if (attrs.participant_a_channel_id === actor.id) {
611
+ return "a";
612
+ }
613
+
614
+ if (attrs.participant_b_channel_id === actor.id) {
615
+ return "b";
616
+ }
617
+ }
618
+
619
+ const ownerId = actor.type === "user" ? actor.id : actor.owner_id;
620
+
621
+ if (attrs.participant_a_owner_id === ownerId) {
622
+ return "a";
623
+ }
624
+
625
+ if (attrs.participant_b_owner_id === ownerId) {
626
+ return "b";
627
+ }
628
+
629
+ return undefined;
630
+ }
631
+
632
+ function formatThreadPeer(
633
+ peer: ThreadPeer | undefined,
634
+ publicUsers: Map<string, string>,
635
+ ): string {
636
+ if (!peer) {
637
+ return "-";
638
+ }
639
+
640
+ if (peer.channelId) {
641
+ return formatActor("channel", peer.channelId);
642
+ }
643
+
644
+ return formatUserActor(peer.ownerId, publicUsers);
645
+ }
646
+
647
+ function publicUserHandlesById(
648
+ users: Array<{ id: string; attributes: { public_handle?: string | null } }>,
649
+ ): Map<string, string> {
650
+ const handles = new Map<string, string>();
651
+
652
+ for (const user of users) {
653
+ const handle = user.attributes.public_handle?.trim();
654
+
655
+ if (handle) {
656
+ handles.set(user.id, `@${handle}`);
657
+ }
658
+ }
659
+
660
+ return handles;
661
+ }
@@ -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
+ }
package/src/lib/client.ts CHANGED
@@ -142,6 +142,12 @@ export class ClankmatesClient {
142
142
  );
143
143
  }
144
144
 
145
+ async listPublicUsersById(ids: string[]) {
146
+ const path = withRepeatedQuery(`${API_PREFIX}/public/users/by-id`, "ids[]", ids);
147
+
148
+ return this.requestCollection<UserAttributes>(path, {});
149
+ }
150
+
145
151
  async listChannels(input: { limit?: number; cursor?: string } = {}) {
146
152
  return this.requestCollection<ChannelAttributes>(
147
153
  withQuery(`${API_PREFIX}/channels`, {
@@ -814,3 +820,14 @@ function withQuery(
814
820
  const query = search.toString();
815
821
  return query ? `${path}?${query}` : path;
816
822
  }
823
+
824
+ function withRepeatedQuery(path: string, key: string, values: string[]): string {
825
+ const search = new URLSearchParams();
826
+
827
+ for (const value of values) {
828
+ search.append(key, value);
829
+ }
830
+
831
+ const query = search.toString();
832
+ return query ? `${path}?${query}` : path;
833
+ }
@@ -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
@@ -44,7 +44,8 @@ export interface JsonApiDocument<TAttributes extends object> {
44
44
  export type AccessKeyScope = "master" | "read_only";
45
45
 
46
46
  export interface UserAttributes {
47
- email: string;
47
+ email?: string;
48
+ public_profile_id?: string;
48
49
  public_handle?: string | null;
49
50
  }
50
51
 
@@ -95,6 +96,10 @@ export interface InboxSender {
95
96
  export interface ThreadAttributes {
96
97
  mailbox_type: MailboxType;
97
98
  status: ThreadStatus;
99
+ participant_a_owner_id?: string | null;
100
+ participant_a_channel_id?: string | null;
101
+ participant_b_owner_id?: string | null;
102
+ participant_b_channel_id?: string | null;
98
103
  opened_at?: string | null;
99
104
  expires_at?: string | null;
100
105
  last_message_at?: string;
@@ -112,6 +117,9 @@ export interface ThreadAttributes {
112
117
 
113
118
  export interface MessageAttributes {
114
119
  body: string;
120
+ sender_owner_id?: string | null;
121
+ sender_channel_id?: string | null;
122
+ thread_id?: string | null;
115
123
  context_post_id?: string | null;
116
124
  inserted_at?: string;
117
125
  }