@clankmates/cli 0.12.0 → 0.13.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.
Files changed (46) hide show
  1. package/README.md +4 -1
  2. package/package.json +1 -1
  3. package/src/commands/auth/access-keys.ts +206 -0
  4. package/src/commands/auth.ts +3 -196
  5. package/src/commands/channel/render.ts +224 -0
  6. package/src/commands/channel/tokens.ts +145 -0
  7. package/src/commands/channel/validation.ts +11 -0
  8. package/src/commands/channel.ts +11 -340
  9. package/src/commands/doctor/checks.ts +123 -0
  10. package/src/commands/doctor/render.ts +140 -0
  11. package/src/commands/doctor/suggestions.ts +42 -0
  12. package/src/commands/doctor/types.ts +75 -0
  13. package/src/commands/doctor.ts +12 -371
  14. package/src/commands/feed.ts +15 -178
  15. package/src/commands/inbox/content.ts +31 -0
  16. package/src/commands/inbox/filters.ts +70 -0
  17. package/src/commands/inbox/messages.ts +69 -0
  18. package/src/commands/inbox/participants.ts +152 -0
  19. package/src/commands/inbox/render.ts +13 -0
  20. package/src/commands/inbox/resource-output.ts +217 -0
  21. package/src/commands/inbox/schema.ts +185 -0
  22. package/src/commands/inbox/screening.ts +76 -0
  23. package/src/commands/inbox/sync-scopes.ts +59 -0
  24. package/src/commands/inbox/thread-output.ts +344 -0
  25. package/src/commands/inbox/watch.ts +203 -0
  26. package/src/commands/inbox.ts +37 -1243
  27. package/src/commands/post.ts +9 -114
  28. package/src/lib/args.ts +1 -0
  29. package/src/lib/cache/scopes.ts +216 -0
  30. package/src/lib/cache/store.ts +195 -0
  31. package/src/lib/cache/types.ts +31 -0
  32. package/src/lib/cache.ts +18 -436
  33. package/src/lib/client/auth.ts +122 -0
  34. package/src/lib/client/channel-keys.ts +57 -0
  35. package/src/lib/client/channels.ts +364 -0
  36. package/src/lib/client/core.ts +133 -0
  37. package/src/lib/client/feed.ts +76 -0
  38. package/src/lib/client/inbox.ts +361 -0
  39. package/src/lib/client/posts.ts +213 -0
  40. package/src/lib/client/raw-api.ts +33 -0
  41. package/src/lib/client/users.ts +88 -0
  42. package/src/lib/client.ts +177 -913
  43. package/src/lib/help.ts +26 -0
  44. package/src/lib/json_api.ts +74 -9
  45. package/src/lib/polling.ts +146 -0
  46. package/src/lib/post-output.ts +55 -0
