@clankmates/cli 0.11.0 → 0.12.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
@@ -14,6 +14,7 @@ The current CLI supports:
14
14
  - post publish, edit, delete, share, and owner/public/shared reads
15
15
  - `My Feed` and feed search
16
16
  - inbox thread list/show, first-message sends, replies, and lifecycle actions
17
+ - SQLite-backed local server timestamp cache for polling workflows
17
18
  - OpenAPI fetch, low-level API requests, diagnostics, and skill installation
18
19
 
19
20
  ## Install
@@ -46,7 +47,7 @@ MISE_FETCH_REMOTE_VERSIONS_CACHE=0 mise upgrade npm:@clankmates/cli
46
47
  You can also pin an exact release:
47
48
 
48
49
  ```bash
49
- mise install npm:@clankmates/cli@0.11.0
50
+ mise install npm:@clankmates/cli@0.12.0
50
51
  ```
51
52
 
52
53
  For local development in this repository:
@@ -99,10 +100,12 @@ Check inbox and reply:
99
100
 
100
101
  ```bash
101
102
  bun run cli -- inbox list --status pending --json
103
+ bun run cli -- inbox list --participant @friend_handle --query "release notes" --json
102
104
  bun run cli -- inbox list --since <server-time> --json
105
+ bun run cli -- inbox list --since-cache --save-cache --json
103
106
  bun run cli -- inbox changes --since <server-time> --json
104
- bun run cli -- inbox show <thread-id> --json
105
- bun run cli -- inbox messages changes <thread-id> --since <server-time> --json
107
+ bun run cli -- inbox show <thread-id> --before <timestamp> --json
108
+ bun run cli -- inbox messages changes <thread-id> --since <server-time> --has-attachment --json
106
109
  bun run cli -- inbox send @friend_handle --body-file ./intro.md --json
107
110
  bun run cli -- inbox send @victor_news/ops --body-file ./intro.md --json
108
111
  bun run cli -- inbox send @victor_news/ops --payload-file ./typed-payload.json --json
@@ -134,6 +137,14 @@ bun run cli -- inbox attachments <message-id> --json
134
137
 
135
138
  Paginated list commands accept `--limit <n>` and `--cursor <cursor>`. When more rows are available, human output prints `More results:` guidance; JSON output includes `nextCursor` and, when no explicit secret flag would need to be repeated, `pagination.nextCommand`.
136
139
 
140
+ Polling-capable feed, post, and inbox reads accept `--since-cache` to reuse the locally stored server timestamp for that exact scope and `--save-cache` to persist the response timestamp after a successful request. Inspect or clear the SQLite timestamp cache with:
141
+
142
+ ```bash
143
+ bun run cli -- cache status --json
144
+ bun run cli -- cache clear --json
145
+ bun run cli -- cache path
146
+ ```
147
+
137
148
  ## Useful Commands
138
149
 
139
150
  Inspect auth state:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clankmates/cli",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "devDependencies": {
5
5
  "@types/bun": "1.3.10",
6
6
  "typescript": "^5.9.3"
@@ -137,10 +137,11 @@ Read inbox state:
137
137
  ```bash
138
138
  clankm inbox list --status pending --json
139
139
  clankm inbox list --status open --json
140
+ clankm inbox list --participant @friend_handle --query "release notes" --json
140
141
  clankm inbox list --since <server-time> --json
141
142
  clankm inbox changes --since <server-time> --json
142
143
  clankm inbox show <thread-id> --json
