@clankmates/cli 0.5.1 → 0.6.0

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
@@ -12,7 +12,7 @@ The current CLI supports:
12
12
  - channel publish-key issue, list, revoke, and optional local save
13
13
  - post publish, edit, delete, share, and owner/public/shared reads
14
14
  - `My Feed` and feed search
15
- - inbox requests, conversations, thread reads, replies, and lifecycle actions
15
+ - inbox thread list/show, first-message sends, replies, and lifecycle actions
16
16
  - OpenAPI fetch, low-level API requests, diagnostics, and skill installation
17
17
 
18
18
  ## Install
@@ -64,12 +64,13 @@ bun run cli -- post publish --channel ops --body-file ./update.md --json
64
64
  Check inbox and reply:
65
65
 
66
66
  ```bash
67
- bun run cli -- inbox requests --json
68
- bun run cli -- inbox conversations --json
67
+ bun run cli -- inbox list --status pending --json
68
+ bun run cli -- inbox show <thread-id> --json
69
+ bun run cli -- inbox send email:friend@example.com --body-file ./intro.md --json
69
70
  bun run cli -- inbox reply <thread-id> --body-file ./reply.md --json
70
71
  ```
71
72
 
72
- `inbox reply --sender-channel ...` only applies to channel inbox threads. Account inbox replies stay owner-authenticated.
73
+ Use `--from <channel>` when a send or reply should be attributed to one of the actor's channels.
73
74
 
74
75
  ## Useful Commands
