@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mepuka/skygent",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "Composable Bluesky data filtering and monitoring CLI built with Effect",
5
5
  "module": "index.ts",
6
6
  "type": "module",
@@ -0,0 +1,52 @@
1
+ import type { FeedGeneratorView, ListItemView, ListView, PostLike, ProfileView } from "../domain/bsky.js";
2
+ import type { Post } from "../domain/post.js";
3
+
4
+ type CompactProfile = {
5
+ readonly did: ProfileView["did"];
6
+ readonly handle: ProfileView["handle"];
7
+ readonly displayName?: string;
8
+ };
9
+
10
+ const compactProfile = (profile: ProfileView): CompactProfile => ({
11
+ did: profile.did,
12
+ handle: profile.handle,
13
+ ...(profile.displayName ? { displayName: profile.displayName } : {})
14
+ });
15
+
16
+ export const compactProfileView = (profile: ProfileView) =>
17
+ compactProfile(profile);
18
+
19
+ export const compactFeedGeneratorView = (feed: FeedGeneratorView) => ({
20
+ uri: feed.uri,
21
+ displayName: feed.displayName,
22
+ creator: compactProfile(feed.creator),
23
+ ...(feed.likeCount !== undefined ? { likeCount: feed.likeCount } : {})
24
+ });
25
+
26
+ export const compactListView = (list: ListView) => ({
27
+ uri: list.uri,
28
+ name: list.name,
29
+ purpose: list.purpose,
30
+ creator: compactProfile(list.creator),
31
+ ...(list.listItemCount !== undefined
32
+ ? { listItemCount: list.listItemCount }
33
+ : {})
34
+ });
35
+
36
+ export const compactListItemView = (item: ListItemView) => ({
37
+ uri: item.uri,
38
+ subject: compactProfile(item.subject)
39
+ });
40
+
41
+ export const compactPostLike = (like: PostLike) => ({
42
+ actor: compactProfile(like.actor),
43
+ createdAt: like.createdAt,
44
+ indexedAt: like.indexedAt
45
+ });
46
+
47
+ export const compactPost = (post: Post) => ({
48
+ uri: post.uri,
49
+ author: post.author,
50
+ text: post.text,
51
+ createdAt: post.createdAt
52
+ });
package/src/cli/config.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { Options } from "@effect/cli";
1
+ import { HelpDoc, Options } from "@effect/cli";
2
2
  import { Option, Redacted } from "effect";
3
3
  import { pickDefined } from "../services/shared.js";
4
4
  import { OutputFormat } from "../domain/config.js";
@@ -6,13 +6,33 @@ import { AppConfig } from "../domain/config.js";
6
6
  import type { LogFormat } from "./logging.js";
7
7
  import type { SyncSettingsValue } from "../services/sync-settings.js";
8
8
  import type { CredentialsOverridesValue } from "../services/credential-store.js";
9
+ import { NonNegativeInt, PositiveInt } from "./option-schemas.js";
9
10
 
10
11
 
