@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,44 @@
1
+ import { Effect, Schema } from "effect";
2
+ import { Timestamp } from "../domain/primitives.js";
3
+ import { CliInputError } from "./errors.js";
4
+
5
+ const rangeExample = "2026-01-01T00:00:00Z..2026-01-31T23:59:59Z";
6
+
7
+ const parseTimestamp = (value: string) =>
8
+ Schema.decodeUnknown(Timestamp)(value).pipe(
9
+ Effect.mapError((cause) =>
10
+ CliInputError.make({
11
+ message:
12
+ `Invalid timestamp "${value}". Expected ISO 8601 with timezone ` +
13
+ `(e.g. 2026-01-01T00:00:00Z).`,
14
+ cause
15
+ })
16
+ )
17
+ );
18
+
19
+ export const parseRange = (raw: string) =>
20
+ Effect.gen(function* () {
21
+ const [startRaw = "", endRaw = ""] = raw.split("..");
22
+ if (startRaw.length === 0 || endRaw.length === 0) {
23
+ return yield* CliInputError.make({
24
+ message:
25
+ "Invalid date range format.\n" +
26
+ "Expected: <start>..<end> in ISO 8601 format.\n" +
27
+ `Example: ${rangeExample}\n` +
28
+ `Received: "${raw}"`,
29
+ cause: raw
30
+ });
31
+ }
32
+
33
+ const start = yield* parseTimestamp(startRaw.trim());
34
+ const end = yield* parseTimestamp(endRaw.trim());
35
+
36
+ if (start.getTime() > end.getTime()) {
37
+ return yield* CliInputError.make({
38
+ message: `Range start must be before end. start=${start.toISOString()} end=${end.toISOString()}`,
39
+ cause: raw
40
+ });
41
+ }
42
+
43
+ return { start, end };
44
+ });
@@ -0,0 +1,465 @@
1
+ import { Args, Command, Options } from "@effect/cli";
2
+ import { Effect, Option, Stream } from "effect";
3
+ import { renderTableLegacy } from "./doc/table.js";
4
+ import { BskyClient } from "../services/bsky-client.js";
5
+ import { PostParser } from "../services/post-parser.js";
6
+ import { StoreIndex } from "../services/store-index.js";
7
+ import { renderPostsTable } from "../domain/format.js";
8
+ import { AppConfigService } from "../services/app-config.js";
9
+ import type { FeedGeneratorView, ProfileView } from "../domain/bsky.js";
10
+ import { StoreName } from "../domain/primitives.js";
11
+ import { storeOptions } from "./store.js";
12
+ import { withExamples } from "./help.js";
13
+ import { CliInputError } from "./errors.js";
14
+ import { decodeActor } from "./shared-options.js";
15
+ import { formatSchemaError } from "./shared.js";
16
+ import { writeJson, writeJsonStream, writeText } from "./output.js";
17
+ import { jsonNdjsonTableFormats, resolveOutputFormat } from "./output-format.js";
18
+
19
+ const queryArg = Args.text({ name: "query" }).pipe(
20
+ Args.withDescription("Search query string")
21
+ );
22
+
23
+ const limitOption = Options.integer("limit").pipe(
24
+ Options.withDescription("Maximum number of results"),
25
+ Options.optional
26
+ );
27
+
28
+ const cursorOption = Options.text("cursor").pipe(
29
+ Options.withDescription("Pagination cursor"),
30
+ Options.optional
31
+ );
32
+
33
+ const typeaheadOption = Options.boolean("typeahead").pipe(
34
+ Options.withDescription("Use prefix typeahead search (handles only)")
35
+ );
36
+
37
+ const formatOption = Options.choice("format", jsonNdjsonTableFormats).pipe(
38
+ Options.withDescription("Output format (default: json)"),
39
+ Options.optional
40
+ );
41
+
42
+ const storeOption = Options.text("store").pipe(
43
+ Options.withSchema(StoreName),
44
+ Options.withDescription("Store name to search")
45
+ );
46
+
47
+ const storeOptionOptional = storeOption.pipe(Options.optional);
48
+
49
+ const networkOption = Options.boolean("network").pipe(
50
+ Options.withDescription("Search the Bluesky network instead of a local store")
51
+ );
52
+
53
+ const postCursorOption = Options.text("cursor").pipe(
54
+ Options.withDescription("Pagination cursor (network) or offset (local)"),
55
+ Options.optional
56
+ );
57
+
58
+ const sortOption = Options.text("sort").pipe(
59
+ Options.withDescription("Sort order (local: relevance|newest|oldest, network: top|latest)"),
60
+ Options.optional
61
+ );
62
+
63
+ const sinceOption = Options.text("since").pipe(
64
+ Options.withDescription("Filter network results after datetime (inclusive)"),
65
+ Options.optional
66
+ );
67
+
68
+ const untilOption = Options.text("until").pipe(
69
+ Options.withDescription("Filter network results before datetime (exclusive)"),
70
+ Options.optional
71
+ );
72
+
73
+ const mentionsOption = Options.text("mentions").pipe(
74
+ Options.withDescription("Filter network results by mention (handle or DID)"),
75
+ Options.optional
76
+ );
77
+
78
+ const authorOption = Options.text("author").pipe(
79
+ Options.withDescription("Filter network results by author (handle or DID)"),
80
+ Options.optional
81
+ );
82
+
83
+ const langOption = Options.text("lang").pipe(
84
+ Options.withDescription("Filter network results by language code"),
85
+ Options.optional
86
+ );
87
+
88
+ const domainOption = Options.text("domain").pipe(
89
+ Options.withDescription("Filter network results by link domain"),
90
+ Options.optional
91
+ );
92
+
93
+ const urlOption = Options.text("url").pipe(
94
+ Options.withDescription("Filter network results by URL"),
95
+ Options.optional
96
+ );
97
+
98
+ const tagOption = Options.text("tag").pipe(
99
+ Options.withDescription("Comma-separated tags for network search"),
100
+ Options.optional
101
+ );
102
+
103
+
104
+ type LocalSort = "relevance" | "newest" | "oldest";
105
+
106
+ const renderProfileTable = (
107
+ actors: ReadonlyArray<ProfileView>,
108
+ cursor: string | undefined
109
+ ) => {
110
+ const rows = actors.map((actor) => [
111
+ actor.handle,
112
+ actor.displayName ?? "",
113
+ actor.did
114
+ ]);
115
+ const table = renderTableLegacy(["HANDLE", "DISPLAY NAME", "DID"], rows);
116
+ return cursor ? `${table}\n\nCursor: ${cursor}` : table;
117
+ };
118
+
119
+ const renderFeedTable = (
120
+ feeds: ReadonlyArray<FeedGeneratorView>,
121
+ cursor: string | undefined
122
+ ) => {
123
+ const rows = feeds.map((feed) => [
124
+ feed.displayName,
125
+ feed.creator.handle,
126
+ feed.uri,
127
+ typeof feed.likeCount === "number" ? String(feed.likeCount) : ""
128
+ ]);
129
+ const table = renderTableLegacy(["NAME", "CREATOR", "URI", "LIKES"], rows);
130
+ return cursor ? `${table}\n\nCursor: ${cursor}` : table;
131
+ };
132
+
133
+ const handlesCommand = Command.make(
134
+ "handles",
135
+ {
136
+ query: queryArg,
137
+ limit: limitOption,
138
+ cursor: cursorOption,
139
+ typeahead: typeaheadOption,
140
+ format: formatOption
141
+ },
142
+ ({ query, limit, cursor, typeahead, format }) =>
143
+ Effect.gen(function* () {
144
+ const appConfig = yield* AppConfigService;
145
+ if (typeahead && Option.isSome(cursor)) {
146
+ return yield* CliInputError.make({
147
+ message: "--cursor is not supported with --typeahead.",
148
+ cause: { cursor: cursor.value }
149
+ });
150
+ }
151
+ const client = yield* BskyClient;
152
+ const options = {
153
+ ...(Option.isSome(limit) ? { limit: limit.value } : {}),
154
+ ...(Option.isSome(cursor) ? { cursor: cursor.value } : {}),
155
+ ...(typeahead ? { typeahead: true } : {})
156
+ };
157
+ const result = yield* client.searchActors(query, options);
158
+ const outputFormat = resolveOutputFormat(
159
+ format,
160
+ appConfig.outputFormat,
161
+ jsonNdjsonTableFormats,
162
+ "json"
163
+ );
164
+ if (outputFormat === "ndjson") {
165
+ yield* writeJsonStream(Stream.fromIterable(result.actors));
166
+ return;
167
+ }
168
+ if (outputFormat === "table") {
169
+ yield* writeText(renderProfileTable(result.actors, result.cursor));
170
+ return;
171
+ }
172
+ yield* writeJson(result);
173
+ })
174
+ ).pipe(
175
+ Command.withDescription(
176
+ withExamples("Search for handles (profiles) on Bluesky", [
177
+ "skygent search handles \"dan\" --limit 10",
178
+ "skygent search handles \"alice\" --typeahead"
179
+ ])
180
+ )
181
+ );
182
+
183
+ const feedsCommand = Command.make(
184
+ "feeds",
185
+ { query: queryArg, limit: limitOption, cursor: cursorOption, format: formatOption },
186
+ ({ query, limit, cursor, format }) =>
187
+ Effect.gen(function* () {
188
+ const appConfig = yield* AppConfigService;
189
+ const client = yield* BskyClient;
190
+ const options = {
191
+ ...(Option.isSome(limit) ? { limit: limit.value } : {}),
192
+ ...(Option.isSome(cursor) ? { cursor: cursor.value } : {})
193
+ };
194
+ const result = yield* client.searchFeedGenerators(query, options);
195
+ const outputFormat = resolveOutputFormat(
196
+ format,
197
+ appConfig.outputFormat,
198
+ jsonNdjsonTableFormats,
199
+ "json"
200
+ );
201
+ if (outputFormat === "ndjson") {
202
+ yield* writeJsonStream(Stream.fromIterable(result.feeds));
203
+ return;
204
+ }
205
+ if (outputFormat === "table") {
206
+ yield* writeText(renderFeedTable(result.feeds, result.cursor));
207
+ return;
208
+ }
209
+ yield* writeJson(result);
210
+ })
211
+ ).pipe(
212
+ Command.withDescription(
213
+ withExamples("Search for feed generators on Bluesky", [
214
+ "skygent search feeds \"news\" --limit 10"
215
+ ])
216
+ )
217
+ );
218
+
219
+ const postsCommand = Command.make(
220
+ "posts",
221
+ {
222
+ query: queryArg,
223
+ store: storeOptionOptional,
224
+ network: networkOption,
225
+ limit: limitOption,
226
+ cursor: postCursorOption,
227
+ sort: sortOption,
228
+ since: sinceOption,
229
+ until: untilOption,
230
+ mentions: mentionsOption,
231
+ author: authorOption,
232
+ lang: langOption,
233
+ domain: domainOption,
234
+ url: urlOption,
235
+ tag: tagOption,
236
+ format: formatOption
237
+ },
238
+ ({ query, store, network, limit, cursor, sort, since, until, mentions, author, lang, domain, url, tag, format }) =>
239
+ Effect.gen(function* () {
240
+ const appConfig = yield* AppConfigService;
241
+ if (Option.isSome(limit) && limit.value <= 0) {
242
+ return yield* CliInputError.make({
243
+ message: "--limit must be a positive integer.",
244
+ cause: { limit: limit.value }
245
+ });
246
+ }
247
+ if (network && Option.isSome(store)) {
248
+ return yield* CliInputError.make({
249
+ message: "--store cannot be used with --network.",
250
+ cause: { store: store.value }
251
+ });
252
+ }
253
+ if (!network && Option.isNone(store)) {
254
+ return yield* CliInputError.make({
255
+ message: "Provide --store for local search or --network for Bluesky search.",
256
+ cause: { store: null }
257
+ });
258
+ }
259
+ const hasNetworkOnlyOption =
260
+ Option.isSome(since) ||
261
+ Option.isSome(until) ||
262
+ Option.isSome(mentions) ||
263
+ Option.isSome(author) ||
264
+ Option.isSome(lang) ||
265
+ Option.isSome(domain) ||
266
+ Option.isSome(url) ||
267
+ Option.isSome(tag);
268
+ if (!network && hasNetworkOnlyOption) {
269
+ return yield* CliInputError.make({
270
+ message: "Network-only filters require --network.",
271
+ cause: {
272
+ since: Option.isSome(since),
273
+ until: Option.isSome(until),
274
+ mentions: Option.isSome(mentions),
275
+ author: Option.isSome(author),
276
+ lang: Option.isSome(lang),
277
+ domain: Option.isSome(domain),
278
+ url: Option.isSome(url),
279
+ tag: Option.isSome(tag)
280
+ }
281
+ });
282
+ }
283
+
284
+ const outputFormat = resolveOutputFormat(
285
+ format,
286
+ appConfig.outputFormat,
287
+ jsonNdjsonTableFormats,
288
+ "json"
289
+ );
290
+ const storeValue = Option.getOrElse(store, () => undefined);
291
+
292
+ if (network) {
293
+ const client = yield* BskyClient;
294
+ const parser = yield* PostParser;
295
+ const sortRaw = Option.getOrElse(sort, () => undefined);
296
+ const sortValue = Option.match(sort, {
297
+ onNone: () => undefined,
298
+ onSome: (value) =>
299
+ value === "top" || value === "latest"
300
+ ? value
301
+ : undefined
302
+ });
303
+ if (sortRaw && !sortValue) {
304
+ return yield* CliInputError.make({
305
+ message: "--sort must be one of: top, latest (for --network).",
306
+ cause: { sort: sortRaw }
307
+ });
308
+ }
309
+ const cursorValue = Option.map(cursor, (value) => value);
310
+ const tags = Option.match(tag, {
311
+ onNone: () => [] as ReadonlyArray<string>,
312
+ onSome: (value) =>
313
+ value
314
+ .split(",")
315
+ .map((item) => item.trim())
316
+ .filter((item) => item.length > 0)
317
+ });
318
+ const authorValue = Option.match(author, {
319
+ onNone: () => Effect.void.pipe(Effect.as(undefined)),
320
+ onSome: (value) =>
321
+ Effect.gen(function* () {
322
+ const decoded = yield* decodeActor(value);
323
+ return String(decoded);
324
+ })
325
+ });
326
+ const mentionsValue = Option.match(mentions, {
327
+ onNone: () => Effect.void.pipe(Effect.as(undefined)),
328
+ onSome: (value) =>
329
+ Effect.gen(function* () {
330
+ const decoded = yield* decodeActor(value);
331
+ return String(decoded);
332
+ })
333
+ });
334
+ const parsedAuthor = yield* authorValue;
335
+ const parsedMentions = yield* mentionsValue;
336
+ const result = yield* client.searchPosts(query, {
337
+ ...(Option.isSome(limit) ? { limit: limit.value } : {}),
338
+ ...(Option.isSome(cursorValue) ? { cursor: cursorValue.value } : {}),
339
+ ...(sortValue ? { sort: sortValue } : {}),
340
+ ...(Option.isSome(since) ? { since: since.value } : {}),
341
+ ...(Option.isSome(until) ? { until: until.value } : {}),
342
+ ...(parsedMentions ? { mentions: parsedMentions } : {}),
343
+ ...(parsedAuthor ? { author: parsedAuthor } : {}),
344
+ ...(Option.isSome(lang) ? { lang: lang.value } : {}),
345
+ ...(Option.isSome(domain) ? { domain: domain.value } : {}),
346
+ ...(Option.isSome(url) ? { url: url.value } : {}),
347
+ ...(tags.length > 0 ? { tags } : {})
348
+ });
349
+ const posts = yield* Effect.forEach(
350
+ result.posts,
351
+ (raw) =>
352
+ parser.parsePost(raw).pipe(
353
+ Effect.mapError((error) =>
354
+ CliInputError.make({
355
+ message: `Failed to parse network post: ${formatSchemaError(error)}`,
356
+ cause: error
357
+ })
358
+ )
359
+ ),
360
+ { concurrency: "unbounded" }
361
+ );
362
+ if (outputFormat === "ndjson") {
363
+ yield* writeJsonStream(Stream.fromIterable(posts));
364
+ return;
365
+ }
366
+ if (outputFormat === "table") {
367
+ yield* writeText(renderPostsTable(posts));
368
+ return;
369
+ }
370
+ yield* writeJson({
371
+ query,
372
+ cursor: result.cursor,
373
+ hitsTotal: result.hitsTotal,
374
+ count: posts.length,
375
+ posts
376
+ });
377
+ return;
378
+ }
379
+
380
+ if (!storeValue) {
381
+ return yield* CliInputError.make({
382
+ message: "Missing --store for local search.",
383
+ cause: { store: null }
384
+ });
385
+ }
386
+ const storeRef = yield* storeOptions.loadStoreRef(storeValue);
387
+ const index = yield* StoreIndex;
388
+ const parsedCursor = Option.match(cursor, {
389
+ onNone: () => Effect.succeed(Option.none()),
390
+ onSome: (value) => {
391
+ const raw = value;
392
+ const parsed = Number(raw);
393
+ if (!Number.isInteger(parsed) || parsed < 0) {
394
+ return Effect.fail(
395
+ CliInputError.make({
396
+ message: "--cursor must be a non-negative integer for local search.",
397
+ cause: { cursor: raw }
398
+ })
399
+ );
400
+ }
401
+ return Effect.succeed(Option.some(parsed));
402
+ }
403
+ });
404
+ const cursorValue = yield* parsedCursor;
405
+ const localSortRaw = Option.getOrElse(sort, () => undefined);
406
+ const localSort = Option.match(sort, {
407
+ onNone: () => "relevance" as const,
408
+ onSome: (value) => {
409
+ if (value === "relevance" || value === "newest" || value === "oldest") {
410
+ return value;
411
+ }
412
+ return undefined;
413
+ }
414
+ }) as LocalSort | undefined;
415
+ if (localSortRaw && !localSort) {
416
+ return yield* CliInputError.make({
417
+ message: "--sort must be one of: relevance, newest, oldest (for local search).",
418
+ cause: { sort: localSortRaw }
419
+ });
420
+ }
421
+ const input = {
422
+ query,
423
+ ...(Option.isSome(limit) ? { limit: limit.value } : {}),
424
+ ...(Option.isSome(cursorValue) ? { cursor: cursorValue.value } : {}),
425
+ ...(localSort ? { sort: localSort } : {})
426
+ };
427
+ const result = yield* index.searchPosts(storeRef, input);
428
+
429
+ if (outputFormat === "ndjson") {
430
+ const stream = Stream.fromIterable(result.posts);
431
+ yield* writeJsonStream(stream);
432
+ return;
433
+ }
434
+ if (outputFormat === "table") {
435
+ yield* writeText(renderPostsTable(result.posts));
436
+ return;
437
+ }
438
+ yield* writeJson({
439
+ query,
440
+ cursor: result.cursor,
441
+ count: result.posts.length,
442
+ posts: result.posts
443
+ });
444
+ })
445
+ ).pipe(
446
+ Command.withDescription(
447
+ withExamples("Search posts within a local store using FTS", [
448
+ "skygent search posts \"deep learning\" --store my-store --limit 25",
449
+ "skygent search posts \"bluesky\" --store my-store --format table",
450
+ "skygent search posts \"effect\" --store my-store --sort newest",
451
+ "skygent search posts \"ai\" --network --sort latest"
452
+ ])
453
+ )
454
+ );
455
+
456
+ export const searchCommand = Command.make("search", {}).pipe(
457
+ Command.withSubcommands([handlesCommand, feedsCommand, postsCommand]),
458
+ Command.withDescription(
459
+ withExamples("Search for handles, feeds, or posts", [
460
+ "skygent search handles \"alice\"",
461
+ "skygent search feeds \"news\"",
462
+ "skygent search posts \"ai\" --store my-store"
463
+ ])
464
+ )
465
+ );
@@ -0,0 +1,169 @@
1
+ import { Args, Options } from "@effect/cli";
2
+ import { Effect, Option, Schema } from "effect";
3
+ import { Did, Handle, StoreName } from "../domain/primitives.js";
4
+ import { filterDslDescription, filterJsonDescription } from "./filter-help.js";
5
+ import { CliInputError } from "./errors.js";
6
+ import { formatSchemaError } from "./shared.js";
7
+
8
+ /** --store option with StoreName schema validation */
9
+ export const storeNameOption = Options.text("store").pipe(
10
+ Options.withSchema(StoreName),
11
+ Options.withDescription("Store name to write into")
12
+ );
13
+
14
+ /** --filter DSL option (optional) */
15
+ export const filterOption = Options.text("filter").pipe(
16
+ Options.withDescription(filterDslDescription()),
17
+ Options.optional
18
+ );
19
+
20
+ /** --filter-json option (optional) */
21
+ export const filterJsonOption = Options.text("filter-json").pipe(
22
+ Options.withDescription(filterJsonDescription()),
23
+ Options.optional
24
+ );
25
+
26
+ /** --post-filter DSL option (optional) */
27
+ export const postFilterOption = Options.text("post-filter").pipe(
28
+ Options.withDescription(filterDslDescription()),
29
+ Options.optional
30
+ );
31
+
32
+ /** --post-filter-json option (optional) */
33
+ export const postFilterJsonOption = Options.text("post-filter-json").pipe(
34
+ Options.withDescription(filterJsonDescription()),
35
+ Options.optional
36
+ );
37
+
38
+ /** --quiet flag to suppress progress output */
39
+ export const quietOption = Options.boolean("quiet").pipe(
40
+ Options.withDescription("Suppress progress output")
41
+ );
42
+
43
+ /** --refresh flag to update existing posts instead of deduping */
44
+ export const refreshOption = Options.boolean("refresh").pipe(
45
+ Options.withDescription("Refresh existing posts instead of deduping")
46
+ );
47
+
48
+ /** --strict flag to stop on first error */
49
+ export const strictOption = Options.boolean("strict").pipe(
50
+ Options.withDescription("Stop on first error and do not advance the checkpoint")
51
+ );
52
+
53
+ /** --max-errors option (optional) */
54
+ export const maxErrorsOption = Options.integer("max-errors").pipe(
55
+ Options.withDescription("Stop after exceeding N errors (default: unlimited)"),
56
+ Options.optional
57
+ );
58
+
59
+ /** Positional arg for feed URI */
60
+ export const feedUriArg = Args.text({ name: "uri" }).pipe(
61
+ Args.withDescription("Bluesky feed URI (at://...)")
62
+ );
63
+
64
+ /** Positional arg for list URI */
65
+ export const listUriArg = Args.text({ name: "uri" }).pipe(
66
+ Args.withDescription("Bluesky list URI (at://...)")
67
+ );
68
+
69
+ /** Positional arg for author handle or DID */
70
+ export const actorArg = Args.text({ name: "actor" }).pipe(
71
+ Args.withDescription("Bluesky handle or DID")
72
+ );
73
+
74
+ /** Positional arg for post URI */
75
+ export const postUriArg = Args.text({ name: "uri" }).pipe(
76
+ Args.withDescription("Bluesky post URI (at://...)")
77
+ );
78
+
79
+ export const authorFeedFilterValues = [
80
+ "posts_with_replies",
81
+ "posts_no_replies",
82
+ "posts_with_media",
83
+ "posts_and_author_threads"
84
+ ] as const;
85
+
86
+ /** --filter option for author feed API (optional) */
87
+ export const authorFilterOption = Options.choice(
88
+ "filter",
89
+ authorFeedFilterValues
90
+ ).pipe(
91
+ Options.withDescription(
92
+ "Author feed filter (posts_with_replies, posts_no_replies, posts_with_media, posts_and_author_threads)"
93
+ ),
94
+ Options.optional
95
+ );
96
+
97
+ /** --include-pins flag for author feed API */
98
+ export const includePinsOption = Options.boolean("include-pins").pipe(
99
+ Options.withDescription("Include pinned posts in author feeds")
100
+ );
101
+
102
+ /** Validate --max-errors value is non-negative */
103
+ export const parseMaxErrors = (maxErrors: Option.Option<number>) =>
104
+ Option.match(maxErrors, {
105
+ onNone: () => Effect.succeed(Option.none()),
106
+ onSome: (value) =>
107
+ value < 0
108
+ ? Effect.fail(
109
+ CliInputError.make({
110
+ message: "max-errors must be a non-negative integer.",
111
+ cause: value
112
+ })
113
+ )
114
+ : Effect.succeed(Option.some(value))
115
+ });
116
+
117
+ export const decodeActor = (actor: string) => {
118
+ if (actor.startsWith("did:")) {
119
+ return Schema.decodeUnknown(Did)(actor).pipe(
120
+ Effect.mapError((error) =>
121
+ CliInputError.make({
122
+ message: `Invalid DID: ${formatSchemaError(error)}`,
123
+ cause: { actor }
124
+ })
125
+ )
126
+ );
127
+ }
128
+ return Schema.decodeUnknown(Handle)(actor).pipe(
129
+ Effect.mapError((error) =>
130
+ CliInputError.make({
131
+ message: `Invalid handle: ${formatSchemaError(error)}`,
132
+ cause: { actor }
133
+ })
134
+ )
135
+ );
136
+ };
137
+
138
+ export const parseLimit = (limit: Option.Option<number>) =>
139
+ Option.match(limit, {
140
+ onNone: () => Effect.succeed(Option.none()),
141
+ onSome: (value) =>
142
+ value <= 0
143
+ ? Effect.fail(
144
+ CliInputError.make({
145
+ message: "--limit must be a positive integer.",
146
+ cause: value
147
+ })
148
+ )
149
+ : Effect.succeed(Option.some(value))
150
+ });
151
+
152
+ export const parseBoundedIntOption = (
153
+ value: Option.Option<number>,
154
+ name: string,
155
+ min: number,
156
+ max: number
157
+ ) =>
158
+ Option.match(value, {
159
+ onNone: () => Effect.succeed(Option.none()),
160
+ onSome: (raw) =>
161
+ raw < min || raw > max
162
+ ? Effect.fail(
163
+ CliInputError.make({
164
+ message: `${name} must be between ${min} and ${max}.`,
165
+ cause: raw
166
+ })
167
+ )
168
+ : Effect.succeed(Option.some(raw))
169
+ });
@@ -0,0 +1,20 @@
1
+ export { formatSchemaError } from "../services/shared.js";
2
+
3
+ /** Safely parse JSON, returning `undefined` on failure. */
4
+ export const safeParseJson = (raw: string): unknown => {
5
+ try {
6
+ return JSON.parse(raw);
7
+ } catch {
8
+ return undefined;
9
+ }
10
+ };
11
+
12
+ /** Format schema issues into an array of "path: message" strings. */
13
+ export const issueDetails = (
14
+ issues: ReadonlyArray<{ readonly path: ReadonlyArray<unknown>; readonly message: string }>
15
+ ) =>
16
+ issues.map((issue) => {
17
+ const path =
18
+ issue.path.length > 0 ? issue.path.map((entry) => String(entry)).join(".") : "value";
19
+ return `${path}: ${issue.message}`;
20
+ });