@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/graph.ts
ADDED
|
@@ -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
|
+
);
|
package/src/cli/help.ts
ADDED
|
@@ -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
|
+
});
|