@mepuka/skygent 0.2.0 → 0.3.1

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 (62) hide show
  1. package/README.md +269 -31
  2. package/index.ts +18 -3
  3. package/package.json +1 -1
  4. package/src/cli/app.ts +4 -2
  5. package/src/cli/compact-output.ts +52 -0
  6. package/src/cli/config.ts +46 -4
  7. package/src/cli/doc/table-renderers.ts +29 -0
  8. package/src/cli/doc/thread.ts +2 -4
  9. package/src/cli/exit-codes.ts +2 -0
  10. package/src/cli/feed.ts +78 -61
  11. package/src/cli/filter-dsl.ts +146 -11
  12. package/src/cli/filter-errors.ts +13 -11
  13. package/src/cli/filter-help.ts +7 -0
  14. package/src/cli/filter-input.ts +3 -2
  15. package/src/cli/filter.ts +83 -5
  16. package/src/cli/graph.ts +297 -169
  17. package/src/cli/input.ts +45 -0
  18. package/src/cli/interval.ts +4 -33
  19. package/src/cli/jetstream.ts +2 -0
  20. package/src/cli/layers.ts +10 -0
  21. package/src/cli/logging.ts +8 -0
  22. package/src/cli/option-schemas.ts +22 -0
  23. package/src/cli/output-format.ts +11 -0
  24. package/src/cli/output-render.ts +14 -0
  25. package/src/cli/pagination.ts +17 -0
  26. package/src/cli/parse-errors.ts +30 -0
  27. package/src/cli/parse.ts +1 -47
  28. package/src/cli/pipe-input.ts +18 -0
  29. package/src/cli/pipe.ts +154 -0
  30. package/src/cli/post.ts +88 -66
  31. package/src/cli/query-fields.ts +13 -3
  32. package/src/cli/query.ts +354 -100
  33. package/src/cli/search.ts +93 -136
  34. package/src/cli/shared-options.ts +11 -63
  35. package/src/cli/shared.ts +1 -20
  36. package/src/cli/store-errors.ts +28 -21
  37. package/src/cli/store-tree.ts +6 -4
  38. package/src/cli/store.ts +41 -2
  39. package/src/cli/stream-merge.ts +105 -0
  40. package/src/cli/sync-factory.ts +24 -7
  41. package/src/cli/sync.ts +46 -67
  42. package/src/cli/thread-options.ts +25 -0
  43. package/src/cli/time.ts +171 -0
  44. package/src/cli/view-thread.ts +29 -32
  45. package/src/cli/watch.ts +55 -26
  46. package/src/domain/errors.ts +6 -1
  47. package/src/domain/format.ts +21 -0
  48. package/src/domain/order.ts +24 -0
  49. package/src/domain/primitives.ts +20 -3
  50. package/src/graph/relationships.ts +129 -0
  51. package/src/services/bsky-client.ts +11 -5
  52. package/src/services/jetstream-sync.ts +4 -4
  53. package/src/services/lineage-store.ts +15 -1
  54. package/src/services/shared.ts +48 -1
  55. package/src/services/store-cleaner.ts +5 -2
  56. package/src/services/store-commit.ts +60 -0
  57. package/src/services/store-manager.ts +69 -2
  58. package/src/services/store-renamer.ts +288 -0
  59. package/src/services/store-stats.ts +7 -5
  60. package/src/services/sync-engine.ts +149 -89
  61. package/src/services/sync-reporter.ts +3 -1
  62. package/src/services/sync-settings.ts +24 -0
package/src/cli/graph.ts CHANGED
@@ -3,30 +3,38 @@ import { Effect, Option, Stream } from "effect";
3
3
  import { BskyClient } from "../services/bsky-client.js";
4
4
  import { AppConfigService } from "../services/app-config.js";
5
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";
6
+ import { ProfileResolver } from "../services/profile-resolver.js";
7
+ import type { ListItemView, ListView } from "../domain/bsky.js";
8
+ import { AtUri } from "../domain/primitives.js";
9
+ import { actorArg, decodeActor } from "./shared-options.js";
8
10
  import { CliInputError } from "./errors.js";
9
11
  import { withExamples } from "./help.js";
10
12
  import { writeJson, writeJsonStream, writeText } from "./output.js";
11
13
  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
