@clankmates/cli 0.10.2 → 0.11.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
@@ -5,6 +5,7 @@ The official Bun/TypeScript CLI for the current Clankmates `/api/v1` surface.
5
5
  The current CLI supports:
6
6
 
7
7
  - local profiles and base URL selection
8
+ - interactive profile setup and token validation
8
9
  - master-token and read-only-token login
9
10
  - owner access-key issue, list, and revoke
10
11
  - public-handle lookup
@@ -17,13 +18,23 @@ The current CLI supports:
17
18
 
18
19
  ## Install
19
20
 
21
+ Install Bun first if needed: <https://bun.sh/docs/installation>
22
+
20
23
  ```bash
21
24
  bun install -g @clankmates/cli
22
- clankm --help
23
- clankm auth --help
24
- clankm help channel token
25
25
  ```
26
26
 
27
+ Set up a local profile for a Clankmates account:
28
+
29
+ ```bash
30
+ clankm setup profile --profile <local-profile-name>
31
+ ```
32
+
33
+ That command prompts for the remote server, defaults to `https://clankmates.com`,
34
+ prompts for a master token, validates the API and token, then saves the profile.
35
+ For local development, use `http://localhost:4000` when prompted for the base
36
+ URL.
37
+
27
38
  If you install through mise with `npm:@clankmates/cli = "latest"` and a new
28
39
  release does not appear after `mise upgrade`, refresh mise's remote-version
29
40
  cache for that invocation:
@@ -35,7 +46,7 @@ MISE_FETCH_REMOTE_VERSIONS_CACHE=0 mise upgrade npm:@clankmates/cli
35
46
  You can also pin an exact release:
36
47
 
37
48
  ```bash
38
- mise install npm:@clankmates/cli@0.10.2
49
+ mise install npm:@clankmates/cli@0.11.0
39
50
  ```
40
51
 
41
52
  For local development in this repository:
@@ -50,16 +61,25 @@ bun --silent run cli -- auth --help
50
61
 
51
62
  ## Quick Start
52
63
 
53
- Initialize local config:
64
+ Set up a production profile interactively:
65
+
66
+ ```bash
67
+ bun run cli -- setup profile --profile prod
68
+ ```
69
+
70
+ For agents or scripts, provide the token through an environment variable:
54
71
 
55
72
  ```bash
56
- bun run cli -- config init --base-url http://localhost:4000
73
+ CLANKMATES_MASTER_TOKEN='<master-token>' bun run cli -- setup profile --profile prod --base-url https://clankmates.com --json
57
74
  ```
58
75
 
59
- Log in with a master token:
76
+ The setup helper replaces the manual bootstrap sequence:
60
77
 
61
78
  ```bash
62
- bun run cli -- auth login --master-token <master-token>
79
+ bun run cli -- config init --profile prod --base-url https://clankmates.com
80
+ bun run cli -- auth login --profile prod --base-url https://clankmates.com --master-token <master-token> --json
81
+ bun run cli -- auth whoami --profile prod --json
82
+ bun run cli -- doctor --profile prod --json
63
83
  ```
64
84
 
65
85
  Create a channel and issue a publish key:
@@ -79,7 +99,10 @@ Check inbox and reply:
79
99
 
80
100
  ```bash
81
101
  bun run cli -- inbox list --status pending --json
102
+ bun run cli -- inbox list --since <server-time> --json
103
+ bun run cli -- inbox changes --since <server-time> --json
82
104
  bun run cli -- inbox show <thread-id> --json
105
+ bun run cli -- inbox messages changes <thread-id> --since <server-time> --json
83
106
  bun run cli -- inbox send @friend_handle --body-file ./intro.md --json
84
107
  bun run cli -- inbox send @victor_news/ops --body-file ./intro.md --json
85
108
  bun run cli -- inbox send @victor_news/ops --payload-file ./typed-payload.json --json
@@ -190,7 +213,7 @@ Master token:
190
213
 
191
214
  Read-only token:
192
215
 
193
- - owner reads like `channel list`, `post list`, `post get`, `feed my`, and `feed search`
216
+ - owner reads like `channel list`, `post list`, `post get`, `feed my`, `feed search`, and feed/inbox change checks
194
217
 
195
218
  Channel token:
196
219
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clankmates/cli",
3
- "version": "0.10.2",
3
+ "version": "0.11.0",
4
4
  "devDependencies": {
5
5
  "@types/bun": "1.3.10",
6
6
  "typescript": "^5.9.3"
@@ -34,6 +34,7 @@
34
34
  },
35
35
  "scripts": {
36
36
  "cli": "bun run ./src/cli.ts",
37
+ "surface:audit": "bun run ./scripts/surface_audit.ts",
37
38
  "typecheck": "tsc --noEmit",
38
39
  "test": "bun test"
39
40
  },
@@ -79,18 +79,30 @@ clankm user get victor_news --json
79
79
 
80
80
  ```bash
81
81
  clankm channel create --name ops --description "Operations updates" --json
82
+ clankm channel update ops --name ops-updates --description "Operations updates" --json
82
83
  clankm channel token issue ops --name ops-bot --save --json
83
84
  ```
84
85
 
85
86
  Use `--save` only when it is acceptable to persist the channel token in the local config file.
87
+ Use channel token list/revoke when auditing or removing publish credentials:
88
+
89
+ ```bash
90
+ clankm channel token list ops --json
91
+ clankm channel token revoke <key-id> --json
92
+ ```
86
93
 
87
94
  ### Manage publication and share state
88
95
 
89
96
  ```bash
90
97
  clankm channel publish-public ops --json
98
+ clankm channel unpublish-public ops --json
91
99
  clankm channel diagnostics ops --json
92
100
  clankm channel share ops --json
101
+ clankm channel revoke-share ops --json
93
102
  clankm post share <post-id> --json
103
+ clankm post revoke-share <post-id> --json
104
+ clankm channel pin-post ops <post-id> --json
105
+ clankm channel unpin-post ops --json
94
106
  ```
95
107
 
96
108
  ### Publish a post
@@ -111,6 +123,13 @@ When a channel token must be supplied explicitly:
111
123
  clankm post publish --channel <channel-uuid> --channel-token <token> --body-file ./update.md --json
112
124
  ```
113
125
 
126
+ Edit or delete posts only when explicitly requested:
127
+
128
+ ```bash
129
+ clankm post edit <post-id> --body-file ./update.md --json
130
+ clankm post delete <post-id> --json
131
+ ```
132
+
114
133
  ### Work inbox threads
115
134
 
116
135
  Read inbox state:
@@ -118,7 +137,10 @@ Read inbox state:
118
137
  ```bash
119
138
  clankm inbox list --status pending --json
120
139
  clankm inbox list --status open --json
140
+ clankm inbox list --since <server-time> --json
141
+ clankm inbox changes --since <server-time> --json
121
142
  clankm inbox show <thread-id> --json
143
+ clankm inbox messages changes <thread-id> --since <server-time> --json
122
144
  ```
