@mepuka/skygent 0.3.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.
package/src/cli/graph.ts CHANGED
@@ -5,7 +5,8 @@ import { AppConfigService } from "../services/app-config.js";
5
5
  import { IdentityResolver } from "../services/identity-resolver.js";
6
6
  import { ProfileResolver } from "../services/profile-resolver.js";
7
7
  import type { ListItemView, ListView } from "../domain/bsky.js";
8
- import { decodeActor } from "./shared-options.js";
8
+ import { AtUri } from "../domain/primitives.js";
9
+ import { actorArg, decodeActor } from "./shared-options.js";
9
10
  import { CliInputError } from "./errors.js";
10
11
  import { withExamples } from "./help.js";
11
12
  import { writeJson, writeJsonStream, writeText } from "./output.js";
@@ -14,6 +15,8 @@ import { renderProfileTable } from "./doc/table-renderers.js";
14
15
  import { jsonNdjsonTableFormats } from "./output-format.js";
15
16
  import { emitWithFormat } from "./output-render.js";
16
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";
17
20
  import {
18
21
  buildRelationshipGraph,
19
22
  relationshipEntries,
@@ -21,11 +24,8 @@ import {
21
24
  type RelationshipNode
22
25
  } from "../graph/relationships.js";
23
26
 
24
- const actorArg = Args.text({ name: "actor" }).pipe(
25
- Args.withDescription("Bluesky handle or DID")
26
- );
27
-
28
27
  const listUriArg = Args.text({ name: "uri" }).pipe(
28
+ Args.withSchema(AtUri),
29
29
  Args.withDescription("Bluesky list URI (at://...)")
30
30
  );
31
31
 
@@ -42,6 +42,17 @@ const formatOption = Options.choice("format", jsonNdjsonTableFormats).pipe(
42
42
  Options.optional
43
43
  );
44
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
+
45
56
  const purposeOption = Options.choice("purpose", ["modlist", "curatelist"]).pipe(
46
57
  Options.withDescription("List purpose filter"),
47
58
  Options.optional
@@ -121,22 +132,35 @@ const followersCommand = Command.make(
121
132
  ({ actor, limit, cursor, format }) =>
122
133
  Effect.gen(function* () {
123
134
  const appConfig = yield* AppConfigService;
135
+ yield* ensureSupportedFormat(format, appConfig.outputFormat);
136
+ const preferences = yield* CliPreferences;
124
137
  const client = yield* BskyClient;
125
- const resolvedActor = yield* decodeActor(actor);
126
- const { limit: limitValue, cursor: cursorValue } = yield* parsePagination(limit, cursor);
138
+ const { limit: limitValue, cursor: cursorValue } = parsePagination(limit, cursor);
127
139
  const options = {
128
140
  ...(limitValue !== undefined ? { limit: limitValue } : {}),
129
141
  ...(cursorValue !== undefined ? { cursor: cursorValue } : {})
130
142
  };
131
- const result = yield* client.getFollowers(resolvedActor, options);
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
+ );
132
156
  yield* emitWithFormat(
133
157
  format,
134
158
  appConfig.outputFormat,
135
159
  jsonNdjsonTableFormats,
136
160
  "json",
137
161
  {
138
- json: writeJson(result),
139
- ndjson: writeJsonStream(Stream.fromIterable(result.followers)),
162
+ json: writeJson(payload),
163
+ ndjson: writeJsonStream(followersStream),
140
164
  table: writeText(renderProfileTable(result.followers, result.cursor))
141
165
  }
142
166
  );
@@ -156,22 +180,35 @@ const followsCommand = Command.make(
156
180
  ({ actor, limit, cursor, format }) =>
157
181
  Effect.gen(function* () {
158
182
  const appConfig = yield* AppConfigService;
183
+ yield* ensureSupportedFormat(format, appConfig.outputFormat);
184
+ const preferences = yield* CliPreferences;
159
185
  const client = yield* BskyClient;
160
- const resolvedActor = yield* decodeActor(actor);
161
- const { limit: limitValue, cursor: cursorValue } = yield* parsePagination(limit, cursor);
186
+ const { limit: limitValue, cursor: cursorValue } = parsePagination(limit, cursor);
162
187
  const options = {
163
188
  ...(limitValue !== undefined ? { limit: limitValue } : {}),
164
189
  ...(cursorValue !== undefined ? { cursor: cursorValue } : {})
165
190
  };
166
- const result = yield* client.getFollows(resolvedActor, options);
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
+ );
167
204
  yield* emitWithFormat(
168
205
  format,
169
206
  appConfig.outputFormat,
170
207
  jsonNdjsonTableFormats,
171
208
  "json",
172
209
  {
173
- json: writeJson(result),
174
- ndjson: writeJsonStream(Stream.fromIterable(result.follows)),
210
+ json: writeJson(payload),
211
+ ndjson: writeJsonStream(followsStream),
175
212
  table: writeText(renderProfileTable(result.follows, result.cursor))
176
213
  }
177
214
  );
@@ -191,22 +228,35 @@ const knownFollowersCommand = Command.make(
191
228
  ({ actor, limit, cursor, format }) =>
192
229
  Effect.gen(function* () {
193
230
  const appConfig = yield* AppConfigService;
231
+ yield* ensureSupportedFormat(format, appConfig.outputFormat);
232
+ const preferences = yield* CliPreferences;
194
233
  const client = yield* BskyClient;
195
- const resolvedActor = yield* decodeActor(actor);
196
- const { limit: limitValue, cursor: cursorValue } = yield* parsePagination(limit, cursor);
234
+ const { limit: limitValue, cursor: cursorValue } = parsePagination(limit, cursor);
197
235
  const options = {
198
236
  ...(limitValue !== undefined ? { limit: limitValue } : {}),
199
237
  ...(cursorValue !== undefined ? { cursor: cursorValue } : {})
200
238
  };
201
- const result = yield* client.getKnownFollowers(resolvedActor, options);
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
+ );
202
252
  yield* emitWithFormat(
203
253
  format,
204
254
  appConfig.outputFormat,
205
255
  jsonNdjsonTableFormats,
206
256
  "json",
207
257
  {
208
- json: writeJson(result),
209
- ndjson: writeJsonStream(Stream.fromIterable(result.followers)),
258
+ json: writeJson(payload),
259
+ ndjson: writeJsonStream(followersStream),
210
260
  table: writeText(renderProfileTable(result.followers, result.cursor))
211
261
  }
212
262
  );
@@ -225,6 +275,7 @@ const relationshipsCommand = Command.make(
225
275
  ({ actor, others, format, raw }) =>
226
276
  Effect.gen(function* () {
227
277
  const appConfig = yield* AppConfigService;
278
+ yield* ensureSupportedFormat(format, appConfig.outputFormat);
228
279
  const client = yield* BskyClient;
229
280
  const identities = yield* IdentityResolver;
230
281
  const profiles = yield* ProfileResolver;
@@ -236,7 +287,9 @@ const relationshipsCommand = Command.make(
236
287
  ? actorValue
237
288
  : yield* identities.resolveDid(actorValue);
238
289
  });
239
- const resolvedActor = yield* resolveDid(actor);
290
+ const resolvedActor = actor.startsWith("did:")
291
+ ? actor
292
+ : yield* identities.resolveDid(actor);
240
293
  const parsedOthers = others
241
294
  .split(",")
242
295
  .map((item) => item.trim())
@@ -352,23 +405,31 @@ const listsCommand = Command.make(
352
405
  ({ actor, limit, cursor, purpose, format }) =>
353
406
  Effect.gen(function* () {
354
407
  const appConfig = yield* AppConfigService;
408
+ yield* ensureSupportedFormat(format, appConfig.outputFormat);
409
+ const preferences = yield* CliPreferences;
355
410
  const client = yield* BskyClient;
356
- const resolvedActor = yield* decodeActor(actor);
357
- const { limit: limitValue, cursor: cursorValue } = yield* parsePagination(limit, cursor);
411
+ const { limit: limitValue, cursor: cursorValue } = parsePagination(limit, cursor);
358
412
  const options = {
359
413
  ...(limitValue !== undefined ? { limit: limitValue } : {}),
360
414
  ...(cursorValue !== undefined ? { cursor: cursorValue } : {}),
361
415
  ...(Option.isSome(purpose) ? { purposes: [purpose.value] } : {})
362
416
  };
363
- const result = yield* client.getLists(resolvedActor, options);
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
+ );
364
425
  yield* emitWithFormat(
365
426
  format,
366
427
  appConfig.outputFormat,
367
428
  jsonNdjsonTableFormats,
368
429
  "json",
369
430
  {
370
- json: writeJson(result),
371
- ndjson: writeJsonStream(Stream.fromIterable(result.lists)),
431
+ json: writeJson(payload),
432
+ ndjson: writeJsonStream(listsStream),
372
433
  table: writeText(renderListTable(result.lists, result.cursor))
373
434
  }
374
435
  );
@@ -388,13 +449,25 @@ const listCommand = Command.make(
388
449
  ({ uri, limit, cursor, format }) =>
389
450
  Effect.gen(function* () {
390
451
  const appConfig = yield* AppConfigService;
452
+ yield* ensureSupportedFormat(format, appConfig.outputFormat);
453
+ const preferences = yield* CliPreferences;
391
454
  const client = yield* BskyClient;
392
- const { limit: limitValue, cursor: cursorValue } = yield* parsePagination(limit, cursor);
455
+ const { limit: limitValue, cursor: cursorValue } = parsePagination(limit, cursor);
393
456
  const options = {
394
457
  ...(limitValue !== undefined ? { limit: limitValue } : {}),
395
458
  ...(cursorValue !== undefined ? { cursor: cursorValue } : {})
396
459
  };
397
460
  const result = yield* client.getList(uri, options);
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
+ );
398
471
  const header = `${result.list.name} (${result.list.purpose}) by ${result.list.creator.handle}`;
399
472
  const body = renderListItemsTable(result.items, result.cursor);
400
473
  yield* emitWithFormat(
@@ -403,8 +476,8 @@ const listCommand = Command.make(
403
476
  jsonNdjsonTableFormats,
404
477
  "json",
405
478
  {
406
- json: writeJson(result),
407
- ndjson: writeJsonStream(Stream.fromIterable(result.items)),
479
+ json: writeJson(payload),
480
+ ndjson: writeJsonStream(itemsStream),
408
481
  table: writeText(`${header}\n\n${body}`)
409
482
  }
410
483
  );
@@ -423,24 +496,33 @@ const blocksCommand = Command.make(
423
496
  ({ limit, cursor, format }) =>
424
497
  Effect.gen(function* () {
425
498
  const appConfig = yield* AppConfigService;
499
+ yield* ensureSupportedFormat(format, appConfig.outputFormat);
500
+ const preferences = yield* CliPreferences;
426
501
  const client = yield* BskyClient;
427
- const { limit: limitValue, cursor: cursorValue } = yield* parsePagination(limit, cursor);
502
+ const { limit: limitValue, cursor: cursorValue } = parsePagination(limit, cursor);
428
503
  const options = {
429
504
  ...(limitValue !== undefined ? { limit: limitValue } : {}),
430
505
  ...(cursorValue !== undefined ? { cursor: cursorValue } : {})
431
506
  };
432
507
  const result = yield* client.getBlocks(options);
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
+ );
433
515
  yield* emitWithFormat(
434
516
  format,
435
517
  appConfig.outputFormat,
436
518
  jsonNdjsonTableFormats,
437
519
  "json",
438
520
  {
439
- json: writeJson(result),
521
+ json: writeJson(payload),
440
522
  ndjson:
441
- result.blocks.length === 0
523
+ blocks.length === 0
442
524
  ? writeText("[]")
443
- : writeJsonStream(Stream.fromIterable(result.blocks)),
525
+ : writeJsonStream(blocksStream),
444
526
  table: writeText(renderProfileTable(result.blocks, result.cursor))
445
527
  }
446
528
  );
@@ -459,24 +541,33 @@ const mutesCommand = Command.make(
459
541
  ({ limit, cursor, format }) =>
460
542
  Effect.gen(function* () {
461
543
  const appConfig = yield* AppConfigService;
544
+ yield* ensureSupportedFormat(format, appConfig.outputFormat);
545
+ const preferences = yield* CliPreferences;
462
546
  const client = yield* BskyClient;
463
- const { limit: limitValue, cursor: cursorValue } = yield* parsePagination(limit, cursor);
547
+ const { limit: limitValue, cursor: cursorValue } = parsePagination(limit, cursor);
464
548
  const options = {
465
549
  ...(limitValue !== undefined ? { limit: limitValue } : {}),
466
550
  ...(cursorValue !== undefined ? { cursor: cursorValue } : {})
467
551
  };
468
552
  const result = yield* client.getMutes(options);
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
+ );
469
560
  yield* emitWithFormat(
470
561
  format,
471
562
  appConfig.outputFormat,
472
563
  jsonNdjsonTableFormats,
473
564
  "json",
474
565
  {
475
- json: writeJson(result),
566
+ json: writeJson(payload),
476
567
  ndjson:
477
- result.mutes.length === 0
568
+ mutes.length === 0
478
569
  ? writeText("[]")
479
- : writeJsonStream(Stream.fromIterable(result.mutes)),
570
+ : writeJsonStream(mutesStream),
480
571
  table: writeText(renderProfileTable(result.mutes, result.cursor))
481
572
  }
482
573
  );
@@ -1,35 +1,6 @@
1
- import { Duration, Effect, Option } from "effect";
2
- import { CliInputError } from "./errors.js";
1
+ import { Duration, Option } from "effect";
3
2
 
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
- );
3
+ export const parseInterval = (interval: Option.Option<Duration.Duration>) =>
4
+ Option.getOrElse(interval, () => Duration.seconds(30));
24
5
 
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
- });
6
+ export const parseOptionalDuration = (value: Option.Option<Duration.Duration>) => value;
@@ -5,6 +5,7 @@ import { DataSource } from "../domain/sync.js";
5
5
  import type { StoreRef } from "../domain/store.js";
6
6
  import { SyncCheckpointStore } from "../services/sync-checkpoint-store.js";
7
7
  import { CliInputError } from "./errors.js";
8
+ import { PositiveInt } from "./option-schemas.js";
8
9
 
9
10
  const DEFAULT_COLLECTIONS = ["app.bsky.feed.post"];
10
11
 
@@ -31,6 +32,7 @@ export const jetstreamOptions = {
31
32
  Options.withDescription("Enable compression if supported by runtime")
32
33
  ),
33
34
  maxMessageSize: Options.integer("max-message-size").pipe(
35
+ Options.withSchema(PositiveInt),
34
36
  Options.withDescription("Max message size in bytes"),
35
37
  Options.optional
36
38
  )
@@ -0,0 +1,22 @@
1
+ import { Duration, Effect, ParseResult, Schema } from "effect";
2
+ import { parseDurationInput } from "./time.js";
3
+
4
+ export const PositiveInt = Schema.Int.pipe(Schema.greaterThan(0));
5
+
6
+ export const NonNegativeInt = Schema.NonNegativeInt;
7
+
8
+ export const boundedInt = (min: number, max: number) =>
9
+ Schema.Int.pipe(
10
+ Schema.greaterThanOrEqualTo(min),
11
+ Schema.lessThanOrEqualTo(max)
12
+ );
13
+
14
+ export const DurationInput = Schema.transformOrFail(Schema.String, Schema.DurationFromSelf, {
15
+ strict: true,
16
+ decode: (raw, _options, ast) =>
17
+ parseDurationInput(raw).pipe(
18
+ Effect.mapError((error) => new ParseResult.Type(ast, raw, error.message))
19
+ ),
20
+ encode: (duration) =>
21
+ Effect.succeed(`${Duration.toMillis(duration)} millis`)
22
+ }).pipe(Schema.greaterThanOrEqualToDuration(0));
@@ -13,6 +13,17 @@ export type TextJsonFormat = typeof textJsonFormats[number];
13
13
  export const treeTableJsonFormats = ["tree", "table", "json"] as const;
14
14
  export type TreeTableJsonFormat = typeof treeTableJsonFormats[number];
15
15
 
16
+ export const queryOutputFormats = [
17
+ "json",
18
+ "ndjson",
19
+ "markdown",
20
+ "table",
21
+ "compact",
22
+ "card",
23
+ "thread"
24
+ ] as const;
25
+ export type QueryOutputFormat = typeof queryOutputFormats[number];
26
+
16
27
  export const resolveOutputFormat = <T extends string>(
17
28
  format: Option.Option<T>,
18
29
  configFormat: OutputFormat,
@@ -1,18 +1,17 @@
1
1
  import { Options } from "@effect/cli";
2
- import { Effect, Option } from "effect";
3
- import { parseLimit } from "./shared-options.js";
2
+ import { Option } from "effect";
3
+ import { PositiveInt } from "./option-schemas.js";
4
4
 
5
- export const limitOption = Options.integer("limit").pipe(Options.optional);
5
+ export const limitOption = Options.integer("limit").pipe(
6
+ Options.withSchema(PositiveInt),
7
+ Options.optional
8
+ );
6
9
  export const cursorOption = Options.text("cursor").pipe(Options.optional);
7
10
 
8
11
  export const parsePagination = (
9
12
  limit: Option.Option<number>,
10
13
  cursor: Option.Option<string>
11
- ) =>
12
- Effect.gen(function* () {
13
- const parsedLimit = yield* parseLimit(limit);
14
- return {
15
- limit: Option.getOrUndefined(parsedLimit),
16
- cursor: Option.getOrUndefined(cursor)
17
- };
18
- });
14
+ ) => ({
15
+ limit: Option.getOrUndefined(limit),
16
+ cursor: Option.getOrUndefined(cursor)
17
+ });
@@ -1,3 +1,5 @@
1
+ type ParseIssue = { readonly _tag: string; readonly message?: string };
2
+
1
3
  /** Safely parse JSON, returning `undefined` on failure. */
2
4
  export const safeParseJson = (raw: string): unknown => {
3
5
  try {
@@ -7,6 +9,16 @@ export const safeParseJson = (raw: string): unknown => {
7
9
  }
8
10
  };
9
11
 
12
+ export const jsonParseTip = "Tip: wrap JSON in single quotes to avoid shell escaping issues.";
13
+
14
+ export const findJsonParseIssue = (issues: ReadonlyArray<ParseIssue>) =>
15
+ issues.find(
16
+ (issue) =>
17
+ issue._tag === "Transformation" &&
18
+ typeof issue.message === "string" &&
19
+ issue.message.startsWith("JSON Parse error")
20
+ );
21
+
10
22
  /** Format schema issues into an array of "path: message" strings. */
11
23
  export const issueDetails = (
12
24
  issues: ReadonlyArray<{ readonly path: ReadonlyArray<unknown>; readonly message: string }>
package/src/cli/parse.ts CHANGED
@@ -1,52 +1,6 @@
1
1
  import { Effect, ParseResult, Schema } from "effect";
2
2
  import { CliJsonError } from "./errors.js";
3
-
4
- type FormatParseErrorOptions = {
5
- readonly label?: string;
6
- readonly maxIssues?: number;
7
- };
8
-
9
- const formatPath = (path: ReadonlyArray<unknown>) =>
10
- path.length > 0 ? path.map((entry) => String(entry)).join(".") : "value";
11
-
12
- const formatParseError = (
13
- error: ParseResult.ParseError,
14
- options?: FormatParseErrorOptions
15
- ) => {
16
- const issues = ParseResult.ArrayFormatter.formatErrorSync(error);
17
- if (issues.length === 0) {
18
- return ParseResult.TreeFormatter.formatErrorSync(error);
19
- }
20
-
21
- const jsonParseIssue = issues.find(
22
- (issue) =>
23
- issue._tag === "Transformation" &&
24
- typeof issue.message === "string" &&
25
- issue.message.startsWith("JSON Parse error")
26
- );
27
- if (jsonParseIssue) {
28
- const header = options?.label
29
- ? `Invalid JSON input for ${options.label}.`
30
- : "Invalid JSON input.";
31
- return [
32
- header,
33
- jsonParseIssue.message,
34
- "Tip: wrap JSON in single quotes to avoid shell escaping issues."
35
- ].join("\n");
36
- }
37
-
38
- const maxIssues = options?.maxIssues ?? 6;
39
- const lines = issues.slice(0, maxIssues).map((issue) => {
40
- const path = formatPath(issue.path);
41
- return `${path}: ${issue.message}`;
42
- });
43
- if (issues.length > maxIssues) {
44
- lines.push(`Additional issues: ${issues.length - maxIssues}`);
45
- }
46
-
47
- const header = options?.label ? `Invalid ${options.label}.` : undefined;
48
- return header ? [header, ...lines].join("\n") : lines.join("\n");
49
- };
3
+ import { formatParseError } from "./shared.js";
50
4
 
51
5
  type DecodeJsonOptions = {
52
6
  readonly formatter?: (error: ParseResult.ParseError, raw: string) => string;
@@ -0,0 +1,18 @@
1
+ import { Schema } from "effect";
2
+ import { RawPost } from "../domain/raw.js";
3
+ import { Post } from "../domain/post.js";
4
+ import { StoreName } from "../domain/primitives.js";
5
+
6
+ export class StorePostInput extends Schema.Class<StorePostInput>("StorePostInput")({
7
+ store: StoreName,
8
+ post: Post
9
+ }) {}
10
+
11
+ export const PipeInput = Schema.Union(RawPost, Post, StorePostInput);
12
+ export type PipeInput = typeof PipeInput.Type;
13
+
14
+ export const isRawPostInput = (value: PipeInput): value is RawPost =>
15
+ typeof value === "object" && value !== null && "record" in value;
16
+
17
+ export const isStorePostInput = (value: PipeInput): value is StorePostInput =>
18
+ typeof value === "object" && value !== null && "post" in value;
package/src/cli/pipe.ts CHANGED
@@ -1,7 +1,6 @@
1
1
  import { Command, Options } from "@effect/cli";
2
2
  import { Chunk, Effect, Option, Ref, Stream } from "effect";
3
3
  import { ParseResult } from "effect";
4
- import { RawPost } from "../domain/raw.js";
5
4
  import type { Post } from "../domain/post.js";
6
5
  import { FilterRuntime } from "../services/filter-runtime.js";
7
6
  import { PostParser } from "../services/post-parser.js";
@@ -9,12 +8,14 @@ import { CliInput } from "./input.js";
9
8
  import { CliInputError, CliJsonError } from "./errors.js";
10
9
  import { parseFilterExpr } from "./filter-input.js";
11
10
  import { decodeJson } from "./parse.js";
11
+ import { PipeInput, isRawPostInput, isStorePostInput } from "./pipe-input.js";
12
12
  import { withExamples } from "./help.js";
13
13
  import { filterOption, filterJsonOption } from "./shared-options.js";
14
14
  import { formatSchemaError } from "./shared.js";
15
- import { writeJsonStream, writeText } from "./output.js";
15
+ import { writeJsonStream } from "./output.js";
16
16
  import { filterByFlags } from "../typeclass/chunk.js";
17
17
  import { logErrorEvent, logWarn } from "./logging.js";
18
+ import { PositiveInt } from "./option-schemas.js";
18
19
 
19
20
  const onErrorOption = Options.choice("on-error", ["fail", "skip", "report"]).pipe(
20
21
  Options.withDescription("Behavior on invalid input lines"),
@@ -22,6 +23,7 @@ const onErrorOption = Options.choice("on-error", ["fail", "skip", "report"]).pip
22
23
  );
23
24
 
24
25
  const batchSizeOption = Options.integer("batch-size").pipe(
26
+ Options.withSchema(PositiveInt),
25
27
  Options.withDescription("Posts per filter batch (default: 50)"),
26
28
  Options.optional
27
29
  );
@@ -78,16 +80,8 @@ export const pipeCommand = Command.make(
78
80
  const evaluateBatch = yield* runtime.evaluateBatch(expr);
79
81
 
80
82
  const size = Option.getOrElse(batchSize, () => 50);
81
- if (size <= 0) {
82
- return yield* CliInputError.make({
83
- message: "--batch-size must be a positive integer.",
84
- cause: { batchSize: size }
85
- });
86
- }
87
83
 
88
84
  const lineRef = yield* Ref.make(0);
89
- const countRef = yield* Ref.make(0);
90
-
91
85
  const parsed = input.lines.pipe(
92
86
  Stream.map((line) => line.trim()),
93
87
  Stream.filter((line) => line.length > 0),
@@ -97,8 +91,16 @@ export const pipeCommand = Command.make(
97
91
  )
98
92
  ),
99
93
  Stream.mapEffect(({ line, lineNumber }) =>
100
- decodeJson(RawPost, line).pipe(
101
- Effect.flatMap((raw) => parser.parsePost(raw)),
94
+ decodeJson(PipeInput, line).pipe(
95
+ Effect.flatMap((inputPost) => {
96
+ if (isRawPostInput(inputPost)) {
97
+ return parser.parsePost(inputPost);
98
+ }
99
+ if (isStorePostInput(inputPost)) {
100
+ return Effect.succeed(inputPost.post);
101
+ }
102
+ return Effect.succeed(inputPost);
103
+ }),
102
104
  Effect.map(Option.some),
103
105
  Effect.catchAll((error) => {
104
106
  if (onError === "fail") {
@@ -131,15 +133,10 @@ export const pipeCommand = Command.make(
131
133
  Effect.map((flags) => filterByFlags(batch, flags))
132
134
  )
133
135
  ),
134
- Stream.mapConcat((chunk) => Chunk.toReadonlyArray(chunk)),
135
- Stream.tap(() => Ref.update(countRef, (count) => count + 1))
136
+ Stream.mapConcat((chunk) => Chunk.toReadonlyArray(chunk))
136
137
  );
137
138
 
138
139
  yield* writeJsonStream(filtered);
139
- const count = yield* Ref.get(countRef);
140
- if (count === 0) {
141
- yield* writeText("[]");
142
- }
143
140
  })
144
141
  ).pipe(
145
142
  Command.withDescription(
@@ -150,7 +147,7 @@ export const pipeCommand = Command.make(
150
147
  "cat posts.ndjson | skygent pipe --filter-json '{\"_tag\":\"All\"}'"
151
148
  ],
152
149
  [
153
- "Note: stdin must be raw post NDJSON (app.bsky.feed.getPosts result)."
150
+ "Note: stdin must be raw post NDJSON or skygent post NDJSON (from query --format ndjson)."
154
151
  ]
155
152
  )
156
153
  )