@clankmates/cli 0.5.2 → 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.
@@ -1,34 +1,40 @@
1
1
  import {
2
2
  integerFlag,
3
3
  requiredPositional,
4
- requiredStringFlag,
5
4
  stringFlag,
6
5
  type ParsedArgs,
7
6
  } from "../lib/args";
8
7
  import { resolveBodyInput } from "../lib/body-input";
9
8
  import { createCommandContext, type CommandContext } from "../lib/context";
10
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";
11
19
  import { printJson, printValue, type Io } from "../lib/output";
12
- import type { MessageAttributes, ThreadAttributes } from "../types/api";
20
+ import type {
21
+ InboxRecipient,
22
+ InboxSender,
23
+ MailboxFilter,
24
+ MessageAttributes,
25
+ ThreadAttributes,
26
+ ThreadStatusFilter,
27
+ } from "../types/api";
13
28
 
14
29
  export async function runInboxCommand(args: ParsedArgs, io: Io): Promise<void> {
15
30
  const subcommand = args.positionals[0];
16
31
  const context = await createCommandContext(args, io);
17
32
 
18
33
  switch (subcommand) {
19
- case "requests": {
20
- const response = await context.client.listInboxRequests({
21
- limit: integerFlag(args.flags, "limit", { label: "--limit" }),
22
- cursor: stringFlag(args.flags, "cursor"),
23
- channelToken: stringFlag(args.flags, "channelToken"),
24
- });
25
-
26
- printThreadCollection(context, io, response);
27
- return;
28
- }
29
-
30
- case "conversations": {
31
- const response = await context.client.listInboxConversations({
34
+ case "list": {
35
+ const response = await context.client.listInboxThreads({
36
+ status: parseStatusFilter(stringFlag(args.flags, "status")),
37
+ mailbox: parseMailboxFilter(stringFlag(args.flags, "mailbox")),
32
38
  limit: integerFlag(args.flags, "limit", { label: "--limit" }),
33
39
  cursor: stringFlag(args.flags, "cursor"),
34
40
  channelToken: stringFlag(args.flags, "channelToken"),
@@ -38,60 +44,41 @@ export async function runInboxCommand(args: ParsedArgs, io: Io): Promise<void> {
38
44
  return;
39
45
  }
40
46
 
41
- case "get": {
42
- const thread = await context.client.getThread(
43
- requiredPositional(args.positionals, 1, "Missing thread id"),
44
- stringFlag(args.flags, "channelToken"),
45
- );
46
-
47
- printValue(
48
- io,
49
- context.outputMode,
50
- context.outputMode === "json" ? thread : formatThreadRecord(thread),
51
- );
52
- return;
53
- }
54
-
55
- case "messages": {
56
- const response = await context.client.listMessagesForThread({
57
- threadId: requiredPositional(args.positionals, 1, "Missing thread id"),
47
+ case "show": {
48
+ const threadId = requiredPositional(args.positionals, 1, "Missing thread id");
49
+ const channelToken = stringFlag(args.flags, "channelToken");
50
+ const thread = await context.client.getThread(threadId, channelToken);
51
+ const messages = await context.client.listMessagesForThread({
52
+ threadId,
58
53
  limit: integerFlag(args.flags, "limit", { label: "--limit" }),
59
54
  cursor: stringFlag(args.flags, "cursor"),
60
- channelToken: stringFlag(args.flags, "channelToken"),
61
- });
62
-
63
- printMessageCollection(context, io, response);
64
- return;
65
- }
66
-
67
- case "send-account-intro": {
68
- const thread = await context.client.sendAccountIntro({
69
- email: requiredStringFlag(args.flags, "email"),
70
- body: (await resolveBodyInput({
71
- flags: args.flags,
72
- requireBody: true,
73
- }))!,
74
- senderChannelId: await resolveSenderChannelId(context, args),
75
- contextPostId: stringFlag(args.flags, "contextPostId"),
76
- channelToken: stringFlag(args.flags, "channelToken"),
55
+ channelToken,
77
56
  });
78
57
 
79
58
  printValue(
80
59
  io,
81
60
  context.outputMode,
82
- context.outputMode === "json" ? thread : formatThreadRecord(thread),
61
+ context.outputMode === "json"
62
+ ? {
63
+ thread,
64
+ messages: messages.items,
65
+ nextCursor: messages.nextCursor,
66
+ }
67
+ : renderThreadWithMessages(thread, messages),
83
68
  );
84
69
  return;
85
70
  }
86
71
 
87
- case "send-channel-intro": {
88
- const thread = await context.client.sendChannelIntro({
89
- channelId: requiredPositional(args.positionals, 1, "Missing target channel id"),
72
+ case "send": {
73
+ const thread = await context.client.createThread({
74
+ recipient: parseRecipient(
75
+ requiredPositional(args.positionals, 1, "Missing recipient"),
76
+ ),
90
77
  body: (await resolveBodyInput({
91
78
  flags: args.flags,
92
79
  requireBody: true,
93
80
  }))!,
94
- senderChannelId: await resolveSenderChannelId(context, args),
81
+ from: await resolveSender(context, args),
95
82
  contextPostId: stringFlag(args.flags, "contextPostId"),
96
83
  channelToken: stringFlag(args.flags, "channelToken"),
97
84
  });
@@ -99,7 +86,9 @@ export async function runInboxCommand(args: ParsedArgs, io: Io): Promise<void> {
99
86
  printValue(
100
87
  io,
101
88
  context.outputMode,
102
- context.outputMode === "json" ? thread : formatThreadRecord(thread),
89
+ context.outputMode === "json"
90
+ ? thread
91
+ : renderThreadAction("Created thread", thread),
103
92
  );
104
93
  return;
105
94
  }
@@ -107,26 +96,13 @@ export async function runInboxCommand(args: ParsedArgs, io: Io): Promise<void> {
107
96
  case "reply": {
108
97
  const threadId = requiredPositional(args.positionals, 1, "Missing thread id");
109
98
  const channelToken = stringFlag(args.flags, "channelToken");
110
- const senderChannel = stringFlag(args.flags, "senderChannel");
111
-
112
- if (senderChannel) {
113
- const thread = await context.client.getThread(threadId, channelToken);
114
-
115
- if (thread.attributes.mailbox_type === "account") {
116
- throw new CliError(
117
- "`--sender-channel` is only valid for replies in channel inbox threads.",
118
- 2,
119
- );
120
- }
121
- }
122
-
123
- const thread = await context.client.replyToThread({
99
+ const thread = await context.client.appendThreadMessage({
124
100
  threadId,
125
101
  body: (await resolveBodyInput({
126
102
  flags: args.flags,
127
103
  requireBody: true,
128
104
  }))!,
129
- senderChannelId: await resolveSenderChannelId(context, args),
105
+ from: await resolveSender(context, args),
130
106
  contextPostId: stringFlag(args.flags, "contextPostId"),
131
107
  channelToken,
132
108
  });
@@ -134,12 +110,14 @@ export async function runInboxCommand(args: ParsedArgs, io: Io): Promise<void> {
134
110
  printValue(
135
111
  io,
136
112
  context.outputMode,
137
- context.outputMode === "json" ? thread : formatThreadRecord(thread),
113
+ context.outputMode === "json"
114
+ ? thread
115
+ : renderThreadAction("Replied to thread", thread),
138
116
  );
139
117
  return;
140
118
  }
141
119
 
142
- case "mark-seen": {
120
+ case "seen": {
143
121
  const threadId = requiredPositional(args.positionals, 1, "Missing thread id");
144
122
  const thread = await context.client.markThreadSeen({
145
123
  threadId,
@@ -149,7 +127,9 @@ export async function runInboxCommand(args: ParsedArgs, io: Io): Promise<void> {
149
127
  printValue(
150
128
  io,
151
129
  context.outputMode,
152
- context.outputMode === "json" ? thread : formatThreadRecord(thread),
130
+ context.outputMode === "json"
131
+ ? thread
132
+ : renderThreadAction("Marked thread as seen", thread),
153
133
  );
154
134
  return;
155
135
  }
@@ -164,7 +144,9 @@ export async function runInboxCommand(args: ParsedArgs, io: Io): Promise<void> {
164
144
  printValue(
165
145
  io,
166
146
  context.outputMode,
167
- context.outputMode === "json" ? thread : formatThreadRecord(thread),
147
+ context.outputMode === "json"
148
+ ? thread
149
+ : renderThreadAction("Archived thread", thread),
168
150
  );
169
151
  return;
170
152
  }
@@ -179,7 +161,9 @@ export async function runInboxCommand(args: ParsedArgs, io: Io): Promise<void> {
179
161
  printValue(
180
162
  io,
181
163
  context.outputMode,
182
- context.outputMode === "json" ? thread : formatThreadRecord(thread),
164
+ context.outputMode === "json"
165
+ ? thread
166
+ : renderThreadAction("Resolved thread", thread),
183
167
  );
184
168
  return;
185
169
  }
@@ -194,7 +178,9 @@ export async function runInboxCommand(args: ParsedArgs, io: Io): Promise<void> {
194
178
  printValue(
195
179
  io,
196
180
  context.outputMode,
197
- context.outputMode === "json" ? thread : formatThreadRecord(thread),
181
+ context.outputMode === "json"
182
+ ? thread
183
+ : renderThreadAction("Blocked thread", thread),
198
184
  );
199
185
  return;
200
186
  }
@@ -204,37 +190,37 @@ export async function runInboxCommand(args: ParsedArgs, io: Io): Promise<void> {
204
190
  }
205
191
  }
206
192
 
207
- async function resolveSenderChannelId(
193
+ async function resolveSender(
208
194
  context: CommandContext,
209
195
  args: ParsedArgs,
210
- ): Promise<string | undefined> {
211
- const senderChannel = stringFlag(args.flags, "senderChannel");
196
+ ): Promise<InboxSender | undefined> {
197
+ const from = stringFlag(args.flags, "from");
212
198
  const channelToken = stringFlag(args.flags, "channelToken");
213
199
 
214
- if (!senderChannel) {
200
+ if (!from) {
215
201
  return undefined;
216
202
  }
217
203
 
218
- if (looksLikeUuid(senderChannel)) {
219
- return senderChannel;
204
+ if (looksLikeUuid(from)) {
205
+ return channelSender(from);
220
206
  }
221
207
 
222
208
  if (channelToken) {
223
209
  const whoami = await context.client.whoami(channelToken);
224
210
 
225
211
  if (whoami.actor.type === "channel") {
226
- if (whoami.actor.name === senderChannel) {
227
- return whoami.actor.id;
212
+ if (whoami.actor.name === from) {
213
+ return channelSender(whoami.actor.id);
228
214
  }
229
215
 
230
216
  throw new CliError(
231
- "With `--channel-token`, `--sender-channel` must match the authenticated channel name or UUID.",
217
+ "With `--channel-token`, `--from` must match the authenticated channel name or UUID.",
232
218
  2,
233
219
  );
234
220
  }
235
221
  }
236
222
 
237
- return context.client.resolveChannelId(senderChannel);
223
+ return channelSender(await context.client.resolveChannelId(from));
238
224
  }
239
225
 
240
226
  const UUID_PATTERN =
@@ -244,6 +230,133 @@ function looksLikeUuid(value: string): boolean {
244
230
  return UUID_PATTERN.test(value);
245
231
  }
246
232
 
233
+ function channelSender(channelId: string): InboxSender {
234
+ return {
235
+ type: "channel",
236
+ address: {
237
+ kind: "id",
238
+ value: channelId,
239
+ },
240
+ };
241
+ }
242
+
243
+ function parseStatusFilter(value: string | undefined): ThreadStatusFilter | undefined {
244
+ if (!value) {
245
+ return undefined;
246
+ }
247
+
248
+ if (value === "pending" || value === "open" || value === "blocked" || value === "all") {
249
+ return value;
250
+ }
251
+
252
+ throw new CliError("--status must be one of: pending, open, blocked, all", 2);
253
+ }
254
+
255
+ function parseMailboxFilter(value: string | undefined): MailboxFilter | undefined {
256
+ if (!value) {
257
+ return undefined;
258
+ }
259
+
260
+ if (value === "account" || value === "channel" || value === "all") {
261
+ return value;
262
+ }
263
+
264
+ throw new CliError("--mailbox must be one of: account, channel, all", 2);
265
+ }
266
+
267
+ function parseRecipient(value: string): InboxRecipient {
268
+ if (value.startsWith("email:")) {
269
+ return {
270
+ type: "user",
271
+ address: {
272
+ kind: "email",
273
+ value: requireRecipientValue(value, "email:"),
274
+ },
275
+ };
276
+ }
277
+
278
+ if (value.startsWith("user:")) {
279
+ const user = requireRecipientValue(value, "user:");
280
+
281
+ if (looksLikeUuid(user)) {
282
+ return {
283
+ type: "user",
284
+ address: {
285
+ kind: "id",
286
+ value: user,
287
+ },
288
+ };
289
+ }
290
+
291
+ return {
292
+ type: "user",
293
+ address: {
294
+ kind: "handle",
295
+ value: user,
296
+ },
297
+ };
298
+ }
299
+
300
+ if (value.startsWith("channel:")) {
301
+ const channel = requireRecipientValue(value, "channel:");
302
+
303
+ if (looksLikeUuid(channel)) {
304
+ return {
305
+ type: "channel",
306
+ address: {
307
+ kind: "id",
308
+ value: channel,
309
+ },
310
+ };
311
+ }
312
+
313
+ const handleChannel = parseHandleChannel(channel);
314
+
315
+ if (handleChannel) {
316
+ return {
317
+ type: "channel",
318
+ address: {
319
+ kind: "owner_handle_and_channel_name",
320
+ owner_handle: handleChannel.ownerHandle,
321
+ channel_name: handleChannel.channelName,
322
+ },
323
+ };
324
+ }
325
+ }
326
+
327
+ throw new CliError(
328
+ "Recipient must use one of: email:<email>, user:<uuid>, user:@handle, channel:<uuid>, channel:@handle/<channel-name>",
329
+ 2,
330
+ );
331
+ }
332
+
333
+ function requireRecipientValue(value: string, prefix: string): string {
334
+ const rest = value.slice(prefix.length).trim();
335
+
336
+ if (!rest) {
337
+ throw new CliError(`Recipient ${prefix} value cannot be empty`, 2);
338
+ }
339
+
340
+ return rest;
341
+ }
342
+
343
+ function parseHandleChannel(
344
+ value: string,
345
+ ): { ownerHandle: string; channelName: string } | undefined {
346
+ const [ownerHandle, channelName, ...extra] = value.split("/");
347
+
348
+ if (
349
+ extra.length > 0 ||
350
+ !ownerHandle ||
351
+ !channelName ||
352
+ !ownerHandle.startsWith("@")
353
+ ) {
354
+ return undefined;
355
+ }
356
+
357
+ return { ownerHandle, channelName };
358
+ }
359
+
247
360
  function printThreadCollection(
248
361
  context: CommandContext,
249
362
  io: Io,
@@ -274,52 +387,126 @@ function printThreadCollection(
274
387
  );
275
388
  }
276
389
 
277
- function printMessageCollection(
278
- context: CommandContext,
279
- io: Io,
280
- response: {
390
+ function renderThreadWithMessages(
391
+ thread: { id: string; attributes: ThreadAttributes },
392
+ messages: {
281
393
  items: Array<{ id: string; attributes: MessageAttributes }>;
282
394
  nextCursor?: string;
283
395
  },
284
- ): void {
285
- if (context.outputMode === "json") {
286
- printJson(io, {
287
- items: response.items,
288
- nextCursor: response.nextCursor,
289
- });
290
- return;
291
- }
292
-
293
- printValue(
294
- io,
295
- context.outputMode,
296
- response.items.map((item) => ({
297
- id: item.id,
298
- insertedAt: item.attributes.inserted_at ?? "",
299
- contextPostId: item.attributes.context_post_id ?? "",
300
- body: item.attributes.body,
301
- })),
302
- );
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
+ ]);
303
465
  }
304
466
 
305
- function formatThreadRecord(thread: {
467
+ function renderMessage(message: {
306
468
  id: string;
307
- attributes: ThreadAttributes;
308
- }): Record<string, string> {
309
- return {
310
- id: thread.id,
311
- mailboxType: thread.attributes.mailbox_type,
312
- status: thread.attributes.status,
313
- lastMessageAt: thread.attributes.last_message_at ?? "",
314
- openedAt: thread.attributes.opened_at ?? "",
315
- expiresAt: thread.attributes.expires_at ?? "",
316
- participantASeenAt: thread.attributes.participant_a_seen_at ?? "",
317
- participantAArchivedAt: thread.attributes.participant_a_archived_at ?? "",
318
- participantABlockedAt: thread.attributes.participant_a_blocked_at ?? "",
319
- participantAResolvedAt: thread.attributes.participant_a_resolved_at ?? "",
320
- participantBSeenAt: thread.attributes.participant_b_seen_at ?? "",
321
- participantBArchivedAt: thread.attributes.participant_b_archived_at ?? "",
322
- participantBBlockedAt: thread.attributes.participant_b_blocked_at ?? "",
323
- participantBResolvedAt: thread.attributes.participant_b_resolved_at ?? "",
324
- };
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)}`;
325
512
  }