- );
14
+ import { renderProfileTable } from "./doc/table-renderers.js";
15
+ import { jsonNdjsonTableFormats } from "./output-format.js";
16
+ import { emitWithFormat } from "./output-render.js";
17
+ import { cursorOption as baseCursorOption, limitOption as baseLimitOption, parsePagination } from "./pagination.js";
18
+ import { CliPreferences } from "./preferences.js";
19
+ import { compactListItemView, compactListView, compactProfileView } from "./compact-output.js";
20
+ import {
21
+ buildRelationshipGraph,
22
+ relationshipEntries,
23
+ type RelationshipEntry,
24
+ type RelationshipNode
25
+ } from "../graph/relationships.js";
17
26
 
18
27
  const listUriArg = Args.text({ name: "uri" }).pipe(
28
+ Args.withSchema(AtUri),
19
29
  Args.withDescription("Bluesky list URI (at://...)")
20
30
  );
21
31
 
22
- const limitOption = Options.integer("limit").pipe(
23
- Options.withDescription("Maximum number of results"),
24
- Options.optional
32
+ const limitOption = baseLimitOption.pipe(
33
+ Options.withDescription("Maximum number of results")
25
34
  );
26
35
 
27
- const cursorOption = Options.text("cursor").pipe(
28
- Options.withDescription("Pagination cursor"),
29
- Options.optional
36
+ const cursorOption = baseCursorOption.pipe(
37
+ Options.withDescription("Pagination cursor")
30
38
  );
31
39
 
32
40
  const formatOption = Options.choice("format", jsonNdjsonTableFormats).pipe(
@@ -34,6 +42,17 @@ const formatOption = Options.choice("format", jsonNdjsonTableFormats).pipe(
34
42
  Options.optional
35
43
  );
36
44
 
45
+ const ensureSupportedFormat = (
46
+ format: Option.Option<typeof jsonNdjsonTableFormats[number]>,
47
+ configFormat: string
48
+ ) =>
49
+ Option.isNone(format) && configFormat === "markdown"
50
+ ? CliInputError.make({
51
+ message: 'Output format "markdown" is not supported for graph commands. Use --format json|ndjson|table.',
52
+ cause: { format: configFormat }
53
+ })
54
+ : Effect.void;
55
+
37
56
  const purposeOption = Options.choice("purpose", ["modlist", "curatelist"]).pipe(
38
57
  Options.withDescription("List purpose filter"),
39
58
  Options.optional
@@ -42,19 +61,9 @@ const purposeOption = Options.choice("purpose", ["modlist", "curatelist"]).pipe(
42
61
  const othersOption = Options.text("others").pipe(
43
62
  Options.withDescription("Comma-separated list of actors to compare" )
44
63
  );
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
- };
64
+ const rawOption = Options.boolean("raw").pipe(
65
+ Options.withDescription("Output raw relationship data (no wrapper fields)")
66
+ );
58
67
 
59
68
  const renderListTable = (
60
69
  lists: ReadonlyArray<ListView>,
@@ -71,35 +80,33 @@ const renderListTable = (
71
80
  return cursor ? `${table}\n\nCursor: ${cursor}` : table;
72
81
  };
73
82
 
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
- };
83
+ const renderRelationshipsTable = (entries: ReadonlyArray<RelationshipEntry>) => {
84
+ const rows = entries.map((entry) => {
85
+ const otherInputs = entry.other.inputs.join(", ");
86
+ const handle = entry.other.handle ?? "";
87
+ const did = entry.other.did ?? (entry.other.notFound ? "not-found" : "");
88
+ const rel = entry.relationship;
88
89
  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" : ""
90
+ otherInputs,
91
+ handle,
92
+ did,
93
+ rel.following ? "yes" : "",
94
+ rel.followedBy ? "yes" : "",
95
+ rel.mutual ? "yes" : "",
96
+ rel.blocking ? "yes" : "",
97
+ rel.blockedBy ? "yes" : "",
98
+ rel.blockingByList ? "yes" : "",
99
+ rel.blockedByList ? "yes" : ""
96
100
  ];
97
101
  });
98
102
  return renderTableLegacy(
99
103
  [
104
+ "INPUTS",
105
+ "HANDLE",
100
106
  "DID",
101
107
  "FOLLOWING",
102
108
  "FOLLOWED BY",
109
+ "MUTUAL",
103
110
  "BLOCKING",
104
111
  "BLOCKED BY",
105
112
  "BLOCK BY LIST",
@@ -125,29 +132,38 @@ const followersCommand = Command.make(
125
132
  ({ actor, limit, cursor, format }) =>
126
133
  Effect.gen(function* () {
127
134
  const appConfig = yield* AppConfigService;
135
+ yield* ensureSupportedFormat(format, appConfig.outputFormat);
136
+ const preferences = yield* CliPreferences;
128
137
  const client = yield* BskyClient;
129
- const resolvedActor = yield* decodeActor(actor);
130
- const parsedLimit = yield* parseLimit(limit);
138
+ const { limit: limitValue, cursor: cursorValue } = parsePagination(limit, cursor);
131
139
  const options = {
132
- ...(Option.isSome(parsedLimit) ? { limit: parsedLimit.value } : {}),
133
- ...(Option.isSome(cursor) ? { cursor: cursor.value } : {})
140
+ ...(limitValue !== undefined ? { limit: limitValue } : {}),
141
+ ...(cursorValue !== undefined ? { cursor: cursorValue } : {})
134
142
  };
135
- const result = yield* client.getFollowers(resolvedActor, options);
136
- const outputFormat = resolveOutputFormat(
143
+ const result = yield* client.getFollowers(actor, options);
144
+ const subject = preferences.compact
145
+ ? compactProfileView(result.subject)
146
+ : result.subject;
147
+ const followers = preferences.compact
148
+ ? result.followers.map(compactProfileView)
149
+ : result.followers;
150
+ const payload = result.cursor
151
+ ? { subject, followers, cursor: result.cursor }
152
+ : { subject, followers };
153
+ const followersStream = Stream.fromIterable(
154
+ followers as ReadonlyArray<unknown>
155
+ );
156
+ yield* emitWithFormat(
137
157
  format,
138
158
  appConfig.outputFormat,
139
159
  jsonNdjsonTableFormats,
140
- "json"
160
+ "json",
161
+ {
162
+ json: writeJson(payload),
163
+ ndjson: writeJsonStream(followersStream),
164
+ table: writeText(renderProfileTable(result.followers, result.cursor))
165
+ }
141
166
  );
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
167
  })
152
168
  ).pipe(
153
169
  Command.withDescription(
@@ -164,29 +180,38 @@ const followsCommand = Command.make(
164
180
  ({ actor, limit, cursor, format }) =>
165
181
  Effect.gen(function* () {
166
182
  const appConfig = yield* AppConfigService;
183
+ yield* ensureSupportedFormat(format, appConfig.outputFormat);
184
+ const preferences = yield* CliPreferences;
167
185
  const client = yield* BskyClient;
168
- const resolvedActor = yield* decodeActor(actor);
169
- const parsedLimit = yield* parseLimit(limit);
186
+ const { limit: limitValue, cursor: cursorValue } = parsePagination(limit, cursor);
170
187
  const options = {
171
- ...(Option.isSome(parsedLimit) ? { limit: parsedLimit.value } : {}),
172
- ...(Option.isSome(cursor) ? { cursor: cursor.value } : {})
188
+ ...(limitValue !== undefined ? { limit: limitValue } : {}),
189
+ ...(cursorValue !== undefined ? { cursor: cursorValue } : {})
173
190
  };
174
- const result = yield* client.getFollows(resolvedActor, options);
175
- const outputFormat = resolveOutputFormat(
191
+ const result = yield* client.getFollows(actor, options);
192
+ const subject = preferences.compact
193
+ ? compactProfileView(result.subject)
194
+ : result.subject;
195
+ const follows = preferences.compact
196
+ ? result.follows.map(compactProfileView)
197
+ : result.follows;
198
+ const payload = result.cursor
199
+ ? { subject, follows, cursor: result.cursor }
200
+ : { subject, follows };
201
+ const followsStream = Stream.fromIterable(
202
+ follows as ReadonlyArray<unknown>
203
+ );
204
+ yield* emitWithFormat(
176
205
  format,
177
206
  appConfig.outputFormat,
178
207
  jsonNdjsonTableFormats,
179
- "json"
208
+ "json",
209
+ {
210
+ json: writeJson(payload),
211
+ ndjson: writeJsonStream(followsStream),
212
+ table: writeText(renderProfileTable(result.follows, result.cursor))
213
+ }
180
214
  );
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
215
  })
191
216
  ).pipe(
192
217
  Command.withDescription(
@@ -203,29 +228,38 @@ const knownFollowersCommand = Command.make(
203
228
  ({ actor, limit, cursor, format }) =>
204
229
  Effect.gen(function* () {
205
230
  const appConfig = yield* AppConfigService;
231
+ yield* ensureSupportedFormat(format, appConfig.outputFormat);
232
+ const preferences = yield* CliPreferences;
206
233
  const client = yield* BskyClient;
207
- const resolvedActor = yield* decodeActor(actor);
208
- const parsedLimit = yield* parseLimit(limit);
234
+ const { limit: limitValue, cursor: cursorValue } = parsePagination(limit, cursor);
209
235
  const options = {
210
- ...(Option.isSome(parsedLimit) ? { limit: parsedLimit.value } : {}),
211
- ...(Option.isSome(cursor) ? { cursor: cursor.value } : {})
236
+ ...(limitValue !== undefined ? { limit: limitValue } : {}),
237
+ ...(cursorValue !== undefined ? { cursor: cursorValue } : {})
212
238
  };
213
- const result = yield* client.getKnownFollowers(resolvedActor, options);
214
- const outputFormat = resolveOutputFormat(
239
+ const result = yield* client.getKnownFollowers(actor, options);
240
+ const subject = preferences.compact
241
+ ? compactProfileView(result.subject)
242
+ : result.subject;
243
+ const followers = preferences.compact
244
+ ? result.followers.map(compactProfileView)
245
+ : result.followers;
246
+ const payload = result.cursor
247
+ ? { subject, followers, cursor: result.cursor }
248
+ : { subject, followers };
249
+ const followersStream = Stream.fromIterable(
250
+ followers as ReadonlyArray<unknown>
251
+ );
252
+ yield* emitWithFormat(
215
253
  format,
216
254
  appConfig.outputFormat,
217
255
  jsonNdjsonTableFormats,
218
- "json"
256
+ "json",
257
+ {
258
+ json: writeJson(payload),
259
+ ndjson: writeJsonStream(followersStream),
260
+ table: writeText(renderProfileTable(result.followers, result.cursor))
261
+ }
219
262
  );
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
263
  })
230
264
  ).pipe(
231
265
  Command.withDescription(
@@ -237,12 +271,14 @@ const knownFollowersCommand = Command.make(
237
271
 
238
272
  const relationshipsCommand = Command.make(
239
273
  "relationships",
240
- { actor: actorArg, others: othersOption, format: formatOption },
241
- ({ actor, others, format }) =>
274
+ { actor: actorArg, others: othersOption, format: formatOption, raw: rawOption },
275
+ ({ actor, others, format, raw }) =>
242
276
  Effect.gen(function* () {
243
277
  const appConfig = yield* AppConfigService;
278
+ yield* ensureSupportedFormat(format, appConfig.outputFormat);
244
279
  const client = yield* BskyClient;
245
280
  const identities = yield* IdentityResolver;
281
+ const profiles = yield* ProfileResolver;
246
282
  const resolveDid = (value: string) =>
247
283
  Effect.gen(function* () {
248
284
  const decoded = yield* decodeActor(value);
@@ -251,7 +287,9 @@ const relationshipsCommand = Command.make(
251
287
  ? actorValue
252
288
  : yield* identities.resolveDid(actorValue);
253
289
  });
254
- const resolvedActor = yield* resolveDid(actor);
290
+ const resolvedActor = actor.startsWith("did:")
291
+ ? actor
292
+ : yield* identities.resolveDid(actor);
255
293
  const parsedOthers = others
256
294
  .split(",")
257
295
  .map((item) => item.trim())
@@ -274,22 +312,84 @@ const relationshipsCommand = Command.make(
274
312
  (value) => resolveDid(value),
275
313
  { concurrency: "unbounded" }
276
314
  );
315
+ const didToInputs = new Map<string, Array<string>>();
316
+ resolvedOthers.forEach((did, index) => {
317
+ const input = uniqueOthers[index];
318
+ if (input) {
319
+ const existing = didToInputs.get(did);
320
+ if (existing) {
321
+ existing.push(input);
322
+ } else {
323
+ didToInputs.set(did, [input]);
324
+ }
325
+ }
326
+ });
277
327
  const result = yield* client.getRelationships(resolvedActor, resolvedOthers);
278
- const outputFormat = resolveOutputFormat(
328
+ const handleFromInputs = (inputs: ReadonlyArray<string>) =>
329
+ inputs.find((input) => !input.startsWith("did:"));
330
+ const inputsForActor = [actor];
331
+ const didsNeedingHandle = [resolvedActor, ...resolvedOthers].filter((did) => {
332
+ const inputs = did === resolvedActor ? inputsForActor : didToInputs.get(did) ?? [];
333
+ return handleFromInputs(inputs) === undefined;
334
+ });
335
+ const handles = yield* Effect.forEach(
336
+ didsNeedingHandle,
337
+ (did) =>
338
+ profiles.handleForDid(did).pipe(
339
+ Effect.either,
340
+ Effect.map((result) => [did, result] as const)
341
+ ),
342
+ { concurrency: "unbounded" }
343
+ );
344
+ const handleMap = new Map<string, string>();
345
+ for (const [did, result] of handles) {
346
+ if (result._tag === "Right") {
347
+ handleMap.set(did, String(result.right));
348
+ }
349
+ }
350
+ const buildNode = (
351
+ did: string,
352
+ inputs: ReadonlyArray<string>,
353
+ handle?: string
354
+ ): RelationshipNode => ({
355
+ did,
356
+ inputs,
357
+ ...(handle ? { handle } : {})
358
+ });
359
+ const actorHandle = handleFromInputs(inputsForActor) ?? handleMap.get(resolvedActor);
360
+ const nodesByKey = new Map<string, RelationshipNode>();
361
+ nodesByKey.set(resolvedActor, buildNode(resolvedActor, inputsForActor, actorHandle));
362
+ for (const [did, inputs] of didToInputs.entries()) {
363
+ const handle = handleFromInputs(inputs) ?? handleMap.get(did);
364
+ nodesByKey.set(did, buildNode(did, inputs, handle));
365
+ }
366
+ const graphResult = buildRelationshipGraph(
367
+ resolvedActor,
368
+ nodesByKey,
369
+ result.relationships
370
+ );
371
+ const entries = relationshipEntries(graphResult.graph);
372
+ yield* emitWithFormat(
279
373
  format,
280
374
  appConfig.outputFormat,
281
375
  jsonNdjsonTableFormats,
282
- "json"
376
+ "json",
377
+ {
378
+ json: raw
379
+ ? writeJson(result)
380
+ : writeJson({
381
+ actor: nodesByKey.get(resolvedActor) ?? {
382
+ did: resolvedActor,
383
+ inputs: [actor]
384
+ },
385
+ relationships: entries
386
+ }),
387
+ ndjson: raw
388
+ ? writeJsonStream(Stream.fromIterable(result.relationships))
389
+ : writeJsonStream(Stream.fromIterable(entries)),
390
+ table: writeText(renderRelationshipsTable(entries))
391
+ }
283
392
  );
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
393
  })
294
394
  ).pipe(
295
395
  Command.withDescription(
@@ -305,30 +405,34 @@ const listsCommand = Command.make(
305
405
  ({ actor, limit, cursor, purpose, format }) =>
306
406
  Effect.gen(function* () {
307
407
  const appConfig = yield* AppConfigService;
408
+ yield* ensureSupportedFormat(format, appConfig.outputFormat);
409
+ const preferences = yield* CliPreferences;
308
410
  const client = yield* BskyClient;
309
- const resolvedActor = yield* decodeActor(actor);
310
- const parsedLimit = yield* parseLimit(limit);
411
+ const { limit: limitValue, cursor: cursorValue } = parsePagination(limit, cursor);
311
412
  const options = {
312
- ...(Option.isSome(parsedLimit) ? { limit: parsedLimit.value } : {}),
313
- ...(Option.isSome(cursor) ? { cursor: cursor.value } : {}),
413
+ ...(limitValue !== undefined ? { limit: limitValue } : {}),
414
+ ...(cursorValue !== undefined ? { cursor: cursorValue } : {}),
314
415
  ...(Option.isSome(purpose) ? { purposes: [purpose.value] } : {})
315
416
  };
316
- const result = yield* client.getLists(resolvedActor, options);
317
- const outputFormat = resolveOutputFormat(
417
+ const result = yield* client.getLists(actor, options);
418
+ const lists = preferences.compact
419
+ ? result.lists.map(compactListView)
420
+ : result.lists;
421
+ const payload = result.cursor ? { lists, cursor: result.cursor } : { lists };
422
+ const listsStream = Stream.fromIterable(
423
+ lists as ReadonlyArray<ListView | ReturnType<typeof compactListView>>
424
+ );
425
+ yield* emitWithFormat(
318
426
  format,
319
427
  appConfig.outputFormat,
320
428
  jsonNdjsonTableFormats,
321
- "json"
429
+ "json",
430
+ {
431
+ json: writeJson(payload),
432
+ ndjson: writeJsonStream(listsStream),
433
+ table: writeText(renderListTable(result.lists, result.cursor))
434
+ }
322
435
  );
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
436
  })
333
437
  ).pipe(
334
438
  Command.withDescription(
@@ -345,30 +449,38 @@ const listCommand = Command.make(
345
449
  ({ uri, limit, cursor, format }) =>
346
450
  Effect.gen(function* () {
347
451
  const appConfig = yield* AppConfigService;
452
+ yield* ensureSupportedFormat(format, appConfig.outputFormat);
453
+ const preferences = yield* CliPreferences;
348
454
  const client = yield* BskyClient;
349
- const parsedLimit = yield* parseLimit(limit);
455
+ const { limit: limitValue, cursor: cursorValue } = parsePagination(limit, cursor);
350
456
  const options = {
351
- ...(Option.isSome(parsedLimit) ? { limit: parsedLimit.value } : {}),
352
- ...(Option.isSome(cursor) ? { cursor: cursor.value } : {})
457
+ ...(limitValue !== undefined ? { limit: limitValue } : {}),
458
+ ...(cursorValue !== undefined ? { cursor: cursorValue } : {})
353
459
  };
354
460
  const result = yield* client.getList(uri, options);
355
- const outputFormat = resolveOutputFormat(
461
+ const list = preferences.compact ? compactListView(result.list) : result.list;
462
+ const items = preferences.compact
463
+ ? result.items.map(compactListItemView)
464
+ : result.items;
465
+ const payload = result.cursor
466
+ ? { list, items, cursor: result.cursor }
467
+ : { list, items };
468
+ const itemsStream = Stream.fromIterable(
469
+ items as ReadonlyArray<ListItemView | ReturnType<typeof compactListItemView>>
470
+ );
471
+ const header = `${result.list.name} (${result.list.purpose}) by ${result.list.creator.handle}`;
472
+ const body = renderListItemsTable(result.items, result.cursor);
473
+ yield* emitWithFormat(
356
474
  format,
357
475
  appConfig.outputFormat,
358
476
  jsonNdjsonTableFormats,
359
- "json"
477
+ "json",
478
+ {
479
+ json: writeJson(payload),
480
+ ndjson: writeJsonStream(itemsStream),
481
+ table: writeText(`${header}\n\n${body}`)
482
+ }
360
483
  );
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
484
  })
373
485
  ).pipe(
374
486
  Command.withDescription(
@@ -384,28 +496,36 @@ const blocksCommand = Command.make(
384
496
  ({ limit, cursor, format }) =>
385
497
  Effect.gen(function* () {
386
498
  const appConfig = yield* AppConfigService;
499
+ yield* ensureSupportedFormat(format, appConfig.outputFormat);
500
+ const preferences = yield* CliPreferences;
387
501
  const client = yield* BskyClient;
388
- const parsedLimit = yield* parseLimit(limit);
502
+ const { limit: limitValue, cursor: cursorValue } = parsePagination(limit, cursor);
389
503
  const options = {
390
- ...(Option.isSome(parsedLimit) ? { limit: parsedLimit.value } : {}),
391
- ...(Option.isSome(cursor) ? { cursor: cursor.value } : {})
504
+ ...(limitValue !== undefined ? { limit: limitValue } : {}),
505
+ ...(cursorValue !== undefined ? { cursor: cursorValue } : {})
392
506
  };
393
507
  const result = yield* client.getBlocks(options);
394
- const outputFormat = resolveOutputFormat(
508
+ const blocks = preferences.compact
509
+ ? result.blocks.map(compactProfileView)
510
+ : result.blocks;
511
+ const payload = result.cursor ? { blocks, cursor: result.cursor } : { blocks };
512
+ const blocksStream = Stream.fromIterable(
513
+ blocks as ReadonlyArray<unknown>
514
+ );
515
+ yield* emitWithFormat(
395
516
  format,
396
517
  appConfig.outputFormat,
397
518
  jsonNdjsonTableFormats,
398
- "json"
519
+ "json",
520
+ {
521
+ json: writeJson(payload),
522
+ ndjson:
523
+ blocks.length === 0
524
+ ? writeText("[]")
525
+ : writeJsonStream(blocksStream),
526
+ table: writeText(renderProfileTable(result.blocks, result.cursor))
527
+ }
399
528
  );
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
529
  })
410
530
  ).pipe(
411
531
  Command.withDescription(
@@ -421,28 +541,36 @@ const mutesCommand = Command.make(
421
541
  ({ limit, cursor, format }) =>
422
542
  Effect.gen(function* () {
423
543
  const appConfig = yield* AppConfigService;
544
+ yield* ensureSupportedFormat(format, appConfig.outputFormat);
545
+ const preferences = yield* CliPreferences;
424
546
  const client = yield* BskyClient;
425
- const parsedLimit = yield* parseLimit(limit);
547
+ const { limit: limitValue, cursor: cursorValue } = parsePagination(limit, cursor);
426
548
  const options = {
427
- ...(Option.isSome(parsedLimit) ? { limit: parsedLimit.value } : {}),
428
- ...(Option.isSome(cursor) ? { cursor: cursor.value } : {})
549
+ ...(limitValue !== undefined ? { limit: limitValue } : {}),
550
+ ...(cursorValue !== undefined ? { cursor: cursorValue } : {})
429
551
  };
430
552
  const result = yield* client.getMutes(options);
431
- const outputFormat = resolveOutputFormat(
553
+ const mutes = preferences.compact
554
+ ? result.mutes.map(compactProfileView)
555
+ : result.mutes;
556
+ const payload = result.cursor ? { mutes, cursor: result.cursor } : { mutes };
557
+ const mutesStream = Stream.fromIterable(
558
+ mutes as ReadonlyArray<unknown>
559
+ );
560
+ yield* emitWithFormat(
432
561
  format,
433
562
  appConfig.outputFormat,
434
563
  jsonNdjsonTableFormats,
435
- "json"
564
+ "json",
565
+ {
566
+ json: writeJson(payload),
567
+ ndjson:
568
+ mutes.length === 0
569
+ ? writeText("[]")
570
+ : writeJsonStream(mutesStream),
571
+ table: writeText(renderProfileTable(result.mutes, result.cursor))
572
+ }
436
573
  );
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
574
  })
447
575
  ).pipe(
448
576
  Command.withDescription(
@@ -0,0 +1,45 @@
1
+ import { SystemError, type PlatformError } from "@effect/platform/Error";
2
+ import { Context, Effect, Layer, Stream } from "effect";
3
+ import { createInterface } from "node:readline";
4
+
5
+ export interface CliInputService {
6
+ readonly lines: Stream.Stream<string, PlatformError>;
7
+ }
8
+
9
+ const makeLines = () =>
10
+ Stream.unwrapScoped(
11
+ Effect.acquireRelease(
12
+ Effect.sync(() =>
13
+ createInterface({
14
+ input: process.stdin,
15
+ crlfDelay: Infinity
16
+ })
17
+ ),
18
+ (rl) => Effect.sync(() => rl.close())
19
+ ).pipe(
20
+ Effect.map((rl) =>
21
+ Stream.fromAsyncIterable(
22
+ rl,
23
+ (cause) =>
24
+ new SystemError({
25
+ module: "Stream",
26
+ method: "stdin",
27
+ reason: "Unknown",
28
+ cause
29
+ })
30
+ )
31
+ )
32
+ )
33
+ );
34
+
35
+ export class CliInput extends Context.Tag("@skygent/CliInput")<
36
+ CliInput,
37
+ CliInputService
38
+ >() {
39
+ static readonly layer = Layer.succeed(
40
+ CliInput,
41
+ CliInput.of({
42
+ lines: makeLines()
43
+ })
44
+ );
45
+ }