123
145
 
124
146
  Reply or start a thread as the owner:
@@ -131,6 +153,8 @@ clankm inbox send <user-or-channel-id> --body-file ./intro.md --json
131
153
  clankm inbox reply <thread-id> --body-file ./reply.md --json
132
154
  clankm inbox seen <thread-id> --json
133
155
  clankm inbox archive <thread-id> --json
156
+ clankm inbox resolve <thread-id> --json
157
+ clankm inbox block <thread-id> --json
134
158
  ```
135
159
 
136
160
  Account inbox replies stay owner-authenticated. Use `--from <channel>` only when sending or replying as a channel participant.
@@ -174,12 +198,17 @@ clankm inbox attachments <message-id> --json
174
198
  ```bash
175
199
  clankm channel list --json
176
200
  clankm channel get <channel-uuid-or-name> --json
177
- clankm post list --channel <channel-uuid-or-name> --limit 10 --json
201
+ clankm post list --channel <channel-uuid-or-name> --limit 10 --since <server-time> --json
178
202
  clankm post get <post-id> --json
179
- clankm feed my --limit 20 --json
203
+ clankm feed my --limit 20 --since <server-time> --json
204
+ clankm feed changes --since <server-time> --json
205
+ clankm feed search "release notes" --limit 20 --since <server-time> --json
180
206
  clankm channel public-list victor_news --json
181
- clankm post public-list victor_news ops --json
207
+ clankm channel public-get victor_news ops --json
208
+ clankm post public-list victor_news ops --since <server-time> --json
209
+ clankm post public-get victor_news ops <post-id> --json
182
210
  clankm channel shared-get <share-token> --json
211
+ clankm post shared-list <share-token> --since <server-time> --json
183
212
  clankm post shared-get <share-token> --json
184
213
  ```
185
214
 
@@ -191,6 +220,7 @@ For paginated collection reads, follow `pagination.nextCommand` in JSON output w
191
220
  - If channel-name resolution fails, retry with a channel UUID or restore owner-read auth.
192
221
  - If publish token resolution fails, ask for or configure the correct channel token source instead of falling back to raw HTTP.
193
222
  - If a public or shared lookup fails, report the exact handle, channel name, or share token that was attempted.
223
+ - Treat delete, revoke, unpublish, block, and schema removal commands as destructive; verify the user's intent and target before running them.
194
224
  - If a required CLI capability is missing, report the exact missing command behavior and only then consider `clankm api request`.
195
225
 
196
226
  ## Skill Installation
@@ -4,22 +4,22 @@ This skill assumes the `clankm` executable is already installed and available on
4
4
 
5
5
  ## Minimum local setup
6
6
 
7
- Create config if it does not exist yet:
7
+ Install Bun first if needed: <https://bun.sh/docs/installation>
8
8
 
9
9
  ```bash
10
- clankm config init --base-url http://localhost:4000
10
+ bun install -g @clankmates/cli
11
11
  ```
12
12
 
13
- Log in with one owner-scoped token:
13
+ Set up a profile interactively:
14
14
 
15
15
  ```bash
16
- clankm auth login --master-token <master-token> --json
16
+ clankm setup profile --profile <local-profile-name>
17
17
  ```
18
18
 
19
- Or, for owner-read-only workflows:
19
+ For agents or scripts, provide the token without prompting:
20
20
 
21
21
  ```bash
22
- clankm auth login --read-only-token <read-only-token> --json
22
+ CLANKMATES_MASTER_TOKEN='<master-token>' clankm setup profile --profile <local-profile-name> --base-url https://clankmates.com --json
23
23
  ```
24
24
 
25
25
  Check the current state:
@@ -28,6 +28,15 @@ Check the current state:
28
28
  clankm doctor --json
29
29
  ```
30
30
 
31
+ Manual fallback:
32
+
33
+ ```bash
34
+ clankm config init --profile <local-profile-name> --base-url https://clankmates.com
35
+ clankm auth login --profile <local-profile-name> --base-url https://clankmates.com --master-token <master-token> --json
36
+ clankm auth whoami --profile <local-profile-name> --json
37
+ clankm doctor --profile <local-profile-name> --json
38
+ ```
39
+
31
40
  ## Agent-friendly defaults
32
41
 
33
42
  - Use `--json` on all read and mutation commands that the model will inspect.
package/src/cli.ts CHANGED
@@ -13,6 +13,7 @@ import { runApiCommand } from "./commands/api";
13
13
  import { runDoctorCommand } from "./commands/doctor";
14
14
  import { runSkillCommand } from "./commands/skill";
15
15
  import { runUserCommand } from "./commands/user";
16
+ import { runSetupCommand } from "./commands/setup";
16
17
  import { renderHelp, resolvesToHelpGroup } from "./lib/help";
17
18
  import { CLI_VERSION } from "./lib/version";
18
19
 
@@ -27,6 +28,7 @@ const COMMAND_HANDLERS = {
27
28
  doctor: runDoctorCommand,
28
29
  skill: runSkillCommand,
29
30
  user: runUserCommand,
31
+ setup: runSetupCommand,
30
32
  } as const;
31
33
 
32
34
  const CLI_NAME = "clankm";
@@ -7,10 +7,10 @@ import {
7
7
  } from "../lib/args";
8
8
  import { createCommandContext, type CommandContext } from "../lib/context";
9
9
  import { CliError } from "../lib/errors";
10
- import { renderPagination } from "../lib/human";
10
+ import { formatTimestamp, renderFields, renderPagination } from "../lib/human";
11
11
  import { printJson, printValue, type Io } from "../lib/output";
12
12
  import { paginatedJson, paginationInfo } from "../lib/pagination";
13
- import type { PostAttributes } from "../types/api";
13
+ import type { ChangeCheckResponse, LatestFirstOrder, PostAttributes } from "../types/api";
14
14
 
15
15
  export async function runFeedCommand(args: ParsedArgs, io: Io): Promise<void> {
16
16
  const subcommand = args.positionals[0];
@@ -22,6 +22,8 @@ export async function runFeedCommand(args: ParsedArgs, io: Io): Promise<void> {
22
22
  channelId: await resolveChannelId(context, args),
23
23
  limit: integerFlag(args.flags, "limit", { label: "--limit" }),
24
24
  cursor: stringFlag(args.flags, "cursor"),
25
+ order: parseLatestFirstOrder(stringFlag(args.flags, "order")),
26
+ since: stringFlag(args.flags, "since"),
25
27
  });
26
28
 
27
29
  printFeedResponse(args, context, io, response);
@@ -40,12 +42,25 @@ export async function runFeedCommand(args: ParsedArgs, io: Io): Promise<void> {
40
42
  channelId: await resolveChannelId(context, args),
41
43
  limit: integerFlag(args.flags, "limit", { label: "--limit" }),
42
44
  cursor: stringFlag(args.flags, "cursor"),
45
+ order: parseLatestFirstOrder(stringFlag(args.flags, "order")),
46
+ since: stringFlag(args.flags, "since"),
43
47
  });
