@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/post.ts CHANGED
@@ -8,12 +8,16 @@ import { renderPostsTable } from "../domain/format.js";
8
8
  import { AppConfigService } from "../services/app-config.js";
9
9
  import { withExamples } from "./help.js";
10
10
  import { postUriArg } from "./shared-options.js";
11
+ import { PostCid } from "../domain/primitives.js";
11
12
  import { writeJson, writeJsonStream, writeText } from "./output.js";
12
13
  import { renderTableLegacy } from "./doc/table.js";
13
14
  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 { CliInputError } from "./errors.js";
19
+ import { CliPreferences } from "./preferences.js";
20
+ import { compactPost, compactPostLike, compactProfileView } from "./compact-output.js";
17
21
 
18
22
  const limitOption = baseLimitOption.pipe(
19
23
  Options.withDescription("Maximum number of results")
@@ -28,7 +32,19 @@ const formatOption = Options.choice("format", jsonNdjsonTableFormats).pipe(
28
32
  Options.optional
29
33
  );
30
34
 
35
+ const ensureSupportedFormat = (
36
+ format: Option.Option<typeof jsonNdjsonTableFormats[number]>,
37
+ configFormat: string
38
+ ) =>
39
+ Option.isNone(format) && configFormat === "markdown"
40
+ ? CliInputError.make({
41
+ message: 'Output format "markdown" is not supported for post commands. Use --format json|ndjson|table.',
42
+ cause: { format: configFormat }
43
+ })
44
+ : Effect.void;
45
+
31
46
  const cidOption = Options.text("cid").pipe(
47
+ Options.withSchema(PostCid),
32
48
  Options.withDescription("Filter engagement by specific record CID"),
33
49
  Options.optional
34
50
  );
@@ -60,21 +76,30 @@ const likesCommand = Command.make(
60
76
  ({ uri, cid, limit, cursor, format }) =>
61
77
  Effect.gen(function* () {
62
78
  const appConfig = yield* AppConfigService;
79
+ yield* ensureSupportedFormat(format, appConfig.outputFormat);
80
+ const preferences = yield* CliPreferences;
63
81
  const client = yield* BskyClient;
64
- const { limit: limitValue, cursor: cursorValue } = yield* parsePagination(limit, cursor);
82
+ const { limit: limitValue, cursor: cursorValue } = parsePagination(limit, cursor);
65
83
  const result = yield* client.getLikes(uri, {
66
84
  ...(limitValue !== undefined ? { limit: limitValue } : {}),
67
85
  ...(cursorValue !== undefined ? { cursor: cursorValue } : {}),
68
86
  ...(Option.isSome(cid) ? { cid: cid.value } : {})
69
87
  });
88
+ const likes = preferences.compact
89
+ ? result.likes.map(compactPostLike)
90
+ : result.likes;
91
+ const payload = result.cursor ? { likes, cursor: result.cursor } : { likes };
92
+ const likesStream = Stream.fromIterable(
93
+ likes as ReadonlyArray<unknown>
94
+ );
70
95
  yield* emitWithFormat(
71
96
  format,
72
97
  appConfig.outputFormat,
73
98
  jsonNdjsonTableFormats,
74
99
  "json",
75
100
  {
76
- json: writeJson(result),
77
- ndjson: writeJsonStream(Stream.fromIterable(result.likes)),
101
+ json: writeJson(payload),
102
+ ndjson: writeJsonStream(likesStream),
78
103
  table: writeText(renderLikesTable(result.likes, result.cursor))
79
104
  }
80
105
  );
@@ -94,21 +119,32 @@ const repostedByCommand = Command.make(
94
119
  ({ uri, cid, limit, cursor, format }) =>
95
120
  Effect.gen(function* () {
96
121
  const appConfig = yield* AppConfigService;
122
+ yield* ensureSupportedFormat(format, appConfig.outputFormat);
123
+ const preferences = yield* CliPreferences;
97
124
  const client = yield* BskyClient;
98
- const { limit: limitValue, cursor: cursorValue } = yield* parsePagination(limit, cursor);
125
+ const { limit: limitValue, cursor: cursorValue } = parsePagination(limit, cursor);
99
126
  const result = yield* client.getRepostedBy(uri, {
100
127
  ...(limitValue !== undefined ? { limit: limitValue } : {}),
101
128
  ...(cursorValue !== undefined ? { cursor: cursorValue } : {}),
102
129
  ...(Option.isSome(cid) ? { cid: cid.value } : {})
103
130
  });
131
+ const repostedBy = preferences.compact
132
+ ? result.repostedBy.map(compactProfileView)
133
+ : result.repostedBy;
134
+ const payload = result.cursor
135
+ ? { repostedBy, cursor: result.cursor }
136
+ : { repostedBy };
137
+ const repostedStream = Stream.fromIterable(
138
+ repostedBy as ReadonlyArray<unknown>
139
+ );
104
140
  yield* emitWithFormat(
105
141
  format,
106
142
  appConfig.outputFormat,
107
143
  jsonNdjsonTableFormats,
108
144
  "json",
109
145
  {
110
- json: writeJson(result),
111
- ndjson: writeJsonStream(Stream.fromIterable(result.repostedBy)),
146
+ json: writeJson(payload),
147
+ ndjson: writeJsonStream(repostedStream),
112
148
  table: writeText(renderProfileTable(result.repostedBy, result.cursor))
113
149
  }
114
150
  );
@@ -127,26 +163,35 @@ const quotesCommand = Command.make(
127
163
  ({ uri, cid, limit, cursor, format }) =>
128
164
  Effect.gen(function* () {
129
165
  const appConfig = yield* AppConfigService;
166
+ yield* ensureSupportedFormat(format, appConfig.outputFormat);
167
+ const preferences = yield* CliPreferences;
130
168
  const client = yield* BskyClient;
131
169
  const parser = yield* PostParser;
132
- const { limit: limitValue, cursor: cursorValue } = yield* parsePagination(limit, cursor);
170
+ const { limit: limitValue, cursor: cursorValue } = parsePagination(limit, cursor);
133
171
  const result = yield* client.getQuotes(uri, {
134
172
  ...(limitValue !== undefined ? { limit: limitValue } : {}),
135
173
  ...(cursorValue !== undefined ? { cursor: cursorValue } : {}),
136
174
  ...(Option.isSome(cid) ? { cid: cid.value } : {})
137
175
  });
138
176
  const posts = yield* parseRawPosts(parser, result.posts);
177
+ const compactPosts = preferences.compact
178
+ ? posts.map(compactPost)
179
+ : posts;
180
+ const payload = {
181
+ ...result,
182
+ posts: compactPosts
183
+ };
184
+ const postStream = Stream.fromIterable(
185
+ compactPosts as ReadonlyArray<unknown>
186
+ );
139
187
  yield* emitWithFormat(
140
188
  format,
141
189
  appConfig.outputFormat,
142
190
  jsonNdjsonTableFormats,
143
191
  "json",
144
192
  {
145
- json: writeJson({
146
- ...result,
147
- posts
148
- }),
149
- ndjson: writeJsonStream(Stream.fromIterable(posts)),
193
+ json: writeJson(payload),
194
+ ndjson: writeJsonStream(postStream),
150
195
  table: writeText(renderPostsTable(posts))
151
196
  }
152
197
  );
@@ -7,6 +7,11 @@ type FieldSelector = {
7
7
  readonly raw: string;
8
8
  };
9
9
 
10
+ export type FieldSelectorsResolution = {
11
+ readonly selectors: Option.Option<ReadonlyArray<FieldSelector>>;
12
+ readonly source: "implicit" | "explicit";
13
+ };
14
+
10
15
  const fieldPresets: Record<string, ReadonlyArray<string>> = {
11
16
  minimal: ["uri", "author", "text", "createdAt"],
12
17
  social: ["uri", "author", "text", "metrics", "hashtags"],
@@ -162,11 +167,16 @@ export const parseFieldSelectors = (
162
167
  export const resolveFieldSelectors = (
163
168
  fields: Option.Option<string>,
164
169
  compact: boolean
165
- ): Effect.Effect<Option.Option<ReadonlyArray<FieldSelector>>, CliInputError> =>
170
+ ): Effect.Effect<FieldSelectorsResolution, CliInputError> =>
166
171
  Option.match(fields, {
167
172
  onNone: () =>
168
- compact ? parseFieldSelectors("@minimal") : Effect.succeed(Option.none()),
169
- onSome: (raw) => parseFieldSelectors(raw)
173
+ (compact ? parseFieldSelectors("@minimal") : Effect.succeed(Option.none())).pipe(
174
+ Effect.map((selectors) => ({ selectors, source: "implicit" as const }))
175
+ ),
176
+ onSome: (raw) =>
177
+ parseFieldSelectors(raw).pipe(
178
+ Effect.map((selectors) => ({ selectors, source: "explicit" as const }))
179
+ )
170
180
  });
171
181
 
172
182
  const getPathValue = (source: unknown, path: ReadonlyArray<string>): unknown => {
package/src/cli/query.ts CHANGED
@@ -34,6 +34,8 @@ import { StoreNotFound } from "../domain/errors.js";
34
34
  import { StorePostOrder } from "../domain/order.js";
35
35
  import { formatSchemaError } from "./shared.js";
36
36
  import { mergeOrderedStreams } from "./stream-merge.js";
37
+ import { queryOutputFormats, resolveOutputFormat } from "./output-format.js";
38
+ import { PositiveInt } from "./option-schemas.js";
37
39
 
38
40
  const storeNamesArg = Args.text({ name: "store" }).pipe(
39
41
  Args.repeated,
@@ -56,10 +58,12 @@ const untilOption = Options.text("until").pipe(
56
58
  Options.optional
57
59
  );
58
60
  const limitOption = Options.integer("limit").pipe(
61
+ Options.withSchema(PositiveInt),
59
62
  Options.withDescription("Maximum number of posts to return"),
60
63
  Options.optional
61
64
  );
62
65
  const scanLimitOption = Options.integer("scan-limit").pipe(
66
+ Options.withSchema(PositiveInt),
63
67
  Options.withDescription("Maximum rows to scan before filtering (advanced)"),
64
68
  Options.optional
65
69
  );
@@ -70,15 +74,7 @@ const sortOption = Options.choice("sort", ["asc", "desc"]).pipe(
70
74
  const newestFirstOption = Options.boolean("newest-first").pipe(
71
75
  Options.withDescription("Sort newest posts first (alias for --sort desc)")
72
76
  );
73
- const formatOption = Options.choice("format", [
74
- "json",
75
- "ndjson",
76
- "markdown",
77
- "table",
78
- "compact",
79
- "card",
80
- "thread"
81
- ]).pipe(
77
+ const formatOption = Options.choice("format", queryOutputFormats).pipe(
82
78
  Options.optional,
83
79
  Options.withDescription("Output format (default: config output format)")
84
80
  );
@@ -89,6 +85,7 @@ const ansiOption = Options.boolean("ansi").pipe(
89
85
  Options.withDescription("Enable ANSI colors in output")
90
86
  );
91
87
  const widthOption = Options.integer("width").pipe(
88
+ Options.withSchema(PositiveInt),
92
89
  Options.withDescription("Line width for terminal output"),
93
90
  Options.optional
94
91
  );
@@ -284,8 +281,11 @@ export const queryCommand = Command.make(
284
281
  const parsedRange = yield* parseRangeOptions(range, since, until);
285
282
  const parsedFilter = yield* parseOptionalFilterExpr(filter, filterJson);
286
283
  const expr = Option.getOrElse(parsedFilter, () => all());
287
- const outputFormat = Option.getOrElse(format, () =>
288
- appConfig.outputFormat === "ndjson" ? "compact" : appConfig.outputFormat
284
+ const outputFormat = resolveOutputFormat(
285
+ format,
286
+ appConfig.outputFormat,
287
+ queryOutputFormats,
288
+ "json"
289
289
  );
290
290
  if (multiStore && outputFormat === "thread") {
291
291
  return yield* CliInputError.make({
@@ -294,19 +294,20 @@ export const queryCommand = Command.make(
294
294
  });
295
295
  }
296
296
  const compact = preferences.compact;
297
- const selectorsOption = yield* resolveFieldSelectors(fields, compact);
297
+ const { selectors: selectorsOption, source: selectorsSource } =
298
+ yield* resolveFieldSelectors(fields, compact);
298
299
  const project = (post: Post) =>
299
300
  Option.match(selectorsOption, {
300
301
  onNone: () => post,
301
302
  onSome: (selectors) => projectFields(post, selectors)
302
303
  });
303
- if (Option.isSome(selectorsOption) && outputFormat !== "json" && outputFormat !== "ndjson") {
304
+ if (selectorsSource === "explicit" && outputFormat !== "json" && outputFormat !== "ndjson") {
304
305
  return yield* CliInputError.make({
305
306
  message: "--fields is only supported with json or ndjson output.",
306
307
  cause: { format: outputFormat }
307
308
  });
308
309
  }
309
- if (count && Option.isSome(selectorsOption)) {
310
+ if (count && selectorsSource === "explicit") {
310
311
  return yield* CliInputError.make({
311
312
  message: "--count cannot be combined with --fields.",
312
313
  cause: { count, fields }
@@ -315,18 +316,6 @@ export const queryCommand = Command.make(
315
316
 
316
317
  const w = Option.getOrUndefined(width);
317
318
 
318
- if (Option.isSome(limit) && limit.value <= 0) {
319
- return yield* CliInputError.make({
320
- message: "--limit must be a positive integer.",
321
- cause: { limit: limit.value }
322
- });
323
- }
324
- if (Option.isSome(scanLimit) && scanLimit.value <= 0) {
325
- return yield* CliInputError.make({
326
- message: "--scan-limit must be a positive integer.",
327
- cause: { scanLimit: scanLimit.value }
328
- });
329
- }
330
319
  const sortValue = Option.getOrUndefined(sort);
331
320
  const order =
332
321
  newestFirst
@@ -366,13 +355,12 @@ export const queryCommand = Command.make(
366
355
  }
367
356
 
368
357
  if (
369
- hasFilter &&
370
358
  Option.isNone(limit) &&
371
- (outputFormat === "thread" || outputFormat === "table")
359
+ (outputFormat === "thread" || outputFormat === "table" || outputFormat === "markdown")
372
360
  ) {
373
361
  yield* output
374
362
  .writeStderr(
375
- "Warning: thread/table output collects all matched posts in memory. Consider adding --limit."
363
+ "Warning: table/markdown/thread output collects all matched posts in memory. Consider adding --limit."
376
364
  )
377
365
  .pipe(Effect.catchAll(() => Effect.void));
378
366
  }
@@ -538,16 +526,7 @@ export const queryCommand = Command.make(
538
526
  }
539
527
 
540
528
  if (outputFormat === "ndjson") {
541
- const countRef = yield* Ref.make(0);
542
- const counted = stream.pipe(
543
- Stream.map(toOutput),
544
- Stream.tap(() => Ref.update(countRef, (count) => count + 1))
545
- );
546
- yield* writeJsonStream(counted);
547
- const count = yield* Ref.get(countRef);
548
- if (count === 0) {
549
- yield* writeText("[]");
550
- }
529
+ yield* writeJsonStream(stream.pipe(Stream.map(toOutput)));
551
530
  yield* warnIfScanLimitReached();
552
531
  return;
553
532
  }
package/src/cli/search.ts CHANGED
@@ -6,17 +6,15 @@ import { PostParser } from "../services/post-parser.js";
6
6
  import { StoreIndex } from "../services/store-index.js";
7
7
  import { renderPostsTable } from "../domain/format.js";
8
8
  import { AppConfigService } from "../services/app-config.js";
9
- import { StoreName } from "../domain/primitives.js";
9
+ import { ActorId, StoreName } from "../domain/primitives.js";
10
10
  import { storeOptions } from "./store.js";
11
11
  import { withExamples } from "./help.js";
12
12
  import { CliInputError } from "./errors.js";
13
- import { decodeActor } from "./shared-options.js";
14
13
  import { formatSchemaError } from "./shared.js";
15
14
  import { writeJson, writeJsonStream, writeText } from "./output.js";
16
15
  import { jsonNdjsonTableFormats } from "./output-format.js";
17
16
  import { emitWithFormat } from "./output-render.js";
18
17
  import { cursorOption as baseCursorOption, limitOption as baseLimitOption, parsePagination } from "./pagination.js";
19
- import { parseLimit } from "./shared-options.js";
20
18
 
21
19
  const queryArg = Args.text({ name: "query" }).pipe(
22
20
  Args.withDescription("Search query string")
@@ -70,11 +68,13 @@ const untilOption = Options.text("until").pipe(
70
68
  );
71
69
 
72
70
  const mentionsOption = Options.text("mentions").pipe(
71
+ Options.withSchema(ActorId),
73
72
  Options.withDescription("Filter network results by mention (handle or DID)"),
74
73
  Options.optional
75
74
  );
76
75
 
77
76
  const authorOption = Options.text("author").pipe(
77
+ Options.withSchema(ActorId),
78
78
  Options.withDescription("Filter network results by author (handle or DID)"),
79
79
  Options.optional
80
80
  );
@@ -135,7 +135,7 @@ const handlesCommand = Command.make(
135
135
  });
136
136
  }
137
137
  const client = yield* BskyClient;
138
- const { limit: limitValue, cursor: cursorValue } = yield* parsePagination(limit, cursor);
138
+ const { limit: limitValue, cursor: cursorValue } = parsePagination(limit, cursor);
139
139
  const options = {
140
140
  ...(limitValue !== undefined ? { limit: limitValue } : {}),
141
141
  ...(cursorValue !== undefined ? { cursor: cursorValue } : {}),
@@ -171,7 +171,7 @@ const feedsCommand = Command.make(
171
171
  const appConfig = yield* AppConfigService;
172
172
  const queryValue = yield* requireNonEmptyQuery(query);
173
173
  const client = yield* BskyClient;
174
- const { limit: limitValue, cursor: cursorValue } = yield* parsePagination(limit, cursor);
174
+ const { limit: limitValue, cursor: cursorValue } = parsePagination(limit, cursor);
175
175
  const options = {
176
176
  ...(limitValue !== undefined ? { limit: limitValue } : {}),
177
177
  ...(cursorValue !== undefined ? { cursor: cursorValue } : {})
@@ -220,8 +220,7 @@ const postsCommand = Command.make(
220
220
  Effect.gen(function* () {
221
221
  const appConfig = yield* AppConfigService;
222
222
  const queryValue = yield* requireNonEmptyQuery(query);
223
- const parsedLimit = yield* parseLimit(limit);
224
- const limitValue = Option.getOrUndefined(parsedLimit);
223
+ const limitValue = Option.getOrUndefined(limit);
225
224
  if (network && Option.isSome(store)) {
226
225
  return yield* CliInputError.make({
227
226
  message: "--store cannot be used with --network.",
@@ -287,24 +286,8 @@ const postsCommand = Command.make(
287
286
  .map((item) => item.trim())
288
287
  .filter((item) => item.length > 0)
289
288
  });
290
- const authorValue = Option.match(author, {
291
- onNone: () => Effect.void.pipe(Effect.as(undefined)),
292
- onSome: (value) =>
293
- Effect.gen(function* () {
294
- const decoded = yield* decodeActor(value);
295
- return String(decoded);
296
- })
297
- });
298
- const mentionsValue = Option.match(mentions, {
299
- onNone: () => Effect.void.pipe(Effect.as(undefined)),
300
- onSome: (value) =>
301
- Effect.gen(function* () {
302
- const decoded = yield* decodeActor(value);
303
- return String(decoded);
304
- })
305
- });
306
- const parsedAuthor = yield* authorValue;
307
- const parsedMentions = yield* mentionsValue;
289
+ const parsedAuthor = Option.getOrUndefined(author);
290
+ const parsedMentions = Option.getOrUndefined(mentions);
308
291
  const result = yield* client.searchPosts(queryValue, {
309
292
  ...(limitValue !== undefined ? { limit: limitValue } : {}),
310
293
  ...(Option.isSome(cursorValue) ? { cursor: cursorValue.value } : {}),
@@ -1,9 +1,10 @@
1
1
  import { Args, Options } from "@effect/cli";
2
- import { Effect, Option, Schema } from "effect";
3
- import { Did, Handle, StoreName } from "../domain/primitives.js";
2
+ import { Effect, Schema } from "effect";
3
+ import { ActorId, AtUri, PostUri, StoreName } from "../domain/primitives.js";
4
4
  import { filterDslDescription, filterJsonDescription } from "./filter-help.js";
5
5
  import { CliInputError } from "./errors.js";
6
6
  import { formatSchemaError } from "./shared.js";
7
+ import { NonNegativeInt } from "./option-schemas.js";
7
8
 
8
9
  /** --store option with StoreName schema validation */
9
10
  export const storeNameOption = Options.text("store").pipe(
@@ -52,27 +53,32 @@ export const strictOption = Options.boolean("strict").pipe(
52
53
 
53
54
  /** --max-errors option (optional) */
54
55
  export const maxErrorsOption = Options.integer("max-errors").pipe(
56
+ Options.withSchema(NonNegativeInt),
55
57
  Options.withDescription("Stop after exceeding N errors (default: unlimited)"),
56
58
  Options.optional
57
59
  );
58
60
 
59
61
  /** Positional arg for feed URI */
60
62
  export const feedUriArg = Args.text({ name: "uri" }).pipe(
63
+ Args.withSchema(AtUri),
61
64
  Args.withDescription("Bluesky feed URI (at://...)")
62
65
  );
63
66
 
64
67
  /** Positional arg for list URI */
65
68
  export const listUriArg = Args.text({ name: "uri" }).pipe(
69
+ Args.withSchema(AtUri),
66
70
  Args.withDescription("Bluesky list URI (at://...)")
67
71
  );
68
72
 
69
73
  /** Positional arg for author handle or DID */
70
74
  export const actorArg = Args.text({ name: "actor" }).pipe(
75
+ Args.withSchema(ActorId),
71
76
  Args.withDescription("Bluesky handle or DID")
72
77
  );
73
78
 
74
79
  /** Positional arg for post URI */
75
80
  export const postUriArg = Args.text({ name: "uri" }).pipe(
81
+ Args.withSchema(PostUri),
76
82
  Args.withDescription("Bluesky post URI (at://...)")
77
83
  );
78
84
 
@@ -100,70 +106,12 @@ export const includePinsOption = Options.boolean("include-pins").pipe(
100
106
  );
101
107
 
102
108
  /** 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(
109
+ export const decodeActor = (actor: string) =>
110
+ Schema.decodeUnknown(ActorId)(actor).pipe(
129
111
  Effect.mapError((error) =>
130
112
  CliInputError.make({
131
- message: `Invalid handle: ${formatSchemaError(error)}`,
113
+ message: `Invalid actor: ${formatSchemaError(error)}`,
132
114
  cause: { actor }
133
115
  })
134
116
  )
135
117
  );
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
- });
package/src/cli/shared.ts CHANGED
@@ -1 +1 @@
1
- export { formatSchemaError } from "../services/shared.js";
1
+ export { formatParseError, formatSchemaError } from "../services/shared.js";
@@ -1,5 +1,5 @@
1
1
  import { ParseResult } from "effect";
2
- import { safeParseJson, issueDetails } from "./parse-errors.js";
2
+ import { safeParseJson, issueDetails, findJsonParseIssue, jsonParseTip } from "./parse-errors.js";
3
3
  import { formatAgentError } from "./errors.js";
4
4
 
5
5
  const storeConfigExample = {
@@ -35,20 +35,16 @@ export const formatStoreConfigParseError = (
35
35
  const received = safeParseJson(raw);
36
36
  const receivedValue = received === undefined ? raw : received;
37
37
 
38
- const jsonParseIssue = issues.find(
39
- (issue) =>
40
- issue._tag === "Transformation" &&
41
- typeof issue.message === "string" &&
42
- issue.message.startsWith("JSON Parse error")
43
- );
38
+ const jsonParseIssue = findJsonParseIssue(issues);
44
39
  if (jsonParseIssue) {
40
+ const jsonMessage = jsonParseIssue.message ?? "Invalid JSON input.";
45
41
  return formatAgentError({
46
42
  error: "StoreConfigJsonParseError",
47
43
  message: `Invalid JSON in --config-json. ${storeConfigDocHint}`,
48
44
  received: raw,
49
45
  details: [
50
- jsonParseIssue.message,
51
- "Tip: wrap JSON in single quotes to avoid shell escaping issues."
46
+ jsonMessage,
47
+ jsonParseTip
52
48
  ],
53
49
  expected: storeConfigExample
54
50
  });
package/src/cli/store.ts CHANGED
@@ -21,6 +21,7 @@ import { StoreStats } from "../services/store-stats.js";
21
21
  import { withExamples } from "./help.js";
22
22
  import { resolveOutputFormat, treeTableJsonFormats } from "./output-format.js";
23
23
  import { StoreRenamer } from "../services/store-renamer.js";
24
+ import { PositiveInt } from "./option-schemas.js";
24
25
  import {
25
26
  buildStoreTreeData,
26
27
  renderStoreTree,
@@ -62,6 +63,7 @@ const treeAnsiOption = Options.boolean("ansi").pipe(
62
63
  Options.withDescription("Enable ANSI color output for tree format")
63
64
  );
64
65
  const treeWidthOption = Options.integer("width").pipe(
66
+ Options.withSchema(PositiveInt),
65
67
  Options.withDescription("Line width for tree rendering (enables wrapping)"),
66
68
  Options.optional
67
69
  );
@@ -248,6 +250,10 @@ export const storeDelete = Command.make(
248
250
  const cleaner = yield* StoreCleaner;
249
251
  const result = yield* cleaner.deleteStore(name);
250
252
  if (!result.deleted) {
253
+ if (result.reason === "missing") {
254
+ yield* writeJson(result);
255
+ return;
256
+ }
251
257
  return yield* CliInputError.make({
252
258
  message: `Store "${name}" was not deleted.`,
253
259
  cause: result
@@ -1,4 +1,4 @@
1
- import { Effect, Option, Stream } from "effect";
1
+ import { Duration, Effect, Option, Stream } from "effect";
2
2
  import { DataSource, SyncResult, WatchConfig } from "../domain/sync.js";
3
3
  import { SyncEngine } from "../services/sync-engine.js";
4
4
  import { SyncReporter } from "../services/sync-reporter.js";
@@ -10,7 +10,6 @@ import { CliOutput, writeJson, writeJsonStream } from "./output.js";
10
10
  import { storeOptions } from "./store.js";
11
11
  import { logInfo, logWarn, makeSyncReporter } from "./logging.js";
12
12
  import { parseInterval, parseOptionalDuration } from "./interval.js";
13
- import { CliInputError } from "./errors.js";
14
13
  import type { StoreName } from "../domain/primitives.js";
15
14
 
16
15
  /** Common options shared by sync and watch API-based commands */
@@ -20,6 +19,7 @@ export interface CommonCommandInput {
20
19
  readonly filterJson: Option.Option<string>;
21
20
  readonly quiet: boolean;
22
21
  readonly refresh: boolean;
22
+ readonly limit?: Option.Option<number>;
23
23
  }
24
24
 
25
25
  /** Build the command body for a one-shot sync command (timeline, feed, notifications). */
@@ -47,8 +47,12 @@ export const makeSyncCommandBody = (
47
47
  store: storeRef.name
48
48
  });
49
49
  }
50
+ const limitValue = Option.getOrUndefined(input.limit ?? Option.none());
50
51
  const result = yield* sync
51
- .sync(makeDataSource(), storeRef, expr, { policy })
52
+ .sync(makeDataSource(), storeRef, expr, {
53
+ policy,
54
+ ...(limitValue !== undefined ? { limit: limitValue } : {})
55
+ })
52
56
  .pipe(
53
57
  Effect.provideService(SyncReporter, makeSyncReporter(input.quiet, monitor, output))
54
58
  );
@@ -66,9 +70,9 @@ export const makeSyncCommandBody = (
66
70
 
67
71
  /** Common options for watch API-based commands */
68
72
  export interface WatchCommandInput extends CommonCommandInput {
69
- readonly interval: Option.Option<string>;
73
+ readonly interval: Option.Option<Duration.Duration>;
70
74
  readonly maxCycles: Option.Option<number>;
71
- readonly until: Option.Option<string>;
75
+ readonly until: Option.Option<Duration.Duration>;
72
76
  }
73
77
 
74
78
  /** Build the command body for a watch command (timeline, feed, notifications). */
@@ -87,20 +91,8 @@ export const makeWatchCommandBody = (
87
91
  const expr = yield* parseFilterExpr(input.filter, input.filterJson);
88
92
  const basePolicy = storeConfig.syncPolicy ?? "dedupe";
89
93
  const policy = input.refresh ? "refresh" : basePolicy;
90
- const parsedInterval = yield* parseInterval(input.interval);
91
- const parsedUntil = yield* parseOptionalDuration(input.until);
92
- const parsedMaxCycles = yield* Option.match(input.maxCycles, {
93
- onNone: () => Effect.succeed(Option.none<number>()),
94
- onSome: (value) =>
95
- value <= 0
96
- ? Effect.fail(
97
- CliInputError.make({
98
- message: "--max-cycles must be a positive integer.",
99
- cause: { maxCycles: value }
100
- })
101
- )
102
- : Effect.succeed(Option.some(value))
103
- });
94
+ const parsedInterval = parseInterval(input.interval);
95
+ const parsedUntil = parseOptionalDuration(input.until);
104
96
  yield* logInfo("Starting watch", { source: sourceName, store: storeRef.name, ...extraLogFields });
105
97
  if (policy === "refresh") {
106
98
  yield* logWarn("Refresh mode updates existing posts and may grow the event log.", {
@@ -122,8 +114,8 @@ export const makeWatchCommandBody = (
122
114
  Stream.map((event) => event.result),
123
115
  Stream.provideService(SyncReporter, makeSyncReporter(input.quiet, monitor, output))
124
116
  );
125
- if (Option.isSome(parsedMaxCycles)) {
126
- stream = stream.pipe(Stream.take(parsedMaxCycles.value));
117
+ if (Option.isSome(input.maxCycles)) {
118
+ stream = stream.pipe(Stream.take(input.maxCycles.value));
127
119
  }
128
120
  if (Option.isSome(parsedUntil)) {
129
121
  stream = stream.pipe(Stream.interruptWhen(Effect.sleep(parsedUntil.value)));