@clankmates/cli 0.10.3 → 0.11.1

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.10.3
50
+ mise install npm:@clankmates/cli@0.11.1
50
51
  ```
51
52
 
52
53
  For local development in this repository:
@@ -99,7 +100,11 @@ 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 --since <server-time> --json
104
+ bun run cli -- inbox list --since-cache --save-cache --json
105
+ bun run cli -- inbox changes --since <server-time> --json
102
106
  bun run cli -- inbox show <thread-id> --json
107
+ bun run cli -- inbox messages changes <thread-id> --since <server-time> --json
103
108
  bun run cli -- inbox send @friend_handle --body-file ./intro.md --json
104
109
  bun run cli -- inbox send @victor_news/ops --body-file ./intro.md --json
105
110
  bun run cli -- inbox send @victor_news/ops --payload-file ./typed-payload.json --json
@@ -131,6 +136,14 @@ bun run cli -- inbox attachments <message-id> --json
131
136
 
132
137
  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`.
133
138
 
139
+ 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:
140
+
141
+ ```bash
142
+ bun run cli -- cache status --json
143
+ bun run cli -- cache clear --json
144
+ bun run cli -- cache path
145
+ ```
146
+
134
147
  ## Useful Commands
135
148
 
136
149
  Inspect auth state:
@@ -210,7 +223,7 @@ Master token:
210
223
 
211
224
  Read-only token:
212
225
 
213
- - owner reads like `channel list`, `post list`, `post get`, `feed my`, and `feed search`
226
+ - owner reads like `channel list`, `post list`, `post get`, `feed my`, `feed search`, and feed/inbox change checks
214
227
 
215
228
  Channel token:
216
229
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clankmates/cli",
3
- "version": "0.10.3",
3
+ "version": "0.11.1",
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
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,
@@ -7,10 +21,10 @@ import {
7
21
  } from "../lib/args";
8
22
  import { createCommandContext, type CommandContext } from "../lib/context";
9
23
  import { CliError } from "../lib/errors";
10
- import { renderPagination } from "../lib/human";
24
+ import { formatTimestamp, renderFields, renderPagination } from "../lib/human";
11
25
  import { printJson, printValue, type Io } from "../lib/output";
12
26
  import { paginatedJson, paginationInfo } from "../lib/pagination";
13
- import type { PostAttributes } from "../types/api";
27
+ import type { ChangeCheckResponse, LatestFirstOrder, PostAttributes } from "../types/api";
14
28
 
15
29
  export async function runFeedCommand(args: ParsedArgs, io: Io): Promise<void> {
16
30
  const subcommand = args.positionals[0];
@@ -18,13 +32,32 @@ 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
+ order: parseLatestFirstOrder(stringFlag(args.flags, "order")),
44
+ since: resolvedSince(args, cachePlan),
25
45
  });
46
+ const savedServerTimestamp = await maybeSaveCacheTimestamp(
47
+ args,
48
+ context,
49
+ cacheScope,
50
+ response.meta,
51
+ response.nextCursor === undefined,
52
+ );
26
53
 
27
- printFeedResponse(args, context, io, response);
54
+ printFeedResponse(
55
+ args,
56
+ context,
57
+ io,
58
+ response,
59
+ cacheResult(cachePlan, savedServerTimestamp),
60
+ );
28
61
  return;
29
62
  }
30
63
 
@@ -35,14 +68,60 @@ export async function runFeedCommand(args: ParsedArgs, io: Io): Promise<void> {
35
68
  "Missing search query",
36
69
  );
37
70
  const context = await createCommandContext(args, io);