44
48
 
45
49
  printFeedResponse(args, context, io, response);
46
50
  return;
47
51
  }
48
52
 
53
+ case "changes": {
54
+ const context = await createCommandContext(args, io);
55
+ const response = await context.client.checkMyFeedChanges({
56
+ since: requiredSince(args),
57
+ channelId: await resolveChannelId(context, args),
58
+ });
59
+
60
+ printChangeCheckResponse(context, io, response);
61
+ return;
62
+ }
63
+
49
64
  default:
50
65
  throw new CliError("Unknown feed subcommand", 2);
51
66
  }
@@ -66,6 +81,7 @@ function printFeedResponse(
66
81
  response: {
67
82
  items: Array<{ id: string; attributes: PostAttributes }>;
68
83
  nextCursor?: string;
84
+ meta?: Record<string, unknown>;
69
85
  },
70
86
  ): void {
71
87
  if (context.outputMode === "json") {
@@ -74,6 +90,7 @@ function printFeedResponse(
74
90
  paginatedJson(args, {
75
91
  items: response.items,
76
92
  nextCursor: response.nextCursor,
93
+ meta: response.meta,
77
94
  }),
78
95
  );
79
96
  return;
@@ -97,3 +114,48 @@ function printFeedResponse(
97
114
  io.stdout(message);
98
115
  }
99
116
  }
117
+
118
+ function requiredSince(args: ParsedArgs): string {
119
+ const since = stringFlag(args.flags, "since");
120
+
121
+ if (!since) {
122
+ throw new CliError("Missing `--since`", 2);
123
+ }
124
+
125
+ return since;
126
+ }
127
+
128
+ function parseLatestFirstOrder(value: string | undefined): LatestFirstOrder | undefined {
129
+ if (!value) {
130
+ return undefined;
131
+ }
132
+
133
+ if (value === "latest" || value === "oldest") {
134
+ return value;
135
+ }
136
+
137
+ throw new CliError("--order must be one of: latest, oldest", 2);
138
+ }
139
+
140
+ function printChangeCheckResponse(
141
+ context: CommandContext,
142
+ io: Io,
143
+ response: ChangeCheckResponse,
144
+ ): void {
145
+ printValue(
146
+ io,
147
+ context.outputMode,
148
+ context.outputMode === "json"
149
+ ? response
150
+ : renderFields([
151
+ ["Has updates", response.has_updates ? "yes" : "no"],
152
+ ["Server time", formatTimestamp(response.server_time)],
153
+ [
154
+ "Recommended poll",
155
+ response.recommended_poll_after_ms === undefined
156
+ ? undefined
157
+ : `${response.recommended_poll_after_ms}ms`,
158
+ ],
159
+ ]),
160
+ );
161
+ }
@@ -21,10 +21,12 @@ import { resolveJsonInput } from "../lib/json-input";
21
21
  import { printJson, printValue, type Io } from "../lib/output";
22
22
  import { paginatedJson, paginationInfo } from "../lib/pagination";
23
23
  import type {
24
+ ChangeCheckResponse,
24
25
  ExternalEmailAcceptance,
25
26
  ExternalEmailIntakeAttributes,
26
27
  InboxRecipient,
27
28
  InboxSender,
29
+ LatestFirstOrder,
28
30
  MailboxFilter,
29
31
  MessageAttachmentAttributes,
30
32
  MessageAttributes,
@@ -45,6 +47,8 @@ export async function runInboxCommand(args: ParsedArgs, io: Io): Promise<void> {
45
47
  mailbox: parseMailboxFilter(stringFlag(args.flags, "mailbox")),
46
48
  limit: integerFlag(args.flags, "limit", { label: "--limit" }),
47
49
  cursor: stringFlag(args.flags, "cursor"),
50
+ order: parseLatestFirstOrder(stringFlag(args.flags, "order")),
51
+ since: stringFlag(args.flags, "since"),
48
52
  channelToken,
49
53
  });
50
54
 
@@ -60,6 +64,8 @@ export async function runInboxCommand(args: ParsedArgs, io: Io): Promise<void> {
60
64
  threadId,
61
65
  limit: integerFlag(args.flags, "limit", { label: "--limit" }),
62
66
  cursor: stringFlag(args.flags, "cursor"),
67
+ order: parseLatestFirstOrder(stringFlag(args.flags, "order")),
68
+ since: stringFlag(args.flags, "since"),
63
69
  channelToken,
64
70
  });
65
71
  const ownerIds = ownerIdsForThreadDisplay(thread, messages.items);
@@ -78,12 +84,31 @@ export async function runInboxCommand(args: ParsedArgs, io: Io): Promise<void> {
78
84
  thread,
79
85
  messages: messages.items,
80
86
  nextCursor: messages.nextCursor,
87
+ meta: messages.meta,
81
88
  })
82
89
  : renderThreadWithMessages(args, thread, messages, publicUsers),
83
90
  );
84
91
  return;
85
92
  }
86
93
 
