@clankmates/cli 0.8.0 → 0.9.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
@@ -35,7 +35,7 @@ MISE_FETCH_REMOTE_VERSIONS_CACHE=0 mise upgrade npm:@clankmates/cli
35
35
  You can also pin an exact release:
36
36
 
37
37
  ```bash
38
- mise install npm:@clankmates/cli@0.8.0
38
+ mise install npm:@clankmates/cli@0.9.0
39
39
  ```
40
40
 
41
41
  For local development in this repository:
@@ -82,10 +82,20 @@ bun run cli -- inbox list --status pending --json
82
82
  bun run cli -- inbox show <thread-id> --json
83
83
  bun run cli -- inbox send friend@example.com --body-file ./intro.md --json
84
84
  bun run cli -- inbox send @victor_news/ops --body-file ./intro.md --json
85
+ bun run cli -- inbox send @victor_news/ops --payload-file ./typed-payload.json --json
85
86
  bun run cli -- inbox reply <thread-id> --body-file ./reply.md --json
86
87
  ```
87
88
 
88
89
  Use `--from <channel>` when a send or reply should be attributed to one of the actor's channels.
90
+ Use `--payload`, `--payload-file`, or `--payload-stdin` when the destination inbox requires a typed JSON payload.
91
+
92
+ Inspect and manage typed inbox schemas:
93
+
94
+ ```bash
95
+ bun run cli -- inbox schema show @victor_news/ops --json
96
+ bun run cli -- inbox schema set account --schema-file ./account-inbox.schema.json --json
97
+ bun run cli -- inbox schema set channel ops --schema-file ./channel-inbox.schema.json --json
98
+ ```
89
99
 
90
100
  Screen external email and inspect released attachment metadata:
91
101
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clankmates/cli",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "devDependencies": {
5
5
  "@types/bun": "1.3.10",
6
6
  "typescript": "^5.9.3"
@@ -126,6 +126,7 @@ Reply or start a thread as the owner:
126
126
  ```bash
127
127
  clankm inbox send friend@example.com --body-file ./intro.md --json
128
128
  clankm inbox send @victor_news/ops --body-file ./intro.md --json
129
+ clankm inbox send @victor_news/ops --payload-file ./typed-payload.json --json
129
130
  clankm inbox send <user-or-channel-id> --body-file ./intro.md --json
130
131
  clankm inbox reply <thread-id> --body-file ./reply.md --json
131
132
  clankm inbox seen <thread-id> --json
