@mepuka/skygent 0.2.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 (114) hide show
  1. package/README.md +59 -0
  2. package/index.ts +146 -0
  3. package/package.json +56 -0
  4. package/src/cli/app.ts +75 -0
  5. package/src/cli/config-command.ts +140 -0
  6. package/src/cli/config.ts +91 -0
  7. package/src/cli/derive.ts +205 -0
  8. package/src/cli/doc/annotation.ts +36 -0
  9. package/src/cli/doc/filter.ts +69 -0
  10. package/src/cli/doc/index.ts +9 -0
  11. package/src/cli/doc/post.ts +155 -0
  12. package/src/cli/doc/primitives.ts +25 -0
  13. package/src/cli/doc/render.ts +18 -0
  14. package/src/cli/doc/table.ts +114 -0
  15. package/src/cli/doc/thread.ts +46 -0
  16. package/src/cli/doc/tree.ts +126 -0
  17. package/src/cli/errors.ts +59 -0
  18. package/src/cli/exit-codes.ts +52 -0
  19. package/src/cli/feed.ts +177 -0
  20. package/src/cli/filter-dsl.ts +1411 -0
  21. package/src/cli/filter-errors.ts +208 -0
  22. package/src/cli/filter-help.ts +70 -0
  23. package/src/cli/filter-input.ts +54 -0
  24. package/src/cli/filter.ts +435 -0
  25. package/src/cli/graph.ts +472 -0
  26. package/src/cli/help.ts +14 -0
  27. package/src/cli/interval.ts +35 -0
  28. package/src/cli/jetstream.ts +173 -0
  29. package/src/cli/layers.ts +180 -0
  30. package/src/cli/logging.ts +136 -0
  31. package/src/cli/output-format.ts +26 -0
  32. package/src/cli/output.ts +82 -0
  33. package/src/cli/parse.ts +80 -0
  34. package/src/cli/post.ts +193 -0
  35. package/src/cli/preferences.ts +11 -0
  36. package/src/cli/query-fields.ts +247 -0
  37. package/src/cli/query.ts +415 -0
  38. package/src/cli/range.ts +44 -0
  39. package/src/cli/search.ts +465 -0
  40. package/src/cli/shared-options.ts +169 -0
  41. package/src/cli/shared.ts +20 -0
  42. package/src/cli/store-errors.ts +80 -0
  43. package/src/cli/store-tree.ts +392 -0
  44. package/src/cli/store.ts +395 -0
  45. package/src/cli/sync-factory.ts +107 -0
  46. package/src/cli/sync.ts +366 -0
  47. package/src/cli/view-thread.ts +196 -0
  48. package/src/cli/view.ts +47 -0
  49. package/src/cli/watch.ts +344 -0
  50. package/src/db/migrations/store-catalog/001_init.ts +14 -0
  51. package/src/db/migrations/store-index/001_init.ts +34 -0
  52. package/src/db/migrations/store-index/002_event_log.ts +24 -0
  53. package/src/db/migrations/store-index/003_fts_and_derived.ts +52 -0
  54. package/src/db/migrations/store-index/004_query_indexes.ts +9 -0
  55. package/src/db/migrations/store-index/005_post_lang.ts +15 -0
  56. package/src/db/migrations/store-index/006_has_embed.ts +10 -0
  57. package/src/db/migrations/store-index/007_event_seq_and_checkpoints.ts +68 -0
  58. package/src/domain/bsky.ts +467 -0
  59. package/src/domain/config.ts +11 -0
  60. package/src/domain/credentials.ts +6 -0
  61. package/src/domain/defaults.ts +8 -0
  62. package/src/domain/derivation.ts +55 -0
  63. package/src/domain/errors.ts +71 -0
  64. package/src/domain/events.ts +55 -0
  65. package/src/domain/extract.ts +64 -0
  66. package/src/domain/filter-describe.ts +551 -0
  67. package/src/domain/filter-explain.ts +9 -0
  68. package/src/domain/filter.ts +797 -0
  69. package/src/domain/format.ts +91 -0
  70. package/src/domain/index.ts +13 -0
  71. package/src/domain/indexes.ts +17 -0
  72. package/src/domain/policies.ts +16 -0
  73. package/src/domain/post.ts +88 -0
  74. package/src/domain/primitives.ts +50 -0
  75. package/src/domain/raw.ts +140 -0
  76. package/src/domain/store.ts +103 -0
  77. package/src/domain/sync.ts +211 -0
  78. package/src/domain/text-width.ts +56 -0
  79. package/src/services/app-config.ts +278 -0
  80. package/src/services/bsky-client.ts +2113 -0
  81. package/src/services/credential-store.ts +408 -0
  82. package/src/services/derivation-engine.ts +502 -0
  83. package/src/services/derivation-settings.ts +61 -0
  84. package/src/services/derivation-validator.ts +68 -0
  85. package/src/services/filter-compiler.ts +269 -0
  86. package/src/services/filter-library.ts +371 -0
  87. package/src/services/filter-runtime.ts +821 -0
  88. package/src/services/filter-settings.ts +30 -0
  89. package/src/services/identity-resolver.ts +563 -0
  90. package/src/services/jetstream-sync.ts +636 -0
  91. package/src/services/lineage-store.ts +89 -0
  92. package/src/services/link-validator.ts +244 -0
  93. package/src/services/output-manager.ts +274 -0
  94. package/src/services/post-parser.ts +62 -0
  95. package/src/services/profile-resolver.ts +223 -0
  96. package/src/services/resource-monitor.ts +106 -0
  97. package/src/services/shared.ts +69 -0
  98. package/src/services/store-cleaner.ts +43 -0
  99. package/src/services/store-commit.ts +168 -0
  100. package/src/services/store-db.ts +248 -0
  101. package/src/services/store-event-log.ts +285 -0
  102. package/src/services/store-index-sql.ts +289 -0
  103. package/src/services/store-index.ts +1152 -0
  104. package/src/services/store-keys.ts +4 -0
  105. package/src/services/store-manager.ts +358 -0
  106. package/src/services/store-stats.ts +522 -0
  107. package/src/services/store-writer.ts +200 -0
  108. package/src/services/sync-checkpoint-store.ts +169 -0
  109. package/src/services/sync-engine.ts +547 -0
  110. package/src/services/sync-reporter.ts +16 -0
  111. package/src/services/sync-settings.ts +72 -0
  112. package/src/services/trending-topics.ts +226 -0
  113. package/src/services/view-checkpoint-store.ts +238 -0
  114. package/src/typeclass/chunk.ts +84 -0