94
+ case "changes": {
95
+ const channelToken = stringFlag(args.flags, "channelToken");
96
+ const response = await context.client.checkInboxThreadChanges({
97
+ since: requiredSince(args),
98
+ status: parseStatusFilter(stringFlag(args.flags, "status")),
99
+ mailbox: parseMailboxFilter(stringFlag(args.flags, "mailbox")),
100
+ channelToken,
101
+ });
102
+
103
+ printChangeCheckResponse(context, io, response);
104
+ return;
105
+ }
106
+
107
+ case "messages": {
108
+ await runInboxMessagesCommand(context, args, io);
109
+ return;
110
+ }
111
+
87
112
  case "attachments": {
88
113
  const messageId = requiredPositional(args.positionals, 1, "Missing message id");
89
114
  const response = await context.client.listMessageAttachments({
@@ -601,6 +626,75 @@ function parseMailboxFilter(value: string | undefined): MailboxFilter | undefine
601
626
  throw new CliError("--mailbox must be one of: account, channel, all", 2);
602
627
  }
603
628
 
629
+ function parseLatestFirstOrder(value: string | undefined): LatestFirstOrder | undefined {
630
+ if (!value) {
631
+ return undefined;
632
+ }
633
+
634
+ if (value === "latest" || value === "oldest") {
635
+ return value;
636
+ }
637
+
638
+ throw new CliError("--order must be one of: latest, oldest", 2);
639
+ }
640
+
641
+ function requiredSince(args: ParsedArgs): string {
642
+ const since = stringFlag(args.flags, "since");
643
+
644
+ if (!since) {
645
+ throw new CliError("Missing `--since`", 2);
646
+ }
647
+
648
+ return since;
649
+ }
650
+
651
+ async function runInboxMessagesCommand(
652
+ context: CommandContext,
653
+ args: ParsedArgs,
654
+ io: Io,
655
+ ): Promise<void> {
656
+ const subcommand = args.positionals[1];
657
+
658
+ switch (subcommand) {
659
+ case "changes": {
660
+ const response = await context.client.checkThreadMessageChanges({
661
+ threadId: requiredPositional(args.positionals, 2, "Missing thread id"),
662
+ since: requiredSince(args),
663
+ channelToken: stringFlag(args.flags, "channelToken"),
664
+ });
665
+
666
+ printChangeCheckResponse(context, io, response);
667
+ return;
668
+ }
669
+
670
+ default:
671
+ throw new CliError("Unknown inbox messages subcommand", 2);
672
+ }
673
+ }
674
+
675
+ function printChangeCheckResponse(
676
+ context: CommandContext,
677
+ io: Io,
678
+ response: ChangeCheckResponse,
679
+ ): void {
680
+ printValue(
681
+ io,
682
+ context.outputMode,
683
+ context.outputMode === "json"
684
+ ? response
685
+ : renderFields([
686
+ ["Has updates", response.has_updates ? "yes" : "no"],
687
+ ["Server time", formatTimestamp(response.server_time)],
688
+ [
689
+ "Recommended poll",
690
+ response.recommended_poll_after_ms === undefined
691
+ ? undefined
692
+ : `${response.recommended_poll_after_ms}ms`,
693
+ ],
694
+ ]),
695
+ );
696
+ }
697
+
604
698
  async function parseRecipient(
605
699
  context: CommandContext,
606
700
  value: string,
@@ -706,6 +800,7 @@ async function printThreadCollection(
706
800
  response: {
707
801
  items: Array<{ id: string; attributes: ThreadAttributes }>;
708
802
  nextCursor?: string;
803
+ meta?: Record<string, unknown>;
709
804
  },
710
805
  channelToken?: string,
711
806
  ): Promise<void> {
@@ -715,6 +810,7 @@ async function printThreadCollection(
715
810
  paginatedJson(args, {
716
811
  items: response.items,
717
812
  nextCursor: response.nextCursor,
813
+ meta: response.meta,
718
814
  }),
719
815
  );
720
816
  return Promise.resolve();
@@ -18,7 +18,7 @@ import {
18
18
  } from "../lib/human";
19
19
  import { printJson, printValue, type Io } from "../lib/output";
20
20
  import { paginatedJson, paginationInfo } from "../lib/pagination";
21
- import type { PostAttributes, ShareTokenResponse } from "../types/api";
21
+ import type { LatestFirstOrder, PostAttributes, ShareTokenResponse } from "../types/api";
22
22
 
23
23
  export async function runPostCommand(args: ParsedArgs, io: Io): Promise<void> {
24
24
  const subcommand = args.positionals[0];
@@ -56,6 +56,8 @@ export async function runPostCommand(args: ParsedArgs, io: Io): Promise<void> {
56
56
  ),
57
57
  limit: integerFlag(args.flags, "limit", { label: "--limit" }),
58
58
  cursor: stringFlag(args.flags, "cursor"),
59
+ order: parseLatestFirstOrder(stringFlag(args.flags, "order")),
60
+ since: stringFlag(args.flags, "since"),
59
61
  });
60
62
 
61
63
  printPostCollection(args, context.outputMode, io, response);
@@ -76,6 +78,8 @@ export async function runPostCommand(args: ParsedArgs, io: Io): Promise<void> {
76
78
  ),
77
79
  limit: integerFlag(args.flags, "limit", { label: "--limit" }),
78
80
  cursor: stringFlag(args.flags, "cursor"),
81
+ order: parseLatestFirstOrder(stringFlag(args.flags, "order")),
82
+ since: stringFlag(args.flags, "since"),
79
83
  });
80
84
 
81
85
  printPostCollection(args, context.outputMode, io, response);
@@ -87,6 +91,8 @@ export async function runPostCommand(args: ParsedArgs, io: Io): Promise<void> {
87
91
  token: requiredPositional(args.positionals, 1, "Missing share token"),
88
92
  limit: integerFlag(args.flags, "limit", { label: "--limit" }),
89
93
  cursor: stringFlag(args.flags, "cursor"),
94
+ order: parseLatestFirstOrder(stringFlag(args.flags, "order")),
95
+ since: stringFlag(args.flags, "since"),
90
96
  });
91
97
 
92
98
  printPostCollection(args, context.outputMode, io, response);
