@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.
- package/README.md +59 -0
- package/index.ts +146 -0
- package/package.json +56 -0
- package/src/cli/app.ts +75 -0
- package/src/cli/config-command.ts +140 -0
- package/src/cli/config.ts +91 -0
- package/src/cli/derive.ts +205 -0
- package/src/cli/doc/annotation.ts +36 -0
- package/src/cli/doc/filter.ts +69 -0
- package/src/cli/doc/index.ts +9 -0
- package/src/cli/doc/post.ts +155 -0
- package/src/cli/doc/primitives.ts +25 -0
- package/src/cli/doc/render.ts +18 -0
- package/src/cli/doc/table.ts +114 -0
- package/src/cli/doc/thread.ts +46 -0
- package/src/cli/doc/tree.ts +126 -0
- package/src/cli/errors.ts +59 -0
- package/src/cli/exit-codes.ts +52 -0
- package/src/cli/feed.ts +177 -0
- package/src/cli/filter-dsl.ts +1411 -0
- package/src/cli/filter-errors.ts +208 -0
- package/src/cli/filter-help.ts +70 -0
- package/src/cli/filter-input.ts +54 -0
- package/src/cli/filter.ts +435 -0
- package/src/cli/graph.ts +472 -0
- package/src/cli/help.ts +14 -0
- package/src/cli/interval.ts +35 -0
- package/src/cli/jetstream.ts +173 -0
- package/src/cli/layers.ts +180 -0
- package/src/cli/logging.ts +136 -0
- package/src/cli/output-format.ts +26 -0
- package/src/cli/output.ts +82 -0
- package/src/cli/parse.ts +80 -0
- package/src/cli/post.ts +193 -0
- package/src/cli/preferences.ts +11 -0
- package/src/cli/query-fields.ts +247 -0
- package/src/cli/query.ts +415 -0
- package/src/cli/range.ts +44 -0
- package/src/cli/search.ts +465 -0
- package/src/cli/shared-options.ts +169 -0
- package/src/cli/shared.ts +20 -0
- package/src/cli/store-errors.ts +80 -0
- package/src/cli/store-tree.ts +392 -0
- package/src/cli/store.ts +395 -0
- package/src/cli/sync-factory.ts +107 -0
- package/src/cli/sync.ts +366 -0
- package/src/cli/view-thread.ts +196 -0
- package/src/cli/view.ts +47 -0
- package/src/cli/watch.ts +344 -0
- package/src/db/migrations/store-catalog/001_init.ts +14 -0
- package/src/db/migrations/store-index/001_init.ts +34 -0
- package/src/db/migrations/store-index/002_event_log.ts +24 -0
- package/src/db/migrations/store-index/003_fts_and_derived.ts +52 -0
- package/src/db/migrations/store-index/004_query_indexes.ts +9 -0
- package/src/db/migrations/store-index/005_post_lang.ts +15 -0
- package/src/db/migrations/store-index/006_has_embed.ts +10 -0
- package/src/db/migrations/store-index/007_event_seq_and_checkpoints.ts +68 -0
- package/src/domain/bsky.ts +467 -0
- package/src/domain/config.ts +11 -0
- package/src/domain/credentials.ts +6 -0
- package/src/domain/defaults.ts +8 -0
- package/src/domain/derivation.ts +55 -0
- package/src/domain/errors.ts +71 -0
- package/src/domain/events.ts +55 -0
- package/src/domain/extract.ts +64 -0
- package/src/domain/filter-describe.ts +551 -0
- package/src/domain/filter-explain.ts +9 -0
- package/src/domain/filter.ts +797 -0
- package/src/domain/format.ts +91 -0
- package/src/domain/index.ts +13 -0
- package/src/domain/indexes.ts +17 -0
- package/src/domain/policies.ts +16 -0
- package/src/domain/post.ts +88 -0
- package/src/domain/primitives.ts +50 -0
- package/src/domain/raw.ts +140 -0
- package/src/domain/store.ts +103 -0
- package/src/domain/sync.ts +211 -0
- package/src/domain/text-width.ts +56 -0
- package/src/services/app-config.ts +278 -0
- package/src/services/bsky-client.ts +2113 -0
- package/src/services/credential-store.ts +408 -0
- package/src/services/derivation-engine.ts +502 -0
- package/src/services/derivation-settings.ts +61 -0
- package/src/services/derivation-validator.ts +68 -0
- package/src/services/filter-compiler.ts +269 -0
- package/src/services/filter-library.ts +371 -0
- package/src/services/filter-runtime.ts +821 -0
- package/src/services/filter-settings.ts +30 -0
- package/src/services/identity-resolver.ts +563 -0
- package/src/services/jetstream-sync.ts +636 -0
- package/src/services/lineage-store.ts +89 -0
- package/src/services/link-validator.ts +244 -0
- package/src/services/output-manager.ts +274 -0
- package/src/services/post-parser.ts +62 -0
- package/src/services/profile-resolver.ts +223 -0
- package/src/services/resource-monitor.ts +106 -0
- package/src/services/shared.ts +69 -0
- package/src/services/store-cleaner.ts +43 -0
- package/src/services/store-commit.ts +168 -0
- package/src/services/store-db.ts +248 -0
- package/src/services/store-event-log.ts +285 -0
- package/src/services/store-index-sql.ts +289 -0
- package/src/services/store-index.ts +1152 -0
- package/src/services/store-keys.ts +4 -0
- package/src/services/store-manager.ts +358 -0
- package/src/services/store-stats.ts +522 -0
- package/src/services/store-writer.ts +200 -0
- package/src/services/sync-checkpoint-store.ts +169 -0
- package/src/services/sync-engine.ts +547 -0
- package/src/services/sync-reporter.ts +16 -0
- package/src/services/sync-settings.ts +72 -0
- package/src/services/trending-topics.ts +226 -0
- package/src/services/view-checkpoint-store.ts +238 -0
- package/src/typeclass/chunk.ts +84 -0
package/src/cli/range.ts
ADDED
|
@@ -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
|
+
});
|