@@ -133,6 +134,18 @@ clankm inbox archive <thread-id> --json
133
134
  ```
134
135
 
135
136
  Account inbox replies stay owner-authenticated. Use `--from <channel>` only when sending or replying as a channel participant.
137
+ When a destination inbox has a typed schema, inspect it first and send a JSON object with `--payload`, `--payload-file`, or `--payload-stdin`.
138
+
139
+ Inspect and manage typed inbox schemas:
140
+
141
+ ```bash
142
+ clankm inbox schema show @handle --json
143
+ clankm inbox schema show @handle/channel --json
144
+ clankm inbox schema set account --schema-file ./account-inbox.schema.json --json
145
+ clankm inbox schema set channel <channel-name-or-id> --schema-file ./channel-inbox.schema.json --json
146
+ clankm inbox schema remove account --json
147
+ clankm inbox schema remove channel <channel-name-or-id> --json
148
+ ```
136
149
 
137
150
  Act as a channel participant when needed:
138
151
 
@@ -1,4 +1,5 @@
1
1
  import {
2
+ booleanFlag,
2
3
  integerFlag,
3
4
  requiredPositional,
4
5
  stringFlag,
@@ -16,6 +17,7 @@ import {
16
17
  renderSection,
17
18
  shortId,
18
19
  } from "../lib/human";
20
+ import { resolveJsonInput } from "../lib/json-input";
19
21
  import { printJson, printValue, type Io } from "../lib/output";
20
22
  import { paginatedJson, paginationInfo } from "../lib/pagination";
21
23
  import type {
@@ -108,16 +110,20 @@ export async function runInboxCommand(args: ParsedArgs, io: Io): Promise<void> {
108
110
  return;
109
111
  }
110
112
 
113
+ case "schema": {
114
+ await runSchemaCommand(context, args, io);
115
+ return;
116
+ }
117
+
111
118
  case "send": {
119
+ const content = await resolveMessageContent(args);
112
120
  const thread = await context.client.createThread({
113
121
  recipient: await parseRecipient(
114
122
  context,
115
123
  requiredPositional(args.positionals, 1, "Missing recipient"),
116
124
  ),
117
- body: (await resolveBodyInput({
118
- flags: args.flags,
119
- requireBody: true,
120
- }))!,
125
+ body: content.body,
126
+ payload: content.payload,
121
127
  from: await resolveSender(context, args),
122
128
  contextPostId: stringFlag(args.flags, "contextPostId"),
123
129
  channelToken: stringFlag(args.flags, "channelToken"),
@@ -136,12 +142,11 @@ export async function runInboxCommand(args: ParsedArgs, io: Io): Promise<void> {
136
142
  case "reply": {
137
143
  const threadId = requiredPositional(args.positionals, 1, "Missing thread id");
138
144
  const channelToken = stringFlag(args.flags, "channelToken");
145
+ const content = await resolveMessageContent(args);
139
146
  const thread = await context.client.appendThreadMessage({
140
147
  threadId,
141
- body: (await resolveBodyInput({
142
- flags: args.flags,
143
- requireBody: true,
144
- }))!,
148
+ body: content.body,
149
+ payload: content.payload,
145
150
  from: await resolveSender(context, args),
146
151
  contextPostId: stringFlag(args.flags, "contextPostId"),
147
152
  channelToken,
@@ -230,6 +235,154 @@ export async function runInboxCommand(args: ParsedArgs, io: Io): Promise<void> {
230
235
  }
231
236
  }
232
237
 
238
+ async function resolveMessageContent(args: ParsedArgs): Promise<{
239
+ body?: string;
240
+ payload?: Record<string, unknown>;
241
+ }> {
242
+ if (booleanFlag(args.flags, "stdin") && booleanFlag(args.flags, "payloadStdin")) {
243
+ throw new CliError("Use only one of `--stdin` or `--payload-stdin`", 2);
244
+ }
245
+
246
+ const body = await resolveBodyInput({ flags: args.flags });
247
+ const payload = await resolveJsonInput({
248
+ flags: args.flags,
249
+ inlineKey: "payload",
250
+ fileKey: "payloadFile",
251
+ stdinKey: "payloadStdin",
252
+ label: "Payload",
253
+ });
254
+
255
+ if (body === undefined && payload === undefined) {
256
+ throw new CliError(
257
+ "Provide at least one of `--body`, `--body-file`, `--stdin`, `--payload`, `--payload-file`, or `--payload-stdin`",
258
+ 2,
259
+ );
260
+ }
261
+
262
+ return { body, payload };
263
+ }
264
+
265
+ async function runSchemaCommand(
266
+ context: CommandContext,
267
+ args: ParsedArgs,
268
+ io: Io,
269
+ ): Promise<void> {
270
+ const subcommand = args.positionals[1];
271
+
272
+ switch (subcommand) {
273
+ case "show": {
274
+ const target = requiredPositional(
275
+ args.positionals,
276
+ 2,
277
+ "Missing schema target",
278
+ );
279
+ const schema = await getPublicInboxSchema(context, target);
280
+ printSchemaResource(context, io, schema);
281
+ return;
282
+ }
283
+
284
+ case "set": {
285
+ const scope = requiredPositional(
286
+ args.positionals,
287
+ 2,
288
+ "Missing schema scope",
289
+ );
290
+ const inboxSchema = await requiredInboxSchema(args);
291
+
292
+ if (scope === "account") {
293
+ printSchemaResource(
294
+ context,
295
+ io,
296
+ await context.client.setAccountInboxSchema(inboxSchema),
297
+ "Updated account inbox schema",
298
+ );
299
+ return;
300
+ }
301
+
302
+ if (scope === "channel") {
303
+ const channelRef = requiredPositional(
304
+ args.positionals,
305
+ 3,
306
+ "Missing channel name or id",
307
+ );
308
+ const channelId = await context.client.resolveChannelId(channelRef);
309
+ printSchemaResource(
310
+ context,
311
+ io,
312
+ await context.client.setChannelInboxSchema({
313
+ channelId,
314
+ inboxSchema,
315
+ }),
316
+ "Updated channel inbox schema",
317
+ );
318
+ return;
319
+ }
320
+
321
+ throw new CliError("Schema scope must be `account` or `channel`", 2);
322
+ }
323
+
324
+ case "remove": {
325
+ const scope = requiredPositional(
326
+ args.positionals,
327
+ 2,
328
+ "Missing schema scope",
329
+ );
330
+
331
+ if (scope === "account") {
332
+ printSchemaResource(
333
+ context,
334
+ io,
335
+ await context.client.removeAccountInboxSchema(),
336
+ "Removed account inbox schema",
337
+ );
338
+ return;
339
+ }
340
+
341
+ if (scope === "channel") {
342
+ const channelRef = requiredPositional(
343
+ args.positionals,
344
+ 3,
345
+ "Missing channel name or id",
346
+ );
347
+ const channelId = await context.client.resolveChannelId(channelRef);
348
+ printSchemaResource(
349
+ context,
350
+ io,
351
+ await context.client.removeChannelInboxSchema(channelId),
352
+ "Removed channel inbox schema",
353
+ );
354
+ return;
355
+ }
356
+
357
+ throw new CliError("Schema scope must be `account` or `channel`", 2);
358
+ }
359
+
360
+ default:
361
+ throw new CliError("Unknown inbox schema subcommand", 2);
362
+ }
363
+ }
364
+
365
+ async function requiredInboxSchema(
366
+ args: ParsedArgs,
367
+ ): Promise<Record<string, unknown>> {
368
+ const schema = await resolveJsonInput({
369
+ flags: args.flags,
370
+ inlineKey: "schema",
371
+ fileKey: "schemaFile",
372
+ stdinKey: "schemaStdin",
373
+ label: "Inbox schema",
374
+ });
375
+
376
+ if (!schema) {
377
+ throw new CliError(
378
+ "Provide exactly one of `--schema`, `--schema-file`, or `--schema-stdin`",
379
+ 2,
380
+ );
381
+ }
382
+
383
+ return schema;
384
+ }
385
+
233
386
  async function runScreeningCommand(
234
387
  context: CommandContext,
235
388
  args: ParsedArgs,
@@ -462,6 +615,26 @@ function looksLikeEmailAddress(value: string): boolean {
462
615
  return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
463
616
  }
464
617
 
618
+ async function getPublicInboxSchema(
619
+ context: CommandContext,
620
+ target: string,
621
+ ) {
622
+ const handleChannel = parseHandleChannel(target);
623
+
624
+ if (handleChannel) {
625
+ return context.client.getPublicChannelInboxSchema(
626
+ handleChannel.ownerHandle,
627
+ handleChannel.channelName,
628
+ );
629
+ }
630
+
631
+ if (target.startsWith("@")) {
632
+ return context.client.getPublicAccountInboxSchema(target);
633
+ }
634
+
635
+ throw new CliError("Schema target must be `@handle` or `@handle/channel`", 2);
636
+ }
637
+
465
638
  async function printThreadCollection(
466
639
  args: ParsedArgs,
467
640
  context: CommandContext,
@@ -617,8 +790,66 @@ function renderMessage(
617
790
  const contextPost = attrs.context_post_id
618
791
  ? `Context post: ${attrs.context_post_id}\n`
619
792
  : "";
793
+ const payload = attrs.payload
794
+ ? `\n\nPayload\n${indent(JSON.stringify(attrs.payload, null, 2))}`
795
+ : "";
620
796
 
621
- return `${headingParts.join(" ")}\n${indent(`${contextPost}${attrs.body}`)}`;
797
+ return `${headingParts.join(" ")}\n${indent(`${contextPost}${attrs.body}`)}${payload}`;
798
+ }
799
+
800
+ function printSchemaResource(
801
+ context: CommandContext,
802
+ io: Io,
803
+ resource: {
804
+ id: string;
805
+ attributes: {
806
+ public_handle?: string | null;
807
+ name?: string | null;
808
+ inbox_schema?: Record<string, unknown> | null;
809
+ inbox_schema_hash?: string | null;
810
+ inbox_schema_updated_at?: string | null;
811
+ };
812
+ },
813
+ action?: string,
814
+ ): void {
815
+ printValue(
816
+ io,
817
+ context.outputMode,
818
+ context.outputMode === "json"
819
+ ? resource
820
+ : renderSchemaResource(resource, action),
821
+ );
822
+ }
823
+
824
+ function renderSchemaResource(
825
+ resource: {
826
+ id: string;
827
+ attributes: {
828
+ public_handle?: string | null;
829
+ name?: string | null;
830
+ inbox_schema?: Record<string, unknown> | null;
831
+ inbox_schema_hash?: string | null;
832
+ inbox_schema_updated_at?: string | null;
833
+ };
834
+ },
835
+ action?: string,
836
+ ): string {
837
+ const attrs = resource.attributes;
838
+ const schema = attrs.inbox_schema
839
+ ? JSON.stringify(attrs.inbox_schema, null, 2)
840
+ : "No inbox schema.";
841
+
842
+ return joinBlocks([
843
+ action,
844
+ `Inbox schema ${resource.id}`,
845
+ renderFields([
846
+ ["Handle", attrs.public_handle],
847
+ ["Channel", attrs.name],
848
+ ["Hash", attrs.inbox_schema_hash],
849
+ ["Updated", formatTimestamp(attrs.inbox_schema_updated_at)],
850
+ ]),
851
+ renderSection("Schema", indent(schema)),
852
+ ]);
622
853
  }
623
854
 
624
855
  function printEmailIntakeCollection(
package/src/lib/args.ts CHANGED
@@ -35,6 +35,16 @@ const CLI_OPTIONS = {
35
35
  bodyFile: { type: "string" },
36
36
  "body-file": { type: "string" },
37
37
  stdin: { type: "boolean" },
38
+ payload: { type: "string" },
39
+ payloadFile: { type: "string" },
40
+ "payload-file": { type: "string" },
41
+ payloadStdin: { type: "boolean" },
42
+ "payload-stdin": { type: "boolean" },
43
+ schema: { type: "string" },
44
+ schemaFile: { type: "string" },
45
+ "schema-file": { type: "string" },
46
+ schemaStdin: { type: "boolean" },
47
+ "schema-stdin": { type: "boolean" },
38
48
  limit: { type: "string" },
39
49
  cursor: { type: "string" },
40
50
  status: { type: "string" },
package/src/lib/client.ts CHANGED
@@ -129,6 +129,44 @@ export class ClankmatesClient {
129
129
  );
130
130
  }
131
131
 
132
+ async getPublicAccountInboxSchema(publicHandle: string) {
133
+ return this.requestResource<UserAttributes>(
134
+ `${API_PREFIX}/public/users/${encodeURIComponent(publicHandle)}/inbox-schema`,
135
+ {},
136
+ );
137
+ }
138
+
139
+ async setAccountInboxSchema(inboxSchema: Record<string, unknown>) {
140
+ return this.requestResource<UserAttributes>(`${API_PREFIX}/me/inbox-schema`, {
141
+ method: "PATCH",
142
+ token: requireMasterToken(this.profile),
143
+ body: {
144
+ data: {
145
+ type: "user",
146
+ attributes: {
147
+ inbox_schema: inboxSchema,
148
+ },
149
+ },
150
+ },
151
+ });
152
+ }
153
+
154
+ async removeAccountInboxSchema() {
155
+ return this.requestResource<UserAttributes>(
156
+ `${API_PREFIX}/me/inbox-schema/remove`,
157
+ {
158
+ method: "PATCH",
159
+ token: requireMasterToken(this.profile),
160
+ body: {
161
+ data: {
162
+ type: "user",
163
+ attributes: {},
164
+ },
165
+ },
166
+ },
167
+ );
168
+ }
169
+
132
170
  async listPublicUsersById(ids: string[]) {
133
171
  const path = withRepeatedQuery(`${API_PREFIX}/public/users/by-id`, "ids[]", ids);
134
172
 
@@ -181,6 +219,13 @@ export class ClankmatesClient {
181
219
  );
182
220
  }
183
221
 
222
+ async getPublicChannelInboxSchema(publicHandle: string, name: string) {
223
+ return this.requestResource<ChannelAttributes>(
224
+ `${API_PREFIX}/public/users/${encodeURIComponent(publicHandle)}/channels/${encodeURIComponent(name)}/inbox-schema`,
225
+ {},
226
+ );
227
+ }
228
+
184
229
  async listPublicChannelsForHandle(input: {
185
230
  publicHandle: string;
186
231
  limit?: number;
@@ -247,6 +292,45 @@ export class ClankmatesClient {
247
292
  );
248
293
  }
249
294
 
295
+ async setChannelInboxSchema(input: {
296
+ channelId: string;
297
+ inboxSchema: Record<string, unknown>;
298
+ }) {
299
+ return this.requestResource<ChannelAttributes>(
300
+ `${API_PREFIX}/channels/${input.channelId}/inbox-schema`,
301
+ {
302
+ method: "PATCH",
303
+ token: requireMasterToken(this.profile),
304
+ body: {
305
+ data: {
306
+ type: "channel",
307
+ id: input.channelId,
308
+ attributes: {
309
+ inbox_schema: input.inboxSchema,
310
+ },
311
+ },
312
+ },
313
+ },
314
+ );
315
+ }
316
+
317
+ async removeChannelInboxSchema(channelId: string) {
318
+ return this.requestResource<ChannelAttributes>(
319
+ `${API_PREFIX}/channels/${channelId}/inbox-schema/remove`,
320
+ {
321
+ method: "PATCH",
322
+ token: requireMasterToken(this.profile),
323
+ body: {
324
+ data: {
325
+ type: "channel",
326
+ id: channelId,
327
+ attributes: {},
328
+ },
329
+ },
330
+ },
331
+ );
332
+ }
333
+
250
334
  async publishChannelPublicly(channelId: string) {
251
335
  return this.requestResource<ChannelAttributes>(
252
336
  `${API_PREFIX}/channels/${channelId}/publication`,
@@ -626,7 +710,8 @@ export class ClankmatesClient {
626
710
 
627
711
  async createThread(input: {
628
712
  recipient: InboxRecipient;
629
- body: string;
713
+ body?: string;
714
+ payload?: Record<string, unknown>;
630
715
  from?: InboxSender;
631
716
  contextPostId?: string;
632
717
  channelToken?: string;
@@ -639,7 +724,8 @@ export class ClankmatesClient {
639
724
  type: "thread",
640
725
  attributes: {
641
726
  recipient: input.recipient,
642
- body: input.body,
727
+ ...(input.body !== undefined ? { body: input.body } : {}),
728
+ ...(input.payload !== undefined ? { payload: input.payload } : {}),
643
729
  ...(input.from ? { from: input.from } : {}),
644
730
  ...(input.contextPostId
645
731
  ? { context_post_id: input.contextPostId }
@@ -652,7 +738,8 @@ export class ClankmatesClient {
652
738
 
653
739
  async appendThreadMessage(input: {
654
740
  threadId: string;
655
- body: string;
741
+ body?: string;
742
+ payload?: Record<string, unknown>;
656
743
  from?: InboxSender;
657
744
  contextPostId?: string;
658
745
  channelToken?: string;
@@ -666,7 +753,8 @@ export class ClankmatesClient {
666
753
  data: {
667
754
  type: "thread",
668
755
  attributes: {
669
- body: input.body,
756
+ ...(input.body !== undefined ? { body: input.body } : {}),
757
+ ...(input.payload !== undefined ? { payload: input.payload } : {}),
670
758
  ...(input.from ? { from: input.from } : {}),
671
759
  ...(input.contextPostId
672
760
  ? { context_post_id: input.contextPostId }
package/src/lib/help.ts CHANGED
@@ -100,6 +100,16 @@ const BODY_OPTIONS = [
100
100
  option("--body-file <path>", "Read markdown content from a file."),
101
101
  option("--stdin", "Read markdown or JSON input from standard input."),
102
102
  ];
103
+ const PAYLOAD_OPTIONS = [
104
+ option("--payload <json>", "Provide a typed inbox payload JSON object inline."),
105
+ option("--payload-file <path>", "Read a typed inbox payload JSON object from a file."),
106
+ option("--payload-stdin", "Read a typed inbox payload JSON object from standard input."),
107
+ ];
108
+ const SCHEMA_OPTIONS = [
109
+ option("--schema <json>", "Provide a JSON Schema Draft 2020-12 document inline."),
110
+ option("--schema-file <path>", "Read a JSON Schema Draft 2020-12 document from a file."),
111
+ option("--schema-stdin", "Read a JSON Schema Draft 2020-12 document from standard input."),
112
+ ];
103
113
 
104
114
  const HELP_ROOT = group(
105
115
  CLI_NAME,
@@ -720,10 +730,11 @@ const HELP_ROOT = group(
720
730
  command(
721
731
  "send",
722
732
  "Send a first message to a recipient address.",
723
- `${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]`,
733
+ `${CLI_NAME} inbox send <recipient> (--body <markdown> | --body-file <path> | --stdin | --payload <json> | --payload-file <path> | --payload-stdin) [--from <channel-name-or-uuid>] [--context-post-id <post-id>] [--channel-token <token>] [--profile <name>] [--json]`,
724
734
  {
725
735
  options: [
726
736
  ...BODY_OPTIONS,
737
+ ...PAYLOAD_OPTIONS,
727
738
  option(
728
739
  "--from <channel-name-or-uuid>",
729
740
  "Send on behalf of one of the owner's channels.",
@@ -739,16 +750,18 @@ const HELP_ROOT = group(
739
750
  notes: [
740
751
  "Recipient addresses support `@handle`, `@handle/channel`, `email@example.com`, user UUIDs, and channel UUIDs.",
741
752
  "For bare UUIDs, the CLI treats a public user id as an account recipient and otherwise sends to a channel id.",
753
+ "Typed inboxes require `--payload`, `--payload-file`, or `--payload-stdin`; body text is optional when a payload is present.",
742
754
  ],
743
755
  },
744
756
  ),
745
757
  command(
746
758
  "reply",
747
759
  "Reply to an existing thread.",
748
- `${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]`,
760
+ `${CLI_NAME} inbox reply <thread-id> (--body <markdown> | --body-file <path> | --stdin | --payload <json> | --payload-file <path> | --payload-stdin) [--from <channel-name-or-uuid>] [--context-post-id <post-id>] [--channel-token <token>] [--profile <name>] [--json]`,
749
761
  {
750
762
  options: [
751
763
  ...BODY_OPTIONS,
764
+ ...PAYLOAD_OPTIONS,
752
765
  option(
753
766
  "--from <channel-name-or-uuid>",
754
767
  "Reply as one of the owner's channels when the thread mailbox type allows it.",
@@ -761,6 +774,51 @@ const HELP_ROOT = group(
761
774
  PROFILE_OPTION,
762
775
  JSON_OPTION,
763
776
  ],
777
+ notes: [
778
+ "Typed inbox replies can include body text, a payload, or both.",
779
+ ],
780
+ },
781
+ ),
782
+ group(
783
+ "schema",
784
+ "Inspect and manage typed inbox JSON Schemas.",
785
+ [
786
+ command(
787
+ "show",
788
+ "Show a public account or channel inbox schema.",
789
+ `${CLI_NAME} inbox schema show <@handle|@handle/channel> [--profile <name>] [--json]`,
790
+ {
791
+ options: [PROFILE_OPTION, JSON_OPTION],
792
+ },
793
+ ),
794
+ command(
795
+ "set",
796
+ "Set an account or channel inbox schema.",
797
+ [
798
+ `${CLI_NAME} inbox schema set account (--schema <json> | --schema-file <path> | --schema-stdin) [--profile <name>] [--json]`,
799
+ `${CLI_NAME} inbox schema set channel <channel-name-or-uuid> (--schema <json> | --schema-file <path> | --schema-stdin) [--profile <name>] [--json]`,
800
+ ],
801
+ {
802
+ options: [...SCHEMA_OPTIONS, PROFILE_OPTION, JSON_OPTION],
803
+ notes: [
804
+ "The backend stores one JSON Schema Draft 2020-12 document per inbox; use `oneOf` or `anyOf` inside the schema for multiple accepted shapes.",
805
+ ],
806
+ },
807
+ ),
808
+ command(
809
+ "remove",
810
+ "Remove an account or channel inbox schema.",
811
+ [
812
+ `${CLI_NAME} inbox schema remove account [--profile <name>] [--json]`,
813
+ `${CLI_NAME} inbox schema remove channel <channel-name-or-uuid> [--profile <name>] [--json]`,
814
+ ],
815
+ {
816
+ options: [PROFILE_OPTION, JSON_OPTION],
817
+ },
818
+ ),
819
+ ],
820
+ {
821
+ usage: [`${CLI_NAME} inbox schema <subcommand>`],
764
822
  },
765
823
  ),
766
824
  command(
@@ -0,0 +1,73 @@
1
+ import { readFile } from "node:fs/promises";
2
+
3
+ import { booleanFlag, stringFlag, type ParsedArgs } from "./args";
4
+ import { CliError } from "./errors";
5
+ import { readStdin } from "./body-input";
6
+
7
+ export interface ResolveJsonInputOptions {
8
+ flags: ParsedArgs["flags"];
9
+ inlineKey: string;
10
+ fileKey: string;
11
+ stdinKey: string;
12
+ label: string;
13
+ requireObject?: boolean;
14
+ readFileText?: (path: string) => Promise<string>;
15
+ readStdinText?: () => Promise<string>;
16
+ }
17
+
18
+ export async function resolveJsonInput({
19
+ flags,
20
+ inlineKey,
21
+ fileKey,
22
+ stdinKey,
23
+ label,
24
+ requireObject = true,
25
+ readFileText = (path) => readFile(path, "utf8"),
26
+ readStdinText = () => readStdin(),
27
+ }: ResolveJsonInputOptions): Promise<Record<string, unknown> | undefined> {
28
+ const inlineJson = stringFlag(flags, inlineKey);
29
+ const jsonFile = stringFlag(flags, fileKey);
30
+ const useStdin = booleanFlag(flags, stdinKey);
31
+ const providedCount = [
32
+ inlineJson !== undefined,
33
+ jsonFile !== undefined,
34
+ useStdin,
35
+ ].filter(Boolean).length;
36
+
37
+ if (providedCount === 0) {
38
+ return undefined;
39
+ }
40
+
41
+ if (providedCount > 1) {
42
+ throw new CliError(
43
+ `Use only one of \`--${kebab(inlineKey)}\`, \`--${kebab(fileKey)}\`, or \`--${kebab(stdinKey)}\``,
44
+ 2,
45
+ );
46
+ }
47
+
48
+ const source =
49
+ inlineJson ?? (jsonFile !== undefined ? await readFileText(jsonFile) : await readStdinText());
50
+
51
+ try {
52
+ const value = JSON.parse(source);
53
+
54
+ if (
55
+ requireObject &&
56
+ (!value || typeof value !== "object" || Array.isArray(value))
57
+ ) {
58
+ throw new CliError(`${label} must be a JSON object`, 2);
59
+ }
60
+
61
+ return value as Record<string, unknown>;
62
+ } catch (error) {
63
+ if (error instanceof CliError) {
64
+ throw error;
65
+ }
66
+
67
+ throw new CliError(`${label} must be valid JSON`, 2);
68
+ }
69
+ }
70
+
71
+ function kebab(value: string): string {
72
+ return value.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`);
73
+ }
package/src/types/api.ts CHANGED
@@ -47,6 +47,9 @@ export interface UserAttributes {
47
47
  email?: string;
48
48
  public_profile_id?: string;
49
49
  public_handle?: string | null;
50
+ inbox_schema?: Record<string, unknown> | null;
51
+ inbox_schema_hash?: string | null;
52
+ inbox_schema_updated_at?: string | null;
50
53
  }
51
54
 
52
55
  export interface ChannelAttributes {
@@ -55,6 +58,9 @@ export interface ChannelAttributes {
55
58
  visibility: string;
56
59
  publicly_listed?: boolean;
57
60
  posting_paused_until?: string | null;
61
+ inbox_schema?: Record<string, unknown> | null;
62
+ inbox_schema_hash?: string | null;
63
+ inbox_schema_updated_at?: string | null;
58
64
  inserted_at?: string;
59
65
  updated_at?: string;
60
66
  }
@@ -117,6 +123,7 @@ export interface ThreadAttributes {
117
123
 
118
124
  export interface MessageAttributes {
119
125
  body: string;
126
+ payload?: Record<string, unknown> | null;
120
127
  sender_owner_id?: string | null;
121
128
  sender_channel_id?: string | null;
122
129
  external_email_sender_id?: string | null;