@@ -0,0 +1,344 @@
1
+ import type { ParsedArgs } from "../../lib/args";
2
+ import type { CommandContext } from "../../lib/context";
3
+ import type { CacheResult } from "../../lib/cache";
4
+ import {
5
+ formatTimestamp,
6
+ indent,
7
+ joinBlocks,
8
+ renderFields,
9
+ renderPagination,
10
+ renderSection,
11
+ shortId,
12
+ } from "../../lib/human";
13
+ import { printJson, printValue, type Io } from "../../lib/output";
14
+ import { paginatedJson, paginationInfo } from "../../lib/pagination";
15
+ import { printCacheNote, renderCacheNote } from "../../lib/polling";
16
+ import type {
17
+ MessageAttributes,
18
+ ThreadAttributes,
19
+ WhoamiActor,
20
+ } from "../../types/api";
21
+
22
+ export async function printThreadCollection(
23
+ args: ParsedArgs,
24
+ context: CommandContext,
25
+ io: Io,
26
+ response: {
27
+ items: Array<{ id: string; attributes: ThreadAttributes }>;
28
+ nextCursor?: string;
29
+ meta?: Record<string, unknown>;
30
+ },
31
+ channelToken?: string,
32
+ cache?: CacheResult,
33
+ ): Promise<void> {
34
+ if (context.outputMode === "json") {
35
+ printJson(
36
+ io,
37
+ paginatedJson(args, {
38
+ items: response.items,
39
+ nextCursor: response.nextCursor,
40
+ meta: response.meta,
41
+ ...(cache ? { cache } : {}),
42
+ }),
43
+ );
44
+ return Promise.resolve();
45
+ }
46
+
47
+ const actor = (await context.client.whoami(channelToken)).actor;
48
+ const peers = response.items.map((item) => threadPeer(item.attributes, actor));
49
+ const ownerIds = ownerIdsForThreadList(peers);
50
+ const publicUsers =
51
+ ownerIds.length === 0
52
+ ? new Map<string, string>()
53
+ : publicUserHandlesById(
54
+ (await context.client.listPublicUsersById(ownerIds)).items,
55
+ );
56
+
57
+ printValue(
58
+ io,
59
+ context.outputMode,
60
+ response.items.map((item, index) => ({
61
+ id: item.id,
62
+ with: formatThreadPeer(peers[index], publicUsers),
63
+ mailboxType: item.attributes.mailbox_type,
64
+ status: item.attributes.status,
65
+ lastMessageAt: item.attributes.last_message_at ?? "",
66
+ openedAt: item.attributes.opened_at ?? "",
67
+ expiresAt: item.attributes.expires_at ?? "",
68
+ })),
69
+ );
70
+ const pagination = paginationInfo(args, response.nextCursor);
71
+ const message = renderPagination(
72
+ pagination?.nextCursor,
73
+ pagination?.nextCommand,
74
+ );
75
+
76
+ if (message) {
77
+ io.stdout(message);
78
+ }
79
+
80
+ printCacheNote(io, cache);
81
+ }
82
+
83
+ export function renderThreadWithMessages(
84
+ args: ParsedArgs,
85
+ thread: { id: string; attributes: ThreadAttributes },
86
+ messages: {
87
+ items: Array<{ id: string; attributes: MessageAttributes }>;
88
+ nextCursor?: string;
89
+ },
90
+ publicUsers: Map<string, string>,
91
+ cache?: CacheResult,
92
+ ): string {
93
+ const attrs = thread.attributes;
94
+ const messageBlocks =
95
+ messages.items.length === 0
96
+ ? "No messages."
97
+ : messages.items
98
+ .map((message) => renderMessage(message, publicUsers))
99
+ .join("\n\n");
100
+ const pagination = paginationInfo(args, messages.nextCursor);
101
+
102
+ return joinBlocks([
103
+ `Thread ${thread.id}`,
104
+ renderFields([
105
+ ["Mailbox", attrs.mailbox_type],
106
+ ["Status", attrs.status],
107
+ ["Last message", formatTimestamp(attrs.last_message_at)],
108
+ ["Opened", formatTimestamp(attrs.opened_at)],
109
+ [
110
+ "Expires",
111
+ attrs.expires_at ? formatTimestamp(attrs.expires_at) : undefined,
112
+ ],
113
+ ]),
114
+ renderSection(
115
+ "Participants",
116
+ renderFields([
117
+ ["A owner", formatUserActor(attrs.participant_a_owner_id, publicUsers)],
118
+ ["A channel", formatActor("channel", attrs.participant_a_channel_id)],
119
+ ["A seen", formatTimestamp(attrs.participant_a_seen_at)],
120
+ [
121
+ "A archived",
122
+ attrs.participant_a_archived_at
123
+ ? formatTimestamp(attrs.participant_a_archived_at)
124
+ : undefined,
125
+ ],
126
+ [
127
+ "A blocked",
128
+ attrs.participant_a_blocked_at
129
+ ? formatTimestamp(attrs.participant_a_blocked_at)
130
+ : undefined,
131
+ ],
132
+ [
133
+ "A resolved",
134
+ attrs.participant_a_resolved_at
135
+ ? formatTimestamp(attrs.participant_a_resolved_at)
136
+ : undefined,
137
+ ],
138
+ ["B owner", formatUserActor(attrs.participant_b_owner_id, publicUsers)],
139
+ ["B channel", formatActor("channel", attrs.participant_b_channel_id)],
140
+ ["B seen", formatTimestamp(attrs.participant_b_seen_at)],
141
+ [
142
+ "B archived",
143
+ attrs.participant_b_archived_at
144
+ ? formatTimestamp(attrs.participant_b_archived_at)
145
+ : undefined,
146
+ ],
147
+ [
148
+ "B blocked",
149
+ attrs.participant_b_blocked_at
150
+ ? formatTimestamp(attrs.participant_b_blocked_at)
151
+ : undefined,
152
+ ],
153
+ [
154
+ "B resolved",
155
+ attrs.participant_b_resolved_at
156
+ ? formatTimestamp(attrs.participant_b_resolved_at)
157
+ : undefined,
158
+ ],
159
+ ]),
160
+ ),
161
+ renderSection("Messages", messageBlocks),
162
+ renderPagination(pagination?.nextCursor, pagination?.nextCommand),
163
+ renderCacheNote(cache),
164
+ ]);
165
+ }
166
+
167
+ export function renderThreadAction(
168
+ action: string,
169
+ thread: { id: string; attributes: ThreadAttributes },
170
+ ): string {
171
+ const attrs = thread.attributes;
172
+
173
+ return joinBlocks([
174
+ `${action}: ${thread.id}`,
175
+ renderFields([
176
+ ["Mailbox", attrs.mailbox_type],
177
+ ["Status", attrs.status],
178
+ ["Last message", formatTimestamp(attrs.last_message_at)],
179
+ ["Opened", formatTimestamp(attrs.opened_at)],
180
+ [
181
+ "Expires",
182
+ attrs.expires_at ? formatTimestamp(attrs.expires_at) : undefined,
183
+ ],
184
+ ]),
185
+ ]);
186
+ }
187
+
188
+ export function ownerIdsForThreadDisplay(
189
+ thread: { attributes: ThreadAttributes },
190
+ messages: Array<{ attributes: MessageAttributes }>,
191
+ ): string[] {
192
+ const ids = [
193
+ thread.attributes.participant_a_owner_id,
194
+ thread.attributes.participant_b_owner_id,
195
+ ...messages.map((message) => message.attributes.sender_owner_id),
196
+ ];
197
+
198
+ return Array.from(new Set(ids.filter((id): id is string => Boolean(id))));
199
+ }
200
+
201
+ export function publicUserHandlesById(
202
+ users: Array<{ id: string; attributes: { public_handle?: string | null } }>,
203
+ ): Map<string, string> {
204
+ const handles = new Map<string, string>();
205
+
206
+ for (const user of users) {
207
+ const handle = user.attributes.public_handle?.trim();
208
+
209
+ if (handle) {
210
+ handles.set(user.id, `@${handle}`);
211
+ }
212
+ }
213
+
214
+ return handles;
215
+ }
216
+
217
+ function renderMessage(
218
+ message: {
219
+ id: string;
220
+ attributes: MessageAttributes;
221
+ },
222
+ publicUsers: Map<string, string>,
223
+ ): string {
224
+ const attrs = message.attributes;
225
+ const headingParts = [
226
+ formatTimestamp(attrs.inserted_at),
227
+ shortId(message.id),
228
+ formatUserActor(attrs.sender_owner_id, publicUsers),
229
+ formatActor("channel", attrs.sender_channel_id),
230
+ formatActor("email-sender", attrs.external_email_sender_id),
231
+ ].filter((part) => part !== "-");
232
+ const contextPost = attrs.context_post_id
233
+ ? `Context post: ${attrs.context_post_id}\n`
234
+ : "";
235
+ const payload = attrs.payload
236
+ ? `\n\nPayload\n${indent(JSON.stringify(attrs.payload, null, 2))}`
237
+ : "";
238
+
239
+ return `${headingParts.join(" ")}\n${indent(`${contextPost}${attrs.body}`)}${payload}`;
240
+ }
241
+
242
+ function formatActor(
243
+ kind: "user" | "channel" | "email-sender",
244
+ id?: string | null,
245
+ ): string {
246
+ if (!id) {
247
+ return "-";
248
+ }
249
+
250
+ return `${kind}:${shortId(id)}`;
251
+ }
252
+
253
+ function formatUserActor(
254
+ id: string | null | undefined,
255
+ publicUsers: Map<string, string>,
256
+ ): string {
257
+ if (!id) {
258
+ return "-";
259
+ }
260
+
261
+ return publicUsers.get(id) ?? formatActor("user", id);
262
+ }
263
+
264
+ function ownerIdsForThreadList(
265
+ peers: Array<ThreadPeer>,
266
+ ): string[] {
267
+ return Array.from(
268
+ new Set(
269
+ peers
270
+ .map((peer) => peer.ownerId)
271
+ .filter((id): id is string => Boolean(id)),
272
+ ),
273
+ );
274
+ }
275
+
276
+ interface ThreadPeer {
277
+ ownerId?: string | null;
278
+ channelId?: string | null;
279
+ }
280
+
281
+ function threadPeer(attrs: ThreadAttributes, actor: WhoamiActor): ThreadPeer {
282
+ const side = threadActorSide(attrs, actor);
283
+
284
+ if (side === "a") {
285
+ return {
286
+ ownerId: attrs.participant_b_owner_id,
287
+ channelId: attrs.participant_b_channel_id,
288
+ };
289
+ }
290
+
291
+ if (side === "b") {
292
+ return {
293
+ ownerId: attrs.participant_a_owner_id,
294
+ channelId: attrs.participant_a_channel_id,
295
+ };
296
+ }
297
+
298
+ return {
299
+ ownerId: attrs.participant_a_owner_id,
300
+ channelId: attrs.participant_a_channel_id,
301
+ };
302
+ }
303
+
304
+ function threadActorSide(
305
+ attrs: ThreadAttributes,
306
+ actor: WhoamiActor,
307
+ ): "a" | "b" | undefined {
308
+ if (actor.type === "channel") {
309
+ if (attrs.participant_a_channel_id === actor.id) {
310
+ return "a";
311
+ }
312
+
313
+ if (attrs.participant_b_channel_id === actor.id) {
314
+ return "b";
315
+ }
316
+ }
317
+
318
+ const ownerId = actor.type === "user" ? actor.id : actor.owner_id;
319
+
320
+ if (attrs.participant_a_owner_id === ownerId) {
321
+ return "a";
322
+ }
323
+
324
+ if (attrs.participant_b_owner_id === ownerId) {
325
+ return "b";
326
+ }
327
+
328
+ return undefined;
329
+ }
330
+
331
+ function formatThreadPeer(
332
+ peer: ThreadPeer | undefined,
333
+ publicUsers: Map<string, string>,
334
+ ): string {
335
+ if (!peer) {
336
+ return "-";
337
+ }
338
+
339
+ if (peer.channelId) {
340
+ return formatActor("channel", peer.channelId);
341
+ }
342
+
343
+ return formatUserActor(peer.ownerId, publicUsers);
344
+ }
@@ -0,0 +1,203 @@
1
+ import {
2
+ assertSinceFlags,
3
+ authenticatedActorKey,
4
+ cacheFlags,
5
+ extractServerTimestamp,
6
+ inboxMessagesScope,
7
+ prepareCachePlan,
8
+ saveCacheTimestamp,
9
+ type CacheScope,
10
+ } from "../../lib/cache";
11
+ import {
12
+ booleanFlag,
13
+ integerFlag,
14
+ requiredPositional,
15
+ stringFlag,
16
+ type ParsedArgs,
17
+ } from "../../lib/args";
18
+ import type { CommandContext } from "../../lib/context";
19
+ import { CliError } from "../../lib/errors";
20
+ import type { Io } from "../../lib/output";
21
+
22
+ export async function runInboxWatchCommand(
23
+ context: CommandContext,
24
+ args: ParsedArgs,
25
+ io: Io,
26
+ ): Promise<void> {
27
+ const subcommand = args.positionals[1];
28
+
29
+ switch (subcommand) {
30
+ case "messages": {
31
+ const threadId = requiredPositional(args.positionals, 2, "Missing thread id");
32
+ assertSinceFlags(args);
33
+ const channelToken = stringFlag(args.flags, "channelToken");
34
+ const scope = inboxMessagesScope({
35
+ context,
36
+ actorKey: await authenticatedActorKey(context, channelToken),
37
+ threadId,
38
+ query: stringFlag(args.flags, "query"),
39
+ hasAttachment: booleanFlag(args.flags, "hasAttachment") || undefined,
40
+ before: stringFlag(args.flags, "before"),
41
+ });
42
+ const initialPlan = await prepareCachePlan(context, scope);
43
+ let since =
44
+ stringFlag(args.flags, "since") ??
45
+ initialPlan.previousServerTimestamp ??
46
+ (await initialMessageWatchSince({
47
+ context,
48
+ args,
49
+ scope,
50
+ threadId,
51
+ channelToken,
52
+ }));
53
+
54
+ do {
55
+ const result = await runMessageWatchCycle({
56
+ context,
57
+ io,
58
+ args,
59
+ scope,
60
+ threadId,
61
+ channelToken,
62
+ since,
63
+ });
64
+
65
+ since = result.nextSince ?? since;
66
+
67
+ if (booleanFlag(args.flags, "once")) {
68
+ return;
69
+ }
70
+
71
+ await sleep(result.pollAfterMs);
72
+ } while (true);
73
+ }
74
+
75
+ default:
76
+ throw new CliError("Unknown inbox watch subcommand", 2);
77
+ }
78
+ }
79
+
80
+ async function initialMessageWatchSince(input: {
81
+ context: CommandContext;
82
+ args: ParsedArgs;
83
+ scope: CacheScope;
84
+ threadId: string;
85
+ channelToken?: string;
86
+ }): Promise<string> {
87
+ if (cacheFlags(input.args).sinceCache) {
88
+ throw new CliError(
89
+ "No cached server timestamp for this inbox message scope. Run a read command with `--save-cache` first.",
90
+ 2,
91
+ );
92
+ }
93
+
94
+ return bootstrapMessageWatchSince(input);
95
+ }
96
+
97
+ async function bootstrapMessageWatchSince(input: {
98
+ context: CommandContext;
99
+ args: ParsedArgs;
100
+ scope: CacheScope;
101
+ threadId: string;
102
+ channelToken?: string;
103
+ }): Promise<string> {
104
+ const response = await input.context.client.listMessagesForThread({
105
+ threadId: input.threadId,
106
+ limit: 1,
107
+ before: stringFlag(input.args.flags, "before"),
108
+ order: "latest",
109
+ query: stringFlag(input.args.flags, "query"),
110
+ hasAttachment: booleanFlag(input.args.flags, "hasAttachment") || undefined,
111
+ channelToken: input.channelToken,
112
+ });
113
+ const savedServerTimestamp = await saveCacheTimestamp(
114
+ input.context,
115
+ input.scope,
116
+ response.meta,
117
+ );
118
+
119
+ if (!savedServerTimestamp) {
120
+ throw new CliError(
121
+ "Watch startup response did not include a server timestamp. Use `--since` explicitly.",
122
+ 1,
123
+ );
124
+ }
125
+
126
+ return savedServerTimestamp;
127
+ }
128
+
129
+ async function runMessageWatchCycle(input: {
130
+ context: CommandContext;
131
+ io: Io;
132
+ args: ParsedArgs;
133
+ scope: CacheScope;
134
+ threadId: string;
135
+ channelToken?: string;
136
+ since: string;
137
+ }): Promise<{ nextSince?: string; pollAfterMs: number }> {
138
+ let cursor: string | undefined;
139
+ let latestMeta: Record<string, unknown> | undefined;
140
+ let pollAfterMs = DEFAULT_WATCH_POLL_AFTER_MS;
141
+
142
+ do {
143
+ const response = await input.context.client.listMessagesForThread({
144
+ threadId: input.threadId,
145
+ limit: integerFlag(input.args.flags, "limit", { label: "--limit" }),
146
+ cursor,
147
+ before: stringFlag(input.args.flags, "before"),
148
+ order: "oldest",
149
+ since: input.since,
150
+ query: stringFlag(input.args.flags, "query"),
151
+ hasAttachment: booleanFlag(input.args.flags, "hasAttachment") || undefined,
152
+ channelToken: input.channelToken,
153
+ });
154
+
155
+ latestMeta = response.meta;
156
+ pollAfterMs = recommendedPollAfterMs(response.meta) ?? pollAfterMs;
157
+
158
+ for (const message of response.items) {
159
+ input.io.stdout(
160
+ JSON.stringify({
161
+ kind: "message",
162
+ source: "inbox.messages",
163
+ threadId: input.threadId,
164
+ messageId: message.id,
165
+ message,
166
+ meta: {
167
+ cacheScope: input.scope.scopeKey,
168
+ serverTime: extractServerTimestamp(response.meta),
169
+ },
170
+ }),
171
+ );
172
+ }
173
+
174
+ cursor = response.nextCursor;
175
+ } while (cursor);
176
+
177
+ const savedServerTimestamp = await saveCacheTimestamp(
178
+ input.context,
179
+ input.scope,
180
+ latestMeta,
181
+ );
182
+
183
+ return {
184
+ nextSince: savedServerTimestamp,
185
+ pollAfterMs,
186
+ };
187
+ }
188
+
189
+ const DEFAULT_WATCH_POLL_AFTER_MS = 5_000;
190
+
191
+ function recommendedPollAfterMs(meta: Record<string, unknown> | undefined): number | undefined {
192
+ const value = meta?.recommended_poll_after_ms;
193
+
194
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
195
+ return undefined;
196
+ }
197
+
198
+ return value;
199
+ }
200
+
201
+ function sleep(ms: number): Promise<void> {
202
+ return new Promise((resolve) => setTimeout(resolve, ms));
203
+ }