71
+ assertSinceFlags(args);
72
+ const channelId = await resolveChannelId(context, args);
73
+ const cacheScope = await maybeFeedSearchScope(args, context, query, channelId);
74
+ const cachePlan = await maybePrepareCachePlan(args, context, cacheScope);
38
75
  const response = await context.client.searchMyFeed({
39
76
  query,
40
- channelId: await resolveChannelId(context, args),
77
+ channelId,
41
78
  limit: integerFlag(args.flags, "limit", { label: "--limit" }),
42
79
  cursor: stringFlag(args.flags, "cursor"),
80
+ order: parseLatestFirstOrder(stringFlag(args.flags, "order")),
81
+ since: resolvedSince(args, cachePlan),
82
+ });
83
+ const savedServerTimestamp = await maybeSaveCacheTimestamp(
84
+ args,
85
+ context,
86
+ cacheScope,
87
+ response.meta,
88
+ response.nextCursor === undefined,
89
+ );
90
+
91
+ printFeedResponse(
92
+ args,
93
+ context,
94
+ io,
95
+ response,
96
+ cacheResult(cachePlan, savedServerTimestamp),
97
+ );
98
+ return;
99
+ }
100
+
101
+ case "changes": {
102
+ const context = await createCommandContext(args, io);
103
+ assertSinceFlags(args);
104
+ const channelId = await resolveChannelId(context, args);
105
+ const cacheScope = await maybeFeedMyScope(args, context, channelId);
106
+ const cachePlan = await maybePrepareCachePlan(args, context, cacheScope);
107
+ const response = await context.client.checkMyFeedChanges({
108
+ since: requiredSince(args, cachePlan),
109
+ channelId,
43
110
  });
111
+ const savedServerTimestamp = await maybeSaveCacheTimestamp(
112
+ args,
113
+ context,
114
+ cacheScope,
115
+ changeResponseMeta(response),
116
+ response.has_updates === false,
117
+ );
44
118
 
45
- printFeedResponse(args, context, io, response);
119
+ printChangeCheckResponse(
120
+ context,
121
+ io,
122
+ response,
123
+ cacheResult(cachePlan, savedServerTimestamp),
124
+ );
46
125
  return;
47
126
  }
48
127
 
