@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,366 @@
1
+ import { Command, Options } from "@effect/cli";
2
+ import { Duration, Effect, Layer, Option } from "effect";
3
+ import { Jetstream } from "effect-jetstream";
4
+ import { filterExprSignature } from "../domain/filter.js";
5
+ import { DataSource, SyncResult } from "../domain/sync.js";
6
+ import { JetstreamSyncEngine } from "../services/jetstream-sync.js";
7
+ import { storeOptions } from "./store.js";
8
+ import { logInfo, makeSyncReporter } from "./logging.js";
9
+ import { SyncReporter } from "../services/sync-reporter.js";
10
+ import { ResourceMonitor } from "../services/resource-monitor.js";
11
+ import { OutputManager } from "../services/output-manager.js";
12
+ import { CliOutput, writeJson } from "./output.js";
13
+ import { parseFilterExpr } from "./filter-input.js";
14
+ import { withExamples } from "./help.js";
15
+ import { buildJetstreamSelection, jetstreamOptions } from "./jetstream.js";
16
+ import { CliInputError } from "./errors.js";
17
+ import { makeSyncCommandBody } from "./sync-factory.js";
18
+ import {
19
+ feedUriArg,
20
+ listUriArg,
21
+ postUriArg,
22
+ actorArg,
23
+ storeNameOption,
24
+ filterOption,
25
+ filterJsonOption,
26
+ postFilterOption,
27
+ postFilterJsonOption,
28
+ authorFilterOption,
29
+ includePinsOption,
30
+ decodeActor,
31
+ quietOption,
32
+ refreshOption,
33
+ strictOption,
34
+ maxErrorsOption,
35
+ parseMaxErrors,
36
+ parseLimit,
37
+ parseBoundedIntOption
38
+ } from "./shared-options.js";
39
+
40
+ const limitOption = Options.integer("limit").pipe(
41
+ Options.withDescription("Maximum number of Jetstream events to process"),
42
+ Options.optional
43
+ );
44
+ const durationOption = Options.text("duration").pipe(
45
+ Options.withDescription("Stop after a duration (e.g. \"2 minutes\")"),
46
+ Options.optional
47
+ );
48
+ const depthOption = Options.integer("depth").pipe(
49
+ Options.withDescription("Thread reply depth to include (0-1000, default 6)"),
50
+ Options.optional
51
+ );
52
+ const parentHeightOption = Options.integer("parent-height").pipe(
53
+ Options.withDescription("Thread parent height to include (0-1000, default 80)"),
54
+ Options.optional
55
+ );
56
+
57
+ const parseDuration = (value: Option.Option<string>) =>
58
+ Option.match(value, {
59
+ onNone: () => Effect.succeed(Option.none()),
60
+ onSome: (raw) =>
61
+ Effect.try({
62
+ try: () => Duration.decode(raw as Duration.DurationInput),
63
+ catch: (cause) =>
64
+ CliInputError.make({
65
+ message: `Invalid duration: ${raw}. Use formats like \"2 minutes\".`,
66
+ cause
67
+ })
68
+ }).pipe(
69
+ Effect.flatMap((duration) =>
70
+ Duration.toMillis(duration) < 0
71
+ ? Effect.fail(
72
+ CliInputError.make({
73
+ message: "Duration must be non-negative.",
74
+ cause: duration
75
+ })
76
+ )
77
+ : Effect.succeed(Option.some(duration))
78
+ )
79
+ )
80
+ });
81
+
82
+ const timelineCommand = Command.make(
83
+ "timeline",
84
+ { store: storeNameOption, filter: filterOption, filterJson: filterJsonOption, quiet: quietOption, refresh: refreshOption },
85
+ makeSyncCommandBody("timeline", () => DataSource.timeline())
86
+ ).pipe(
87
+ Command.withDescription(
88
+ withExamples(
89
+ "Sync the authenticated timeline into a store",
90
+ [
91
+ "skygent sync timeline --store my-store",
92
+ "skygent sync timeline --store my-store --filter 'hashtag:#ai' --quiet"
93
+ ],
94
+ ["Tip: add --quiet to suppress progress logs."]
95
+ )
96
+ )
97
+ );
98
+
99
+ const feedCommand = Command.make(
100
+ "feed",
101
+ { uri: feedUriArg, store: storeNameOption, filter: filterOption, filterJson: filterJsonOption, quiet: quietOption, refresh: refreshOption },
102
+ ({ uri, ...rest }) => makeSyncCommandBody("feed", () => DataSource.feed(uri), { uri })(rest)
103
+ ).pipe(
104
+ Command.withDescription(
105
+ withExamples(
106
+ "Sync a feed URI into a store",
107
+ [
108
+ "skygent sync feed at://did:plc:example/app.bsky.feed.generator/xyz --store my-store"
109
+ ],
110
+ ["Tip: add --quiet to suppress progress logs."]
111
+ )
112
+ )
113
+ );
114
+
115
+ const listCommand = Command.make(
116
+ "list",
117
+ { uri: listUriArg, store: storeNameOption, filter: filterOption, filterJson: filterJsonOption, quiet: quietOption, refresh: refreshOption },
118
+ ({ uri, ...rest }) => makeSyncCommandBody("list", () => DataSource.list(uri), { uri })(rest)
119
+ ).pipe(
120
+ Command.withDescription(
121
+ withExamples(
122
+ "Sync a list feed URI into a store",
123
+ [
124
+ "skygent sync list at://did:plc:example/app.bsky.graph.list/xyz --store my-store"
125
+ ],
126
+ ["Tip: add --quiet to suppress progress logs."]
127
+ )
128
+ )
129
+ );
130
+
131
+ const notificationsCommand = Command.make(
132
+ "notifications",
133
+ { store: storeNameOption, filter: filterOption, filterJson: filterJsonOption, quiet: quietOption, refresh: refreshOption },
134
+ makeSyncCommandBody("notifications", () => DataSource.notifications())
135
+ ).pipe(
136
+ Command.withDescription(
137
+ withExamples(
138
+ "Sync notifications into a store",
139
+ ["skygent sync notifications --store my-store --quiet"],
140
+ ["Tip: add --quiet to suppress progress logs."]
141
+ )
142
+ )
143
+ );
144
+
145
+ const authorCommand = Command.make(
146
+ "author",
147
+ {
148
+ actor: actorArg,
149
+ store: storeNameOption,
150
+ filter: authorFilterOption,
151
+ includePins: includePinsOption,
152
+ postFilter: postFilterOption,
153
+ postFilterJson: postFilterJsonOption,
154
+ quiet: quietOption,
155
+ refresh: refreshOption
156
+ },
157
+ ({ actor, filter, includePins, postFilter, postFilterJson, store, quiet, refresh }) =>
158
+ Effect.gen(function* () {
159
+ const resolvedActor = yield* decodeActor(actor);
160
+ const apiFilter = Option.getOrUndefined(filter);
161
+ const source = DataSource.author(resolvedActor, {
162
+ ...(apiFilter !== undefined ? { filter: apiFilter } : {}),
163
+ ...(includePins ? { includePins: true } : {})
164
+ });
165
+ const run = makeSyncCommandBody("author", () => source, {
166
+ actor: resolvedActor,
167
+ ...(apiFilter !== undefined ? { filter: apiFilter } : {}),
168
+ ...(includePins ? { includePins: true } : {})
169
+ });
170
+ return yield* run({
171
+ store,
172
+ filter: postFilter,
173
+ filterJson: postFilterJson,
174
+ quiet,
175
+ refresh
176
+ });
177
+ })
178
+ ).pipe(
179
+ Command.withDescription(
180
+ withExamples(
181
+ "Sync posts from a specific author",
182
+ [
183
+ "skygent sync author alice.bsky.social --store my-store",
184
+ "skygent sync author did:plc:example --store my-store --filter posts_no_replies --include-pins"
185
+ ],
186
+ ["Tip: use --post-filter to apply the DSL filter to synced posts."]
187
+ )
188
+ )
189
+ );
190
+
191
+ const threadCommand = Command.make(
192
+ "thread",
193
+ {
194
+ uri: postUriArg,
195
+ store: storeNameOption,
196
+ depth: depthOption,
197
+ parentHeight: parentHeightOption,
198
+ filter: filterOption,
199
+ filterJson: filterJsonOption,
200
+ quiet: quietOption,
201
+ refresh: refreshOption
202
+ },
203
+ ({ uri, depth, parentHeight, filter, filterJson, store, quiet, refresh }) =>
204
+ Effect.gen(function* () {
205
+ const parsedDepth = yield* parseBoundedIntOption(depth, "depth", 0, 1000);
206
+ const parsedParentHeight = yield* parseBoundedIntOption(
207
+ parentHeight,
208
+ "parent-height",
209
+ 0,
210
+ 1000
211
+ );
212
+ const depthValue = Option.getOrUndefined(parsedDepth);
213
+ const parentHeightValue = Option.getOrUndefined(parsedParentHeight);
214
+ const source = DataSource.thread(uri, {
215
+ ...(depthValue !== undefined ? { depth: depthValue } : {}),
216
+ ...(parentHeightValue !== undefined ? { parentHeight: parentHeightValue } : {})
217
+ });
218
+ const run = makeSyncCommandBody("thread", () => source, {
219
+ uri,
220
+ ...(depthValue !== undefined ? { depth: depthValue } : {}),
221
+ ...(parentHeightValue !== undefined ? { parentHeight: parentHeightValue } : {})
222
+ });
223
+ return yield* run({ store, filter, filterJson, quiet, refresh });
224
+ })
225
+ ).pipe(
226
+ Command.withDescription(
227
+ withExamples(
228
+ "Sync a post thread (parents + replies) into a store",
229
+ [
230
+ "skygent sync thread at://did:plc:example/app.bsky.feed.post/xyz --store my-store",
231
+ "skygent sync thread at://did:plc:example/app.bsky.feed.post/xyz --store my-store --depth 10 --parent-height 5"
232
+ ],
233
+ ["Tip: use --filter to apply the DSL filter to thread posts."]
234
+ )
235
+ )
236
+ );
237
+
238
+ const jetstreamCommand = Command.make(
239
+ "jetstream",
240
+ {
241
+ store: storeNameOption,
242
+ filter: filterOption,
243
+ filterJson: filterJsonOption,
244
+ quiet: quietOption,
245
+ endpoint: jetstreamOptions.endpoint,
246
+ collections: jetstreamOptions.collections,
247
+ dids: jetstreamOptions.dids,
248
+ cursor: jetstreamOptions.cursor,
249
+ compress: jetstreamOptions.compress,
250
+ maxMessageSize: jetstreamOptions.maxMessageSize,
251
+ limit: limitOption,
252
+ duration: durationOption,
253
+ strict: strictOption,
254
+ maxErrors: maxErrorsOption
255
+ },
256
+ ({
257
+ store,
258
+ filter,
259
+ filterJson,
260
+ quiet,
261
+ endpoint,
262
+ collections,
263
+ dids,
264
+ cursor,
265
+ compress,
266
+ maxMessageSize,
267
+ limit,
268
+ duration,
269
+ strict,
270
+ maxErrors
271
+ }) =>
272
+ Effect.gen(function* () {
273
+ const monitor = yield* ResourceMonitor;
274
+ const output = yield* CliOutput;
275
+ const outputManager = yield* OutputManager;
276
+ const storeRef = yield* storeOptions.loadStoreRef(store);
277
+ const expr = yield* parseFilterExpr(filter, filterJson);
278
+ const filterHash = filterExprSignature(expr);
279
+ const selection = yield* buildJetstreamSelection(
280
+ {
281
+ endpoint,
282
+ collections,
283
+ dids,
284
+ cursor,
285
+ compress,
286
+ maxMessageSize
287
+ },
288
+ storeRef,
289
+ filterHash
290
+ );
291
+ const parsedLimit = yield* parseLimit(limit);
292
+ const parsedDuration = yield* parseDuration(duration);
293
+ const parsedMaxErrors = yield* parseMaxErrors(maxErrors);
294
+ if (Option.isNone(parsedLimit) && Option.isNone(parsedDuration)) {
295
+ return yield* CliInputError.make({
296
+ message:
297
+ "Jetstream sync requires --limit or --duration. Use watch jetstream for continuous streaming.",
298
+ cause: { limit, duration }
299
+ });
300
+ }
301
+ const engineLayer = JetstreamSyncEngine.layer.pipe(
302
+ Layer.provideMerge(Jetstream.live(selection.config))
303
+ );
304
+ yield* logInfo("Starting sync", {
305
+ source: "jetstream",
306
+ store: storeRef.name
307
+ });
308
+ const result = yield* Effect.gen(function* () {
309
+ const engine = yield* JetstreamSyncEngine;
310
+ const limitValue = Option.getOrUndefined(parsedLimit);
311
+ const durationValue = Option.getOrUndefined(parsedDuration);
312
+ const maxErrorsValue = Option.getOrUndefined(parsedMaxErrors);
313
+ return yield* engine.sync({
314
+ source: selection.source,
315
+ store: storeRef,
316
+ filter: expr,
317
+ command: "sync jetstream",
318
+ ...(limitValue !== undefined ? { limit: limitValue } : {}),
319
+ ...(durationValue !== undefined ? { duration: durationValue } : {}),
320
+ ...(selection.cursor !== undefined ? { cursor: selection.cursor } : {}),
321
+ ...(strict ? { strict } : {}),
322
+ ...(maxErrorsValue !== undefined ? { maxErrors: maxErrorsValue } : {})
323
+ });
324
+ }).pipe(
325
+ Effect.provide(engineLayer),
326
+ Effect.provideService(SyncReporter, makeSyncReporter(quiet, monitor, output))
327
+ );
328
+ const materialized = yield* outputManager.materializeStore(storeRef);
329
+ if (materialized.filters.length > 0) {
330
+ yield* logInfo("Materialized filter outputs", {
331
+ store: storeRef.name,
332
+ filters: materialized.filters.map((spec) => spec.name)
333
+ });
334
+ }
335
+ yield* logInfo("Sync complete", { source: "jetstream", store: storeRef.name });
336
+ yield* writeJson(result as SyncResult);
337
+ })
338
+ ).pipe(
339
+ Command.withDescription(
340
+ withExamples(
341
+ "Sync Jetstream events into a store (posts only)",
342
+ [
343
+ "skygent sync jetstream --store my-store --limit 500",
344
+ "skygent sync jetstream --store my-store --duration \"2 minutes\""
345
+ ],
346
+ ["Tip: use watch jetstream for continuous streaming."]
347
+ )
348
+ )
349
+ );
350
+
351
+ export const syncCommand = Command.make("sync", {}).pipe(
352
+ Command.withSubcommands([
353
+ timelineCommand,
354
+ feedCommand,
355
+ listCommand,
356
+ notificationsCommand,
357
+ authorCommand,
358
+ threadCommand,
359
+ jetstreamCommand
360
+ ]),
361
+ Command.withDescription(
362
+ withExamples("Sync content into stores", [
363
+ "skygent sync timeline --store my-store"
364
+ ])
365
+ )
366
+ );
@@ -0,0 +1,196 @@
1
+ import { Args, Command, Options } from "@effect/cli";
2
+ import { Chunk, Console, Effect, Option, Schema, Stream } from "effect";
3
+ import { PostUri, StoreName } from "../domain/primitives.js";
4
+ import type { Post } from "../domain/post.js";
5
+ import { all } from "../domain/filter.js";
6
+ import { StoreQuery } from "../domain/events.js";
7
+ import { DataSource } from "../domain/sync.js";
8
+ import { BskyClient } from "../services/bsky-client.js";
9
+ import { PostParser } from "../services/post-parser.js";
10
+ import { StoreIndex } from "../services/store-index.js";
11
+ import { SyncEngine } from "../services/sync-engine.js";
12
+ import { renderThread } from "./doc/thread.js";
13
+ import { renderPlain, renderAnsi } from "./doc/render.js";
14
+ import { writeJson, writeText } from "./output.js";
15
+ import { storeOptions } from "./store.js";
16
+ import { withExamples } from "./help.js";
17
+ import { CliInputError } from "./errors.js";
18
+ import { formatSchemaError } from "./shared.js";
19
+ import { parseBoundedIntOption } from "./shared-options.js";
20
+ import { textJsonFormats } from "./output-format.js";
21
+
22
+ const uriArg = Args.text({ name: "uri" }).pipe(
23
+ Args.withDescription("AT-URI of any post in the thread")
24
+ );
25
+
26
+ const storeOption = Options.text("store").pipe(
27
+ Options.withSchema(StoreName),
28
+ Options.withDescription("Query from local store instead of API"),
29
+ Options.optional
30
+ );
31
+
32
+ const compactOption = Options.boolean("compact").pipe(
33
+ Options.withDescription("Single-line rendering (default: card)")
34
+ );
35
+
36
+ const ansiOption = Options.boolean("ansi").pipe(
37
+ Options.withDescription("Enable ANSI colors in output")
38
+ );
39
+
40
+ const widthOption = Options.integer("width").pipe(
41
+ Options.withDescription("Line width for terminal output"),
42
+ Options.optional
43
+ );
44
+
45
+ const formatOption = Options.choice("format", textJsonFormats).pipe(
46
+ Options.withDescription("Output format (default: text)"),
47
+ Options.optional
48
+ );
49
+
50
+ const depthOption = Options.integer("depth").pipe(
51
+ Options.withDescription("Reply depth (API only, default: 6)"),
52
+ Options.optional
53
+ );
54
+
55
+ const parentHeightOption = Options.integer("parent-height").pipe(
56
+ Options.withDescription("Parent height (API only, default: 80)"),
57
+ Options.optional
58
+ );
59
+
60
+ export const threadCommand = Command.make(
61
+ "thread",
62
+ {
63
+ uri: uriArg,
64
+ store: storeOption,
65
+ compact: compactOption,
66
+ ansi: ansiOption,
67
+ width: widthOption,
68
+ format: formatOption,
69
+ depth: depthOption,
70
+ parentHeight: parentHeightOption
71
+ },
72
+ ({ uri, store, compact, ansi, width, format, depth, parentHeight }) =>
73
+ Effect.gen(function* () {
74
+ const outputFormat = Option.getOrElse(format, () => "text" as const);
75
+ const w = Option.getOrUndefined(width);
76
+ const parsedDepth = yield* parseBoundedIntOption(depth, "depth", 0, 1000);
77
+ const parsedParentHeight = yield* parseBoundedIntOption(
78
+ parentHeight,
79
+ "parent-height",
80
+ 0,
81
+ 1000
82
+ );
83
+ const d = Option.getOrElse(parsedDepth, () => 6);
84
+ const ph = Option.getOrElse(parsedParentHeight, () => 80);
85
+
86
+ let posts: ReadonlyArray<Post>;
87
+
88
+ if (Option.isSome(store)) {
89
+ const index = yield* StoreIndex;
90
+ const storeRef = yield* storeOptions.loadStoreRef(store.value);
91
+ const targetUri = yield* Schema.decodeUnknown(PostUri)(uri).pipe(
92
+ Effect.mapError((error) =>
93
+ CliInputError.make({
94
+ message: `Invalid post URI: ${formatSchemaError(error)}`,
95
+ cause: error
96
+ })
97
+ )
98
+ );
99
+ const hasTarget = yield* index.hasUri(storeRef, targetUri);
100
+ if (!hasTarget) {
101
+ const engine = yield* SyncEngine;
102
+ const source = DataSource.thread(uri, { depth: d, parentHeight: ph });
103
+ yield* engine.sync(source, storeRef, all());
104
+ }
105
+ const query = StoreQuery.make({});
106
+ const stream = index.query(storeRef, query);
107
+ const collected = yield* Stream.runCollect(stream);
108
+ const allPosts = Chunk.toReadonlyArray(collected);
109
+ const threadPosts = selectThreadPosts(allPosts, String(targetUri));
110
+ if (threadPosts.length === 0) {
111
+ return yield* CliInputError.make({
112
+ message: `Thread not found for ${uri}.`,
113
+ cause: { uri, store: storeRef.name }
114
+ });
115
+ }
116
+ // B1: Hint when only root post exists in store
117
+ if (threadPosts.length === 1 && threadPosts[0]?.uri === targetUri) {
118
+ yield* Console.log("\nℹ️ Only root post found in store. Use --no-store to fetch full thread from API.\n");
119
+ }
120
+ posts = threadPosts;
121
+ } else {
122
+ const client = yield* BskyClient;
123
+ const parser = yield* PostParser;
124
+ const rawPosts = yield* client.getPostThread(uri, { depth: d, parentHeight: ph });
125
+ posts = yield* Effect.forEach(rawPosts, (raw) => parser.parsePost(raw));
126
+ }
127
+
128
+ if (outputFormat === "json") {
129
+ yield* writeJson(posts);
130
+ return;
131
+ }
132
+
133
+ const doc = renderThread(
134
+ posts,
135
+ w === undefined ? { compact } : { compact, lineWidth: w }
136
+ );
137
+ yield* writeText(ansi ? renderAnsi(doc, w) : renderPlain(doc, w));
138
+ })
139
+ ).pipe(
140
+ Command.withDescription(
141
+ withExamples(
142
+ "Display a thread from the API or a local store",
143
+ [
144
+ "skygent view thread at://did:plc:example/app.bsky.feed.post/xyz --ansi",
145
+ "skygent view thread at://did:plc:example/app.bsky.feed.post/xyz --compact --ansi",
146
+ "skygent view thread at://did:plc:example/app.bsky.feed.post/xyz --store my-store --ansi --width 100",
147
+ "skygent view thread at://did:plc:example/app.bsky.feed.post/xyz --format json"
148
+ ]
149
+ )
150
+ )
151
+ );
152
+
153
+ const selectThreadPosts = (posts: ReadonlyArray<Post>, targetUri: string) => {
154
+ const byUri = new Map(posts.map((post) => [String(post.uri), post]));
155
+ if (!byUri.has(targetUri)) {
156
+ return [] as ReadonlyArray<Post>;
157
+ }
158
+
159
+ const childMap = new Map<string, Post[]>();
160
+ for (const post of posts) {
161
+ const parentUri = post.reply?.parent?.uri ? String(post.reply.parent.uri) : undefined;
162
+ if (!parentUri || !byUri.has(parentUri)) {
163
+ continue;
164
+ }
165
+ const siblings = childMap.get(parentUri) ?? [];
166
+ siblings.push(post);
167
+ childMap.set(parentUri, siblings);
168
+ }
169
+
170
+ const threadUris = new Set<string>();
171
+ let current: Post | undefined = byUri.get(targetUri);
172
+ while (current) {
173
+ const currentUri = String(current.uri);
174
+ threadUris.add(currentUri);
175
+ const parentUri = current.reply?.parent?.uri
176
+ ? String(current.reply.parent.uri)
177
+ : undefined;
178
+ current = parentUri ? byUri.get(parentUri) : undefined;
179
+ }
180
+
181
+ const queue: Array<string> = [targetUri];
182
+ while (queue.length > 0) {
183
+ const next = queue.shift();
184
+ if (!next) break;
185
+ const children = childMap.get(next) ?? [];
186
+ for (const child of children) {
187
+ const childUri = String(child.uri);
188
+ if (!threadUris.has(childUri)) {
189
+ threadUris.add(childUri);
190
+ queue.push(childUri);
191
+ }
192
+ }
193
+ }
194
+
195
+ return posts.filter((post) => threadUris.has(String(post.uri)));
196
+ };
@@ -0,0 +1,47 @@
1
+ import { Args, Command } from "@effect/cli";
2
+ import { Effect } from "effect";
3
+ import { StoreName } from "../domain/primitives.js";
4
+ import { DerivationValidator } from "../services/derivation-validator.js";
5
+ import { writeJson } from "./output.js";
6
+ import { withExamples } from "./help.js";
7
+ import { threadCommand } from "./view-thread.js";
8
+
9
+ const viewArg = Args.text({ name: "view" }).pipe(
10
+ Args.withSchema(StoreName),
11
+ Args.withDescription("Derived view store name")
12
+ );
13
+ const sourceArg = Args.text({ name: "source" }).pipe(
14
+ Args.withSchema(StoreName),
15
+ Args.withDescription("Source store name")
16
+ );
17
+
18
+ const statusCommand = Command.make(
19
+ "status",
20
+ { view: viewArg, source: sourceArg },
21
+ ({ view, source }) =>
22
+ Effect.gen(function* () {
23
+ const validator = yield* DerivationValidator;
24
+ const isStale = yield* validator.isStale(view, source);
25
+
26
+ yield* writeJson({
27
+ view,
28
+ source,
29
+ status: isStale ? "stale" : "ready"
30
+ });
31
+ })
32
+ ).pipe(
33
+ Command.withDescription(
34
+ withExamples("Check if a derived view is stale relative to its source", [
35
+ "skygent view status derived-store source-store"
36
+ ])
37
+ )
38
+ );
39
+
40
+ export const viewCommand = Command.make("view", {}).pipe(
41
+ Command.withSubcommands([statusCommand, threadCommand]),
42
+ Command.withDescription(
43
+ withExamples("View derivation status and metadata", [
44
+ "skygent view status derived-store source-store"
45
+ ])
46
+ )
47
+ );