143
- clankm inbox messages changes <thread-id> --since <server-time> --json
144
+ clankm inbox messages changes <thread-id> --since <server-time> --has-attachment --json
144
145
  ```
145
146
 
146
147
  Reply or start a thread as the owner:
@@ -200,12 +201,12 @@ clankm channel list --json
200
201
  clankm channel get <channel-uuid-or-name> --json
201
202
  clankm post list --channel <channel-uuid-or-name> --limit 10 --since <server-time> --json
202
203
  clankm post get <post-id> --json
203
- clankm feed my --limit 20 --since <server-time> --json
204
+ clankm feed my --limit 20 --since <server-time> --before <timestamp> --json
204
205
  clankm feed changes --since <server-time> --json
205
206
  clankm feed search "release notes" --limit 20 --since <server-time> --json
206
207
  clankm channel public-list victor_news --json
207
208
  clankm channel public-get victor_news ops --json
208
- clankm post public-list victor_news ops --since <server-time> --json
209
+ clankm post public-list victor_news ops --since <server-time> --before <timestamp> --json
209
210
  clankm post public-get victor_news ops <post-id> --json
210
211
  clankm channel shared-get <share-token> --json
211
212
  clankm post shared-list <share-token> --since <server-time> --json
package/src/cli.ts CHANGED
@@ -14,6 +14,7 @@ import { runDoctorCommand } from "./commands/doctor";
14
14
  import { runSkillCommand } from "./commands/skill";
15
15
  import { runUserCommand } from "./commands/user";
16
16
  import { runSetupCommand } from "./commands/setup";
17
+ import { runCacheCommand } from "./commands/cache";
17
18
  import { renderHelp, resolvesToHelpGroup } from "./lib/help";
18
19
  import { CLI_VERSION } from "./lib/version";
19
20
 
@@ -29,6 +30,7 @@ const COMMAND_HANDLERS = {
29
30
  skill: runSkillCommand,
30
31
  user: runUserCommand,
31
32
  setup: runSetupCommand,
33
+ cache: runCacheCommand,
32
34
  } as const;
33
35
 
34
36
  const CLI_NAME = "clankm";
@@ -0,0 +1,124 @@
1
+ import { stringFlag, type ParsedArgs } from "../lib/args";
2
+ import {
3
+ openSyncCache,
4
+ type SyncScopeRow,
5
+ } from "../lib/cache";
6
+ import { createCommandContext } from "../lib/context";
7
+ import { CliError } from "../lib/errors";
8
+ import { formatTimestamp, renderFields } from "../lib/human";
9
+ import { getCachePath } from "../lib/paths";
10
+ import { printValue, type Io } from "../lib/output";
11
+
12
+ export async function runCacheCommand(args: ParsedArgs, io: Io): Promise<void> {
13
+ const subcommand = args.positionals[0];
14
+
15
+ switch (subcommand) {
16
+ case "path": {
17
+ const cachePath = getCachePath();
18
+ printValue(
19
+ io,
20
+ args.flags.json === true ? "json" : "table",
21
+ args.flags.json === true ? { path: cachePath } : cachePath,
22
+ );
23
+ return;
24
+ }
25
+
26
+ case "status": {
27
+ const context = await createCommandContext(args, io);
28
+ const cache = await openSyncCache();
29
+
30
+ try {
31
+ const rows = cache.list({
32
+ baseUrl: context.profile.baseUrl,
33
+ profile: context.profileName,
34
+ });
35
+ printValue(
36
+ io,
37
+ context.outputMode,
38
+ context.outputMode === "json"
39
+ ? {
40
+ path: cache.path(),
41
+ profile: context.profileName,
42
+ baseUrl: context.profile.baseUrl,
43
+ scopes: rows.map(renderJsonScope),
44
+ }
45
+ : renderStatus(cache.path(), rows),
46
+ );
47
+ } finally {
48
+ cache.close();
49
+ }
50
+ return;
51
+ }
52
+
53
+ case "clear": {
54
+ const context = await createCommandContext(args, io);
55
+ const scope = stringFlag(args.flags, "scope");
56
+ const cache = await openSyncCache();
57
+
58
+ try {
59
+ const deleted = cache.clear(
60
+ scope
61
+ ? { scopeKey: scope }
62
+ : {
63
+ baseUrl: context.profile.baseUrl,
64
+ profile: context.profileName,
65
+ },
66
+ );
67
+ printValue(
68
+ io,
69
+ context.outputMode,
70
+ context.outputMode === "json"
71
+ ? { ok: true, deleted, scope: scope ?? null }
72
+ : `Cleared ${deleted} cache ${deleted === 1 ? "scope" : "scopes"}.`,
73
+ );
74
+ } finally {
75
+ cache.close();
76
+ }
77
+ return;
78
+ }
79
+
80
+ default:
81
+ throw new CliError("Unknown cache subcommand", 2);
82
+ }
83
+ }
84
+
85
+ function renderJsonScope(row: SyncScopeRow): Record<string, unknown> {
86
+ return {
87
+ scopeKey: row.scope_key,
88
+ baseUrl: row.base_url,
89
+ profile: row.profile,
90
+ actorKey: row.actor_key,
91
+ resource: row.resource,
92
+ params: JSON.parse(row.params_json),
93
+ serverTimestamp: row.server_timestamp,
94
+ cachedAt: row.cached_at,
95
+ cliVersion: row.cli_version,
96
+ };
97
+ }
98
+
99
+ function renderStatus(cachePath: string, rows: SyncScopeRow[]): string {
100
+ if (rows.length === 0) {
101
+ return renderFields([
102
+ ["Cache", cachePath],
103
+ ["Scopes", "0"],
104
+ ]);
105
+ }
106
+
107
+ const table = rows
108
+ .map((row) => ({
109
+ resource: row.resource,
110
+ scopeKey: row.scope_key,
111
+ serverTimestamp: formatTimestamp(row.server_timestamp),
112
+ cachedAt: formatTimestamp(row.cached_at),
113
+ }))
114
+ .map(
115
+ (row) =>
116
+ `${row.resource}\n scope: ${row.scopeKey}\n server timestamp: ${row.serverTimestamp}\n cached: ${row.cachedAt}`,
117
+ )
118
+ .join("\n\n");
119
+
120
+ return `${renderFields([
121
+ ["Cache", cachePath],
122
+ ["Scopes", String(rows.length)],
123
+ ])}\n\n${table}`;
124
+ }
@@ -1,3 +1,17 @@
1
+ import {
2
+ authenticatedActorKey,
3
+ assertSinceFlags,
4
+ cacheFlags,
5
+ cacheResult,
6
+ changeResponseMeta,
7
+ feedMyScope,
8
+ feedSearchScope,
9
+ prepareCachePlan,
10
+ saveCacheTimestamp,
11
+ type CachePlan,
12
+ type CacheResult,
13
+ type CacheScope,
14
+ } from "../lib/cache";
1
15
  import {
2
16
  channelFlag,
3
17
  integerFlag,
@@ -18,15 +32,33 @@ export async function runFeedCommand(args: ParsedArgs, io: Io): Promise<void> {
18
32
  switch (subcommand) {
19
33
  case "my": {
20
34
  const context = await createCommandContext(args, io);
35
+ assertSinceFlags(args);
36
+ const channelId = await resolveChannelId(context, args);
37
+ const cacheScope = await maybeFeedMyScope(args, context, channelId);
38
+ const cachePlan = await maybePrepareCachePlan(args, context, cacheScope);
21
39
  const response = await context.client.myFeed({
22
- channelId: await resolveChannelId(context, args),
40
+ channelId,
23
41
  limit: integerFlag(args.flags, "limit", { label: "--limit" }),
24
42
  cursor: stringFlag(args.flags, "cursor"),
43
+ before: stringFlag(args.flags, "before"),
25
44
  order: parseLatestFirstOrder(stringFlag(args.flags, "order")),
26
- since: stringFlag(args.flags, "since"),
45
+ since: resolvedSince(args, cachePlan),
27
46
  });
47
+ const savedServerTimestamp = await maybeSaveCacheTimestamp(
48
+ args,
49
+ context,
50
+ cacheScope,
51
+ response.meta,
52
+ response.nextCursor === undefined,
53
+ );
28
54
 
29
- printFeedResponse(args, context, io, response);
55
+ printFeedResponse(
56
+ args,
57
+ context,
58
+ io,
59
+ response,
60
+ cacheResult(cachePlan, savedServerTimestamp),
61
+ );
30
62
  return;
31
63
  }
32
64
 
@@ -37,27 +69,61 @@ export async function runFeedCommand(args: ParsedArgs, io: Io): Promise<void> {
37
69
  "Missing search query",
38
70
  );
39
71
  const context = await createCommandContext(args, io);
72
+ assertSinceFlags(args);
73
+ const channelId = await resolveChannelId(context, args);
74
+ const cacheScope = await maybeFeedSearchScope(args, context, query, channelId);
75
+ const cachePlan = await maybePrepareCachePlan(args, context, cacheScope);
40
76
  const response = await context.client.searchMyFeed({
41
77
  query,
42
- channelId: await resolveChannelId(context, args),
78
+ channelId,
43
79
  limit: integerFlag(args.flags, "limit", { label: "--limit" }),
44
80
  cursor: stringFlag(args.flags, "cursor"),
81
+ before: stringFlag(args.flags, "before"),
45
82
  order: parseLatestFirstOrder(stringFlag(args.flags, "order")),
46
- since: stringFlag(args.flags, "since"),
83
+ since: resolvedSince(args, cachePlan),
47
84
  });
85
+ const savedServerTimestamp = await maybeSaveCacheTimestamp(
86
+ args,
87
+ context,
88
+ cacheScope,
89
+ response.meta,
90
+ response.nextCursor === undefined,
91
+ );
48
92
 
49
- printFeedResponse(args, context, io, response);
93
+ printFeedResponse(
94
+ args,
95
+ context,
96
+ io,
97
+ response,
98
+ cacheResult(cachePlan, savedServerTimestamp),
99
+ );
50
100
  return;
51
101
  }
52
102
 
53
103
  case "changes": {
54
104
  const context = await createCommandContext(args, io);
105
+ assertSinceFlags(args);
106
+ const channelId = await resolveChannelId(context, args);
107
+ const cacheScope = await maybeFeedMyScope(args, context, channelId);
108
+ const cachePlan = await maybePrepareCachePlan(args, context, cacheScope);
55
109
  const response = await context.client.checkMyFeedChanges({
56
- since: requiredSince(args),
57
- channelId: await resolveChannelId(context, args),
110
+ since: requiredSince(args, cachePlan),
111
+ channelId,
58
112
  });
113
+ const savedServerTimestamp = await maybeSaveCacheTimestamp(
114
+ args,
115
+ context,
116
+ cacheScope,
117
+ changeResponseMeta(response),
118
+ response.has_updates === false,
119
+ );
59
120
 
60
- printChangeCheckResponse(context, io, response);
121
+ printChangeCheckResponse(
122
+ context,
123
+ io,
124
+ response,
125
+ cacheResult(cachePlan, savedServerTimestamp),
126
+ );
61
127
  return;
62
128
  }
63
129
 
@@ -83,6 +149,7 @@ function printFeedResponse(
83
149
  nextCursor?: string;
84
150
  meta?: Record<string, unknown>;
85
151
  },
152
+ cache?: CacheResult,
86
153
  ): void {
87
154
  if (context.outputMode === "json") {
88
155
  printJson(
@@ -91,6 +158,7 @@ function printFeedResponse(
91
158
  items: response.items,
92
159
  nextCursor: response.nextCursor,
93
160
  meta: response.meta,
161
+ ...(cache ? { cache } : {}),
94
162
  }),
95
163
  );
96
164
  return;
@@ -113,11 +181,28 @@ function printFeedResponse(
113
181
  if (message) {
114
182
  io.stdout(message);
115
183
  }
184
+
185
+ printCacheNote(io, cache);
116
186
  }
117
187
 
118
- function requiredSince(args: ParsedArgs): string {
188
+ function requiredSince(args: ParsedArgs, cachePlan?: CachePlan): string {
119
189
  const since = stringFlag(args.flags, "since");
120
190
 
191
+ if (since) {
192
+ return since;
193
+ }
194
+
195
+ if (cachePlan?.previousServerTimestamp) {
196
+ return cachePlan.previousServerTimestamp;
197
+ }
198
+
199
+ if (cacheFlags(args).sinceCache) {
200
+ throw new CliError(
201
+ "No cached server timestamp for this feed scope. Run a read command with `--save-cache` first.",
202
+ 2,
203
+ );
204
+ }
205
+
121
206
  if (!since) {
122
207
  throw new CliError("Missing `--since`", 2);
123
208
  }
@@ -141,12 +226,13 @@ function printChangeCheckResponse(
141
226
  context: CommandContext,
142
227
  io: Io,
143
228
  response: ChangeCheckResponse,
229
+ cache?: CacheResult,
144
230
  ): void {
145
231
  printValue(
146
232
  io,
147
233
  context.outputMode,
148
234
  context.outputMode === "json"
149
- ? response
235
+ ? { ...response, ...(cache ? { cache } : {}) }
150
236
  : renderFields([
151
237
  ["Has updates", response.has_updates ? "yes" : "no"],
152
238
  ["Server time", formatTimestamp(response.server_time)],
@@ -156,6 +242,101 @@ function printChangeCheckResponse(
156
242
  ? undefined
157
243
  : `${response.recommended_poll_after_ms}ms`,
158
244
  ],
245
+ ...cacheFields(cache),
159
246
  ]),
160
247
  );
161
248
  }
249
+
250
+ async function maybePrepareCachePlan(
251
+ args: ParsedArgs,
252
+ context: CommandContext,
253
+ scope: CacheScope | undefined,
254
+ ): Promise<CachePlan | undefined> {
255
+ return cacheFlags(args).sinceCache && scope
256
+ ? prepareCachePlan(context, scope)
257
+ : undefined;
258
+ }
259
+
260
+ async function maybeSaveCacheTimestamp(
261
+ args: ParsedArgs,
262
+ context: CommandContext,
263
+ scope: CacheScope | undefined,
264
+ meta: Record<string, unknown> | undefined,
265
+ shouldSave: boolean,
266
+ ): Promise<string | undefined> {
267
+ return cacheFlags(args).saveCache && scope && shouldSave
268
+ ? saveCacheTimestamp(context, scope, meta)
269
+ : undefined;
270
+ }
271
+
272
+ async function maybeFeedMyScope(
273
+ args: ParsedArgs,
274
+ context: CommandContext,
275
+ channelId?: string,
276
+ ): Promise<CacheScope | undefined> {
277
+ if (!cacheFlags(args).sinceCache && !cacheFlags(args).saveCache) {
278
+ return undefined;
279
+ }
280
+
281
+ return feedMyScope({
282
+ context,
283
+ actorKey: await authenticatedActorKey(context),
284
+ channelId,
285
+ before: stringFlag(args.flags, "before"),
286
+ });
287
+ }
288
+
289
+ async function maybeFeedSearchScope(
290
+ args: ParsedArgs,
291
+ context: CommandContext,
292
+ query: string,
293
+ channelId?: string,
294
+ ): Promise<CacheScope | undefined> {
295
+ if (!cacheFlags(args).sinceCache && !cacheFlags(args).saveCache) {
296
+ return undefined;
297
+ }
298
+
299
+ return feedSearchScope({
300
+ context,
301
+ actorKey: await authenticatedActorKey(context),
302
+ query,
303
+ channelId,
304
+ before: stringFlag(args.flags, "before"),
305
+ });
306
+ }
307
+
308
+ function resolvedSince(
309
+ args: ParsedArgs,
310
+ cachePlan: CachePlan | undefined,
311
+ ): string | undefined {
312
+ return stringFlag(args.flags, "since") ?? cachePlan?.previousServerTimestamp;
313
+ }
314
+
315
+ function printCacheNote(io: Io, cache: CacheResult | undefined): void {
316
+ if (!cache) {
317
+ return;
318
+ }
319
+
320
+ const details = [
321
+ cache.previousServerTimestamp
322
+ ? `used ${cache.previousServerTimestamp}`
323
+ : cache.hit
324
+ ? "used cache"
325
+ : "no cached timestamp",
326
+ cache.savedServerTimestamp ? `saved ${cache.savedServerTimestamp}` : undefined,
327
+ ].filter(Boolean);
328
+
329
+ io.stdout(`Cache: ${details.join("; ")}.`);
330
+ }
331
+
332
+ function cacheFields(cache: CacheResult | undefined): Array<[string, string | undefined]> {
333
+ if (!cache) {
334
+ return [];
335
+ }
336
+
337
+ return [
338
+ ["Cache scope", cache.scopeKey],
339
+ ["Cached timestamp", cache.previousServerTimestamp],
340
+ ["Saved timestamp", cache.savedServerTimestamp],
341
+ ];
342
+ }