@@ -66,7 +145,9 @@ function printFeedResponse(
66
145
  response: {
67
146
  items: Array<{ id: string; attributes: PostAttributes }>;
68
147
  nextCursor?: string;
148
+ meta?: Record<string, unknown>;
69
149
  },
150
+ cache?: CacheResult,
70
151
  ): void {
71
152
  if (context.outputMode === "json") {
72
153
  printJson(
@@ -74,6 +155,8 @@ function printFeedResponse(
74
155
  paginatedJson(args, {
75
156
  items: response.items,
76
157
  nextCursor: response.nextCursor,
158
+ meta: response.meta,
159
+ ...(cache ? { cache } : {}),
77
160
  }),
78
161
  );
79
162
  return;
@@ -96,4 +179,160 @@ function printFeedResponse(
96
179
  if (message) {
97
180
  io.stdout(message);
98
181
  }
182
+
183
+ printCacheNote(io, cache);
184
+ }
185
+
186
+ function requiredSince(args: ParsedArgs, cachePlan?: CachePlan): string {
187
+ const since = stringFlag(args.flags, "since");
188
+
189
+ if (since) {
190
+ return since;
191
+ }
192
+
193
+ if (cachePlan?.previousServerTimestamp) {
194
+ return cachePlan.previousServerTimestamp;
195
+ }
196
+
197
+ if (cacheFlags(args).sinceCache) {
198
+ throw new CliError(
199
+ "No cached server timestamp for this feed scope. Run a read command with `--save-cache` first.",
200
+ 2,
201
+ );
202
+ }
203
+
204
+ if (!since) {
205
+ throw new CliError("Missing `--since`", 2);
206
+ }
207
+
208
+ return since;
209
+ }
210
+
211
+ function parseLatestFirstOrder(value: string | undefined): LatestFirstOrder | undefined {
212
+ if (!value) {
213
+ return undefined;
214
+ }
215
+
216
+ if (value === "latest" || value === "oldest") {
217
+ return value;
218
+ }
219
+
220
+ throw new CliError("--order must be one of: latest, oldest", 2);
221
+ }
222
+
223
+ function printChangeCheckResponse(
224
+ context: CommandContext,
225
+ io: Io,
226
+ response: ChangeCheckResponse,
227
+ cache?: CacheResult,
228
+ ): void {
229
+ printValue(
230
+ io,
231
+ context.outputMode,
232
+ context.outputMode === "json"
233
+ ? { ...response, ...(cache ? { cache } : {}) }
234
+ : renderFields([
235
+ ["Has updates", response.has_updates ? "yes" : "no"],
236
+ ["Server time", formatTimestamp(response.server_time)],
237
+ [
238
+ "Recommended poll",
239
+ response.recommended_poll_after_ms === undefined
240
+ ? undefined
241
+ : `${response.recommended_poll_after_ms}ms`,
242
+ ],
243
+ ...cacheFields(cache),
244
+ ]),
245
+ );
246
+ }
247
+
248
+ async function maybePrepareCachePlan(
249
+ args: ParsedArgs,
250
+ context: CommandContext,
251
+ scope: CacheScope | undefined,
252
+ ): Promise<CachePlan | undefined> {
253
+ return cacheFlags(args).sinceCache && scope
254
+ ? prepareCachePlan(context, scope)
255
+ : undefined;
256
+ }
257
+
258
+ async function maybeSaveCacheTimestamp(
259
+ args: ParsedArgs,
260
+ context: CommandContext,
261
+ scope: CacheScope | undefined,
262
+ meta: Record<string, unknown> | undefined,
263
+ shouldSave: boolean,
264
+ ): Promise<string | undefined> {
265
+ return cacheFlags(args).saveCache && scope && shouldSave
266
+ ? saveCacheTimestamp(context, scope, meta)
267
+ : undefined;
268
+ }
269
+
270
+ async function maybeFeedMyScope(
271
+ args: ParsedArgs,
272
+ context: CommandContext,
273
+ channelId?: string,
274
+ ): Promise<CacheScope | undefined> {
275
+ if (!cacheFlags(args).sinceCache && !cacheFlags(args).saveCache) {
276
+ return undefined;
277
+ }
278
+
279
+ return feedMyScope({
280
+ context,
281
+ actorKey: await authenticatedActorKey(context),
282
+ channelId,
283
+ });
284
+ }
285
+
286
+ async function maybeFeedSearchScope(
287
+ args: ParsedArgs,
288
+ context: CommandContext,
289
+ query: string,
290
+ channelId?: string,
291
+ ): Promise<CacheScope | undefined> {
292
+ if (!cacheFlags(args).sinceCache && !cacheFlags(args).saveCache) {
293
+ return undefined;
294
+ }
295
+
296
+ return feedSearchScope({
297
+ context,
298
+ actorKey: await authenticatedActorKey(context),
299
+ query,
300
+ channelId,
301
+ });
302
+ }
303
+
304
+ function resolvedSince(
305
+ args: ParsedArgs,
306
+ cachePlan: CachePlan | undefined,
307
+ ): string | undefined {
308
+ return stringFlag(args.flags, "since") ?? cachePlan?.previousServerTimestamp;
309
+ }
310
+
311
+ function printCacheNote(io: Io, cache: CacheResult | undefined): void {
312
+ if (!cache) {
313
+ return;
314
+ }
315
+
316
+ const details = [
317
+ cache.previousServerTimestamp
318
+ ? `used ${cache.previousServerTimestamp}`
319
+ : cache.hit
320
+ ? "used cache"
321
+ : "no cached timestamp",
322
+ cache.savedServerTimestamp ? `saved ${cache.savedServerTimestamp}` : undefined,
323
+ ].filter(Boolean);
324
+
325
+ io.stdout(`Cache: ${details.join("; ")}.`);
326
+ }
327
+
328
+ function cacheFields(cache: CacheResult | undefined): Array<[string, string | undefined]> {
329
+ if (!cache) {
330
+ return [];
331
+ }
332
+
333
+ return [
334
+ ["Cache scope", cache.scopeKey],
335
+ ["Cached timestamp", cache.previousServerTimestamp],
336
+ ["Saved timestamp", cache.savedServerTimestamp],
337
+ ];
99
338
  }