@mepuka/skygent 0.2.0

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 (114) hide show
  1. package/README.md +59 -0
  2. package/index.ts +146 -0
  3. package/package.json +56 -0
  4. package/src/cli/app.ts +75 -0
  5. package/src/cli/config-command.ts +140 -0
  6. package/src/cli/config.ts +91 -0
  7. package/src/cli/derive.ts +205 -0
  8. package/src/cli/doc/annotation.ts +36 -0
  9. package/src/cli/doc/filter.ts +69 -0
  10. package/src/cli/doc/index.ts +9 -0
  11. package/src/cli/doc/post.ts +155 -0
  12. package/src/cli/doc/primitives.ts +25 -0
  13. package/src/cli/doc/render.ts +18 -0
  14. package/src/cli/doc/table.ts +114 -0
  15. package/src/cli/doc/thread.ts +46 -0
  16. package/src/cli/doc/tree.ts +126 -0
  17. package/src/cli/errors.ts +59 -0
  18. package/src/cli/exit-codes.ts +52 -0
  19. package/src/cli/feed.ts +177 -0
  20. package/src/cli/filter-dsl.ts +1411 -0
  21. package/src/cli/filter-errors.ts +208 -0
  22. package/src/cli/filter-help.ts +70 -0
  23. package/src/cli/filter-input.ts +54 -0
  24. package/src/cli/filter.ts +435 -0
  25. package/src/cli/graph.ts +472 -0
  26. package/src/cli/help.ts +14 -0
  27. package/src/cli/interval.ts +35 -0
  28. package/src/cli/jetstream.ts +173 -0
  29. package/src/cli/layers.ts +180 -0
  30. package/src/cli/logging.ts +136 -0
  31. package/src/cli/output-format.ts +26 -0
  32. package/src/cli/output.ts +82 -0
  33. package/src/cli/parse.ts +80 -0
  34. package/src/cli/post.ts +193 -0
  35. package/src/cli/preferences.ts +11 -0
  36. package/src/cli/query-fields.ts +247 -0
  37. package/src/cli/query.ts +415 -0
  38. package/src/cli/range.ts +44 -0
  39. package/src/cli/search.ts +465 -0
  40. package/src/cli/shared-options.ts +169 -0
  41. package/src/cli/shared.ts +20 -0
  42. package/src/cli/store-errors.ts +80 -0
  43. package/src/cli/store-tree.ts +392 -0
  44. package/src/cli/store.ts +395 -0
  45. package/src/cli/sync-factory.ts +107 -0
  46. package/src/cli/sync.ts +366 -0
  47. package/src/cli/view-thread.ts +196 -0
  48. package/src/cli/view.ts +47 -0
  49. package/src/cli/watch.ts +344 -0
  50. package/src/db/migrations/store-catalog/001_init.ts +14 -0
  51. package/src/db/migrations/store-index/001_init.ts +34 -0
  52. package/src/db/migrations/store-index/002_event_log.ts +24 -0
  53. package/src/db/migrations/store-index/003_fts_and_derived.ts +52 -0
  54. package/src/db/migrations/store-index/004_query_indexes.ts +9 -0
  55. package/src/db/migrations/store-index/005_post_lang.ts +15 -0
  56. package/src/db/migrations/store-index/006_has_embed.ts +10 -0
  57. package/src/db/migrations/store-index/007_event_seq_and_checkpoints.ts +68 -0
  58. package/src/domain/bsky.ts +467 -0
  59. package/src/domain/config.ts +11 -0
  60. package/src/domain/credentials.ts +6 -0
  61. package/src/domain/defaults.ts +8 -0
  62. package/src/domain/derivation.ts +55 -0
  63. package/src/domain/errors.ts +71 -0
  64. package/src/domain/events.ts +55 -0
  65. package/src/domain/extract.ts +64 -0
  66. package/src/domain/filter-describe.ts +551 -0
  67. package/src/domain/filter-explain.ts +9 -0
  68. package/src/domain/filter.ts +797 -0
  69. package/src/domain/format.ts +91 -0
  70. package/src/domain/index.ts +13 -0
  71. package/src/domain/indexes.ts +17 -0
  72. package/src/domain/policies.ts +16 -0
  73. package/src/domain/post.ts +88 -0
  74. package/src/domain/primitives.ts +50 -0
  75. package/src/domain/raw.ts +140 -0
  76. package/src/domain/store.ts +103 -0
  77. package/src/domain/sync.ts +211 -0
  78. package/src/domain/text-width.ts +56 -0
  79. package/src/services/app-config.ts +278 -0
  80. package/src/services/bsky-client.ts +2113 -0
  81. package/src/services/credential-store.ts +408 -0
  82. package/src/services/derivation-engine.ts +502 -0
  83. package/src/services/derivation-settings.ts +61 -0
  84. package/src/services/derivation-validator.ts +68 -0
  85. package/src/services/filter-compiler.ts +269 -0
  86. package/src/services/filter-library.ts +371 -0
  87. package/src/services/filter-runtime.ts +821 -0
  88. package/src/services/filter-settings.ts +30 -0
  89. package/src/services/identity-resolver.ts +563 -0
  90. package/src/services/jetstream-sync.ts +636 -0
  91. package/src/services/lineage-store.ts +89 -0
  92. package/src/services/link-validator.ts +244 -0
  93. package/src/services/output-manager.ts +274 -0
  94. package/src/services/post-parser.ts +62 -0
  95. package/src/services/profile-resolver.ts +223 -0
  96. package/src/services/resource-monitor.ts +106 -0
  97. package/src/services/shared.ts +69 -0
  98. package/src/services/store-cleaner.ts +43 -0
  99. package/src/services/store-commit.ts +168 -0
  100. package/src/services/store-db.ts +248 -0
  101. package/src/services/store-event-log.ts +285 -0
  102. package/src/services/store-index-sql.ts +289 -0
  103. package/src/services/store-index.ts +1152 -0
  104. package/src/services/store-keys.ts +4 -0
  105. package/src/services/store-manager.ts +358 -0
  106. package/src/services/store-stats.ts +522 -0
  107. package/src/services/store-writer.ts +200 -0
  108. package/src/services/sync-checkpoint-store.ts +169 -0
  109. package/src/services/sync-engine.ts +547 -0
  110. package/src/services/sync-reporter.ts +16 -0
  111. package/src/services/sync-settings.ts +72 -0
  112. package/src/services/trending-topics.ts +226 -0
  113. package/src/services/view-checkpoint-store.ts +238 -0
  114. package/src/typeclass/chunk.ts +84 -0