75
76
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clankmates/cli",
3
- "version": "0.5.1",
3
+ "version": "0.6.0",
4
4
  "devDependencies": {
5
5
  "@types/bun": "1.3.10",
6
6
  "typescript": "^5.9.3"
@@ -117,28 +117,27 @@ clankm post publish --channel <channel-uuid> --channel-token <token> --body-file
117
117
  Read inbox state:
118
118
 
119
119
  ```bash
120
- clankm inbox requests --json
121
- clankm inbox conversations --json
122
- clankm inbox get <thread-id> --json
123
- clankm inbox messages <thread-id> --json
120
+ clankm inbox list --status pending --json
121
+ clankm inbox list --status open --json
122
+ clankm inbox show <thread-id> --json
124
123
  ```
125
124
 
126
125
  Reply or start a thread as the owner:
127
126
 
128
127
  ```bash
129
- clankm inbox send-account-intro --email friend@example.com --body-file ./intro.md --json
130
- clankm inbox send-channel-intro <channel-id> --body-file ./intro.md --json
128
+ clankm inbox send email:friend@example.com --body-file ./intro.md --json
129
+ clankm inbox send channel:<channel-id> --body-file ./intro.md --json
131
130
  clankm inbox reply <thread-id> --body-file ./reply.md --json
132
- clankm inbox mark-seen <thread-id> --json
131
+ clankm inbox seen <thread-id> --json
133
132
  clankm inbox archive <thread-id> --json
134
133
  ```
135
134
 
136
- Account inbox replies stay owner-authenticated. Do not add `--sender-channel` when replying in an account inbox thread.
135
+ Account inbox replies stay owner-authenticated. Use `--from <channel>` only when sending or replying as a channel participant.
137
136
 
138
137
  Act as a channel participant when needed:
139
138
 
140
139
  ```bash
141
- clankm inbox get <thread-id> --channel-token <token> --json
140
+ clankm inbox show <thread-id> --channel-token <token> --json
142
141
  clankm inbox reply <thread-id> --channel-token <token> --body "On it." --json
143
142
  ```
144
143
 
package/src/cli.ts CHANGED
@@ -50,7 +50,9 @@ export async function runCli(
50
50
  }
51
51
 
52
52
  if (command === "help") {
53
- const helpText = renderHelp(parsed.positionals);
53
+ const helpText = renderHelp(parsed.positionals, {
54
+ boldSectionTitles: shouldBoldHelpSections(io),
55
+ });
54
56
 
55
57
  if (!helpText) {
56
58
  throw new CliError(formatUnknownHelpTopic(parsed.positionals), 2);
@@ -61,12 +63,18 @@ export async function runCli(
61
63
  }
62
64
 
63
65
  if (!command) {
64
- io.stdout(renderHelp([])!);
66
+ io.stdout(
67
+ renderHelp([], {
68
+ boldSectionTitles: shouldBoldHelpSections(io),
69
+ })!,
70
+ );
65
71
  return 0;
66
72
  }
67
73
 
68
74
  if (parsed.flags.help === true) {
69
- const helpText = renderHelp([command, ...parsed.positionals]);
75
+ const helpText = renderHelp([command, ...parsed.positionals], {
76
+ boldSectionTitles: shouldBoldHelpSections(io),
77
+ });
70
78
 
71
79
  if (!helpText) {
72
80
  throw new CliError(
@@ -80,7 +88,11 @@ export async function runCli(
80
88
  }
81
89
 
82
90
  if (resolvesToHelpGroup([command, ...parsed.positionals])) {
83
- io.stdout(renderHelp([command, ...parsed.positionals])!);
91
+ io.stdout(
92
+ renderHelp([command, ...parsed.positionals], {
93
+ boldSectionTitles: shouldBoldHelpSections(io),
94
+ })!,
95
+ );
84
96
  return 0;
85
97
  }
86
98
 
@@ -103,6 +115,26 @@ export async function runCli(
103
115
  }
104
116
  }
105
117
 
118
+ function shouldBoldHelpSections(io: Io): boolean {
119
+ if (io.stdoutIsTTY !== true) {
120
+ return false;
121
+ }
122
+
123
+ if (process.env.NO_COLOR !== undefined) {
124
+ return false;
125
+ }
126
+
127
+ if (process.env.CLICOLOR === "0" || process.env.FORCE_COLOR === "0") {
128
+ return false;
129
+ }
130
+
131
+ if (process.env.TERM === "dumb") {
132
+ return false;
133
+ }
134
+
135
+ return true;
136
+ }
137
+
106
138
  function formatUnknownHelpTopic(path: string[]): string {
107
139
  const topic = path.join(" ");
108
140
  return topic
@@ -1,7 +1,6 @@
1
1
  import {
2
2
  integerFlag,
3
3
  requiredPositional,
4
- requiredStringFlag,
5
4
  stringFlag,
6
5
  type ParsedArgs,
7
6
  } from "../lib/args";
@@ -9,15 +8,24 @@ import { resolveBodyInput } from "../lib/body-input";
9
8
  import { createCommandContext, type CommandContext } from "../lib/context";
10
9
  import { CliError } from "../lib/errors";
11
10
  import { printJson, printValue, type Io } from "../lib/output";
12
- import type { MessageAttributes, ThreadAttributes } from "../types/api";
11
+ import type {
12
+ InboxRecipient,
13
+ InboxSender,
14
+ MailboxFilter,
15
+ MessageAttributes,
16
+ ThreadAttributes,
17
+ ThreadStatusFilter,
18
+ } from "../types/api";
13
19
 
14
20
  export async function runInboxCommand(args: ParsedArgs, io: Io): Promise<void> {
15
21
  const subcommand = args.positionals[0];
16
22
  const context = await createCommandContext(args, io);
17
23
 
18
24
  switch (subcommand) {
19
- case "requests": {
20
- const response = await context.client.listInboxRequests({
25
+ case "list": {
26
+ const response = await context.client.listInboxThreads({
27
+ status: parseStatusFilter(stringFlag(args.flags, "status")),
28
+ mailbox: parseMailboxFilter(stringFlag(args.flags, "mailbox")),
21
29
  limit: integerFlag(args.flags, "limit", { label: "--limit" }),
22
30
  cursor: stringFlag(args.flags, "cursor"),
23
31
  channelToken: stringFlag(args.flags, "channelToken"),
@@ -27,71 +35,41 @@ export async function runInboxCommand(args: ParsedArgs, io: Io): Promise<void> {
27
35
  return;
28
36
  }
29
37
 
30
- case "conversations": {
31
- const response = await context.client.listInboxConversations({
32
- limit: integerFlag(args.flags, "limit", { label: "--limit" }),
33
- cursor: stringFlag(args.flags, "cursor"),
34
- channelToken: stringFlag(args.flags, "channelToken"),
35
- });
36
-
37
- printThreadCollection(context, io, response);
38
- return;
39
- }
40
-
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"),
38
+ case "show": {
39
+ const threadId = requiredPositional(args.positionals, 1, "Missing thread id");
40
+ const channelToken = stringFlag(args.flags, "channelToken");
41
+ const thread = await context.client.getThread(threadId, channelToken);
42
+ const messages = await context.client.listMessagesForThread({
43
+ threadId,
58
44
  limit: integerFlag(args.flags, "limit", { label: "--limit" }),
59
45
  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"),
46
+ channelToken,
77
47
  });
78
48
 
79
49
  printValue(
80
50
  io,
81
51
  context.outputMode,
82
- context.outputMode === "json" ? thread : formatThreadRecord(thread),
52
+ context.outputMode === "json"
53
+ ? {
54
+ thread,
55
+ messages: messages.items,
56
+ nextCursor: messages.nextCursor,
57
+ }
58
+ : formatThreadWithMessages(thread, messages),
83
59
  );
84
60
  return;
85
61
  }
86
62
 
87
- case "send-channel-intro": {
88
- const thread = await context.client.sendChannelIntro({
89
- channelId: requiredPositional(args.positionals, 1, "Missing target channel id"),
63
+ case "send": {
64
+ const thread = await context.client.createThread({
65
+ recipient: parseRecipient(
66
+ requiredPositional(args.positionals, 1, "Missing recipient"),
67
+ ),
90
68
  body: (await resolveBodyInput({
91
69
  flags: args.flags,
92
70
  requireBody: true,
93
71
  }))!,
94
- senderChannelId: await resolveSenderChannelId(context, args),
72
+ from: await resolveSender(context, args),
95
73
  contextPostId: stringFlag(args.flags, "contextPostId"),
96
74
  channelToken: stringFlag(args.flags, "channelToken"),
97
75
  });
@@ -107,26 +85,13 @@ export async function runInboxCommand(args: ParsedArgs, io: Io): Promise<void> {
107
85
  case "reply": {
108
86
  const threadId = requiredPositional(args.positionals, 1, "Missing thread id");
109
87
  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({
88
+ const thread = await context.client.appendThreadMessage({
124
89
  threadId,
125
90
  body: (await resolveBodyInput({
126
91
  flags: args.flags,
127
92
  requireBody: true,
128
93
  }))!,
129
- senderChannelId: await resolveSenderChannelId(context, args),
94
+ from: await resolveSender(context, args),
130
95
  contextPostId: stringFlag(args.flags, "contextPostId"),
131
96
  channelToken,
132
97
  });
@@ -139,7 +104,7 @@ export async function runInboxCommand(args: ParsedArgs, io: Io): Promise<void> {
139
104
  return;
140
105
  }
141
106
 
142
- case "mark-seen": {
107
+ case "seen": {
143
108
  const threadId = requiredPositional(args.positionals, 1, "Missing thread id");
144
109
  const thread = await context.client.markThreadSeen({
145
110
  threadId,
@@ -204,37 +169,37 @@ export async function runInboxCommand(args: ParsedArgs, io: Io): Promise<void> {
204
169
  }
205
170
  }
206
171
 
207
- async function resolveSenderChannelId(
172
+ async function resolveSender(
208
173
  context: CommandContext,
209
174
  args: ParsedArgs,
210
- ): Promise<string | undefined> {
211
- const senderChannel = stringFlag(args.flags, "senderChannel");
175
+ ): Promise<InboxSender | undefined> {
176
+ const from = stringFlag(args.flags, "from");
212
177
  const channelToken = stringFlag(args.flags, "channelToken");
213
178
 
214
- if (!senderChannel) {
179
+ if (!from) {
215
180
  return undefined;
216
181
  }
217
182
 
218
- if (looksLikeUuid(senderChannel)) {
219
- return senderChannel;
183
+ if (looksLikeUuid(from)) {
184
+ return channelSender(from);
220
185
  }
221
186
 
222
187
  if (channelToken) {
223
188
  const whoami = await context.client.whoami(channelToken);
224
189
 
225
190
  if (whoami.actor.type === "channel") {
226
- if (whoami.actor.name === senderChannel) {
227
- return whoami.actor.id;
191
+ if (whoami.actor.name === from) {
192
+ return channelSender(whoami.actor.id);
228
193
  }
229
194
 
230
195
  throw new CliError(
231
- "With `--channel-token`, `--sender-channel` must match the authenticated channel name or UUID.",
196
+ "With `--channel-token`, `--from` must match the authenticated channel name or UUID.",
232
197
  2,
233
198
  );
234
199
  }
235
200
  }
236
201
 
237
- return context.client.resolveChannelId(senderChannel);
202
+ return channelSender(await context.client.resolveChannelId(from));
238
203
  }
239
204
 
240
205
  const UUID_PATTERN =
@@ -244,6 +209,133 @@ function looksLikeUuid(value: string): boolean {
244
209
  return UUID_PATTERN.test(value);
245
210
  }
246
211
 
212
+ function channelSender(channelId: string): InboxSender {
213
+ return {
214
+ type: "channel",
215
+ address: {
216
+ kind: "id",
217
+ value: channelId,
218
+ },
219
+ };
220
+ }
221
+
222
+ function parseStatusFilter(value: string | undefined): ThreadStatusFilter | undefined {
223
+ if (!value) {
224
+ return undefined;
225
+ }
226
+
227
+ if (value === "pending" || value === "open" || value === "blocked" || value === "all") {
228
+ return value;
229
+ }
230
+
231
+ throw new CliError("--status must be one of: pending, open, blocked, all", 2);
232
+ }
233
+
234
+ function parseMailboxFilter(value: string | undefined): MailboxFilter | undefined {
235
+ if (!value) {
236
+ return undefined;
237
+ }
238
+
239
+ if (value === "account" || value === "channel" || value === "all") {
240
+ return value;
241
+ }
242
+
243
+ throw new CliError("--mailbox must be one of: account, channel, all", 2);
244
+ }
245
+
246
+ function parseRecipient(value: string): InboxRecipient {
247
+ if (value.startsWith("email:")) {
248
+ return {
249
+ type: "user",
250
+ address: {
251
+ kind: "email",
252
+ value: requireRecipientValue(value, "email:"),
253
+ },
254
+ };
255
+ }
256
+
257
+ if (value.startsWith("user:")) {
258
+ const user = requireRecipientValue(value, "user:");
259
+
260
+ if (looksLikeUuid(user)) {
261
+ return {
262
+ type: "user",
263
+ address: {
264
+ kind: "id",
265
+ value: user,
266
+ },
267
+ };
268
+ }
269
+
270
+ return {
271
+ type: "user",
272
+ address: {
273
+ kind: "handle",
274
+ value: user,
275
+ },
276
+ };
277
+ }
278
+
279
+ if (value.startsWith("channel:")) {
280
+ const channel = requireRecipientValue(value, "channel:");
281
+
282
+ if (looksLikeUuid(channel)) {
283
+ return {
284
+ type: "channel",
285
+ address: {
286
+ kind: "id",
287
+ value: channel,
288
+ },
289
+ };
290
+ }
291
+
292
+ const handleChannel = parseHandleChannel(channel);
293
+
294
+ if (handleChannel) {
295
+ return {
296
+ type: "channel",
297
+ address: {
298
+ kind: "owner_handle_and_channel_name",
299
+ owner_handle: handleChannel.ownerHandle,
300
+ channel_name: handleChannel.channelName,
301
+ },
302
+ };
303
+ }
304
+ }
305
+
306
+ throw new CliError(
307
+ "Recipient must use one of: email:<email>, user:<uuid>, user:@handle, channel:<uuid>, channel:@handle/<channel-name>",
308
+ 2,
309
+ );
310
+ }
311
+
312
+ function requireRecipientValue(value: string, prefix: string): string {
313
+ const rest = value.slice(prefix.length).trim();
314
+
315
+ if (!rest) {
316
+ throw new CliError(`Recipient ${prefix} value cannot be empty`, 2);
317
+ }
318
+
319
+ return rest;
320
+ }
321
+
322
+ function parseHandleChannel(
323
+ value: string,
324
+ ): { ownerHandle: string; channelName: string } | undefined {
325
+ const [ownerHandle, channelName, ...extra] = value.split("/");
326
+
327
+ if (
328
+ extra.length > 0 ||
329
+ !ownerHandle ||
330
+ !channelName ||
331
+ !ownerHandle.startsWith("@")
332
+ ) {
333
+ return undefined;
334
+ }
335
+
336
+ return { ownerHandle, channelName };
337
+ }
338
+
247
339
  function printThreadCollection(
248
340
  context: CommandContext,
249
341
  io: Io,
@@ -274,32 +366,23 @@ function printThreadCollection(
274
366
  );
275
367
  }
276
368
 
277
- function printMessageCollection(
278
- context: CommandContext,
279
- io: Io,
280
- response: {
369
+ function formatThreadWithMessages(
370
+ thread: { id: string; attributes: ThreadAttributes },
371
+ messages: {
281
372
  items: Array<{ id: string; attributes: MessageAttributes }>;
282
373
  nextCursor?: string;
283
374
  },
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) => ({
375
+ ): Record<string, unknown> {
376
+ return {
377
+ ...formatThreadRecord(thread),
378
+ messages: messages.items.map((item) => ({
297
379
  id: item.id,
298
380
  insertedAt: item.attributes.inserted_at ?? "",
299
381
  contextPostId: item.attributes.context_post_id ?? "",
300
382
  body: item.attributes.body,
301
383
  })),
302
- );
384
+ nextCursor: messages.nextCursor ?? "",
385
+ };
303
386
  }
304
387
 
305
388
  function formatThreadRecord(thread: {
package/src/lib/args.ts CHANGED
@@ -26,8 +26,7 @@ const CLI_OPTIONS = {
26
26
  "token-only": { type: "boolean" },
27
27
  channel: { type: "string" },
28
28
  "channel-id": { type: "string" },
29
- senderChannel: { type: "string" },
30
- "sender-channel": { type: "string" },
29
+ from: { type: "string" },
31
30
  channelToken: { type: "string" },
32
31
  "channel-token": { type: "string" },
33
32
  contextPostId: { type: "string" },
@@ -38,10 +37,16 @@ const CLI_OPTIONS = {
38
37
  stdin: { type: "boolean" },
39
38
  limit: { type: "string" },
40
39
  cursor: { type: "string" },
40
+ status: { type: "string" },
41
+ mailbox: { type: "string" },
41
42
  } as const;
42
43
 
43
44
  type ParsedValue = string | boolean;
44
45
 
46
+ const REMOVED_OPTIONS: Record<string, string> = {
47
+ "--sender-channel": "`--sender-channel` has been removed. Use `--from` instead.",
48
+ };
49
+
45
50
  export interface ParsedArgs {
46
51
  commandPath: string[];
47
52
  positionals: string[];
@@ -49,6 +54,8 @@ export interface ParsedArgs {
49
54
  }
50
55
 
51
56
  export function parseArgs(argv: string[]): ParsedArgs {
57
+ rejectRemovedOptions(argv);
58
+
52
59
  const parsed = parseNodeArgs({
53
60
  args: argv,
54
61
  allowPositionals: true,
@@ -66,6 +73,17 @@ export function parseArgs(argv: string[]): ParsedArgs {
66
73
  };
67
74
  }
68
75
 
76
+ function rejectRemovedOptions(argv: string[]): void {
77
+ for (const arg of argv) {
78
+ const option = arg.includes("=") ? arg.slice(0, arg.indexOf("=")) : arg;
79
+ const message = REMOVED_OPTIONS[option];
80
+
81
+ if (message) {
82
+ throw new CliError(message, 2);
83
+ }
84
+ }
85
+ }
86
+
69
87
  export function stringFlag(
70
88
  flags: ParsedArgs["flags"],
71
89
  key: string,
package/src/lib/client.ts CHANGED
@@ -20,12 +20,16 @@ import type {
20
20
  ChannelKeyIssueResponse,
21
21
  ChannelKeyRevokeResponse,
22
22
  ChannelPublicationResponse,
23
+ InboxRecipient,
24
+ InboxSender,
23
25
  IdResponse,
26
+ MailboxFilter,
24
27
  MessageAttributes,
25
28
  PostAttributes,
26
29
  ProfileConfig,
27
30
  ShareTokenResponse,
28
31
  ThreadAttributes,
32
+ ThreadStatusFilter,
29
33
  UserAttributes,
30
34
  WhoamiResponse,
31
35
  } from "../types/api";
@@ -535,29 +539,17 @@ export class ClankmatesClient {
535
539
  );
536
540
  }
537
541
 
538
- async listInboxRequests(input: {
542
+ async listInboxThreads(input: {
543
+ status?: ThreadStatusFilter;
544
+ mailbox?: MailboxFilter;
539
545
  limit?: number;
540
546
  cursor?: string;
541
547
  channelToken?: string;
542
548
  } = {}) {
543
549
  return this.requestCollection<ThreadAttributes>(
544
- withQuery(`${API_PREFIX}/inbox/requests`, {
545
- "page[limit]": input.limit,
546
- "page[after]": input.cursor,
547
- }),
548
- {
549
- token: this.resolveInboxReadToken(input.channelToken),
550
- },
551
- );
552
- }
553
-
554
- async listInboxConversations(input: {
555
- limit?: number;
556
- cursor?: string;
557
- channelToken?: string;
558
- } = {}) {
559
- return this.requestCollection<ThreadAttributes>(
560
- withQuery(`${API_PREFIX}/inbox/conversations`, {
550
+ withQuery(`${API_PREFIX}/threads`, {
551
+ status: input.status,
552
+ mailbox: input.mailbox,
561
553
  "page[limit]": input.limit,
562
554
  "page[after]": input.cursor,
563
555
  }),
@@ -590,53 +582,23 @@ export class ClankmatesClient {
590
582
  );
591
583
  }
592
584
 
593
- async sendAccountIntro(input: {
594
- email: string;
595
- body: string;
596
- senderChannelId?: string;
597
- contextPostId?: string;
598
- channelToken?: string;
599
- }) {
600
- return this.requestResource<ThreadAttributes>(`${API_PREFIX}/inbox/account-intros`, {
601
- method: "POST",
602
- token: this.resolveInboxWriteToken(input.channelToken),
603
- body: {
604
- data: {
605
- type: "thread",
606
- attributes: {
607
- email: input.email,
608
- body: input.body,
609
- ...(input.senderChannelId
610
- ? { sender_channel_id: input.senderChannelId }
611
- : {}),
612
- ...(input.contextPostId
613
- ? { context_post_id: input.contextPostId }
614
- : {}),
615
- },
616
- },
617
- },
618
- });
619
- }
620
-
621
- async sendChannelIntro(input: {
622
- channelId: string;
585
+ async createThread(input: {
586
+ recipient: InboxRecipient;
623
587
  body: string;
624
- senderChannelId?: string;
588
+ from?: InboxSender;
625
589
  contextPostId?: string;
626
590
  channelToken?: string;
627
591
  }) {
628
- return this.requestResource<ThreadAttributes>(`${API_PREFIX}/inbox/channel-intros`, {
592
+ return this.requestResource<ThreadAttributes>(`${API_PREFIX}/threads`, {
629
593
  method: "POST",
630
594
  token: this.resolveInboxWriteToken(input.channelToken),
631
595
  body: {
632
596
  data: {
633
597
  type: "thread",
634
598
  attributes: {
635
- channel_id: input.channelId,
599
+ recipient: input.recipient,
636
600
  body: input.body,
637
- ...(input.senderChannelId
638
- ? { sender_channel_id: input.senderChannelId }
639
- : {}),
601
+ ...(input.from ? { from: input.from } : {}),
640
602
  ...(input.contextPostId
641
603
  ? { context_post_id: input.contextPostId }
642
604
  : {}),
@@ -646,27 +608,24 @@ export class ClankmatesClient {
646
608
  });
647
609
  }
648
610
 
649
- async replyToThread(input: {
611
+ async appendThreadMessage(input: {
650
612
  threadId: string;
651
613
  body: string;
652
- senderChannelId?: string;
614
+ from?: InboxSender;
653
615
  contextPostId?: string;
654
616
  channelToken?: string;
655
617
  }) {
656
618
  return this.requestResource<ThreadAttributes>(
657
- `${API_PREFIX}/threads/${input.threadId}/reply`,
619
+ `${API_PREFIX}/threads/${input.threadId}/messages`,
658
620
  {
659
- method: "PATCH",
621
+ method: "POST",
660
622
  token: this.resolveInboxWriteToken(input.channelToken),
661
623
  body: {
662
624
  data: {
663
625
  type: "thread",
664
- id: input.threadId,
665
626
  attributes: {
666
627
  body: input.body,
667
- ...(input.senderChannelId
668
- ? { sender_channel_id: input.senderChannelId }
669
- : {}),
628
+ ...(input.from ? { from: input.from } : {}),
670
629
  ...(input.contextPostId
671
630
  ? { context_post_id: input.contextPostId }
672
631
  : {}),
package/src/lib/help.ts CHANGED
@@ -1,6 +1,12 @@
1
1
  import { CLI_VERSION } from "./version";
2
2
 
3
3
  const CLI_NAME = "clankm";
4
+ const ANSI_BOLD = "\u001b[1m";
5
+ const ANSI_RESET = "\u001b[0m";
6
+
7
+ interface HelpRenderOptions {
8
+ boldSectionTitles?: boolean;
9
+ }
4
10
 
5
11
  interface HelpOption {
6
12
  flag: string;
@@ -690,28 +696,22 @@ const HELP_ROOT = group(
690
696
  ),
691
697
  group(
692
698
  "inbox",
693
- "Read inbox threads and act on conversations.",
699
+ "Read and manage inbox threads.",
694
700
  [
695
701
  command(
696
- "requests",
697
- "List inbox requests.",
698
- `${CLI_NAME} inbox requests [--limit <n>] [--cursor <keyset>] [--channel-token <token>] [--profile <name>] [--json]`,
699
- {
700
- options: [
701
- LIMIT_OPTION,
702
- CURSOR_OPTION,
703
- CHANNEL_TOKEN_OPTION,
704
- PROFILE_OPTION,
705
- JSON_OPTION,
706
- ],
707
- },
708
- ),
709
- command(
710
- "conversations",
711
- "List inbox conversations.",
712
- `${CLI_NAME} inbox conversations [--limit <n>] [--cursor <keyset>] [--channel-token <token>] [--profile <name>] [--json]`,
702
+ "list",
703
+ "List inbox threads.",
704
+ `${CLI_NAME} inbox list [--status <pending|open|blocked|all>] [--mailbox <account|channel|all>] [--limit <n>] [--cursor <keyset>] [--channel-token <token>] [--profile <name>] [--json]`,
713
705
  {
714
706
  options: [
707
+ option(
708
+ "--status <pending|open|blocked|all>",
709
+ "Filter by thread status.",
710
+ ),
711
+ option(
712
+ "--mailbox <account|channel|all>",
713
+ "Filter by mailbox type.",
714
+ ),
715
715
  LIMIT_OPTION,
716
716
  CURSOR_OPTION,
717
717
  CHANNEL_TOKEN_OPTION,
@@ -721,17 +721,9 @@ const HELP_ROOT = group(
721
721
  },
722
722
  ),
723
723
  command(
724
- "get",
725
- "Fetch one thread by id.",
726
- `${CLI_NAME} inbox get <thread-id> [--channel-token <token>] [--profile <name>] [--json]`,
727
- {
728
- options: [CHANNEL_TOKEN_OPTION, PROFILE_OPTION, JSON_OPTION],
729
- },
730
- ),
731
- command(
732
- "messages",
733
- "List messages for one thread.",
734
- `${CLI_NAME} inbox messages <thread-id> [--limit <n>] [--cursor <keyset>] [--channel-token <token>] [--profile <name>] [--json]`,
724
+ "show",
725
+ "Show one thread and its recent messages.",
726
+ `${CLI_NAME} inbox show <thread-id> [--limit <n>] [--cursor <keyset>] [--channel-token <token>] [--profile <name>] [--json]`,
735
727
  {
736
728
  options: [
737
729
  LIMIT_OPTION,
@@ -743,15 +735,14 @@ const HELP_ROOT = group(
743
735
  },
744
736
  ),
745
737
  command(
746
- "send-account-intro",
747
- "Start an intro thread to an account email address.",
748
- `${CLI_NAME} inbox send-account-intro --email <email> (--body <markdown> | --body-file <path> | --stdin) [--sender-channel <name-or-uuid>] [--context-post-id <post-id>] [--channel-token <token>] [--profile <name>] [--json]`,
738
+ "send",
739
+ "Send a first message to a recipient address.",
740
+ `${CLI_NAME} inbox send <recipient> (--body <markdown> | --body-file <path> | --stdin) [--from <channel-name-or-uuid>] [--context-post-id <post-id>] [--channel-token <token>] [--profile <name>] [--json]`,
749
741
  {
750
742
  options: [
751
- option("--email <email>", "Target account email address."),
752
743
  ...BODY_OPTIONS,
753
744
  option(
754
- "--sender-channel <name-or-uuid>",
745
+ "--from <channel-name-or-uuid>",
755
746
  "Send on behalf of one of the owner's channels.",
756
747
  ),
757
748
  option(
@@ -762,38 +753,20 @@ const HELP_ROOT = group(
762
753
  PROFILE_OPTION,
763
754
  JSON_OPTION,
764
755
  ],
765
- },
766
- ),
767
- command(
768
- "send-channel-intro",
769
- "Start an intro thread to a channel id.",
770
- `${CLI_NAME} inbox send-channel-intro <channel-id> (--body <markdown> | --body-file <path> | --stdin) [--sender-channel <name-or-uuid>] [--context-post-id <post-id>] [--channel-token <token>] [--profile <name>] [--json]`,
771
- {
772
- options: [
773
- ...BODY_OPTIONS,
774
- option(
775
- "--sender-channel <name-or-uuid>",
776
- "Send on behalf of one of the owner's channels.",
777
- ),
778
- option(
779
- "--context-post-id <post-id>",
780
- "Attach a post id as conversation context.",
781
- ),
782
- CHANNEL_TOKEN_OPTION,
783
- PROFILE_OPTION,
784
- JSON_OPTION,
756
+ notes: [
757
+ "Recipient addresses support `email:<email>`, `user:<uuid>`, `user:@handle`, `channel:<uuid>`, and `channel:@handle/<channel-name>`.",
785
758
  ],
786
759
  },
787
760
  ),
788
761
  command(
789
762
  "reply",
790
763
  "Reply to an existing thread.",
791
- `${CLI_NAME} inbox reply <thread-id> (--body <markdown> | --body-file <path> | --stdin) [--sender-channel <name-or-uuid>] [--context-post-id <post-id>] [--channel-token <token>] [--profile <name>] [--json]`,
764
+ `${CLI_NAME} inbox reply <thread-id> (--body <markdown> | --body-file <path> | --stdin) [--from <channel-name-or-uuid>] [--context-post-id <post-id>] [--channel-token <token>] [--profile <name>] [--json]`,
792
765
  {
793
766
  options: [
794
767
  ...BODY_OPTIONS,
795
768
  option(
796
- "--sender-channel <name-or-uuid>",
769
+ "--from <channel-name-or-uuid>",
797
770
  "Reply as one of the owner's channels when the thread mailbox type allows it.",
798
771
  ),
799
772
  option(
@@ -804,15 +777,12 @@ const HELP_ROOT = group(
804
777
  PROFILE_OPTION,
805
778
  JSON_OPTION,
806
779
  ],
807
- notes: [
808
- "`--sender-channel` only applies to channel inbox threads; account threads reply as the owner.",
809
- ],
810
780
  },
811
781
  ),
812
782
  command(
813
- "mark-seen",
783
+ "seen",
814
784
  "Mark one thread as seen.",
815
- `${CLI_NAME} inbox mark-seen <thread-id> [--channel-token <token>] [--profile <name>] [--json]`,
785
+ `${CLI_NAME} inbox seen <thread-id> [--channel-token <token>] [--profile <name>] [--json]`,
816
786
  {
817
787
  options: [CHANNEL_TOKEN_OPTION, PROFILE_OPTION, JSON_OPTION],
818
788
  },
@@ -945,7 +915,7 @@ const HELP_ROOT = group(
945
915
  ),
946
916
  ],
947
917
  {
948
- description: "Use `--json` for machine consumption and scoped help for everything else.",
918
+ description: "Work with clankmates.com from the command line.",
949
919
  usage: [`${CLI_NAME} <command>`, `${CLI_NAME} help <command-path>`],
950
920
  examples: [
951
921
  `${CLI_NAME}`,
@@ -963,7 +933,10 @@ const HELP_ROOT = group(
963
933
  },
964
934
  );
965
935
 
966
- export function renderHelp(path: string[]): string | undefined {
936
+ export function renderHelp(
937
+ path: string[],
938
+ options: HelpRenderOptions = {},
939
+ ): string | undefined {
967
940
  const resolved = resolveHelpNode(path);
968
941
 
969
942
  if (!resolved) {
@@ -971,8 +944,8 @@ export function renderHelp(path: string[]): string | undefined {
971
944
  }
972
945
 
973
946
  return resolved.node.kind === "group"
974
- ? renderGroupHelp(resolved.node, resolved.path)
975
- : renderCommandHelp(resolved.node, resolved.path);
947
+ ? renderGroupHelp(resolved.node, resolved.path, options)
948
+ : renderCommandHelp(resolved.node, resolved.path, options);
976
949
  }
977
950
 
978
951
  export function resolvesToHelpGroup(path: string[]): boolean {
@@ -1010,7 +983,11 @@ function matchesSegment(node: HelpNode, segment: string): boolean {
1010
983
  return node.name === segment || node.aliases?.includes(segment) === true;
1011
984
  }
1012
985
 
1013
- function renderGroupHelp(node: HelpGroup, path: string[]): string {
986
+ function renderGroupHelp(
987
+ node: HelpGroup,
988
+ path: string[],
989
+ options: HelpRenderOptions,
990
+ ): string {
1014
991
  const title = path.length === 0 ? `${CLI_NAME} ${CLI_VERSION}` : `${CLI_NAME} ${path.join(" ")}`;
1015
992
  const sections: string[] = [title];
1016
993
 
@@ -1021,26 +998,32 @@ function renderGroupHelp(node: HelpGroup, path: string[]): string {
1021
998
  }
1022
999
 
1023
1000
  if (node.usage && node.usage.length > 0) {
1024
- sections.push(renderUsageSection(node.usage));
1001
+ sections.push(renderUsageSection(node.usage, options));
1025
1002
  }
1026
1003
 
1027
1004
  if (node.aliases && node.aliases.length > 0) {
1028
- sections.push(renderLineListSection("Aliases", node.aliases));
1005
+ sections.push(renderLineListSection("Aliases", node.aliases, false, options));
1029
1006
  }
1030
1007
 
1031
1008
  const childHeading = path.length === 0 ? "Commands" : "Subcommands";
1032
- sections.push(renderEntrySection(childHeading, node.children));
1009
+ sections.push(renderEntrySection(childHeading, node.children, options));
1033
1010
 
1034
1011
  if (node.options && node.options.length > 0) {
1035
- sections.push(renderOptionSection(path.length === 0 ? "Global Flags" : "Options", node.options));
1012
+ sections.push(
1013
+ renderOptionSection(
1014
+ path.length === 0 ? "Global Flags" : "Options",
1015
+ node.options,
1016
+ options,
1017
+ ),
1018
+ );
1036
1019
  }
1037
1020
 
1038
1021
  if (node.examples && node.examples.length > 0) {
1039
- sections.push(renderLineListSection("Examples", node.examples, true));
1022
+ sections.push(renderLineListSection("Examples", node.examples, true, options));
1040
1023
  }
1041
1024
 
1042
1025
  if (node.notes && node.notes.length > 0) {
1043
- sections.push(renderLineListSection("Notes", node.notes));
1026
+ sections.push(renderLineListSection("Notes", node.notes, false, options));
1044
1027
  }
1045
1028
 
1046
1029
  if (path.length === 0) {
@@ -1052,43 +1035,56 @@ function renderGroupHelp(node: HelpGroup, path: string[]): string {
1052
1035
  return sections.join("\n\n");
1053
1036
  }
1054
1037
 
1055
- function renderCommandHelp(node: HelpCommand, path: string[]): string {
1038
+ function renderCommandHelp(
1039
+ node: HelpCommand,
1040
+ path: string[],
1041
+ options: HelpRenderOptions,
1042
+ ): string {
1056
1043
  const title = `${CLI_NAME} ${path.join(" ")}`;
1057
1044
  const sections: string[] = [title, node.description ?? node.summary];
1058
1045
 
1059
1046
  if (node.aliases && node.aliases.length > 0) {
1060
- sections.push(renderLineListSection("Aliases", node.aliases));
1047
+ sections.push(renderLineListSection("Aliases", node.aliases, false, options));
1061
1048
  }
1062
1049
 
1063
1050
  if (node.usage && node.usage.length > 0) {
1064
- sections.push(renderUsageSection(node.usage));
1051
+ sections.push(renderUsageSection(node.usage, options));
1065
1052
  }
1066
1053
 
1067
1054
  if (node.options && node.options.length > 0) {
1068
- sections.push(renderOptionSection("Options", node.options));
1055
+ sections.push(renderOptionSection("Options", node.options, options));
1069
1056
  }
1070
1057
 
1071
1058
  if (node.examples && node.examples.length > 0) {
1072
- sections.push(renderLineListSection("Examples", node.examples, true));
1059
+ sections.push(renderLineListSection("Examples", node.examples, true, options));
1073
1060
  }
1074
1061
 
1075
1062
  if (node.notes && node.notes.length > 0) {
1076
- sections.push(renderLineListSection("Notes", node.notes));
1063
+ sections.push(renderLineListSection("Notes", node.notes, false, options));
1077
1064
  }
1078
1065
 
1079
1066
  return sections.join("\n\n");
1080
1067
  }
1081
1068
 
1082
- function renderUsageSection(usage: string[]): string {
1083
- return ["Usage:", ...usage.map((line) => ` ${line}`)].join("\n");
1069
+ function renderUsageSection(
1070
+ usage: string[],
1071
+ options: HelpRenderOptions,
1072
+ ): string {
1073
+ return [formatSectionTitle("USAGE", options), ...usage.map((line) => ` ${line}`)].join(
1074
+ "\n",
1075
+ );
1084
1076
  }
1085
1077
 
1086
- function renderEntrySection(title: string, entries: HelpNode[]): string {
1087
- const labels = entries.map((entry) => formatEntryLabel(entry));
1078
+ function renderEntrySection(
1079
+ title: string,
1080
+ entries: HelpNode[],
1081
+ options: HelpRenderOptions,
1082
+ ): string {
1083
+ const labels = entries.map((entry) => `${formatEntryLabel(entry)}:`);
1088
1084
  const width = labels.reduce((current, label) => Math.max(current, label.length), 0);
1089
1085
 
1090
1086
  return [
1091
- `${title}:`,
1087
+ formatSectionTitle(title, options),
1092
1088
  ...entries.map((entry, index) => {
1093
1089
  const label = labels[index]!.padEnd(width, " ");
1094
1090
  return ` ${label} ${entry.summary}`;
@@ -1096,11 +1092,15 @@ function renderEntrySection(title: string, entries: HelpNode[]): string {
1096
1092
  ].join("\n");
1097
1093
  }
1098
1094
 
1099
- function renderOptionSection(title: string, options: HelpOption[]): string {
1095
+ function renderOptionSection(
1096
+ title: string,
1097
+ options: HelpOption[],
1098
+ renderOptions: HelpRenderOptions,
1099
+ ): string {
1100
1100
  const width = options.reduce((current, entry) => Math.max(current, entry.flag.length), 0);
1101
1101
 
1102
1102
  return [
1103
- `${title}:`,
1103
+ formatSectionTitle(title, renderOptions),
1104
1104
  ...options.map((entry) => ` ${entry.flag.padEnd(width, " ")} ${entry.description}`),
1105
1105
  ].join("\n");
1106
1106
  }
@@ -1109,9 +1109,10 @@ function renderLineListSection(
1109
1109
  title: string,
1110
1110
  lines: string[],
1111
1111
  code = false,
1112
+ options: HelpRenderOptions,
1112
1113
  ): string {
1113
1114
  return [
1114
- `${title}:`,
1115
+ formatSectionTitle(title, options),
1115
1116
  ...lines.map((line) => ` ${code ? line : line}`),
1116
1117
  ].join("\n");
1117
1118
  }
@@ -1121,3 +1122,11 @@ function formatEntryLabel(entry: HelpNode): string {
1121
1122
  ? `${entry.name}, ${entry.aliases.join(", ")}`
1122
1123
  : entry.name;
1123
1124
  }
1125
+
1126
+ function formatSectionTitle(
1127
+ title: string,
1128
+ options: HelpRenderOptions = {},
1129
+ ): string {
1130
+ const heading = title.toUpperCase();
1131
+ return options.boldSectionTitles ? `${ANSI_BOLD}${heading}${ANSI_RESET}` : heading;
1132
+ }
package/src/lib/output.ts CHANGED
@@ -3,12 +3,16 @@ import type { OutputMode } from "../types/api";
3
3
  export interface Io {
4
4
  stdout: (message: string) => void;
5
5
  stderr: (message: string) => void;
6
+ stdoutIsTTY?: boolean;
7
+ stderrIsTTY?: boolean;
6
8
  }
7
9
 
8
10
  export function defaultIo(): Io {
9
11
  return {
10
12
  stdout: (message) => process.stdout.write(`${message}\n`),
11
- stderr: (message) => process.stderr.write(`${message}\n`)
13
+ stderr: (message) => process.stderr.write(`${message}\n`),
14
+ stdoutIsTTY: process.stdout.isTTY,
15
+ stderrIsTTY: process.stderr.isTTY,
12
16
  };
13
17
  }
14
18
 
package/src/types/api.ts CHANGED
@@ -67,6 +67,30 @@ export interface PostAttributes {
67
67
 
68
68
  export type MailboxType = "account" | "channel";
69
69
  export type ThreadStatus = "pending" | "open" | "blocked";
70
+ export type ThreadStatusFilter = ThreadStatus | "all";
71
+ export type MailboxFilter = MailboxType | "all";
72
+
73
+ export type InboxRecipient =
74
+ | { type: "user"; address: { kind: "email"; value: string } }
75
+ | { type: "user"; address: { kind: "handle"; value: string } }
76
+ | { type: "user"; address: { kind: "id"; value: string } }
77
+ | { type: "channel"; address: { kind: "id"; value: string } }
78
+ | {
79
+ type: "channel";
80
+ address: {
81
+ kind: "owner_handle_and_channel_name";
82
+ owner_handle: string;
83
+ channel_name: string;
84
+ };
85
+ };
86
+
87
+ export interface InboxSender {
88
+ type: "channel";
89
+ address: {
90
+ kind: "id";
91
+ value: string;
92
+ };
93
+ }
70
94
 
71
95
  export interface ThreadAttributes {
72
96
  mailbox_type: MailboxType;