11
- const compactOption = Options.boolean("full", {
12
- negationNames: ["compact"]
12
+ const compactOption = Options.all({
13
+ full: Options.boolean("full").pipe(
14
+ Options.withDescription("Use full JSON output")
15
+ ),
16
+ compact: Options.boolean("compact").pipe(
17
+ Options.withDescription("Use compact JSON output (default)")
18
+ )
13
19
  }).pipe(
14
- Options.withDescription("Use full JSON output (disables compact default)"),
15
- Options.map((full) => !full)
20
+ Options.mapTryCatch(
21
+ ({ full, compact }) => {
22
+ if (full && compact) {
23
+ throw new Error("Use either --full or --compact, not both.");
24
+ }
25
+ if (full) return false;
26
+ if (compact) return true;
27
+ return true;
28
+ },
29
+ (error) =>
30
+ HelpDoc.p(
31
+ typeof error === "object" && error !== null && "message" in error
32
+ ? String((error as { readonly message?: unknown }).message ?? error)
33
+ : String(error)
34
+ )
35
+ )
16
36
  );
17
37
 
18
38
  export const configOptions = {
@@ -44,22 +64,27 @@ export const configOptions = {
44
64
  Options.withDescription("Override log format (json or human)")
45
65
  ),
46
66
  syncConcurrency: Options.integer("sync-concurrency").pipe(
67
+ Options.withSchema(PositiveInt),
47
68
  Options.optional,
48
69
  Options.withDescription("Concurrent sync preparation workers (default: 5)")
49
70
  ),
50
71
  syncBatchSize: Options.integer("sync-batch-size").pipe(
72
+ Options.withSchema(PositiveInt),
51
73
  Options.optional,
52
74
  Options.withDescription("Batch size for sync store writes (default: 100)")
53
75
  ),
54
76
  syncPageLimit: Options.integer("sync-page-limit").pipe(
77
+ Options.withSchema(PositiveInt),
55
78
  Options.optional,
56
79
  Options.withDescription("Page size for sync fetches (default: 100)")
57
80
  ),
58
81
  checkpointEvery: Options.integer("checkpoint-every").pipe(
82
+ Options.withSchema(PositiveInt),
59
83
  Options.optional,
60
84
  Options.withDescription("Checkpoint every N processed posts (default: 100)")
61
85
  ),
62
86
  checkpointIntervalMs: Options.integer("checkpoint-interval-ms").pipe(
87
+ Options.withSchema(NonNegativeInt),
63
88
  Options.optional,
64
89
  Options.withDescription("Checkpoint interval in milliseconds (default: 5000)")
65
90
  )
package/src/cli/feed.ts CHANGED
@@ -1,11 +1,14 @@
1
1
  import { Args, Command, Options } from "@effect/cli";
2
- import { Effect, Stream } from "effect";
2
+ import { Effect, Option, Schema, Stream } from "effect";
3
3
  import { renderTableLegacy } from "./doc/table.js";
4
4
  import { renderFeedTable } from "./doc/table-renderers.js";
5
5
  import { BskyClient } from "../services/bsky-client.js";
6
6
  import { AppConfigService } from "../services/app-config.js";
7
7
  import type { FeedGeneratorView } from "../domain/bsky.js";
8
- import { decodeActor } from "./shared-options.js";
8
+ import { AtUri } from "../domain/primitives.js";
9
+ import { CliPreferences } from "./preferences.js";
10
+ import { compactFeedGeneratorView } from "./compact-output.js";
11
+ import { actorArg } from "./shared-options.js";
9
12
  import { CliInputError } from "./errors.js";
10
13
  import { withExamples } from "./help.js";
11
14
  import { writeJson, writeJsonStream, writeText } from "./output.js";
@@ -14,18 +17,16 @@ import { emitWithFormat } from "./output-render.js";
14
17
  import { cursorOption as baseCursorOption, limitOption as baseLimitOption, parsePagination } from "./pagination.js";
15
18
 
16
19
  const feedUriArg = Args.text({ name: "uri" }).pipe(
20
+ Args.withSchema(AtUri),
17
21
  Args.withDescription("Bluesky feed URI (at://...)")
18
22
  );
19
23
 
20
24
  const feedUrisArg = Args.text({ name: "uri" }).pipe(
21
25
  Args.repeated,
26
+ Args.withSchema(Schema.mutable(Schema.Array(AtUri))),
22
27
  Args.withDescription("Feed URIs to fetch")
23
28
  );
24
29
 
25
- const actorArg = Args.text({ name: "actor" }).pipe(
26
- Args.withDescription("Bluesky handle or DID")
27
- );
28
-
29
30
  const limitOption = baseLimitOption.pipe(
30
31
  Options.withDescription("Maximum number of results")
31
32
  );
@@ -39,6 +40,17 @@ const formatOption = Options.choice("format", jsonNdjsonTableFormats).pipe(
39
40
  Options.optional
40
41
  );
41
42
 
43
+ const ensureSupportedFormat = (
44
+ format: Option.Option<typeof jsonNdjsonTableFormats[number]>,
45
+ configFormat: string
46
+ ) =>
47
+ Option.isNone(format) && configFormat === "markdown"
48
+ ? CliInputError.make({
49
+ message: 'Output format "markdown" is not supported for feed commands. Use --format json|ndjson|table.',
50
+ cause: { format: configFormat }
51
+ })
52
+ : Effect.void;
53
+
42
54
 
43
55
  const renderFeedInfoTable = (
44
56
  view: FeedGeneratorView,
@@ -56,16 +68,24 @@ const showCommand = Command.make(
56
68
  ({ uri, format }) =>
57
69
  Effect.gen(function* () {
58
70
  const appConfig = yield* AppConfigService;
71
+ yield* ensureSupportedFormat(format, appConfig.outputFormat);
72
+ const preferences = yield* CliPreferences;
59
73
  const client = yield* BskyClient;
60
74
  const result = yield* client.getFeedGenerator(uri);
75
+ const payload = preferences.compact
76
+ ? {
77
+ ...result,
78
+ view: compactFeedGeneratorView(result.view)
79
+ }
80
+ : result;
61
81
  yield* emitWithFormat(
62
82
  format,
63
83
  appConfig.outputFormat,
64
84
  jsonNdjsonTableFormats,
65
85
  "json",
66
86
  {
67
- json: writeJson(result),
68
- ndjson: writeJson(result),
87
+ json: writeJson(payload),
88
+ ndjson: writeJson(payload),
69
89
  table: writeText(renderFeedInfoTable(result.view, result.isOnline, result.isValid))
70
90
  }
71
91
  );
@@ -84,6 +104,8 @@ const batchCommand = Command.make(
84
104
  ({ uris, format }) =>
85
105
  Effect.gen(function* () {
86
106
  const appConfig = yield* AppConfigService;
107
+ yield* ensureSupportedFormat(format, appConfig.outputFormat);
108
+ const preferences = yield* CliPreferences;
87
109
  const client = yield* BskyClient;
88
110
  if (uris.length === 0) {
89
111
  return yield* CliInputError.make({
@@ -92,14 +114,21 @@ const batchCommand = Command.make(
92
114
  });
93
115
  }
94
116
  const result = yield* client.getFeedGenerators(uris);
117
+ const feeds = preferences.compact
118
+ ? result.feeds.map(compactFeedGeneratorView)
119
+ : result.feeds;
120
+ const payload = { ...result, feeds };
121
+ const feedsStream = Stream.fromIterable(
122
+ feeds as ReadonlyArray<FeedGeneratorView | ReturnType<typeof compactFeedGeneratorView>>
123
+ );
95
124
  yield* emitWithFormat(
96
125
  format,
97
126
  appConfig.outputFormat,
98
127
  jsonNdjsonTableFormats,
99
128
  "json",
100
129
  {
101
- json: writeJson(result),
102
- ndjson: writeJsonStream(Stream.fromIterable(result.feeds)),
130
+ json: writeJson(payload),
131
+ ndjson: writeJsonStream(feedsStream),
103
132
  table: writeText(renderFeedTable(result.feeds, undefined))
104
133
  }
105
134
  );
@@ -118,21 +147,29 @@ const byActorCommand = Command.make(
118
147
  ({ actor, limit, cursor, format }) =>
119
148
  Effect.gen(function* () {
120
149
  const appConfig = yield* AppConfigService;
150
+ yield* ensureSupportedFormat(format, appConfig.outputFormat);
151
+ const preferences = yield* CliPreferences;
121
152
  const client = yield* BskyClient;
122
- const { limit: limitValue, cursor: cursorValue } = yield* parsePagination(limit, cursor);
123
- const resolvedActor = yield* decodeActor(actor);
124
- const result = yield* client.getActorFeeds(resolvedActor, {
153
+ const { limit: limitValue, cursor: cursorValue } = parsePagination(limit, cursor);
154
+ const result = yield* client.getActorFeeds(actor, {
125
155
  ...(limitValue !== undefined ? { limit: limitValue } : {}),
126
156
  ...(cursorValue !== undefined ? { cursor: cursorValue } : {})
127
157
  });
158
+ const feeds = preferences.compact
159
+ ? result.feeds.map(compactFeedGeneratorView)
160
+ : result.feeds;
161
+ const payload = { ...result, feeds };
162
+ const feedsStream = Stream.fromIterable(
163
+ feeds as ReadonlyArray<FeedGeneratorView | ReturnType<typeof compactFeedGeneratorView>>
164
+ );
128
165
  yield* emitWithFormat(
129
166
  format,
130
167
  appConfig.outputFormat,
131
168
  jsonNdjsonTableFormats,
132
169
  "json",
133
170
  {
134
- json: writeJson(result),
135
- ndjson: writeJsonStream(Stream.fromIterable(result.feeds)),
171
+ json: writeJson(payload),
172
+ ndjson: writeJsonStream(feedsStream),
136
173
  table: writeText(renderFeedTable(result.feeds, result.cursor))
137
174
  }
138
175
  );
@@ -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, type AgentErrorPayload } from "./errors.js";
4
4
 
5
5
  const validFilterTags = [
@@ -76,19 +76,15 @@ export const formatFilterParseError = (error: ParseResult.ParseError, raw: strin
76
76
  const received = safeParseJson(raw);
77
77
  const receivedValue = received === undefined ? raw : received;
78
78
 
79
- const jsonParseIssue = issues.find(
80
- (issue) =>
81
- issue._tag === "Transformation" &&
82
- typeof issue.message === "string" &&
83
- issue.message.startsWith("JSON Parse error")
84
- );
79
+ const jsonParseIssue = findJsonParseIssue(issues);
85
80
  if (jsonParseIssue) {
81
+ const jsonMessage = jsonParseIssue.message ?? "Invalid JSON input.";
86
82
  return jsonParseError({
87
83
  message: "Invalid JSON in --filter-json.",
88
84
  received: raw,
89
85
  details: [
90
- jsonParseIssue.message,
91
- "Tip: wrap JSON in single quotes to avoid shell escaping issues."
86
+ jsonMessage,
87
+ jsonParseTip
92
88
  ]
93
89
  });
94
90
  }
package/src/cli/filter.ts CHANGED
@@ -4,7 +4,7 @@ import { Chunk, Clock, Effect, Option, Stream } from "effect";
4
4
  import { StoreQuery } from "../domain/events.js";
5
5
  import { RawPost } from "../domain/raw.js";
6
6
  import type { Post } from "../domain/post.js";
7
- import { StoreName } from "../domain/primitives.js";
7
+ import { PostUri, StoreName } from "../domain/primitives.js";
8
8
  import { BskyClient } from "../services/bsky-client.js";
9
9
  import { FilterCompiler } from "../services/filter-compiler.js";
10
10
  import { FilterLibrary } from "../services/filter-library.js";
@@ -25,6 +25,7 @@ import { withExamples } from "./help.js";
25
25
  import { filterOption, filterJsonOption } from "./shared-options.js";
26
26
  import { jsonTableFormats, resolveOutputFormat, textJsonFormats } from "./output-format.js";
27
27
  import { filterDslDescription, filterJsonDescription } from "./filter-help.js";
28
+ import { PositiveInt } from "./option-schemas.js";
28
29
 
29
30
  const filterNameArg = Args.text({ name: "name" }).pipe(
30
31
  Args.withSchema(StoreName),
@@ -36,6 +37,7 @@ const postJsonOption = Options.text("post-json").pipe(
36
37
  Options.optional
37
38
  );
38
39
  const postUriOption = Options.text("post-uri").pipe(
40
+ Options.withSchema(PostUri),
39
41
  Options.withDescription("Bluesky post URI (at://...)."),
40
42
  Options.optional
41
43
  );
@@ -49,10 +51,12 @@ const storeTestOption = Options.text("store").pipe(
49
51
  Options.optional
50
52
  );
51
53
  const testLimitOption = Options.integer("limit").pipe(
54
+ Options.withSchema(PositiveInt),
52
55
  Options.withDescription("Number of posts to evaluate (default: 100)"),
53
56
  Options.optional
54
57
  );
55
58
  const sampleSizeOption = Options.integer("sample-size").pipe(
59
+ Options.withSchema(PositiveInt),
56
60
  Options.withDescription("Number of posts to evaluate (default: 1000)"),
57
61
  Options.optional
58
62
  );
@@ -294,12 +298,6 @@ export const filterTest = Command.make(
294
298
  cause: { store: store.value, postJson, postUri }
295
299
  });
296
300
  }
297
- if (Option.isSome(limit) && limit.value <= 0) {
298
- return yield* CliInputError.make({
299
- message: "--limit must be a positive integer.",
300
- cause: { limit: limit.value }
301
- });
302
- }
303
301
  const storeRef = yield* storeOptions.loadStoreRef(store.value);
304
302
  const index = yield* StoreIndex;
305
303
  const evaluateBatch = yield* runtime.evaluateBatch(expr);