@@ -0,0 +1,180 @@
1
+ import * as KeyValueStore from "@effect/platform/KeyValueStore";
2
+ import { Path } from "@effect/platform";
3
+ import * as FetchHttpClient from "@effect/platform/FetchHttpClient";
4
+ import { Effect, Layer } from "effect";
5
+ import { BskyClient } from "../services/bsky-client.js";
6
+ import { FilterRuntime } from "../services/filter-runtime.js";
7
+ import { PostParser } from "../services/post-parser.js";
8
+ import { AppConfigService } from "../services/app-config.js";
9
+ import { CredentialStore } from "../services/credential-store.js";
10
+ import { StoreEventLog } from "../services/store-event-log.js";
11
+ import { StoreIndex } from "../services/store-index.js";
12
+ import { StoreDb } from "../services/store-db.js";
13
+ import { StoreManager } from "../services/store-manager.js";
14
+ import { StoreWriter } from "../services/store-writer.js";
15
+ import { StoreCommitter } from "../services/store-commit.js";
16
+ import { SyncEngine } from "../services/sync-engine.js";
17
+ import { SyncCheckpointStore } from "../services/sync-checkpoint-store.js";
18
+ import { SyncReporter } from "../services/sync-reporter.js";
19
+ import { SyncSettings } from "../services/sync-settings.js";
20
+ import { StoreCleaner } from "../services/store-cleaner.js";
21
+ import { LinkValidator } from "../services/link-validator.js";
22
+ import { TrendingTopics } from "../services/trending-topics.js";
23
+ import { ResourceMonitor } from "../services/resource-monitor.js";
24
+ import { CliOutput } from "./output.js";
25
+ import { DerivationEngine } from "../services/derivation-engine.js";
26
+ import { DerivationValidator } from "../services/derivation-validator.js";
27
+ import { DerivationSettings } from "../services/derivation-settings.js";
28
+ import { ViewCheckpointStore } from "../services/view-checkpoint-store.js";
29
+ import { LineageStore } from "../services/lineage-store.js";
30
+ import { FilterCompiler } from "../services/filter-compiler.js";
31
+ import { FilterSettings } from "../services/filter-settings.js";
32
+ import { OutputManager } from "../services/output-manager.js";
33
+ import { FilterLibrary } from "../services/filter-library.js";
34
+ import { StoreStats } from "../services/store-stats.js";
35
+ import { ProfileResolver } from "../services/profile-resolver.js";
36
+ import { IdentityResolver } from "../services/identity-resolver.js";
37
+
38
+ const appConfigLayer = AppConfigService.layer;
39
+ const credentialLayer = CredentialStore.layer.pipe(Layer.provideMerge(appConfigLayer));
40
+ const bskyLayer = BskyClient.layer.pipe(
41
+ Layer.provideMerge(appConfigLayer),
42
+ Layer.provideMerge(credentialLayer)
43
+ );
44
+
45
+ const storageLayer = Layer.unwrapEffect(
46
+ Effect.gen(function* () {
47
+ const config = yield* AppConfigService;
48
+ const path = yield* Path.Path;
49
+ const kvRoot = path.join(config.storeRoot, "kv");
50
+ return KeyValueStore.layerFileSystem(kvRoot);
51
+ })
52
+ ).pipe(Layer.provide(appConfigLayer));
53
+ const storeDbLayer = StoreDb.layer.pipe(Layer.provideMerge(appConfigLayer));
54
+ const writerLayer = StoreWriter.layer.pipe(Layer.provideMerge(storeDbLayer));
55
+ const committerLayer = StoreCommitter.layer.pipe(
56
+ Layer.provideMerge(storeDbLayer),
57
+ Layer.provideMerge(writerLayer)
58
+ );
59
+ const eventLogLayer = StoreEventLog.layer.pipe(Layer.provideMerge(storeDbLayer));
60
+ const indexLayer = StoreIndex.layer.pipe(
61
+ Layer.provideMerge(storeDbLayer),
62
+ Layer.provideMerge(eventLogLayer)
63
+ );
64
+ const managerLayer = StoreManager.layer.pipe(Layer.provideMerge(appConfigLayer));
65
+ const cleanerLayer = StoreCleaner.layer.pipe(
66
+ Layer.provideMerge(managerLayer),
67
+ Layer.provideMerge(indexLayer),
68
+ Layer.provideMerge(eventLogLayer),
69
+ Layer.provideMerge(storeDbLayer)
70
+ );
71
+ const checkpointLayer = SyncCheckpointStore.layer.pipe(
72
+ Layer.provideMerge(storeDbLayer)
73
+ );
74
+ const linkValidatorLayer = LinkValidator.layer.pipe(
75
+ Layer.provideMerge(storageLayer),
76
+ Layer.provideMerge(FetchHttpClient.layer)
77
+ );
78
+ const trendingTopicsLayer = TrendingTopics.layer.pipe(
79
+ Layer.provideMerge(storageLayer),
80
+ Layer.provideMerge(bskyLayer)
81
+ );
82
+ const resourceMonitorLayer = ResourceMonitor.layer.pipe(
83
+ Layer.provideMerge(appConfigLayer)
84
+ );
85
+ const filterSettingsLayer = FilterSettings.layer;
86
+ const runtimeLayer = FilterRuntime.layer.pipe(
87
+ Layer.provideMerge(filterSettingsLayer),
88
+ Layer.provideMerge(linkValidatorLayer),
89
+ Layer.provideMerge(trendingTopicsLayer)
90
+ );
91
+ const syncSettingsLayer = SyncSettings.layer;
92
+ const syncLayer = SyncEngine.layer.pipe(
93
+ Layer.provideMerge(committerLayer),
94
+ Layer.provideMerge(indexLayer),
95
+ Layer.provideMerge(checkpointLayer),
96
+ Layer.provideMerge(runtimeLayer),
97
+ Layer.provideMerge(PostParser.layer),
98
+ Layer.provideMerge(bskyLayer),
99
+ Layer.provideMerge(SyncReporter.layer),
100
+ Layer.provideMerge(syncSettingsLayer)
101
+ );
102
+ const identityResolverLayer = IdentityResolver.layer.pipe(
103
+ Layer.provideMerge(storageLayer),
104
+ Layer.provideMerge(bskyLayer)
105
+ );
106
+ const profileResolverLayer = ProfileResolver.layer.pipe(
107
+ Layer.provideMerge(bskyLayer),
108
+ Layer.provideMerge(identityResolverLayer)
109
+ );
110
+ const viewCheckpointLayer = ViewCheckpointStore.layer.pipe(
111
+ Layer.provideMerge(storeDbLayer),
112
+ Layer.provideMerge(managerLayer)
113
+ );
114
+ const lineageLayer = LineageStore.layer.pipe(
115
+ Layer.provideMerge(storageLayer)
116
+ );
117
+ const compilerLayer = FilterCompiler.layer;
118
+ const postParserLayer = PostParser.layer;
119
+ const derivationEngineLayer = DerivationEngine.layer.pipe(
120
+ Layer.provideMerge(eventLogLayer),
121
+ Layer.provideMerge(committerLayer),
122
+ Layer.provideMerge(indexLayer),
123
+ Layer.provideMerge(compilerLayer),
124
+ Layer.provideMerge(runtimeLayer),
125
+ Layer.provideMerge(viewCheckpointLayer),
126
+ Layer.provideMerge(lineageLayer),
127
+ Layer.provideMerge(DerivationSettings.layer)
128
+ );
129
+ const derivationValidatorLayer = DerivationValidator.layer.pipe(
130
+ Layer.provideMerge(viewCheckpointLayer),
131
+ Layer.provideMerge(eventLogLayer),
132
+ Layer.provideMerge(managerLayer)
133
+ );
134
+ const outputManagerLayer = OutputManager.layer.pipe(
135
+ Layer.provideMerge(appConfigLayer),
136
+ Layer.provideMerge(managerLayer),
137
+ Layer.provideMerge(indexLayer),
138
+ Layer.provideMerge(runtimeLayer),
139
+ Layer.provideMerge(filterSettingsLayer),
140
+ Layer.provideMerge(compilerLayer)
141
+ );
142
+ const filterLibraryLayer = FilterLibrary.layer.pipe(
143
+ Layer.provideMerge(appConfigLayer)
144
+ );
145
+ const storeStatsLayer = StoreStats.layer.pipe(
146
+ Layer.provideMerge(appConfigLayer),
147
+ Layer.provideMerge(managerLayer),
148
+ Layer.provideMerge(indexLayer),
149
+ Layer.provideMerge(storeDbLayer),
150
+ Layer.provideMerge(lineageLayer),
151
+ Layer.provideMerge(derivationValidatorLayer),
152
+ Layer.provideMerge(eventLogLayer),
153
+ Layer.provideMerge(checkpointLayer)
154
+ );
155
+
156
+ export const CliLive = Layer.mergeAll(
157
+ appConfigLayer,
158
+ filterSettingsLayer,
159
+ credentialLayer,
160
+ CliOutput.layer,
161
+ resourceMonitorLayer,
162
+ managerLayer,
163
+ committerLayer,
164
+ indexLayer,
165
+ eventLogLayer,
166
+ cleanerLayer,
167
+ syncLayer,
168
+ checkpointLayer,
169
+ viewCheckpointLayer,
170
+ derivationEngineLayer,
171
+ derivationValidatorLayer,
172
+ lineageLayer,
173
+ outputManagerLayer,
174
+ storeStatsLayer,
175
+ compilerLayer,
176
+ postParserLayer,
177
+ filterLibraryLayer,
178
+ profileResolverLayer,
179
+ identityResolverLayer
180
+ );
@@ -0,0 +1,136 @@
1
+ import { Terminal } from "@effect/platform";
2
+ import { Effect, Option } from "effect";
3
+ import { SyncProgress } from "../domain/sync.js";
4
+ import { SyncReporter } from "../services/sync-reporter.js";
5
+ import type { ResourceMonitorService, ResourceWarning } from "../services/resource-monitor.js";
6
+ import type { CliOutputService } from "./output.js";
7
+ import { CliOutput } from "./output.js";
8
+ import { CliPreferences } from "./preferences.js";
9
+
10
+ type LogLevel = "INFO" | "WARN" | "ERROR" | "PROGRESS";
11
+ export type LogFormat = "json" | "human";
12
+
13
+ const nowIso = () => new Date().toISOString();
14
+
15
+ const encodeLog = (level: LogLevel, payload: Record<string, unknown>) =>
16
+ JSON.stringify({
17
+ timestamp: nowIso(),
18
+ level,
19
+ ...payload
20
+ });
21
+
22
+ const formatHuman = (level: LogLevel, payload: Record<string, unknown>) => {
23
+ if (level === "PROGRESS" && "progress" in payload) {
24
+ const progress = payload.progress as SyncProgress;
25
+ const rate = Number.isFinite(progress.rate)
26
+ ? progress.rate.toFixed(2)
27
+ : String(progress.rate);
28
+ return `[PROGRESS] processed=${progress.processed} stored=${progress.stored} skipped=${progress.skipped} errors=${progress.errors} rate=${rate}/s elapsedMs=${progress.elapsedMs}`;
29
+ }
30
+
31
+ const message =
32
+ typeof payload.message === "string" && payload.message.length > 0
33
+ ? payload.message
34
+ : "";
35
+ const rest = { ...payload };
36
+ delete rest.message;
37
+ const suffix = Object.keys(rest).length > 0 ? ` ${JSON.stringify(rest)}` : "";
38
+ const base = `[${level}]${message ? ` ${message}` : ""}`;
39
+ return `${base}${suffix}`.trim();
40
+ };
41
+
42
+ const encodeLogFormat = (
43
+ format: LogFormat,
44
+ level: LogLevel,
45
+ payload: Record<string, unknown>
46
+ ) => (format === "human" ? formatHuman(level, payload) : encodeLog(level, payload));
47
+
48
+ const resolveLogFormat = Effect.gen(function* () {
49
+ const preferences = yield* Effect.serviceOption(CliPreferences);
50
+ const override = Option.flatMap(preferences, (value) =>
51
+ Option.fromNullable(value.logFormat)
52
+ );
53
+ if (Option.isSome(override)) {
54
+ return override.value;
55
+ }
56
+ const terminal = yield* Effect.serviceOption(Terminal.Terminal);
57
+ if (Option.isSome(terminal)) {
58
+ const isTTY = yield* terminal.value.isTTY.pipe(Effect.orElseSucceed(() => false));
59
+ return isTTY ? "human" : "json";
60
+ }
61
+ return "json" as const;
62
+ });
63
+
64
+ const logEventWith = (
65
+ output: CliOutputService,
66
+ format: LogFormat,
67
+ level: LogLevel,
68
+ payload: Record<string, unknown>
69
+ ) => output.writeStderr(encodeLogFormat(format, level, payload));
70
+
71
+ const logEvent = (level: LogLevel, payload: Record<string, unknown>) =>
72
+ Effect.gen(function* () {
73
+ const output = yield* CliOutput;
74
+ const format = yield* resolveLogFormat;
75
+ yield* logEventWith(output, format, level, payload);
76
+ });
77
+
78
+ export const logInfo = (message: string, data?: Record<string, unknown>) =>
79
+ logEvent("INFO", { message, ...data });
80
+
81
+ export const logErrorEvent = (message: string, data?: Record<string, unknown>) =>
82
+ logEvent("ERROR", { message, ...data });
83
+
84
+ export const logWarn = (message: string, data?: Record<string, unknown>) =>
85
+ logEvent("WARN", { message, ...data });
86
+
87
+ export const logProgress = (progress: SyncProgress) =>
88
+ logEvent("PROGRESS", { operation: "sync", progress });
89
+
90
+ const warningDetails = (warning: ResourceWarning): Record<string, unknown> => {
91
+ switch (warning._tag) {
92
+ case "StoreSize":
93
+ return {
94
+ kind: warning._tag,
95
+ bytes: warning.bytes,
96
+ threshold: warning.threshold,
97
+ root: warning.root
98
+ };
99
+ case "MemoryRss":
100
+ return {
101
+ kind: warning._tag,
102
+ bytes: warning.bytes,
103
+ threshold: warning.threshold
104
+ };
105
+ }
106
+ };
107
+
108
+ export const makeSyncReporter = (
109
+ quiet: boolean,
110
+ monitor: ResourceMonitorService,
111
+ output: CliOutputService
112
+ ) =>
113
+ SyncReporter.of({
114
+ report: (progress) =>
115
+ Effect.gen(function* () {
116
+ const format = yield* resolveLogFormat;
117
+ if (!quiet) {
118
+ yield* logEventWith(output, format, "PROGRESS", {
119
+ operation: "sync",
120
+ progress
121
+ });
122
+ }
123
+ const warnings = yield* monitor.check();
124
+ if (warnings.length > 0) {
125
+ yield* Effect.forEach(
126
+ warnings,
127
+ (warning) =>
128
+ logEventWith(output, format, "WARN", {
129
+ message: "Resource warning",
130
+ ...warningDetails(warning)
131
+ }),
132
+ { discard: true }
133
+ );
134
+ }
135
+ }).pipe(Effect.orElseSucceed(() => undefined))
136
+ });
@@ -0,0 +1,26 @@
1
+ import { Option } from "effect";
2
+ import type { OutputFormat } from "../domain/config.js";
3
+
4
+ export const jsonNdjsonTableFormats = ["json", "ndjson", "table"] as const;
5
+ export type JsonNdjsonTableFormat = typeof jsonNdjsonTableFormats[number];
6
+
7
+ export const jsonTableFormats = ["json", "table"] as const;
8
+ export type JsonTableFormat = typeof jsonTableFormats[number];
9
+
10
+ export const textJsonFormats = ["text", "json"] as const;
11
+ export type TextJsonFormat = typeof textJsonFormats[number];
12
+
13
+ export const treeTableJsonFormats = ["tree", "table", "json"] as const;
14
+ export type TreeTableJsonFormat = typeof treeTableJsonFormats[number];
15
+
16
+ export const resolveOutputFormat = <T extends string>(
17
+ format: Option.Option<T>,
18
+ configFormat: OutputFormat,
19
+ supported: readonly T[],
20
+ fallback: T
21
+ ) => {
22
+ if (Option.isSome(format)) {
23
+ return format.value;
24
+ }
25
+ return supported.includes(configFormat as T) ? (configFormat as T) : fallback;
26
+ };
@@ -0,0 +1,82 @@
1
+ import { BunSink } from "@effect/platform-bun";
2
+ import { SystemError, type PlatformError } from "@effect/platform/Error";
3
+ import { Context, Effect, Layer, Sink, Stream } from "effect";
4
+
5
+ const jsonLine = (value: unknown, pretty?: boolean) =>
6
+ JSON.stringify(value, null, pretty ? 2 : 0);
7
+
8
+ const ensureNewline = (value: string) => (value.endsWith("\n") ? value : `${value}\n`);
9
+
10
+ const writeToSink = (
11
+ sink: Sink.Sink<void, string | Uint8Array, never, PlatformError>,
12
+ value: string
13
+ ) => Stream.fromIterable([value]).pipe(Stream.run(sink));
14
+
15
+ export interface CliOutputService {
16
+ readonly stdout: Sink.Sink<void, string | Uint8Array, never, PlatformError>;
17
+ readonly stderr: Sink.Sink<void, string | Uint8Array, never, PlatformError>;
18
+ readonly writeJson: (value: unknown, pretty?: boolean) => Effect.Effect<void, PlatformError>;
19
+ readonly writeText: (value: string) => Effect.Effect<void, PlatformError>;
20
+ readonly writeJsonStream: <A, E, R>(
21
+ stream: Stream.Stream<A, E, R>
22
+ ) => Effect.Effect<void, E | PlatformError, R>;
23
+ readonly writeStderr: (value: string) => Effect.Effect<void, PlatformError>;
24
+ }
25
+
26
+ export class CliOutput extends Context.Tag("@skygent/CliOutput")<
27
+ CliOutput,
28
+ CliOutputService
29
+ >() {
30
+ static readonly layer = Layer.succeed(
31
+ CliOutput,
32
+ (() => {
33
+ const stdout = BunSink.fromWritable(
34
+ () => process.stdout,
35
+ (cause) =>
36
+ new SystemError({
37
+ module: "Stream",
38
+ method: "stdout",
39
+ reason: "Unknown",
40
+ cause
41
+ }),
42
+ { endOnDone: false }
43
+ );
44
+ const stderr = BunSink.fromWritable(
45
+ () => process.stderr,
46
+ (cause) =>
47
+ new SystemError({
48
+ module: "Stream",
49
+ method: "stderr",
50
+ reason: "Unknown",
51
+ cause
52
+ }),
53
+ { endOnDone: false }
54
+ );
55
+
56
+ return CliOutput.of({
57
+ stdout,
58
+ stderr,
59
+ writeJson: (value, pretty) =>
60
+ writeToSink(stdout, ensureNewline(jsonLine(value, pretty))),
61
+ writeText: (value) => writeToSink(stdout, ensureNewline(value)),
62
+ writeJsonStream: (stream) =>
63
+ stream.pipe(
64
+ Stream.map((value) => `${jsonLine(value)}\n`),
65
+ Stream.run(stdout)
66
+ ),
67
+ writeStderr: (value) => writeToSink(stderr, ensureNewline(value))
68
+ });
69
+ })()
70
+ );
71
+ }
72
+
73
+ export const writeJson = (value: unknown, pretty?: boolean) =>
74
+ Effect.flatMap(CliOutput, (output) => output.writeJson(value, pretty));
75
+
76
+ export const writeText = (value: string) =>
77
+ Effect.flatMap(CliOutput, (output) => output.writeText(value));
78
+
79
+ export const writeJsonStream = <A, E, R>(
80
+ stream: Stream.Stream<A, E, R>
81
+ ): Effect.Effect<void, E | PlatformError, R | CliOutput> =>
82
+ Effect.flatMap(CliOutput, (output) => output.writeJsonStream(stream));
@@ -0,0 +1,80 @@
1
+ import { Effect, ParseResult, Schema } from "effect";
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
+ };
50
+
51
+ type DecodeJsonOptions = {
52
+ readonly formatter?: (error: ParseResult.ParseError, raw: string) => string;
53
+ readonly label?: string;
54
+ readonly maxIssues?: number;
55
+ };
56
+
57
+ export const decodeJson = <A, I, R>(
58
+ schema: Schema.Schema<A, I, R>,
59
+ input: string,
60
+ options?: DecodeJsonOptions
61
+ ) =>
62
+ Schema.decodeUnknown(Schema.parseJson(schema))(input).pipe(
63
+ Effect.mapError((error) => {
64
+ const formatOptions =
65
+ options?.label !== undefined || options?.maxIssues !== undefined
66
+ ? {
67
+ ...(options?.label !== undefined ? { label: options.label } : {}),
68
+ ...(options?.maxIssues !== undefined
69
+ ? { maxIssues: options.maxIssues }
70
+ : {})
71
+ }
72
+ : undefined;
73
+ const message = ParseResult.isParseError(error)
74
+ ? options?.formatter
75
+ ? options.formatter(error, input)
76
+ : formatParseError(error, formatOptions)
77
+ : String(error);
78
+ return CliJsonError.make({ message, cause: error });
79
+ })
80
+ );
@@ -0,0 +1,193 @@
1
+ import { Command, Options } from "@effect/cli";
2
+ import { Context, Effect, Option, Stream } from "effect";
3
+ import { BskyClient } from "../services/bsky-client.js";
4
+ import { PostParser } from "../services/post-parser.js";
5
+ import type { PostLike, ProfileView } from "../domain/bsky.js";
6
+ import type { RawPost } from "../domain/raw.js";
7
+ import { renderPostsTable } from "../domain/format.js";
8
+ import { AppConfigService } from "../services/app-config.js";
9
+ import { withExamples } from "./help.js";
10
+ import { postUriArg, parseLimit } from "./shared-options.js";
11
+ import { writeJson, writeJsonStream, writeText } from "./output.js";
12
+ import { renderTableLegacy } from "./doc/table.js";
13
+ import { jsonNdjsonTableFormats, resolveOutputFormat } from "./output-format.js";
14
+
15
+ const limitOption = Options.integer("limit").pipe(
16
+ Options.withDescription("Maximum number of results"),
17
+ Options.optional
18
+ );
19
+
20
+ const cursorOption = Options.text("cursor").pipe(
21
+ Options.withDescription("Pagination cursor"),
22
+ Options.optional
23
+ );
24
+
25
+ const formatOption = Options.choice("format", jsonNdjsonTableFormats).pipe(
26
+ Options.withDescription("Output format (default: json)"),
27
+ Options.optional
28
+ );
29
+
30
+ const cidOption = Options.text("cid").pipe(
31
+ Options.withDescription("Filter engagement by specific record CID"),
32
+ Options.optional
33
+ );
34
+
35
+ const renderProfileTable = (
36
+ actors: ReadonlyArray<ProfileView>,
37
+ cursor: string | undefined
38
+ ) => {
39
+ const rows = actors.map((actor) => [
40
+ actor.handle,
41
+ actor.displayName ?? "",
42
+ actor.did
43
+ ]);
44
+ const table = renderTableLegacy(["HANDLE", "DISPLAY NAME", "DID"], rows);
45
+ return cursor ? `${table}\n\nCursor: ${cursor}` : table;
46
+ };
47
+
48
+ const renderLikesTable = (likes: ReadonlyArray<PostLike>, cursor: string | undefined) => {
49
+ const rows = likes.map((like) => [
50
+ like.actor.handle,
51
+ like.actor.displayName ?? "",
52
+ like.actor.did,
53
+ like.createdAt.toISOString(),
54
+ like.indexedAt.toISOString()
55
+ ]);
56
+ const table = renderTableLegacy(
57
+ ["HANDLE", "DISPLAY NAME", "DID", "CREATED AT", "INDEXED AT"],
58
+ rows
59
+ );
60
+ return cursor ? `${table}\n\nCursor: ${cursor}` : table;
61
+ };
62
+
63
+ type PostParserService = Context.Tag.Service<typeof PostParser>;
64
+
65
+ const parseRawPosts = (parser: PostParserService, posts: ReadonlyArray<RawPost>) =>
66
+ Effect.forEach(posts, (raw) => parser.parsePost(raw), { concurrency: "unbounded" });
67
+
68
+ const likesCommand = Command.make(
69
+ "likes",
70
+ { uri: postUriArg, cid: cidOption, limit: limitOption, cursor: cursorOption, format: formatOption },
71
+ ({ uri, cid, limit, cursor, format }) =>
72
+ Effect.gen(function* () {
73
+ const appConfig = yield* AppConfigService;
74
+ const client = yield* BskyClient;
75
+ const parsedLimit = yield* parseLimit(limit);
76
+ const result = yield* client.getLikes(uri, {
77
+ ...(Option.isSome(parsedLimit) ? { limit: parsedLimit.value } : {}),
78
+ ...(Option.isSome(cursor) ? { cursor: cursor.value } : {}),
79
+ ...(Option.isSome(cid) ? { cid: cid.value } : {})
80
+ });
81
+ const outputFormat = resolveOutputFormat(
82
+ format,
83
+ appConfig.outputFormat,
84
+ jsonNdjsonTableFormats,
85
+ "json"
86
+ );
87
+ if (outputFormat === "ndjson") {
88
+ yield* writeJsonStream(Stream.fromIterable(result.likes));
89
+ return;
90
+ }
91
+ if (outputFormat === "table") {
92
+ yield* writeText(renderLikesTable(result.likes, result.cursor));
93
+ return;
94
+ }
95
+ yield* writeJson(result);
96
+ })
97
+ ).pipe(
98
+ Command.withDescription(
99
+ withExamples("List accounts that liked a post", [
100
+ "skygent post likes at://did:plc:example/app.bsky.feed.post/xyz",
101
+ "skygent post likes at://did:plc:example/app.bsky.feed.post/xyz --limit 50"
102
+ ])
103
+ )
104
+ );
105
+
106
+ const repostedByCommand = Command.make(
107
+ "reposted-by",
108
+ { uri: postUriArg, cid: cidOption, limit: limitOption, cursor: cursorOption, format: formatOption },
109
+ ({ uri, cid, limit, cursor, format }) =>
110
+ Effect.gen(function* () {
111
+ const appConfig = yield* AppConfigService;
112
+ const client = yield* BskyClient;
113
+ const parsedLimit = yield* parseLimit(limit);
114
+ const result = yield* client.getRepostedBy(uri, {
115
+ ...(Option.isSome(parsedLimit) ? { limit: parsedLimit.value } : {}),
116
+ ...(Option.isSome(cursor) ? { cursor: cursor.value } : {}),
117
+ ...(Option.isSome(cid) ? { cid: cid.value } : {})
118
+ });
119
+ const outputFormat = resolveOutputFormat(
120
+ format,
121
+ appConfig.outputFormat,
122
+ jsonNdjsonTableFormats,
123
+ "json"
124
+ );
125
+ if (outputFormat === "ndjson") {
126
+ yield* writeJsonStream(Stream.fromIterable(result.repostedBy));
127
+ return;
128
+ }
129
+ if (outputFormat === "table") {
130
+ yield* writeText(renderProfileTable(result.repostedBy, result.cursor));
131
+ return;
132
+ }
133
+ yield* writeJson(result);
134
+ })
135
+ ).pipe(
136
+ Command.withDescription(
137
+ withExamples("List accounts that reposted a post", [
138
+ "skygent post reposted-by at://did:plc:example/app.bsky.feed.post/xyz"
139
+ ])
140
+ )
141
+ );
142
+
143
+ const quotesCommand = Command.make(
144
+ "quotes",
145
+ { uri: postUriArg, cid: cidOption, limit: limitOption, cursor: cursorOption, format: formatOption },
146
+ ({ uri, cid, limit, cursor, format }) =>
147
+ Effect.gen(function* () {
148
+ const appConfig = yield* AppConfigService;
149
+ const client = yield* BskyClient;
150
+ const parser = yield* PostParser;
151
+ const parsedLimit = yield* parseLimit(limit);
152
+ const result = yield* client.getQuotes(uri, {
153
+ ...(Option.isSome(parsedLimit) ? { limit: parsedLimit.value } : {}),
154
+ ...(Option.isSome(cursor) ? { cursor: cursor.value } : {}),
155
+ ...(Option.isSome(cid) ? { cid: cid.value } : {})
156
+ });
157
+ const posts = yield* parseRawPosts(parser, result.posts);
158
+ const outputFormat = resolveOutputFormat(
159
+ format,
160
+ appConfig.outputFormat,
161
+ jsonNdjsonTableFormats,
162
+ "json"
163
+ );
164
+ if (outputFormat === "ndjson") {
165
+ yield* writeJsonStream(Stream.fromIterable(posts));
166
+ return;
167
+ }
168
+ if (outputFormat === "table") {
169
+ yield* writeText(renderPostsTable(posts));
170
+ return;
171
+ }
172
+ yield* writeJson({
173
+ ...result,
174
+ posts
175
+ });
176
+ })
177
+ ).pipe(
178
+ Command.withDescription(
179
+ withExamples("List quote-posts for a post", [
180
+ "skygent post quotes at://did:plc:example/app.bsky.feed.post/xyz"
181
+ ])
182
+ )
183
+ );
184
+
185
+ export const postCommand = Command.make("post", {}).pipe(
186
+ Command.withSubcommands([likesCommand, repostedByCommand, quotesCommand]),
187
+ Command.withDescription(
188
+ withExamples("Inspect post engagement", [
189
+ "skygent post likes at://did:plc:example/app.bsky.feed.post/xyz",
190
+ "skygent post quotes at://did:plc:example/app.bsky.feed.post/xyz"
191
+ ])
192
+ )
193
+ );