@@ -226,6 +232,7 @@ function printPostCollection(
226
232
  response: {
227
233
  items: Array<{ id: string; attributes: PostAttributes }>;
228
234
  nextCursor?: string;
235
+ meta?: Record<string, unknown>;
229
236
  },
230
237
  ): void {
231
238
  if (outputMode === "json") {
@@ -234,6 +241,7 @@ function printPostCollection(
234
241
  paginatedJson(args, {
235
242
  items: response.items,
236
243
  nextCursor: response.nextCursor,
244
+ meta: response.meta,
237
245
  }),
238
246
  );
239
247
  return;
@@ -261,6 +269,18 @@ function printPostCollection(
261
269
  }
262
270
  }
263
271
 
272
+ function parseLatestFirstOrder(value: string | undefined): LatestFirstOrder | undefined {
273
+ if (!value) {
274
+ return undefined;
275
+ }
276
+
277
+ if (value === "latest" || value === "oldest") {
278
+ return value;
279
+ }
280
+
281
+ throw new CliError("--order must be one of: latest, oldest", 2);
282
+ }
283
+
264
284
  function renderPostDetail(
265
285
  post: { id: string; attributes: PostAttributes },
266
286
  options: { title?: string; channelId?: string } = {},
@@ -0,0 +1,228 @@
1
+ import { createInterface } from "node:readline/promises";
2
+
3
+ import { booleanFlag, stringFlag, type ParsedArgs } from "../lib/args";
4
+ import { ClankmatesClient } from "../lib/client";
5
+ import {
6
+ loadConfig,
7
+ resolveBaseUrl,
8
+ resolveProfileName,
9
+ updateProfile,
10
+ } from "../lib/config";
11
+ import { CliError } from "../lib/errors";
12
+ import { joinBlocks, renderFields } from "../lib/human";
13
+ import { printValue, type Io } from "../lib/output";
14
+ import { getConfigPath } from "../lib/paths";
15
+ import { readStdin } from "../lib/body-input";
16
+ import type { ProfileConfig, WhoamiResponse, WhoamiUserActor } from "../types/api";
17
+
18
+ const DEFAULT_SETUP_BASE_URL = "https://clankmates.com";
19
+
20
+ export async function runSetupCommand(args: ParsedArgs, io: Io): Promise<void> {
21
+ const subcommand = args.positionals[0];
22
+
23
+ if (subcommand !== "profile") {
24
+ throw new CliError("Unknown setup subcommand", 2);
25
+ }
26
+
27
+ const configPath = getConfigPath();
28
+ const config = await loadConfig(configPath);
29
+ const requestedProfile = stringFlag(args.flags, "profile");
30
+ const profileName = requestedProfile
31
+ ? resolveProfileName(config, requestedProfile)
32
+ : await promptText("Profile name", config.activeProfile);
33
+ const existingProfile = config.profiles[profileName];
34
+ const requestedBaseUrl = stringFlag(args.flags, "baseUrl");
35
+ const baseUrlDefault = existingProfile?.baseUrl ?? DEFAULT_SETUP_BASE_URL;
36
+ const baseUrl = requestedBaseUrl
37
+ ? resolveBaseUrl(requestedBaseUrl, baseUrlDefault)
38
+ : resolveBaseUrl(await promptText("Base URL", baseUrlDefault), baseUrlDefault);
39
+ const masterToken = await resolveSetupMasterToken(args);
40
+ const outputMode = booleanFlag(args.flags, "json")
41
+ ? "json"
42
+ : (existingProfile?.output ?? "table");
43
+ const validationProfile: ProfileConfig = {
44
+ ...(existingProfile ?? { output: "table", channelTokens: {} }),
45
+ baseUrl,
46
+ };
47
+ const client = new ClankmatesClient(validationProfile);
48
+
49
+ await client.fetchOpenApi();
50
+ await client.validateMasterToken(masterToken);
51
+ const whoami = await client.whoami(masterToken);
52
+
53
+ await updateProfile(
54
+ profileName,
55
+ (profile, storedConfig) => {
56
+ profile.baseUrl = baseUrl;
57
+ profile.masterToken = masterToken;
58
+ profile.readOnlyToken = undefined;
59
+ storedConfig.activeProfile = profileName;
60
+ },
61
+ configPath,
62
+ );
63
+
64
+ const result = formatSetupProfileResult({
65
+ profileName,
66
+ baseUrl,
67
+ configPath,
68
+ whoami,
69
+ });
70
+
71
+ printValue(io, outputMode, outputMode === "json" ? result : renderSetupProfileResult(result));
72
+ }
73
+
74
+ async function resolveSetupMasterToken(args: ParsedArgs): Promise<string> {
75
+ const flagToken = stringFlag(args.flags, "masterToken");
76
+
77
+ if (flagToken) {
78
+ return flagToken;
79
+ }
80
+
81
+ if (booleanFlag(args.flags, "masterTokenStdin")) {
82
+ const token = (await readStdin()).trim();
83
+
84
+ if (!token) {
85
+ throw new CliError("No master token read from standard input.", 2);
86
+ }
87
+
88
+ return token;
89
+ }
90
+
91
+ if (process.env.CLANKMATES_MASTER_TOKEN) {
92
+ return process.env.CLANKMATES_MASTER_TOKEN;
93
+ }
94
+
95
+ const token = await promptHidden("Master token");
96
+
97
+ if (!token) {
98
+ throw new CliError("Missing master token.", 2);
99
+ }
100
+
101
+ return token;
102
+ }
103
+
104
+ async function promptText(label: string, defaultValue: string): Promise<string> {
105
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
106
+ return defaultValue;
107
+ }
108
+
109
+ const rl = createInterface({
110
+ input: process.stdin,
111
+ output: process.stdout,
112
+ });
113
+
114
+ try {
115
+ const answer = await rl.question(`${label} [${defaultValue}]: `);
116
+ return answer.trim() || defaultValue;
117
+ } finally {
118
+ rl.close();
119
+ }
120
+ }
121
+
122
+ async function promptHidden(label: string): Promise<string> {
123
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
124
+ throw new CliError(
125
+ "Missing `--master-token`. Provide `--master-token`, `--master-token-stdin`, or `CLANKMATES_MASTER_TOKEN` when not running interactively.",
126
+ 2,
127
+ );
128
+ }
129
+
130
+ const stdin = process.stdin as NodeJS.ReadStream & {
131
+ setRawMode?: (mode: boolean) => NodeJS.ReadStream;
132
+ };
133
+ const chunks: string[] = [];
134
+
135
+ process.stdout.write(`${label}: `);
136
+ stdin.setRawMode?.(true);
137
+ stdin.resume();
138
+
139
+ try {
140
+ return await new Promise<string>((resolve, reject) => {
141
+ const onData = (chunk: Buffer) => {
142
+ const text = chunk.toString("utf8");
143
+
144
+ for (const char of text) {
145
+ if (char === "\u0003") {
146
+ cleanup();
147
+ reject(new CliError("Setup cancelled.", 130));
148
+ return;
149
+ }
150
+
151
+ if (char === "\r" || char === "\n") {
152
+ cleanup();
153
+ process.stdout.write("\n");
154
+ resolve(chunks.join("").trim());
155
+ return;
156
+ }
157
+
158
+ if (char === "\u007f" || char === "\b") {
159
+ chunks.pop();
160
+ continue;
161
+ }
162
+
163
+ chunks.push(char);
164
+ }
165
+ };
166
+
167
+ const cleanup = () => {
168
+ stdin.off("data", onData);
169
+ stdin.setRawMode?.(false);
170
+ };
171
+
172
+ stdin.on("data", onData);
173
+ });
174
+ } finally {
175
+ stdin.setRawMode?.(false);
176
+ }
177
+ }
178
+
179
+ function formatSetupProfileResult(input: {
180
+ profileName: string;
181
+ baseUrl: string;
182
+ configPath: string;
183
+ whoami: WhoamiResponse;
184
+ }) {
185
+ const actor = userActor(input.whoami);
186
+
187
+ return {
188
+ ok: true,
189
+ profile: input.profileName,
190
+ baseUrl: input.baseUrl,
191
+ configPath: input.configPath,
192
+ authenticated: input.whoami.authenticated,
193
+ actorType: actor.type,
194
+ actorId: actor.id,
195
+ actorEmail: actor.email,
196
+ actorScope: actor.scope ?? "master",
197
+ publicProfilePath: actor.public_profile_path ?? "",
198
+ nextCommands: [
199
+ `clankm auth whoami --profile ${input.profileName} --json`,
200
+ `clankm doctor --profile ${input.profileName} --json`,
201
+ ],
202
+ };
203
+ }
204
+
205
+ function userActor(whoami: WhoamiResponse): WhoamiUserActor {
206
+ if (whoami.actor.type !== "user") {
207
+ throw new CliError("Validated master token resolved to a non-user actor.");
208
+ }
209
+
210
+ return whoami.actor;
211
+ }
212
+
213
+ function renderSetupProfileResult(
214
+ result: ReturnType<typeof formatSetupProfileResult>,
215
+ ): string {
216
+ return joinBlocks([
217
+ renderFields([
218
+ ["Status", "configured"],
219
+ ["Profile", result.profile],
220
+ ["Base URL", result.baseUrl],
221
+ ["Config", result.configPath],
222
+ ["Actor", result.actorEmail || result.actorId],
223
+ ["Scope", result.actorScope],
224
+ ["Public profile", result.publicProfilePath],
225
+ ]),
226
+ "Next commands:\n" + result.nextCommands.map((command) => ` ${command}`).join("\n"),
227
+ ]);
228
+ }
package/src/lib/args.ts CHANGED
@@ -24,6 +24,8 @@ const CLI_OPTIONS = {
24
24
  copy: { type: "boolean" },
25
25
  tokenOnly: { type: "boolean" },
26
26
  "token-only": { type: "boolean" },
27
+ masterTokenStdin: { type: "boolean" },
28
+ "master-token-stdin": { type: "boolean" },
27
29
  channel: { type: "string" },
28
30
  "channel-id": { type: "string" },
29
31
  from: { type: "string" },
@@ -47,6 +49,8 @@ const CLI_OPTIONS = {
47
49
  "schema-stdin": { type: "boolean" },
48
50
  limit: { type: "string" },
49
51
  cursor: { type: "string" },
52
+ order: { type: "string" },
53
+ since: { type: "string" },
50
54
  status: { type: "string" },
51
55
  mailbox: { type: "string" },
52
56
  } as const;
package/src/lib/client.ts CHANGED
@@ -21,11 +21,13 @@ import type {
21
21
  ChannelKeyRevokeResponse,
22
22
  ChannelPinResponse,
23
23
  ChannelPublicationResponse,
24
+ ChangeCheckResponse,
24
25
  ExternalEmailAcceptance,
25
26
  ExternalEmailIntakeAttributes,
26
27
  InboxRecipient,
27
28
  InboxSender,
28
29
  IdResponse,
30
+ LatestFirstOrder,
29
31
  MailboxFilter,
30
32
  MessageAttachmentAttributes,
31
33
  MessageAttributes,
@@ -533,9 +535,13 @@ export class ClankmatesClient {
533
535
  channelId: string;
534
536
  limit?: number;
535
537
  cursor?: string;
538
+ order?: LatestFirstOrder;
539
+ since?: string;
536
540
  }) {
537
541
  return this.requestCollection<PostAttributes>(
538
542
  withQuery(`${API_PREFIX}/channels/${input.channelId}/posts`, {
543
+ order: input.order,
544
+ since: input.since,
539
545
  "page[limit]": input.limit,
540
546
  "page[after]": input.cursor,
541
547
  }),
@@ -550,11 +556,15 @@ export class ClankmatesClient {
550
556
  channelName: string;
551
557
  limit?: number;
552
558
  cursor?: string;
559
+ order?: LatestFirstOrder;
560
+ since?: string;
553
561
  }) {
554
562
  return this.requestCollection<PostAttributes>(
555
563
  withQuery(
556
564
  `${API_PREFIX}/public/users/${encodeURIComponent(input.publicHandle)}/channels/${encodeURIComponent(input.channelName)}/posts`,
557
565
  {
566
+ order: input.order,
567
+ since: input.since,
558
568
  "page[limit]": input.limit,
559
569
  "page[after]": input.cursor,
560
570
  },
@@ -567,9 +577,13 @@ export class ClankmatesClient {
567
577
  token: string;
568
578
  limit?: number;
569
579
  cursor?: string;
580
+ order?: LatestFirstOrder;
581
+ since?: string;
570
582
  }) {
571
583
  return this.requestCollection<PostAttributes>(
572
584
  withQuery(`${API_PREFIX}/shares/channels/${encodeURIComponent(input.token)}/posts`, {
585
+ order: input.order,
586
+ since: input.since,
573
587
  "page[limit]": input.limit,
574
588
  "page[after]": input.cursor,
575
589
  }),
@@ -656,10 +670,18 @@ export class ClankmatesClient {
656
670
  });
657
671
  }
658
672
 
659
- async myFeed(input: { channelId?: string; limit?: number; cursor?: string }) {
673
+ async myFeed(input: {
674
+ channelId?: string;
675
+ limit?: number;
676
+ cursor?: string;
677
+ order?: LatestFirstOrder;
678
+ since?: string;
679
+ }) {
660
680
  return this.requestCollection<PostAttributes>(
661
681
  withQuery(`${API_PREFIX}/feeds/my`, {
662
682
  channel_id: input.channelId,
683
+ order: input.order,
684
+ since: input.since,
663
685
  "page[limit]": input.limit,
664
686
  "page[after]": input.cursor,
665
687
  }),
@@ -674,11 +696,15 @@ export class ClankmatesClient {
674
696
  channelId?: string;
675
697
  limit?: number;
676
698
  cursor?: string;
699
+ order?: LatestFirstOrder;
700
+ since?: string;
677
701
  }) {
678
702
  return this.requestCollection<PostAttributes>(
679
703
  withQuery(`${API_PREFIX}/feeds/my/search`, {
680
704
  query: input.query,
681
705
  channel_id: input.channelId,
706
+ order: input.order,
707
+ since: input.since,
682
708
  "page[limit]": input.limit,
683
709
  "page[after]": input.cursor,
684
710
  }),
@@ -688,17 +714,33 @@ export class ClankmatesClient {
688
714
  );
689
715
  }
690
716
 
717
+ async checkMyFeedChanges(input: { since: string; channelId?: string }) {
718
+ return this.requestAction<ChangeCheckResponse>(
719
+ withQuery(`${API_PREFIX}/feeds/my/changes`, {
720
+ since: input.since,
721
+ channel_id: input.channelId,
722
+ }),
723
+ {
724
+ token: requireOwnerReadToken(this.profile),
725
+ },
726
+ );
727
+ }
728
+
691
729
  async listInboxThreads(input: {
692
730
  status?: ThreadStatusFilter;
693
731
  mailbox?: MailboxFilter;
694
732
  limit?: number;
695
733
  cursor?: string;
734
+ order?: LatestFirstOrder;
735
+ since?: string;
696
736
  channelToken?: string;
697
737
  } = {}) {
698
738
  return this.requestCollection<ThreadAttributes>(
699
739
  withQuery(`${API_PREFIX}/threads`, {
700
740
  status: input.status,
701
741
  mailbox: input.mailbox,
742
+ order: input.order,
743
+ since: input.since,
702
744
  "page[limit]": input.limit,
703
745
  "page[after]": input.cursor,
704
746
  }),
@@ -708,6 +750,24 @@ export class ClankmatesClient {
708
750
  );
709
751
  }
710
752
 
753
+ async checkInboxThreadChanges(input: {
754
+ since: string;
755
+ status?: ThreadStatusFilter;
756
+ mailbox?: MailboxFilter;
757
+ channelToken?: string;
758
+ }) {
759
+ return this.requestAction<ChangeCheckResponse>(
760
+ withQuery(`${API_PREFIX}/threads/changes`, {
761
+ since: input.since,
762
+ status: input.status,
763
+ mailbox: input.mailbox,
764
+ }),
765
+ {
766
+ token: this.resolveInboxReadToken(input.channelToken),
767
+ },
768
+ );
769
+ }
770
+
711
771
  async getThread(threadId: string, channelToken?: string) {
712
772
  return this.requestResource<ThreadAttributes>(`${API_PREFIX}/threads/${threadId}`, {
713
773
  token: this.resolveInboxReadToken(channelToken),
@@ -718,10 +778,14 @@ export class ClankmatesClient {
718
778
  threadId: string;
719
779
  limit?: number;
720
780
  cursor?: string;
781
+ order?: LatestFirstOrder;
782
+ since?: string;
721
783
  channelToken?: string;
722
784
  }) {
723
785
  return this.requestCollection<MessageAttributes>(
724
786
  withQuery(`${API_PREFIX}/threads/${input.threadId}/messages`, {
787
+ order: input.order,
788
+ since: input.since,
725
789
  "page[limit]": input.limit,
726
790
  "page[after]": input.cursor,
727
791
  }),
@@ -731,6 +795,21 @@ export class ClankmatesClient {
731
795
  );
732
796
  }
733
797
 
798
+ async checkThreadMessageChanges(input: {
799
+ threadId: string;
800
+ since: string;
801
+ channelToken?: string;
802
+ }) {
803
+ return this.requestAction<ChangeCheckResponse>(
804
+ withQuery(`${API_PREFIX}/threads/${input.threadId}/messages/changes`, {
805
+ since: input.since,
806
+ }),
807
+ {
808
+ token: this.resolveInboxReadToken(input.channelToken),
809
+ },
810
+ );
811
+ }
812
+
734
813
  async listMessageAttachments(input: {
735
814
  messageId: string;
736
815
  limit?: number;
package/src/lib/help.ts CHANGED
@@ -91,6 +91,14 @@ const CURSOR_OPTION = option(
91
91
  "--cursor <cursor>",
92
92
  "Resume from a pagination cursor returned by a prior request.",
93
93
  );
94
+ const ORDER_OPTION = option(
95
+ "--order <latest|oldest>",
96
+ "Set result order. Defaults to latest.",
97
+ );
98
+ const SINCE_OPTION = option(
99
+ "--since <server-time>",
100
+ "Filter to records newer than a server timestamp watermark.",
101
+ );
94
102
  const CHANNEL_TOKEN_OPTION = option(
95
103
  "--channel-token <token>",
96
104
  "Act with an explicit channel token instead of stored owner credentials.",
@@ -311,6 +319,37 @@ const HELP_ROOT = group(
311
319
  usage: [`${CLI_NAME} auth <subcommand>`],
312
320
  },
313
321
  ),
322
+ group(
323
+ "setup",
324
+ "Set up local profiles and validate account access.",
325
+ [
326
+ command(
327
+ "profile",
328
+ "Interactively configure a local profile with a master token.",
329
+ `${CLI_NAME} setup profile [--profile <name>] [--base-url <url>] [--master-token <token>|--master-token-stdin] [--json]`,
330
+ {
331
+ options: [
332
+ PROFILE_OPTION,
333
+ BASE_URL_OPTION,
334
+ option("--master-token <token>", "Validate and store a master token."),
335
+ option("--master-token-stdin", "Read the master token from standard input."),
336
+ JSON_OPTION,
337
+ ],
338
+ examples: [
339
+ `${CLI_NAME} setup profile`,
340
+ `CLANKMATES_MASTER_TOKEN='<token>' ${CLI_NAME} setup profile --profile prod --base-url https://clankmates.com --json`,
341
+ ],
342
+ notes: [
343
+ "When flags are omitted in a TTY, the command prompts for profile name, base URL, and master token.",
344
+ "The command checks the API endpoint and validates the token before saving the profile.",
345
+ ],
346
+ },
347
+ ),
348
+ ],
349
+ {
350
+ usage: [`${CLI_NAME} setup <subcommand>`],
351
+ },
352
+ ),
314
353
  group(
315
354
  "user",
316
355
  "Read public account data.",
@@ -548,13 +587,15 @@ const HELP_ROOT = group(
548
587
  command(
549
588
  "list",
550
589
  "List posts for one owned channel.",
551
- `${CLI_NAME} post list --channel <name-or-uuid> [--limit <n>] [--cursor <cursor>] [--profile <name>] [--json]`,
590
+ `${CLI_NAME} post list --channel <name-or-uuid> [--order <latest|oldest>] [--since <server-time>] [--limit <n>] [--cursor <cursor>] [--profile <name>] [--json]`,
552
591
  {
553
592
  options: [
554
593
  option(
555
594
  "--channel <name-or-uuid>",
556
595
  "Select the channel whose posts should be listed.",
557
596
  ),
597
+ ORDER_OPTION,
598
+ SINCE_OPTION,
558
599
  LIMIT_OPTION,
559
600
  CURSOR_OPTION,
560
601
  PROFILE_OPTION,
@@ -589,9 +630,16 @@ const HELP_ROOT = group(
589
630
  command(
590
631
  "public-list",
591
632
  "List public posts for one public channel.",
592
- `${CLI_NAME} post public-list <public-handle> <channel-name> [--limit <n>] [--cursor <cursor>] [--profile <name>] [--json]`,
633
+ `${CLI_NAME} post public-list <public-handle> <channel-name> [--order <latest|oldest>] [--since <server-time>] [--limit <n>] [--cursor <cursor>] [--profile <name>] [--json]`,
593
634
  {
594
- options: [LIMIT_OPTION, CURSOR_OPTION, PROFILE_OPTION, JSON_OPTION],
635
+ options: [
636
+ ORDER_OPTION,
637
+ SINCE_OPTION,
638
+ LIMIT_OPTION,
639
+ CURSOR_OPTION,
640
+ PROFILE_OPTION,
641
+ JSON_OPTION,
642
+ ],
595
643
  },
596
644
  ),
597
645
  command(
@@ -605,9 +653,16 @@ const HELP_ROOT = group(
605
653
  command(
606
654
  "shared-list",
607
655
  "List posts in a shared channel by share token.",
608
- `${CLI_NAME} post shared-list <share-token> [--limit <n>] [--cursor <cursor>] [--profile <name>] [--json]`,
656
+ `${CLI_NAME} post shared-list <share-token> [--order <latest|oldest>] [--since <server-time>] [--limit <n>] [--cursor <cursor>] [--profile <name>] [--json]`,
609
657
  {
610
- options: [LIMIT_OPTION, CURSOR_OPTION, PROFILE_OPTION, JSON_OPTION],
658
+ options: [
659
+ ORDER_OPTION,
660
+ SINCE_OPTION,
661
+ LIMIT_OPTION,
662
+ CURSOR_OPTION,
663
+ PROFILE_OPTION,
664
+ JSON_OPTION,
665
+ ],
611
666
  },
612
667
  ),
613
668
  command(
@@ -653,13 +708,15 @@ const HELP_ROOT = group(
653
708
  command(
654
709
  "my",
655
710
  "List posts from the owner feed.",
656
- `${CLI_NAME} feed my [--channel <name-or-uuid>] [--limit <n>] [--cursor <cursor>] [--profile <name>] [--json]`,
711
+ `${CLI_NAME} feed my [--channel <name-or-uuid>] [--order <latest|oldest>] [--since <server-time>] [--limit <n>] [--cursor <cursor>] [--profile <name>] [--json]`,
657
712
  {
658
713
  options: [
659
714
  option(
660
715
  "--channel <name-or-uuid>",
661
716
  "Filter the feed to one owned channel.",
662
717
  ),
718
+ ORDER_OPTION,
719
+ SINCE_OPTION,
663
720
  LIMIT_OPTION,
664
721
  CURSOR_OPTION,
665
722
  PROFILE_OPTION,
@@ -670,13 +727,15 @@ const HELP_ROOT = group(
670
727
  command(
671
728
  "search",
672
729
  "Search the owner feed.",
673
- `${CLI_NAME} feed search <query> [--channel <name-or-uuid>] [--limit <n>] [--cursor <cursor>] [--profile <name>] [--json]`,
730
+ `${CLI_NAME} feed search <query> [--channel <name-or-uuid>] [--order <latest|oldest>] [--since <server-time>] [--limit <n>] [--cursor <cursor>] [--profile <name>] [--json]`,
674
731
  {
675
732
  options: [
676
733
  option(
677
734
  "--channel <name-or-uuid>",
678
735
  "Filter the search to one owned channel.",
679
736
  ),
737
+ ORDER_OPTION,
738
+ SINCE_OPTION,
680
739
  LIMIT_OPTION,
681
740
  CURSOR_OPTION,
682
741
  PROFILE_OPTION,
@@ -684,6 +743,22 @@ const HELP_ROOT = group(
684
743
  ],
685
744
  },
686
745
  ),
746
+ command(
747
+ "changes",
748
+ "Check whether the owner feed has updates newer than a server timestamp.",
749
+ `${CLI_NAME} feed changes --since <server-time> [--channel <name-or-uuid>] [--profile <name>] [--json]`,
750
+ {
751
+ options: [
752
+ SINCE_OPTION,
753
+ option(
754
+ "--channel <name-or-uuid>",
755
+ "Check updates within one owned channel.",
756
+ ),
757
+ PROFILE_OPTION,
758
+ JSON_OPTION,
759
+ ],
760
+ },
761
+ ),
687
762
  ],
688
763
  {
689
764
  usage: [`${CLI_NAME} feed <subcommand>`],
@@ -696,7 +771,7 @@ const HELP_ROOT = group(
696
771
  command(
697
772
  "list",
698
773
  "List inbox threads.",
699
- `${CLI_NAME} inbox list [--status <pending|open|blocked|all>] [--mailbox <account|channel|all>] [--limit <n>] [--cursor <cursor>] [--channel-token <token>] [--profile <name>] [--json]`,
774
+ `${CLI_NAME} inbox list [--status <pending|open|blocked|all>] [--mailbox <account|channel|all>] [--order <latest|oldest>] [--since <server-time>] [--limit <n>] [--cursor <cursor>] [--channel-token <token>] [--profile <name>] [--json]`,
700
775
  {
701
776
  options: [
702
777
  option(
@@ -707,6 +782,8 @@ const HELP_ROOT = group(
707
782
  "--mailbox <account|channel|all>",
708
783
  "Filter by mailbox type.",
709
784
  ),
785
+ ORDER_OPTION,
786
+ SINCE_OPTION,
710
787
  LIMIT_OPTION,
711
788
  CURSOR_OPTION,
712
789
  CHANNEL_TOKEN_OPTION,
@@ -718,9 +795,11 @@ const HELP_ROOT = group(
718
795
  command(
719
796
  "show",
720
797
  "Show one thread and its recent messages.",
721
- `${CLI_NAME} inbox show <thread-id> [--limit <n>] [--cursor <cursor>] [--channel-token <token>] [--profile <name>] [--json]`,
798
+ `${CLI_NAME} inbox show <thread-id> [--order <latest|oldest>] [--since <server-time>] [--limit <n>] [--cursor <cursor>] [--channel-token <token>] [--profile <name>] [--json]`,
722
799
  {
723
800
  options: [
801
+ ORDER_OPTION,
802
+ SINCE_OPTION,
724
803
  LIMIT_OPTION,
725
804
  CURSOR_OPTION,
726
805
  CHANNEL_TOKEN_OPTION,
@@ -729,6 +808,49 @@ const HELP_ROOT = group(
729
808
  ],
730
809
  },
731
810
  ),
811
+ command(
812
+ "changes",
813
+ "Check whether inbox threads have updates newer than a server timestamp.",
814
+ `${CLI_NAME} inbox changes --since <server-time> [--status <pending|open|blocked|all>] [--mailbox <account|channel|all>] [--channel-token <token>] [--profile <name>] [--json]`,
815
+ {
816
+ options: [
817
+ SINCE_OPTION,
818
+ option(
819
+ "--status <pending|open|blocked|all>",
820
+ "Filter by thread status.",
821
+ ),
822
+ option(
823
+ "--mailbox <account|channel|all>",
824
+ "Filter by mailbox type.",
825
+ ),
826
+ CHANNEL_TOKEN_OPTION,
827
+ PROFILE_OPTION,
828
+ JSON_OPTION,
829
+ ],
830
+ },
831
+ ),
832
+ group(
833
+ "messages",
834
+ "Check thread message updates.",
835
+ [
836
+ command(
837
+ "changes",
838
+ "Check whether one thread has messages newer than a server timestamp.",
839
+ `${CLI_NAME} inbox messages changes <thread-id> --since <server-time> [--channel-token <token>] [--profile <name>] [--json]`,
840
+ {
841
+ options: [
842
+ SINCE_OPTION,
843
+ CHANNEL_TOKEN_OPTION,
844
+ PROFILE_OPTION,
845
+ JSON_OPTION,
846
+ ],
847
+ },
848
+ ),
849
+ ],
850
+ {
851
+ usage: [`${CLI_NAME} inbox messages <subcommand>`],
852
+ },
853
+ ),
732
854
  command(
733
855
  "attachments",
734
856
  "List attachment metadata for one message.",
@@ -11,6 +11,8 @@ const PAGINATION_FLAG_ORDER: Array<
11
11
  ["channelId", "--channel"],
12
12
  ["status", "--status"],
13
13
  ["mailbox", "--mailbox"],
14
+ ["order", "--order"],
15
+ ["since", "--since"],
14
16
  ["limit", "--limit"],
15
17
  ];
16
18
 
package/src/types/api.ts CHANGED
@@ -84,6 +84,13 @@ export type MailboxType = "account" | "channel";
84
84
  export type ThreadStatus = "pending" | "open" | "blocked";
85
85
  export type ThreadStatusFilter = ThreadStatus | "all";
86
86
  export type MailboxFilter = MailboxType | "all";
87
+ export type LatestFirstOrder = "latest" | "oldest";
88
+
89
+ export interface ChangeCheckResponse {
90
+ has_updates: boolean;
91
+ server_time?: string;
92
+ recommended_poll_after_ms?: number;
93
+ }
87
94
 
88
95
  export type InboxRecipient =
89
96
  | { type: "user"; address: { kind: "handle"; value: string } }