@@ -0,0 +1,472 @@
1
+ import { Args, Command, Options } from "@effect/cli";
2
+ import { Effect, Option, Stream } from "effect";
3
+ import { BskyClient } from "../services/bsky-client.js";
4
+ import { AppConfigService } from "../services/app-config.js";
5
+ import { IdentityResolver } from "../services/identity-resolver.js";
6
+ import type { ListItemView, ListView, ProfileView, RelationshipView } from "../domain/bsky.js";
7
+ import { decodeActor, parseLimit } from "./shared-options.js";
8
+ import { CliInputError } from "./errors.js";
9
+ import { withExamples } from "./help.js";
10
+ import { writeJson, writeJsonStream, writeText } from "./output.js";
11
+ import { renderTableLegacy } from "./doc/table.js";
12
+ import { jsonNdjsonTableFormats, resolveOutputFormat } from "./output-format.js";
13
+
14
+ const actorArg = Args.text({ name: "actor" }).pipe(
15
+ Args.withDescription("Bluesky handle or DID")
16
+ );
17
+
18
+ const listUriArg = Args.text({ name: "uri" }).pipe(
19
+ Args.withDescription("Bluesky list URI (at://...)")
20
+ );
21
+
22
+ const limitOption = Options.integer("limit").pipe(
23
+ Options.withDescription("Maximum number of results"),
24
+ Options.optional
25
+ );
26
+
27
+ const cursorOption = Options.text("cursor").pipe(
28
+ Options.withDescription("Pagination cursor"),
29
+ Options.optional
30
+ );
31
+
32
+ const formatOption = Options.choice("format", jsonNdjsonTableFormats).pipe(
33
+ Options.withDescription("Output format (default: json)"),
34
+ Options.optional
35
+ );
36
+
37
+ const purposeOption = Options.choice("purpose", ["modlist", "curatelist"]).pipe(
38
+ Options.withDescription("List purpose filter"),
39
+ Options.optional
40
+ );
41
+
42
+ const othersOption = Options.text("others").pipe(
43
+ Options.withDescription("Comma-separated list of actors to compare" )
44
+ );
45
+
46
+ const renderProfileTable = (
47
+ actors: ReadonlyArray<ProfileView>,
48
+ cursor: string | undefined
49
+ ) => {
50
+ const rows = actors.map((actor) => [
51
+ actor.handle,
52
+ actor.displayName ?? "",
53
+ actor.did
54
+ ]);
55
+ const table = renderTableLegacy(["HANDLE", "DISPLAY NAME", "DID"], rows);
56
+ return cursor ? `${table}\n\nCursor: ${cursor}` : table;
57
+ };
58
+
59
+ const renderListTable = (
60
+ lists: ReadonlyArray<ListView>,
61
+ cursor: string | undefined
62
+ ) => {
63
+ const rows = lists.map((list) => [
64
+ list.name,
65
+ list.purpose,
66
+ list.creator.handle,
67
+ list.uri,
68
+ typeof list.listItemCount === "number" ? String(list.listItemCount) : ""
69
+ ]);
70
+ const table = renderTableLegacy(["NAME", "PURPOSE", "CREATOR", "URI", "ITEMS"], rows);
71
+ return cursor ? `${table}\n\nCursor: ${cursor}` : table;
72
+ };
73
+
74
+ const renderRelationshipsTable = (relationships: ReadonlyArray<RelationshipView>) => {
75
+ const rows = relationships.map((rel) => {
76
+ if ("notFound" in rel && rel.notFound) {
77
+ return [String(rel.actor), "not-found", "", "", "", "", ""];
78
+ }
79
+ const relationship = rel as RelationshipView & {
80
+ did: string;
81
+ following?: string;
82
+ followedBy?: string;
83
+ blocking?: string;
84
+ blockedBy?: string;
85
+ blockingByList?: string;
86
+ blockedByList?: string;
87
+ };
88
+ return [
89
+ relationship.did,
90
+ relationship.following ? "yes" : "",
91
+ relationship.followedBy ? "yes" : "",
92
+ relationship.blocking ? "yes" : "",
93
+ relationship.blockedBy ? "yes" : "",
94
+ relationship.blockingByList ? "yes" : "",
95
+ relationship.blockedByList ? "yes" : ""
96
+ ];
97
+ });
98
+ return renderTableLegacy(
99
+ [
100
+ "DID",
101
+ "FOLLOWING",
102
+ "FOLLOWED BY",
103
+ "BLOCKING",
104
+ "BLOCKED BY",
105
+ "BLOCK BY LIST",
106
+ "BLOCKED BY LIST"
107
+ ],
108
+ rows
109
+ );
110
+ };
111
+
112
+ const renderListItemsTable = (items: ReadonlyArray<ListItemView>, cursor: string | undefined) => {
113
+ const rows = items.map((item) => [
114
+ item.subject.handle,
115
+ item.subject.displayName ?? "",
116
+ item.subject.did
117
+ ]);
118
+ const table = renderTableLegacy(["HANDLE", "DISPLAY NAME", "DID"], rows);
119
+ return cursor ? `${table}\n\nCursor: ${cursor}` : table;
120
+ };
121
+
122
+ const followersCommand = Command.make(
123
+ "followers",
124
+ { actor: actorArg, limit: limitOption, cursor: cursorOption, format: formatOption },
125
+ ({ actor, limit, cursor, format }) =>
126
+ Effect.gen(function* () {
127
+ const appConfig = yield* AppConfigService;
128
+ const client = yield* BskyClient;
129
+ const resolvedActor = yield* decodeActor(actor);
130
+ const parsedLimit = yield* parseLimit(limit);
131
+ const options = {
132
+ ...(Option.isSome(parsedLimit) ? { limit: parsedLimit.value } : {}),
133
+ ...(Option.isSome(cursor) ? { cursor: cursor.value } : {})
134
+ };
135
+ const result = yield* client.getFollowers(resolvedActor, options);
136
+ const outputFormat = resolveOutputFormat(
137
+ format,
138
+ appConfig.outputFormat,
139
+ jsonNdjsonTableFormats,
140
+ "json"
141
+ );
142
+ if (outputFormat === "ndjson") {
143
+ yield* writeJsonStream(Stream.fromIterable(result.followers));
144
+ return;
145
+ }
146
+ if (outputFormat === "table") {
147
+ yield* writeText(renderProfileTable(result.followers, result.cursor));
148
+ return;
149
+ }
150
+ yield* writeJson(result);
151
+ })
152
+ ).pipe(
153
+ Command.withDescription(
154
+ withExamples("List followers for an actor", [
155
+ "skygent graph followers alice.bsky.social",
156
+ "skygent graph followers did:plc:example --limit 25"
157
+ ])
158
+ )
159
+ );
160
+
161
+ const followsCommand = Command.make(
162
+ "follows",
163
+ { actor: actorArg, limit: limitOption, cursor: cursorOption, format: formatOption },
164
+ ({ actor, limit, cursor, format }) =>
165
+ Effect.gen(function* () {
166
+ const appConfig = yield* AppConfigService;
167
+ const client = yield* BskyClient;
168
+ const resolvedActor = yield* decodeActor(actor);
169
+ const parsedLimit = yield* parseLimit(limit);
170
+ const options = {
171
+ ...(Option.isSome(parsedLimit) ? { limit: parsedLimit.value } : {}),
172
+ ...(Option.isSome(cursor) ? { cursor: cursor.value } : {})
173
+ };
174
+ const result = yield* client.getFollows(resolvedActor, options);
175
+ const outputFormat = resolveOutputFormat(
176
+ format,
177
+ appConfig.outputFormat,
178
+ jsonNdjsonTableFormats,
179
+ "json"
180
+ );
181
+ if (outputFormat === "ndjson") {
182
+ yield* writeJsonStream(Stream.fromIterable(result.follows));
183
+ return;
184
+ }
185
+ if (outputFormat === "table") {
186
+ yield* writeText(renderProfileTable(result.follows, result.cursor));
187
+ return;
188
+ }
189
+ yield* writeJson(result);
190
+ })
191
+ ).pipe(
192
+ Command.withDescription(
193
+ withExamples("List accounts an actor follows", [
194
+ "skygent graph follows alice.bsky.social",
195
+ "skygent graph follows did:plc:example --limit 25"
196
+ ])
197
+ )
198
+ );
199
+
200
+ const knownFollowersCommand = Command.make(
201
+ "known-followers",
202
+ { actor: actorArg, limit: limitOption, cursor: cursorOption, format: formatOption },
203
+ ({ actor, limit, cursor, format }) =>
204
+ Effect.gen(function* () {
205
+ const appConfig = yield* AppConfigService;
206
+ const client = yield* BskyClient;
207
+ const resolvedActor = yield* decodeActor(actor);
208
+ const parsedLimit = yield* parseLimit(limit);
209
+ const options = {
210
+ ...(Option.isSome(parsedLimit) ? { limit: parsedLimit.value } : {}),
211
+ ...(Option.isSome(cursor) ? { cursor: cursor.value } : {})
212
+ };
213
+ const result = yield* client.getKnownFollowers(resolvedActor, options);
214
+ const outputFormat = resolveOutputFormat(
215
+ format,
216
+ appConfig.outputFormat,
217
+ jsonNdjsonTableFormats,
218
+ "json"
219
+ );
220
+ if (outputFormat === "ndjson") {
221
+ yield* writeJsonStream(Stream.fromIterable(result.followers));
222
+ return;
223
+ }
224
+ if (outputFormat === "table") {
225
+ yield* writeText(renderProfileTable(result.followers, result.cursor));
226
+ return;
227
+ }
228
+ yield* writeJson(result);
229
+ })
230
+ ).pipe(
231
+ Command.withDescription(
232
+ withExamples("List mutual followers (viewer context)", [
233
+ "skygent graph known-followers alice.bsky.social"
234
+ ])
235
+ )
236
+ );
237
+
238
+ const relationshipsCommand = Command.make(
239
+ "relationships",
240
+ { actor: actorArg, others: othersOption, format: formatOption },
241
+ ({ actor, others, format }) =>
242
+ Effect.gen(function* () {
243
+ const appConfig = yield* AppConfigService;
244
+ const client = yield* BskyClient;
245
+ const identities = yield* IdentityResolver;
246
+ const resolveDid = (value: string) =>
247
+ Effect.gen(function* () {
248
+ const decoded = yield* decodeActor(value);
249
+ const actorValue = String(decoded);
250
+ return actorValue.startsWith("did:")
251
+ ? actorValue
252
+ : yield* identities.resolveDid(actorValue);
253
+ });
254
+ const resolvedActor = yield* resolveDid(actor);
255
+ const parsedOthers = others
256
+ .split(",")
257
+ .map((item) => item.trim())
258
+ .filter((item) => item.length > 0);
259
+ if (parsedOthers.length === 0) {
260
+ return yield* CliInputError.make({
261
+ message: "--others must include at least one actor.",
262
+ cause: { others }
263
+ });
264
+ }
265
+ const uniqueOthers = Array.from(new Set(parsedOthers));
266
+ if (uniqueOthers.length > 30) {
267
+ return yield* CliInputError.make({
268
+ message: "--others supports up to 30 actors per request.",
269
+ cause: { count: uniqueOthers.length }
270
+ });
271
+ }
272
+ const resolvedOthers = yield* Effect.forEach(
273
+ uniqueOthers,
274
+ (value) => resolveDid(value),
275
+ { concurrency: "unbounded" }
276
+ );
277
+ const result = yield* client.getRelationships(resolvedActor, resolvedOthers);
278
+ const outputFormat = resolveOutputFormat(
279
+ format,
280
+ appConfig.outputFormat,
281
+ jsonNdjsonTableFormats,
282
+ "json"
283
+ );
284
+ if (outputFormat === "ndjson") {
285
+ yield* writeJsonStream(Stream.fromIterable(result.relationships));
286
+ return;
287
+ }
288
+ if (outputFormat === "table") {
289
+ yield* writeText(renderRelationshipsTable(result.relationships));
290
+ return;
291
+ }
292
+ yield* writeJson(result);
293
+ })
294
+ ).pipe(
295
+ Command.withDescription(
296
+ withExamples("Inspect relationship status between actors", [
297
+ "skygent graph relationships alice.bsky.social --others bob.bsky.social,charlie.bsky.social"
298
+ ])
299
+ )
300
+ );
301
+
302
+ const listsCommand = Command.make(
303
+ "lists",
304
+ { actor: actorArg, limit: limitOption, cursor: cursorOption, purpose: purposeOption, format: formatOption },
305
+ ({ actor, limit, cursor, purpose, format }) =>
306
+ Effect.gen(function* () {
307
+ const appConfig = yield* AppConfigService;
308
+ const client = yield* BskyClient;
309
+ const resolvedActor = yield* decodeActor(actor);
310
+ const parsedLimit = yield* parseLimit(limit);
311
+ const options = {
312
+ ...(Option.isSome(parsedLimit) ? { limit: parsedLimit.value } : {}),
313
+ ...(Option.isSome(cursor) ? { cursor: cursor.value } : {}),
314
+ ...(Option.isSome(purpose) ? { purposes: [purpose.value] } : {})
315
+ };
316
+ const result = yield* client.getLists(resolvedActor, options);
317
+ const outputFormat = resolveOutputFormat(
318
+ format,
319
+ appConfig.outputFormat,
320
+ jsonNdjsonTableFormats,
321
+ "json"
322
+ );
323
+ if (outputFormat === "ndjson") {
324
+ yield* writeJsonStream(Stream.fromIterable(result.lists));
325
+ return;
326
+ }
327
+ if (outputFormat === "table") {
328
+ yield* writeText(renderListTable(result.lists, result.cursor));
329
+ return;
330
+ }
331
+ yield* writeJson(result);
332
+ })
333
+ ).pipe(
334
+ Command.withDescription(
335
+ withExamples("List lists created by an actor", [
336
+ "skygent graph lists alice.bsky.social",
337
+ "skygent graph lists alice.bsky.social --purpose curatelist"
338
+ ])
339
+ )
340
+ );
341
+
342
+ const listCommand = Command.make(
343
+ "list",
344
+ { uri: listUriArg, limit: limitOption, cursor: cursorOption, format: formatOption },
345
+ ({ uri, limit, cursor, format }) =>
346
+ Effect.gen(function* () {
347
+ const appConfig = yield* AppConfigService;
348
+ const client = yield* BskyClient;
349
+ const parsedLimit = yield* parseLimit(limit);
350
+ const options = {
351
+ ...(Option.isSome(parsedLimit) ? { limit: parsedLimit.value } : {}),
352
+ ...(Option.isSome(cursor) ? { cursor: cursor.value } : {})
353
+ };
354
+ const result = yield* client.getList(uri, options);
355
+ const outputFormat = resolveOutputFormat(
356
+ format,
357
+ appConfig.outputFormat,
358
+ jsonNdjsonTableFormats,
359
+ "json"
360
+ );
361
+ if (outputFormat === "ndjson") {
362
+ yield* writeJsonStream(Stream.fromIterable(result.items));
363
+ return;
364
+ }
365
+ if (outputFormat === "table") {
366
+ const header = `${result.list.name} (${result.list.purpose}) by ${result.list.creator.handle}`;
367
+ const body = renderListItemsTable(result.items, result.cursor);
368
+ yield* writeText(`${header}\n\n${body}`);
369
+ return;
370
+ }
371
+ yield* writeJson(result);
372
+ })
373
+ ).pipe(
374
+ Command.withDescription(
375
+ withExamples("View a list and its members", [
376
+ "skygent graph list at://did:plc:example/app.bsky.graph.list/xyz"
377
+ ])
378
+ )
379
+ );
380
+
381
+ const blocksCommand = Command.make(
382
+ "blocks",
383
+ { limit: limitOption, cursor: cursorOption, format: formatOption },
384
+ ({ limit, cursor, format }) =>
385
+ Effect.gen(function* () {
386
+ const appConfig = yield* AppConfigService;
387
+ const client = yield* BskyClient;
388
+ const parsedLimit = yield* parseLimit(limit);
389
+ const options = {
390
+ ...(Option.isSome(parsedLimit) ? { limit: parsedLimit.value } : {}),
391
+ ...(Option.isSome(cursor) ? { cursor: cursor.value } : {})
392
+ };
393
+ const result = yield* client.getBlocks(options);
394
+ const outputFormat = resolveOutputFormat(
395
+ format,
396
+ appConfig.outputFormat,
397
+ jsonNdjsonTableFormats,
398
+ "json"
399
+ );
400
+ if (outputFormat === "ndjson") {
401
+ yield* writeJsonStream(Stream.fromIterable(result.blocks));
402
+ return;
403
+ }
404
+ if (outputFormat === "table") {
405
+ yield* writeText(renderProfileTable(result.blocks, result.cursor));
406
+ return;
407
+ }
408
+ yield* writeJson(result);
409
+ })
410
+ ).pipe(
411
+ Command.withDescription(
412
+ withExamples("List accounts blocked by the authenticated user", [
413
+ "skygent graph blocks --limit 25"
414
+ ])
415
+ )
416
+ );
417
+
418
+ const mutesCommand = Command.make(
419
+ "mutes",
420
+ { limit: limitOption, cursor: cursorOption, format: formatOption },
421
+ ({ limit, cursor, format }) =>
422
+ Effect.gen(function* () {
423
+ const appConfig = yield* AppConfigService;
424
+ const client = yield* BskyClient;
425
+ const parsedLimit = yield* parseLimit(limit);
426
+ const options = {
427
+ ...(Option.isSome(parsedLimit) ? { limit: parsedLimit.value } : {}),
428
+ ...(Option.isSome(cursor) ? { cursor: cursor.value } : {})
429
+ };
430
+ const result = yield* client.getMutes(options);
431
+ const outputFormat = resolveOutputFormat(
432
+ format,
433
+ appConfig.outputFormat,
434
+ jsonNdjsonTableFormats,
435
+ "json"
436
+ );
437
+ if (outputFormat === "ndjson") {
438
+ yield* writeJsonStream(Stream.fromIterable(result.mutes));
439
+ return;
440
+ }
441
+ if (outputFormat === "table") {
442
+ yield* writeText(renderProfileTable(result.mutes, result.cursor));
443
+ return;
444
+ }
445
+ yield* writeJson(result);
446
+ })
447
+ ).pipe(
448
+ Command.withDescription(
449
+ withExamples("List accounts muted by the authenticated user", [
450
+ "skygent graph mutes --limit 25"
451
+ ])
452
+ )
453
+ );
454
+
455
+ export const graphCommand = Command.make("graph", {}).pipe(
456
+ Command.withSubcommands([
457
+ followersCommand,
458
+ followsCommand,
459
+ knownFollowersCommand,
460
+ relationshipsCommand,
461
+ listsCommand,
462
+ listCommand,
463
+ blocksCommand,
464
+ mutesCommand
465
+ ]),
466
+ Command.withDescription(
467
+ withExamples("Inspect Bluesky social graph data", [
468
+ "skygent graph followers alice.bsky.social",
469
+ "skygent graph list at://did:plc:example/app.bsky.graph.list/xyz"
470
+ ])
471
+ )
472
+ );
@@ -0,0 +1,14 @@
1
+ export const withExamples = (
2
+ description: string,
3
+ examples: ReadonlyArray<string>,
4
+ notes: ReadonlyArray<string> = []
5
+ ) => {
6
+ const lines = [description];
7
+ if (notes.length > 0) {
8
+ lines.push("", ...notes);
9
+ }
10
+ if (examples.length > 0) {
11
+ lines.push("", "Examples:", ...examples.map((example) => ` ${example}`));
12
+ }
13
+ return lines.join("\n");
14
+ };
@@ -0,0 +1,35 @@
1
+ import { Duration, Effect, Option } from "effect";
2
+ import { CliInputError } from "./errors.js";
3
+
4
+ const parseDurationText = (value: string) =>
5
+ Effect.try({
6
+ try: () => Duration.decode(value as Duration.DurationInput),
7
+ catch: (cause) =>
8
+ CliInputError.make({
9
+ message: `Invalid duration: ${value}. Use formats like "30 seconds" or "500 millis".`,
10
+ cause
11
+ })
12
+ }).pipe(
13
+ Effect.flatMap((duration) =>
14
+ Duration.toMillis(duration) < 0
15
+ ? Effect.fail(
16
+ CliInputError.make({
17
+ message: "Interval must be non-negative.",
18
+ cause: duration
19
+ })
20
+ )
21
+ : Effect.succeed(duration)
22
+ )
23
+ );
24
+
25
+ export const parseInterval = (interval: Option.Option<string>) =>
26
+ Option.match(interval, {
27
+ onSome: parseDurationText,
28
+ onNone: () => Effect.succeed(Duration.seconds(30))
29
+ });
30
+
31
+ export const parseOptionalDuration = (value: Option.Option<string>) =>
32
+ Option.match(value, {
33
+ onSome: (raw) => parseDurationText(raw).pipe(Effect.map(Option.some)),
34
+ onNone: () => Effect.succeed(Option.none())
35
+ });
@@ -0,0 +1,173 @@
1
+ import { Options } from "@effect/cli";
2
+ import { Effect, Option } from "effect";
3
+ import { JetstreamConfig } from "effect-jetstream";
4
+ import { DataSource } from "../domain/sync.js";
5
+ import type { StoreRef } from "../domain/store.js";
6
+ import { SyncCheckpointStore } from "../services/sync-checkpoint-store.js";
7
+ import { CliInputError } from "./errors.js";
8
+
9
+ const DEFAULT_COLLECTIONS = ["app.bsky.feed.post"];
10
+
11
+ export const jetstreamOptions = {
12
+ endpoint: Options.text("endpoint").pipe(
13
+ Options.withDescription("Jetstream WebSocket endpoint"),
14
+ Options.optional
15
+ ),
16
+ collections: Options.text("collections").pipe(
17
+ Options.withDescription(
18
+ "Comma-separated collections to subscribe (only app.bsky.feed.post supported)"
19
+ ),
20
+ Options.optional
21
+ ),
22
+ dids: Options.text("dids").pipe(
23
+ Options.withDescription("Comma-separated DIDs to subscribe"),
24
+ Options.optional
25
+ ),
26
+ cursor: Options.text("cursor").pipe(
27
+ Options.withDescription("Jetstream cursor (microseconds)"),
28
+ Options.optional
29
+ ),
30
+ compress: Options.boolean("compress").pipe(
31
+ Options.withDescription("Enable compression if supported by runtime")
32
+ ),
33
+ maxMessageSize: Options.integer("max-message-size").pipe(
34
+ Options.withDescription("Max message size in bytes"),
35
+ Options.optional
36
+ )
37
+ };
38
+
39
+ export type JetstreamCliOptions = {
40
+ readonly endpoint: Option.Option<string>;
41
+ readonly collections: Option.Option<string>;
42
+ readonly dids: Option.Option<string>;
43
+ readonly cursor: Option.Option<string>;
44
+ readonly compress: boolean;
45
+ readonly maxMessageSize: Option.Option<number>;
46
+ };
47
+
48
+ export type JetstreamSelection = {
49
+ readonly source: Extract<DataSource, { _tag: "Jetstream" }>;
50
+ readonly config: JetstreamConfig.JetstreamConfig;
51
+ readonly cursor: string | undefined;
52
+ };
53
+
54
+ const parseCsv = (value: string) =>
55
+ value
56
+ .split(",")
57
+ .map((entry) => entry.trim())
58
+ .filter((entry) => entry.length > 0);
59
+
60
+ const parseCursorValue = (value: string, message: string) =>
61
+ Effect.try({
62
+ try: () => {
63
+ const trimmed = value.trim();
64
+ const parsed = Number(trimmed);
65
+ if (
66
+ trimmed.length === 0 ||
67
+ !Number.isFinite(parsed) ||
68
+ !Number.isSafeInteger(parsed) ||
69
+ parsed < 0
70
+ ) {
71
+ throw new Error("Invalid cursor");
72
+ }
73
+ return { raw: trimmed, value: parsed };
74
+ },
75
+ catch: (cause) => CliInputError.make({ message, cause })
76
+ });
77
+
78
+ export const buildJetstreamSelection = (
79
+ options: JetstreamCliOptions,
80
+ store: StoreRef,
81
+ filterHash: string
82
+ ) =>
83
+ Effect.gen(function* () {
84
+ const endpoint = Option.getOrUndefined(options.endpoint);
85
+ const collections = Option.match(options.collections, {
86
+ onNone: () => DEFAULT_COLLECTIONS,
87
+ onSome: parseCsv
88
+ });
89
+ const dids = Option.match(options.dids, {
90
+ onNone: () => [],
91
+ onSome: parseCsv
92
+ });
93
+ const maxMessageSize = Option.getOrUndefined(options.maxMessageSize);
94
+
95
+ const unsupportedCollections = collections.filter(
96
+ (collection) => collection !== DEFAULT_COLLECTIONS[0]
97
+ );
98
+ if (unsupportedCollections.length > 0) {
99
+ return yield* CliInputError.make({
100
+ message:
101
+ "Only app.bsky.feed.post is supported for Jetstream collections.",
102
+ cause: unsupportedCollections
103
+ });
104
+ }
105
+
106
+ if (typeof maxMessageSize === "number" && maxMessageSize <= 0) {
107
+ return yield* CliInputError.make({
108
+ message: "max-message-size must be a positive integer.",
109
+ cause: maxMessageSize
110
+ });
111
+ }
112
+
113
+ const source = DataSource.jetstream({
114
+ ...(endpoint !== undefined ? { endpoint } : {}),
115
+ collections,
116
+ dids,
117
+ compress: options.compress,
118
+ ...(maxMessageSize !== undefined
119
+ ? { maxMessageSizeBytes: maxMessageSize }
120
+ : {})
121
+ }) as Extract<DataSource, { _tag: "Jetstream" }>;
122
+
123
+ const explicitCursor = yield* Option.match(options.cursor, {
124
+ onNone: () => Effect.succeed(Option.none()),
125
+ onSome: (value) =>
126
+ parseCursorValue(
127
+ value,
128
+ `Invalid cursor: ${value}. Use a non-negative integer in microseconds.`
129
+ ).pipe(Effect.map(Option.some))
130
+ });
131
+
132
+ const checkpoints = yield* SyncCheckpointStore;
133
+ const checkpoint = yield* checkpoints.load(store, source);
134
+ const activeCheckpoint = Option.filter(checkpoint, (value) =>
135
+ value.filterHash ? value.filterHash === filterHash : true
136
+ );
137
+ const checkpointCursor = Option.flatMap(activeCheckpoint, (value) =>
138
+ Option.fromNullable(value.cursor)
139
+ );
140
+
141
+ const resolvedCursor = yield* Option.match(explicitCursor, {
142
+ onSome: (cursor) => Effect.succeed(Option.some(cursor)),
143
+ onNone: () =>
144
+ Option.match(checkpointCursor, {
145
+ onSome: (value) =>
146
+ parseCursorValue(
147
+ value,
148
+ `Invalid checkpoint cursor: ${value}. Delete the checkpoint or provide --cursor.`
149
+ ).pipe(Effect.map(Option.some)),
150
+ onNone: () => Effect.succeed(Option.none())
151
+ })
152
+ });
153
+
154
+ const cursorValue = Option.getOrUndefined(
155
+ Option.map(resolvedCursor, (cursor) => cursor.value)
156
+ );
157
+ const cursorRaw = Option.getOrUndefined(
158
+ Option.map(resolvedCursor, (cursor) => cursor.raw)
159
+ );
160
+
161
+ const config = JetstreamConfig.JetstreamConfig.make({
162
+ ...(endpoint !== undefined ? { endpoint } : {}),
163
+ wantedCollections: collections,
164
+ wantedDids: dids,
165
+ ...(cursorValue !== undefined ? { cursor: cursorValue } : {}),
166
+ compress: options.compress,
167
+ ...(maxMessageSize !== undefined
168
+ ? { maxMessageSizeBytes: maxMessageSize }
169
+ : {})
170
+ });
171
+
172
+ return { source, config, cursor: cursorRaw };
173
+ });