@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/README.md CHANGED
@@ -1,59 +1,297 @@
1
- # skygent-bsky
1
+ # @mepuka/skygent
2
2
 
3
- To install dependencies:
3
+ Composable Bluesky data filtering and monitoring CLI built with [Effect](https://effect.website).
4
+
5
+ Sync posts from timelines, feeds, lists, authors, and the real-time Jetstream firehose into local SQLite stores. Query, filter, derive, and export data with a powerful filter DSL and multiple output formats.
6
+
7
+ ## Install
8
+
9
+ ### npm / bun
4
10
 
5
11
  ```bash
12
+ bun add -g @mepuka/skygent
13
+ ```
14
+
15
+ ### From source
16
+
17
+ ```bash
18
+ git clone https://github.com/mepuka/skygent-bsky.git
19
+ cd skygent-bsky
6
20
  bun install
21
+ bun run index.ts --help
7
22
  ```
8
23
 
9
- Create a local `.env` from `.env.example` and set credentials:
24
+ ### Standalone binary
25
+
26
+ Download a prebuilt binary from [GitHub Releases](https://github.com/mepuka/skygent-bsky/releases), or build locally:
10
27
 
11
28
  ```bash
12
- cp .env.example .env
29
+ bun run build:binary
30
+ ./skygent --help
13
31
  ```
14
32
 
15
- To run:
33
+ Cross-platform targets: `linux-x64`, `linux-arm64`, `darwin-x64`, `darwin-arm64`.
34
+
35
+ ## Authentication
36
+
37
+ Skygent needs a Bluesky handle and [app password](https://bsky.app/settings/app-passwords). Credentials are resolved in this order:
38
+
39
+ 1. CLI flags: `--identifier` and `--password`
40
+ 2. Environment variables: `SKYGENT_IDENTIFIER` and `SKYGENT_PASSWORD`
41
+ 3. Encrypted credential file (`~/.skygent/credentials.json`, requires `SKYGENT_CREDENTIALS_KEY`)
42
+
43
+ The simplest setup:
16
44
 
17
45
  ```bash
18
- bun run index.ts
46
+ cp .env.example .env
47
+ # Edit .env with your handle and app password
19
48
  ```
20
49
 
50
+ Bun loads `.env` automatically.
51
+
21
52
  ## Quickstart
22
53
 
23
54
  ```bash
24
- # List stores (compact JSON for agents)
25
- bun run index.ts store list --compact
55
+ # Create a store
56
+ skygent store create my-store
57
+
58
+ # Sync your timeline
59
+ skygent sync timeline --store my-store
26
60
 
27
- # Sync timeline into a store
28
- bun run index.ts sync timeline --store my-store --quiet
61
+ # Query recent posts
62
+ skygent query my-store --limit 10 --format table
29
63
 
30
- # Query recent posts as a table
31
- bun run index.ts query my-store --limit 10 --format table
64
+ # Stream live posts from Jetstream
65
+ skygent watch jetstream --store my-store
32
66
 
33
- # Stream Jetstream posts
34
- bun run index.ts watch jetstream --store my-store --quiet
67
+ # Derive a filtered store
68
+ skygent derive my-store ai-posts --filter 'hashtag:#ai OR hashtag:#ml'
69
+
70
+ # Search posts locally
71
+ skygent search posts "effect typescript" --store my-store
35
72
  ```
36
73
 
37
- Tips:
38
- - Add `--compact` to reduce JSON output size.
39
- - Add `--quiet` to suppress progress logs during sync/watch commands.
74
+ ## Commands
75
+
76
+ ### `store` -- Manage local stores
77
+
78
+ | Subcommand | Description |
79
+ |---|---|
80
+ | `store create <name>` | Create a new store |
81
+ | `store list` | List all stores |
82
+ | `store show <name>` | Show store config and metadata |
83
+ | `store rename <from> <to>` | Rename a store |
84
+ | `store delete <name> --force` | Delete a store |
85
+ | `store stats <name>` | Show store statistics |
86
+ | `store summary` | Summarize all stores |
87
+ | `store tree` | Visualize store lineage |
88
+ | `store materialize <name>` | Materialize filter outputs to disk |
89
+
90
+ ### `sync` -- One-time data sync
91
+
92
+ | Subcommand | Description |
93
+ |---|---|
94
+ | `sync timeline` | Sync your timeline |
95
+ | `sync feed <uri>` | Sync a feed generator |
96
+ | `sync list <uri>` | Sync a list feed |
97
+ | `sync author <actor>` | Sync posts from an author |
98
+ | `sync thread <uri>` | Sync a thread (parents + replies) |
99
+ | `sync notifications` | Sync notifications |
100
+ | `sync jetstream` | Sync from Jetstream firehose |
101
+
102
+ All sync commands accept `--store`, `--filter`, `--quiet`, and `--refresh`.
103
+
104
+ ### `watch` -- Continuous sync
105
+
106
+ Same subcommands as `sync`, with continuous polling. Supports `--interval` (default: 30s).
107
+
108
+ ```bash
109
+ skygent watch timeline --store my-store --interval "5 minutes"
110
+ ```
111
+
112
+ ### `query` -- Query stored posts
113
+
114
+ ```bash
115
+ skygent query my-store --limit 25 --format table
116
+ skygent query my-store --filter 'hashtag:#ai' --sort desc --format json
117
+ skygent query my-store --range 2024-01-01T00:00:00Z..2024-01-31T00:00:00Z
118
+ skygent query my-store --fields @minimal --newest-first
119
+ skygent query store-a,store-b --format ndjson
120
+ ```
121
+
122
+ **Formats:** `json`, `ndjson`, `table`, `markdown`, `compact`, `card`, `thread`
123
+
124
+ **Field presets:** `@minimal`, `@social`, `@full`, or comma-separated field names with dot notation.
125
+
126
+ Multi-store queries accept comma-separated store lists or repeated store arguments and include store names in output by default.
127
+
128
+ ### `derive` -- Create derived stores
129
+
130
+ Apply a filter to a source store to produce a new filtered store:
131
+
132
+ ```bash
133
+ skygent derive source-store target-store --filter 'hashtag:#ai'
134
+ ```
135
+
136
+ **Modes:**
137
+ - `event-time` (default) -- Pure filters only, replayable
138
+ - `derive-time` -- Allows effectful filters (Trending, HasValidLinks)
139
+
140
+ ### `filter` -- Filter management and testing
141
+
142
+ Tip: run `skygent filter help` for a compact list of predicates and aliases.
143
+
144
+ | Subcommand | Description |
145
+ |---|---|
146
+ | `filter create <name>` | Save a named filter |
147
+ | `filter list` | List saved filters |
148
+ | `filter show <name>` | Show a saved filter |
149
+ | `filter delete <name>` | Delete a saved filter |
150
+ | `filter help` | Show filter DSL and JSON help |
151
+ | `filter validate` | Validate a filter expression |
152
+ | `filter test` | Test a filter against a post |
153
+ | `filter explain` | Explain why a post matches |
154
+ | `filter benchmark` | Benchmark filter performance |
155
+ | `filter describe` | Describe a filter in plain text |
156
+
157
+ ### `search` -- Search content
158
+
159
+ | Subcommand | Description |
160
+ |---|---|
161
+ | `search posts <query>` | Search posts locally or `--network` |
162
+ | `search handles <query>` | Search Bluesky profiles |
163
+ | `search feeds <query>` | Search feed generators |
164
+
165
+ ### `graph` -- Social graph
166
+
167
+ | Subcommand | Description |
168
+ |---|---|
169
+ | `graph followers <actor>` | List followers |
170
+ | `graph follows <actor>` | List follows |
171
+ | `graph known-followers <actor>` | Mutual followers |
172
+ | `graph relationships <actor>` | Relationship status |
173
+ | `graph lists <actor>` | Lists created by actor |
174
+ | `graph list <uri>` | View a list's members |
175
+ | `graph blocks` | Your blocked accounts |
176
+ | `graph mutes` | Your muted accounts |
177
+
178
+ ### `feed` -- Feed generators
179
+
180
+ | Subcommand | Description |
181
+ |---|---|
182
+ | `feed show <uri>` | Show feed details |
183
+ | `feed batch <uri>...` | Fetch multiple feeds |
184
+ | `feed by <actor>` | List feeds by an actor |
185
+
186
+ ### `post` -- Post engagement
187
+
188
+ | Subcommand | Description |
189
+ |---|---|
190
+ | `post likes <uri>` | Who liked a post |
191
+ | `post reposted-by <uri>` | Who reposted |
192
+ | `post quotes <uri>` | Quote posts |
193
+
194
+ ### `view` -- Inspect threads and derivations
195
+
196
+ | Subcommand | Description |
197
+ |---|---|
198
+ | `view thread <uri>` | Display a thread |
199
+ | `view status <view> <source>` | Check if a derived view is stale |
200
+
201
+ ### `config` -- Configuration
202
+
203
+ ```bash
204
+ skygent config check # Run health checks
205
+ ```
206
+
207
+ ## Filter DSL
208
+
209
+ Filters are passed via `--filter` (DSL string) or `--filter-json` (JSON AST).
210
+
211
+ ### Primitives
212
+
213
+ | Filter | Example |
214
+ |---|---|
215
+ | `hashtag:#tag` | Match posts with hashtag |
216
+ | `author:handle.bsky.social` | Match posts by author |
217
+ | `contains:"text"` | Text search (case-insensitive by default) |
218
+ | `regex:/pattern/i` | Regex match |
219
+ | `language:en,es` | Match languages |
220
+ | `date:<start>..<end>` | Date range (ISO 8601) |
221
+ | `engagement:minLikes=100` | Engagement thresholds |
222
+ | `is:reply` | Post type (`reply`, `quote`, `repost`, `original`) |
223
+ | `has:images` | Media presence (`images`, `video`, `links`, `media`, `embed`) |
224
+ | `@saved-name` | Reference a saved filter |
225
+
226
+ ### Aliases
227
+
228
+ `from:` = `author:`, `tag:` = `hashtag:`, `text:` = `contains:`, `lang:` = `language:`
229
+
230
+ ### List filters
231
+
232
+ `authorin:alice,bob,charlie` and `hashtagin:#ai,#ml,#dl`
233
+
234
+ ### Boolean operators
235
+
236
+ ```
237
+ hashtag:#ai AND author:user.bsky.social
238
+ hashtag:#ai OR hashtag:#ml
239
+ NOT hashtag:#spam
240
+ (hashtag:#ai OR hashtag:#ml) AND engagement:minLikes=10
241
+ ```
242
+
243
+ Operators: `AND` / `&&`, `OR` / `||`, `NOT` / `!`, parentheses for grouping.
244
+
245
+ ## Configuration
246
+
247
+ ### Environment variables
248
+
249
+ | Variable | Default | Description |
250
+ |---|---|---|
251
+ | `SKYGENT_IDENTIFIER` | -- | Bluesky handle or DID |
252
+ | `SKYGENT_PASSWORD` | -- | App password |
253
+ | `SKYGENT_CREDENTIALS_KEY` | -- | Master key for encrypted credential storage |
254
+ | `SKYGENT_SERVICE` | `https://bsky.social` | Bluesky service URL |
255
+ | `SKYGENT_STORE_ROOT` | `~/.skygent` | Root storage directory |
256
+ | `SKYGENT_OUTPUT_FORMAT` | `ndjson` | Default output format |
257
+ | `SKYGENT_BSKY_RATE_LIMIT` | `250 millis` | Min delay between API calls |
258
+ | `SKYGENT_BSKY_RETRY_MAX` | `5` | Max retry attempts |
259
+ | `SKYGENT_SYNC_CONCURRENCY` | `5` | Concurrent sync workers |
260
+
261
+ ### Global flags
262
+
263
+ - `--full` -- Use verbose JSON output (compact is the default)
264
+ - `--quiet` -- Suppress progress output
265
+ - `--log-format json|human` -- Control log format
266
+
267
+ ## Architecture
268
+
269
+ Skygent is built entirely on Effect with a layered service architecture:
270
+
271
+ - **Domain** (`src/domain/`) -- Data models for posts, stores, filters, events, and derivations using Effect Schema
272
+ - **Services** (`src/services/`) -- Business logic: Bluesky API client, SQLite store, sync engine, filter runtime, derivation engine
273
+ - **CLI** (`src/cli/`) -- Command definitions, output formatting, error handling
274
+
275
+ Stores are local SQLite databases with an append-only event log. Derivations track lineage between stores and support incremental processing with checkpoints.
276
+
277
+ ## Security
278
+
279
+ - Passwords are handled as `Redacted` values and never logged
280
+ - Encrypted credential storage uses AES-GCM with PBKDF2 (100,000 iterations)
281
+ - Avoid putting passwords in config files; use environment variables or the credential store
40
282
 
41
283
  ## Documentation
42
284
 
43
- - docs/README.md
44
- - docs/getting-started.md
45
- - docs/cli.md
46
- - docs/filters.md
47
- - docs/configuration.md
48
- - docs/credentials.md
49
- - docs/stores.md
50
- - docs/outputs.md
285
+ Detailed docs are in `docs/`:
51
286
 
52
- ## Security notes
287
+ - [Getting Started](docs/getting-started.md)
288
+ - [CLI Reference](docs/cli.md)
289
+ - [Filters](docs/filters.md)
290
+ - [Configuration](docs/configuration.md)
291
+ - [Credentials](docs/credentials.md)
292
+ - [Stores](docs/stores.md)
293
+ - [Output Formats](docs/outputs.md)
53
294
 
54
- - Credentials are loaded via Effect `Redacted` config and are not logged.
55
- - Encrypted credential storage uses `SKYGENT_CREDENTIALS_KEY` (AES-GCM).
56
- - Avoid putting passwords in `config.json`; use env or the credential store instead.
57
- - Resource warnings can be configured via `SKYGENT_RESOURCE_*` env vars.
295
+ ## License
58
296
 
59
- This project was created using `bun init` in bun v1.3.4. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime.
297
+ MIT
package/index.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env bun
2
2
  import { Command, HelpDoc, ValidationError } from "@effect/cli";
3
3
  import { BunContext, BunRuntime } from "@effect/platform-bun";
4
- import { Effect, Layer } from "effect";
4
+ import { Clock, Effect, Layer } from "effect";
5
5
  import { app } from "./src/cli/app.js";
6
6
  import pkg from "./package.json" with { type: "json" };
7
7
  import {
@@ -13,7 +13,7 @@ import {
13
13
  import { logErrorEvent } from "./src/cli/logging.js";
14
14
  import { CliOutput } from "./src/cli/output.js";
15
15
  import { exitCodeFor, exitCodeFromExit } from "./src/cli/exit-codes.js";
16
- import { BskyError, StoreNotFound } from "./src/domain/errors.js";
16
+ import { BskyError, StoreAlreadyExists, StoreNotFound } from "./src/domain/errors.js";
17
17
 
18
18
  const cli = Command.run(app, {
19
19
  name: "skygent",
@@ -51,6 +51,9 @@ const formatError = (error: unknown, agentPayload?: AgentErrorPayload) => {
51
51
  if (error instanceof StoreNotFound) {
52
52
  return `Store \"${error.name}\" does not exist.`;
53
53
  }
54
+ if (error instanceof StoreAlreadyExists) {
55
+ return `Store \"${error.name}\" already exists.`;
56
+ }
54
57
  if (error instanceof Error) {
55
58
  return error.message;
56
59
  }
@@ -88,6 +91,9 @@ const errorDetails = (
88
91
  if (error instanceof StoreNotFound) {
89
92
  return { error: { _tag: "StoreNotFound", name: error.name } };
90
93
  }
94
+ if (error instanceof StoreAlreadyExists) {
95
+ return { error: { _tag: "StoreAlreadyExists", name: error.name } };
96
+ }
91
97
  if (error instanceof BskyError) {
92
98
  return {
93
99
  error: {
@@ -114,6 +120,9 @@ const errorSuggestion = (
114
120
  if (error instanceof StoreNotFound) {
115
121
  return `Run: skygent store create ${error.name}`;
116
122
  }
123
+ if (error instanceof StoreAlreadyExists) {
124
+ return "Run: skygent store list";
125
+ }
117
126
  return undefined;
118
127
  };
119
128
 
@@ -130,7 +139,13 @@ const program = cli(process.argv).pipe(
130
139
  ...errorDetails(error, agentPayload)
131
140
  });
132
141
  }),
133
- Effect.provide(Layer.mergeAll(BunContext.layer, CliOutput.layer))
142
+ Effect.provide(
143
+ Layer.mergeAll(
144
+ BunContext.layer,
145
+ CliOutput.layer,
146
+ Layer.succeed(Clock.Clock, Clock.make())
147
+ )
148
+ )
134
149
  );
135
150
 
136
151
  BunRuntime.runMain({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mepuka/skygent",
3
- "version": "0.2.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",
package/src/cli/app.ts CHANGED
@@ -17,6 +17,7 @@ import { searchCommand } from "./search.js";
17
17
  import { graphCommand } from "./graph.js";
18
18
  import { feedCommand } from "./feed.js";
19
19
  import { postCommand } from "./post.js";
20
+ import { pipeCommand } from "./pipe.js";
20
21
  import { configCommand } from "./config-command.js";
21
22
  import {
22
23
  configOptions,
@@ -39,7 +40,8 @@ export const app = Command.make("skygent", configOptions).pipe(
39
40
  searchCommand,
40
41
  graphCommand,
41
42
  feedCommand,
42
- postCommand
43
+ postCommand,
44
+ pipeCommand
43
45
  ]),
44
46
  Command.provide((config) =>
45
47
  Layer.mergeAll(
@@ -69,7 +71,7 @@ export const app = Command.make("skygent", configOptions).pipe(
69
71
  "skygent store list --compact",
70
72
  "skygent sync timeline --store my-store --quiet"
71
73
  ],
72
- ["Tip: add --compact for shorter JSON output."]
74
+ ["Tip: compact output is the default; use --full for verbose JSON."]
73
75
  )
74
76
  )
75
77
  );
@@ -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,8 +6,35 @@ 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
 
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
+ )
19
+ }).pipe(
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
+ )
36
+ );
37
+
11
38
  export const configOptions = {
12
39
  service: Options.text("service").pipe(
13
40
  Options.optional,
@@ -31,22 +58,33 @@ export const configOptions = {
31
58
  Options.optional,
32
59
  Options.withDescription("Override Bluesky password (redacted)")
33
60
  ),
34
- compact: Options.boolean("compact").pipe(
35
- Options.withDescription("Reduce JSON output verbosity for agent consumption")
36
- ),
61
+ compact: compactOption,
37
62
  logFormat: Options.choice("log-format", ["json", "human"]).pipe(
38
63
  Options.optional,
39
64
  Options.withDescription("Override log format (json or human)")
40
65
  ),
41
66
  syncConcurrency: Options.integer("sync-concurrency").pipe(
67
+ Options.withSchema(PositiveInt),
42
68
  Options.optional,
43
69
  Options.withDescription("Concurrent sync preparation workers (default: 5)")
44
70
  ),
71
+ syncBatchSize: Options.integer("sync-batch-size").pipe(
72
+ Options.withSchema(PositiveInt),
73
+ Options.optional,
74
+ Options.withDescription("Batch size for sync store writes (default: 100)")
75
+ ),
76
+ syncPageLimit: Options.integer("sync-page-limit").pipe(
77
+ Options.withSchema(PositiveInt),
78
+ Options.optional,
79
+ Options.withDescription("Page size for sync fetches (default: 100)")
80
+ ),
45
81
  checkpointEvery: Options.integer("checkpoint-every").pipe(
82
+ Options.withSchema(PositiveInt),
46
83
  Options.optional,
47
84
  Options.withDescription("Checkpoint every N processed posts (default: 100)")
48
85
  ),
49
86
  checkpointIntervalMs: Options.integer("checkpoint-interval-ms").pipe(
87
+ Options.withSchema(NonNegativeInt),
50
88
  Options.optional,
51
89
  Options.withDescription("Checkpoint interval in milliseconds (default: 5000)")
52
90
  )
@@ -61,6 +99,8 @@ export type ConfigOptions = {
61
99
  readonly compact: boolean;
62
100
  readonly logFormat: Option.Option<LogFormat>;
63
101
  readonly syncConcurrency: Option.Option<number>;
102
+ readonly syncBatchSize: Option.Option<number>;
103
+ readonly syncPageLimit: Option.Option<number>;
64
104
  readonly checkpointEvery: Option.Option<number>;
65
105
  readonly checkpointIntervalMs: Option.Option<number>;
66
106
  };
@@ -86,6 +126,8 @@ export const toSyncSettingsOverrides = (
86
126
  ): Partial<SyncSettingsValue> =>
87
127
  pickDefined({
88
128
  concurrency: Option.getOrUndefined(options.syncConcurrency),
129
+ batchSize: Option.getOrUndefined(options.syncBatchSize),
130
+ pageLimit: Option.getOrUndefined(options.syncPageLimit),
89
131
  checkpointEvery: Option.getOrUndefined(options.checkpointEvery),
90
132
  checkpointIntervalMs: Option.getOrUndefined(options.checkpointIntervalMs)
91
133
  }) as Partial<SyncSettingsValue>;
@@ -0,0 +1,29 @@
1
+ import { renderTableLegacy } from "./table.js";
2
+ import type { FeedGeneratorView, ProfileView } from "../../domain/bsky.js";
3
+
4
+ export const renderProfileTable = (
5
+ actors: ReadonlyArray<ProfileView>,
6
+ cursor: string | undefined
7
+ ) => {
8
+ const rows = actors.map((actor) => [
9
+ actor.handle,
10
+ actor.displayName ?? "",
11
+ actor.did
12
+ ]);
13
+ const table = renderTableLegacy(["HANDLE", "DISPLAY NAME", "DID"], rows);
14
+ return cursor ? `${table}\n\nCursor: ${cursor}` : table;
15
+ };
16
+
17
+ export const renderFeedTable = (
18
+ feeds: ReadonlyArray<FeedGeneratorView>,
19
+ cursor: string | undefined
20
+ ) => {
21
+ const rows = feeds.map((feed) => [
22
+ feed.displayName,
23
+ feed.creator.handle,
24
+ feed.uri,
25
+ typeof feed.likeCount === "number" ? String(feed.likeCount) : ""
26
+ ]);
27
+ const table = renderTableLegacy(["NAME", "CREATOR", "URI", "LIKES"], rows);
28
+ return cursor ? `${table}\n\nCursor: ${cursor}` : table;
29
+ };
@@ -3,6 +3,7 @@ import type { Annotation } from "./annotation.js";
3
3
  import { renderTree } from "./tree.js";
4
4
  import { renderPostCompact, renderPostCardLines } from "./post.js";
5
5
  import type { Post } from "../../domain/post.js";
6
+ import { PostOrder } from "../../domain/order.js";
6
7
 
7
8
  export const renderThread = (
8
9
  posts: ReadonlyArray<Post>,
@@ -23,10 +24,7 @@ export const renderThread = (
23
24
  }
24
25
  }
25
26
 
26
- const sortPosts = (arr: Post[]) =>
27
- arr.sort((a, b) =>
28
- a.createdAt.getTime() - b.createdAt.getTime() || a.uri.localeCompare(b.uri)
29
- );
27
+ const sortPosts = (arr: Post[]) => arr.sort(PostOrder);
30
28
 
31
29
  sortPosts(roots);
32
30
  for (const children of childMap.values()) sortPosts(children);
@@ -8,6 +8,7 @@ import {
8
8
  FilterEvalError,
9
9
  FilterLibraryError,
10
10
  FilterNotFound,
11
+ StoreAlreadyExists,
11
12
  StoreIoError,
12
13
  StoreIndexError,
13
14
  StoreNotFound
@@ -21,6 +22,7 @@ export const exitCodeFor = (error: unknown): number => {
21
22
  if (error instanceof CliInputError) return 2;
22
23
  if (error instanceof ConfigError) return 2;
23
24
  if (error instanceof StoreNotFound) return 3;
25
+ if (error instanceof StoreAlreadyExists) return 2;
24
26
  if (error instanceof FilterNotFound) return 2;
25
27
  if (error instanceof FilterLibraryError) return 2;
26
28
  if (error instanceof StoreIoError || error instanceof StoreIndexError) return 7;