@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,211 @@
1
+ import { Schema } from "effect";
2
+ import * as Monoid from "@effect/typeclass/Monoid";
3
+ import * as Semigroup from "@effect/typeclass/Semigroup";
4
+ import { MonoidSum } from "@effect/typeclass/data/Number";
5
+ import { AuthorFeedFilter } from "./bsky.js";
6
+ import { FilterExprSchema } from "./filter.js";
7
+ import { StoreRef, SyncUpsertPolicy } from "./store.js";
8
+ import { EventSeq, Timestamp } from "./primitives.js";
9
+
10
+ export const SyncStage = Schema.Literal("source", "parse", "filter", "store");
11
+ export type SyncStage = typeof SyncStage.Type;
12
+
13
+ export class SyncError extends Schema.TaggedError<SyncError>()("SyncError", {
14
+ stage: SyncStage,
15
+ message: Schema.String,
16
+ cause: Schema.optional(Schema.Unknown)
17
+ }) {}
18
+
19
+ export class SyncResult extends Schema.Class<SyncResult>("SyncResult")({
20
+ postsAdded: Schema.Number,
21
+ postsDeleted: Schema.Number,
22
+ postsSkipped: Schema.Number,
23
+ errors: Schema.Array(SyncError)
24
+ }) {}
25
+
26
+ const SyncResultErrorsMonoid = Monoid.array<SyncError>();
27
+
28
+ const SyncResultSemigroup: Semigroup.Semigroup<SyncResult> = Semigroup.make(
29
+ (left, right) =>
30
+ SyncResult.make({
31
+ postsAdded: MonoidSum.combine(left.postsAdded, right.postsAdded),
32
+ postsDeleted: MonoidSum.combine(left.postsDeleted, right.postsDeleted),
33
+ postsSkipped: MonoidSum.combine(left.postsSkipped, right.postsSkipped),
34
+ errors: SyncResultErrorsMonoid.combine(left.errors, right.errors)
35
+ })
36
+ );
37
+
38
+ export const SyncResultMonoid: Monoid.Monoid<SyncResult> = Monoid.fromSemigroup(
39
+ SyncResultSemigroup,
40
+ SyncResult.make({
41
+ postsAdded: MonoidSum.empty,
42
+ postsDeleted: MonoidSum.empty,
43
+ postsSkipped: MonoidSum.empty,
44
+ errors: SyncResultErrorsMonoid.empty
45
+ })
46
+ );
47
+
48
+ export class DataSourceTimeline extends Schema.TaggedClass<DataSourceTimeline>()(
49
+ "Timeline",
50
+ {}
51
+ ) {}
52
+
53
+ export class DataSourceFeed extends Schema.TaggedClass<DataSourceFeed>()("Feed", {
54
+ uri: Schema.String
55
+ }) {}
56
+
57
+ export class DataSourceList extends Schema.TaggedClass<DataSourceList>()("List", {
58
+ uri: Schema.String
59
+ }) {}
60
+
61
+ export class DataSourceNotifications extends Schema.TaggedClass<DataSourceNotifications>()(
62
+ "Notifications",
63
+ {}
64
+ ) {}
65
+
66
+ export class DataSourceAuthor extends Schema.TaggedClass<DataSourceAuthor>()("Author", {
67
+ actor: Schema.String,
68
+ filter: Schema.optional(AuthorFeedFilter),
69
+ includePins: Schema.optional(Schema.Boolean)
70
+ }) {}
71
+
72
+ export class DataSourceThread extends Schema.TaggedClass<DataSourceThread>()("Thread", {
73
+ uri: Schema.String,
74
+ depth: Schema.optional(Schema.NonNegativeInt),
75
+ parentHeight: Schema.optional(Schema.NonNegativeInt)
76
+ }) {}
77
+
78
+ export class DataSourceJetstream extends Schema.TaggedClass<DataSourceJetstream>()(
79
+ "Jetstream",
80
+ {
81
+ endpoint: Schema.optional(Schema.String),
82
+ collections: Schema.optional(Schema.Array(Schema.String)),
83
+ dids: Schema.optional(Schema.Array(Schema.String)),
84
+ compress: Schema.optional(Schema.Boolean),
85
+ maxMessageSizeBytes: Schema.optional(Schema.Number)
86
+ }
87
+ ) {}
88
+
89
+ export const DataSourceSchema = Schema.Union(
90
+ DataSourceTimeline,
91
+ DataSourceFeed,
92
+ DataSourceList,
93
+ DataSourceNotifications,
94
+ DataSourceAuthor,
95
+ DataSourceThread,
96
+ DataSourceJetstream
97
+ );
98
+ export type DataSource = typeof DataSourceSchema.Type;
99
+
100
+ export const DataSource = {
101
+ timeline: (): DataSource => DataSourceTimeline.make({}),
102
+ feed: (uri: string): DataSource => DataSourceFeed.make({ uri }),
103
+ list: (uri: string): DataSource => DataSourceList.make({ uri }),
104
+ notifications: (): DataSource => DataSourceNotifications.make({}),
105
+ author: (
106
+ actor: string,
107
+ options?: {
108
+ readonly filter?: AuthorFeedFilter;
109
+ readonly includePins?: boolean;
110
+ }
111
+ ): DataSource =>
112
+ DataSourceAuthor.make({
113
+ actor,
114
+ filter: options?.filter,
115
+ includePins: options?.includePins
116
+ }),
117
+ thread: (
118
+ uri: string,
119
+ options?: {
120
+ readonly depth?: number;
121
+ readonly parentHeight?: number;
122
+ }
123
+ ): DataSource =>
124
+ DataSourceThread.make({
125
+ uri,
126
+ depth: options?.depth,
127
+ parentHeight: options?.parentHeight
128
+ }),
129
+ jetstream: (options?: {
130
+ readonly endpoint?: string;
131
+ readonly collections?: ReadonlyArray<string>;
132
+ readonly dids?: ReadonlyArray<string>;
133
+ readonly compress?: boolean;
134
+ readonly maxMessageSizeBytes?: number;
135
+ }): DataSource =>
136
+ DataSourceJetstream.make({
137
+ endpoint: options?.endpoint,
138
+ collections: options?.collections ? [...options.collections] : undefined,
139
+ dids: options?.dids ? [...options.dids] : undefined,
140
+ compress: options?.compress,
141
+ maxMessageSizeBytes: options?.maxMessageSizeBytes
142
+ })
143
+ };
144
+
145
+ export class WatchConfig extends Schema.Class<WatchConfig>("WatchConfig")({
146
+ source: DataSourceSchema,
147
+ store: StoreRef,
148
+ filter: FilterExprSchema,
149
+ interval: Schema.optional(Schema.Duration),
150
+ policy: Schema.optional(SyncUpsertPolicy)
151
+ }) {}
152
+
153
+ export class SyncEvent extends Schema.TaggedClass<SyncEvent>()("SyncEvent", {
154
+ result: SyncResult
155
+ }) {}
156
+
157
+ export class SyncProgress extends Schema.Class<SyncProgress>("SyncProgress")({
158
+ processed: Schema.NonNegativeInt,
159
+ stored: Schema.NonNegativeInt,
160
+ skipped: Schema.NonNegativeInt,
161
+ errors: Schema.NonNegativeInt,
162
+ elapsedMs: Schema.NonNegativeInt,
163
+ rate: Schema.Number
164
+ }) {}
165
+
166
+ export class SyncCheckpoint extends Schema.Class<SyncCheckpoint>("SyncCheckpoint")({
167
+ source: DataSourceSchema,
168
+ cursor: Schema.optional(Schema.String),
169
+ lastEventSeq: Schema.optional(EventSeq),
170
+ filterHash: Schema.optional(Schema.String),
171
+ updatedAt: Timestamp
172
+ }) {}
173
+
174
+ export const dataSourceKey = (source: DataSource): string => {
175
+ const normalizeList = (items: ReadonlyArray<string> | undefined) =>
176
+ items && items.length > 0 ? [...items].sort().join(",") : "";
177
+
178
+ switch (source._tag) {
179
+ case "Timeline":
180
+ return "timeline";
181
+ case "Feed":
182
+ return `feed:${source.uri}`;
183
+ case "List":
184
+ return `list:${source.uri}`;
185
+ case "Notifications":
186
+ return "notifications";
187
+ case "Author": {
188
+ const filter = source.filter ?? "";
189
+ const includePins =
190
+ source.includePins === undefined ? "" : source.includePins ? "1" : "0";
191
+ return `author:${encodeURIComponent(source.actor)}:${encodeURIComponent(
192
+ filter
193
+ )}:${includePins}`;
194
+ }
195
+ case "Thread": {
196
+ const depth = source.depth ?? "";
197
+ const parentHeight = source.parentHeight ?? "";
198
+ return `thread:${encodeURIComponent(source.uri)}:${depth}:${parentHeight}`;
199
+ }
200
+ case "Jetstream": {
201
+ const endpoint = source.endpoint ?? "";
202
+ const collections = normalizeList(source.collections);
203
+ const dids = normalizeList(source.dids);
204
+ const compress = source.compress ? "1" : "0";
205
+ const maxMessageSize = source.maxMessageSizeBytes ?? "";
206
+ return `jetstream:${encodeURIComponent(endpoint)}:${encodeURIComponent(
207
+ collections
208
+ )}:${encodeURIComponent(dids)}:${compress}:${maxMessageSize}`;
209
+ }
210
+ }
211
+ };
@@ -0,0 +1,56 @@
1
+ const emojiRegex = (() => {
2
+ try {
3
+ return new RegExp("\\p{Extended_Pictographic}", "u");
4
+ } catch {
5
+ return undefined;
6
+ }
7
+ })();
8
+
9
+ const segmenter =
10
+ typeof Intl !== "undefined" && "Segmenter" in Intl
11
+ ? new Intl.Segmenter(undefined, { granularity: "grapheme" })
12
+ : undefined;
13
+
14
+ const toGraphemes = (text: string) =>
15
+ segmenter
16
+ ? Array.from(segmenter.segment(text), (part) => part.segment)
17
+ : Array.from(text);
18
+
19
+ const isFullwidthCodePoint = (codePoint: number) =>
20
+ codePoint >= 0x1100 &&
21
+ (codePoint <= 0x115f ||
22
+ codePoint === 0x2329 ||
23
+ codePoint === 0x232a ||
24
+ (codePoint >= 0x2e80 && codePoint <= 0xa4cf && codePoint !== 0x303f) ||
25
+ (codePoint >= 0xac00 && codePoint <= 0xd7a3) ||
26
+ (codePoint >= 0xf900 && codePoint <= 0xfaff) ||
27
+ (codePoint >= 0xfe10 && codePoint <= 0xfe19) ||
28
+ (codePoint >= 0xfe30 && codePoint <= 0xfe6f) ||
29
+ (codePoint >= 0xff00 && codePoint <= 0xff60) ||
30
+ (codePoint >= 0xffe0 && codePoint <= 0xffe6) ||
31
+ (codePoint >= 0x1f300 && codePoint <= 0x1f64f) ||
32
+ (codePoint >= 0x1f900 && codePoint <= 0x1f9ff) ||
33
+ (codePoint >= 0x20000 && codePoint <= 0x3fffd));
34
+
35
+ export const displayWidth = (text: string) => {
36
+ let width = 0;
37
+ for (const grapheme of toGraphemes(text)) {
38
+ if (grapheme.length === 0) {
39
+ continue;
40
+ }
41
+ const codePoint = grapheme.codePointAt(0) ?? 0;
42
+ const isWide =
43
+ (emojiRegex ? emojiRegex.test(grapheme) : false) ||
44
+ isFullwidthCodePoint(codePoint);
45
+ width += isWide ? 2 : 1;
46
+ }
47
+ return width;
48
+ };
49
+
50
+ export const padEndDisplay = (text: string, targetWidth: number) => {
51
+ const width = displayWidth(text);
52
+ if (width >= targetWidth) {
53
+ return text;
54
+ }
55
+ return `${text}${" ".repeat(targetWidth - width)}`;
56
+ };
@@ -0,0 +1,278 @@
1
+ import { FileSystem } from "@effect/platform";
2
+ import { Path } from "@effect/platform";
3
+ import { Config, Context, Effect, Layer, Option, Schema } from "effect";
4
+ import { formatSchemaError, pickDefined } from "./shared.js";
5
+ import { AppConfig, OutputFormat } from "../domain/config.js";
6
+ import { ConfigError } from "../domain/errors.js";
7
+
8
+ /**
9
+ * Application Configuration Service
10
+ *
11
+ * This module provides centralized configuration management for the application.
12
+ * It implements a layered configuration resolution strategy with support for
13
+ * multiple configuration sources and home directory expansion.
14
+ *
15
+ * Configuration Resolution Priority (highest to lowest):
16
+ * 1. Runtime overrides (ConfigOverrides service)
17
+ * 2. Environment variables (SKYGENT_*)
18
+ * 3. Config file (~/.skygent/config.json)
19
+ * 4. Default values
20
+ *
21
+ * Key features:
22
+ * - Home directory expansion (~ → $HOME)
23
+ * - Path normalization (relative → absolute)
24
+ * - Schema validation with detailed error messages
25
+ * - Graceful handling of missing config files
26
+ * - Type-safe configuration access via Effect Context
27
+ *
28
+ * Environment Variables:
29
+ * - SKYGENT_SERVICE: Bluesky service URL (default: https://bsky.social)
30
+ * - SKYGENT_STORE_ROOT: Root directory for store data (default: ~/.skygent)
31
+ * - SKYGENT_OUTPUT_FORMAT: Output format (json, ndjson, markdown, table)
32
+ * - SKYGENT_IDENTIFIER: User identifier for authentication
33
+ *
34
+ * @example
35
+ * ```typescript
36
+ * import { Effect } from "effect";
37
+ * import { AppConfigService, ConfigOverrides } from "./services/app-config.js";
38
+ *
39
+ * // Basic usage - read configuration
40
+ * const program = Effect.gen(function* () {
41
+ * const config = yield* AppConfigService;
42
+ * console.log(`Store root: ${config.storeRoot}`);
43
+ * console.log(`Service: ${config.service}`);
44
+ * });
45
+ *
46
+ * // With runtime overrides
47
+ * const withOverrides = program.pipe(
48
+ * Effect.provide(
49
+ * ConfigOverrides.layer({
50
+ * storeRoot: "/custom/path",
51
+ * outputFormat: "json"
52
+ * })
53
+ * )
54
+ * );
55
+ * ```
56
+ *
57
+ * @module services/app-config
58
+ */
59
+
60
+ type AppConfigOverrides = Partial<AppConfig>;
61
+
62
+ /**
63
+ * Service for providing runtime configuration overrides.
64
+ *
65
+ * Allows injection of configuration values at runtime that take precedence
66
+ * over environment variables and config file settings. This is useful for
67
+ * CLI arguments, test configuration, or dynamic configuration scenarios.
68
+ *
69
+ * @example
70
+ * ```typescript
71
+ * // Provide overrides via layer
72
+ * const overridesLayer = ConfigOverrides.layer({
73
+ * storeRoot: "/tmp/test-store",
74
+ * outputFormat: "json"
75
+ * });
76
+ *
77
+ * // Use in program
78
+ * const program = Effect.gen(function* () {
79
+ * const config = yield* AppConfigService;
80
+ * // config.storeRoot will be "/tmp/test-store"
81
+ * }).pipe(Effect.provide(overridesLayer));
82
+ * ```
83
+ */
84
+ export class ConfigOverrides extends Context.Tag("@skygent/ConfigOverrides")<
85
+ ConfigOverrides,
86
+ AppConfigOverrides
87
+ >() {
88
+ /**
89
+ * Default empty configuration overrides layer.
90
+ *
91
+ * Use this as a base layer when no overrides are needed, or extend it
92
+ * with custom overrides using Layer.succeed.
93
+ */
94
+ static readonly layer = Layer.succeed(ConfigOverrides, {});
95
+ }
96
+
97
+ const PartialAppConfig = Schema.Struct({
98
+ service: Schema.optional(Schema.String),
99
+ storeRoot: Schema.optional(Schema.String),
100
+ outputFormat: Schema.optional(OutputFormat),
101
+ identifier: Schema.optional(Schema.String)
102
+ });
103
+
104
+ type PartialAppConfig = typeof PartialAppConfig.Type;
105
+
106
+ const defaultService = "https://bsky.social";
107
+ const defaultOutputFormat: OutputFormat = "ndjson";
108
+ const defaultRootDirName = ".skygent";
109
+ const configFileName = "config.json";
110
+
111
+
112
+
113
+ const resolveHomeDir = () =>
114
+ process.env.HOME ?? process.env.USERPROFILE ?? process.env.HOMEPATH;
115
+
116
+ const expandHome = (path: Path.Path, value: string, home?: string) => {
117
+ if (!home) return value;
118
+ if (value === "~") return home;
119
+ if (value.startsWith("~/")) {
120
+ return path.join(home, value.slice(2));
121
+ }
122
+ return value;
123
+ };
124
+
125
+ const resolveDefaultRoot = (path: Path.Path) => {
126
+ const home = resolveHomeDir();
127
+ return home ? path.join(home, defaultRootDirName) : path.resolve(defaultRootDirName);
128
+ };
129
+
130
+ const normalizeStoreRoot = (path: Path.Path, value: string) => {
131
+ const home = resolveHomeDir();
132
+ const expanded = expandHome(path, value, home);
133
+ return path.isAbsolute(expanded) ? expanded : path.resolve(expanded);
134
+ };
135
+
136
+ const decodeConfigJson = (raw: string, configPath: string) =>
137
+ Schema.decodeUnknown(Schema.parseJson(PartialAppConfig))(raw).pipe(
138
+ Effect.mapError((error) =>
139
+ ConfigError.make({
140
+ message: `Invalid config JSON at ${configPath}: ${formatSchemaError(error)}`,
141
+ path: configPath,
142
+ cause: error
143
+ })
144
+ )
145
+ );
146
+
147
+ const loadFileConfig = (configPath: string) =>
148
+ Effect.gen(function* () {
149
+ const fs = yield* FileSystem.FileSystem;
150
+ const content = yield* fs.readFileString(configPath).pipe(
151
+ Effect.map(Option.some),
152
+ Effect.catchTag("SystemError", (error) =>
153
+ error.reason === "NotFound"
154
+ ? Effect.succeed(Option.none())
155
+ : Effect.fail(
156
+ ConfigError.make({
157
+ message: `Failed to read config at ${configPath}`,
158
+ path: configPath,
159
+ cause: error
160
+ })
161
+ )
162
+ )
163
+ );
164
+
165
+ return yield* Option.match(content, {
166
+ onNone: () => Effect.succeed({} as PartialAppConfig),
167
+ onSome: (raw) => decodeConfigJson(raw, configPath)
168
+ });
169
+ });
170
+
171
+ const envOutputFormat = Config.literal("json", "ndjson", "markdown", "table")(
172
+ "SKYGENT_OUTPUT_FORMAT"
173
+ );
174
+
175
+ /**
176
+ * Service for accessing the resolved application configuration.
177
+ *
178
+ * Provides type-safe access to the fully resolved application configuration
179
+ * with values from all sources (overrides, environment, config file, defaults)
180
+ * merged according to the priority hierarchy.
181
+ *
182
+ * @example
183
+ * ```typescript
184
+ * // Access configuration in an Effect
185
+ * const program = Effect.gen(function* () {
186
+ * const config = yield* AppConfigService;
187
+ *
188
+ * // All configuration values are resolved and validated
189
+ * const { service, storeRoot, outputFormat, identifier } = config;
190
+ *
191
+ * // Use configuration values
192
+ * console.log(`Using service: ${service}`);
193
+ * console.log(`Storing data in: ${storeRoot}`);
194
+ * });
195
+ *
196
+ * // Provide the configuration layer
197
+ * const runnable = program.pipe(Effect.provide(AppConfigService.layer));
198
+ * ```
199
+ */
200
+ export class AppConfigService extends Context.Tag("@skygent/AppConfig")<
201
+ AppConfigService,
202
+ AppConfig
203
+ >() {
204
+ /**
205
+ * Layer that constructs the AppConfigService by resolving configuration
206
+ * from all sources in priority order.
207
+ *
208
+ * Resolution order (highest to lowest priority):
209
+ * 1. ConfigOverrides service values
210
+ * 2. Environment variables (SKYGENT_*)
211
+ * 3. ~/.skygent/config.json file
212
+ * 4. Default values
213
+ *
214
+ * @returns Layer providing the resolved AppConfigService
215
+ * @throws ConfigError if configuration validation fails
216
+ *
217
+ * @example
218
+ * ```typescript
219
+ * // Basic usage with defaults
220
+ * const program = Effect.provide(myProgram, AppConfigService.layer);
221
+ *
222
+ * // With custom overrides
223
+ * const customLayer = Layer.merge(
224
+ * AppConfigService.layer,
225
+ * ConfigOverrides.layer({ outputFormat: "json" })
226
+ * );
227
+ * ```
228
+ */
229
+ static readonly layer = Layer.effect(
230
+ AppConfigService,
231
+ Effect.gen(function* () {
232
+ const overrides = yield* ConfigOverrides;
233
+ const path = yield* Path.Path;
234
+ const defaultRoot = resolveDefaultRoot(path);
235
+ const configPath = path.join(defaultRoot, configFileName);
236
+
237
+ const fileConfig = yield* loadFileConfig(configPath);
238
+
239
+ const envService = yield* Config.string("SKYGENT_SERVICE").pipe(Config.option);
240
+ const envStoreRoot = yield* Config.string("SKYGENT_STORE_ROOT").pipe(Config.option);
241
+ const envFormat = yield* envOutputFormat.pipe(Config.option);
242
+ const envIdentifier = yield* Config.string("SKYGENT_IDENTIFIER").pipe(Config.option);
243
+
244
+ const envConfig = pickDefined({
245
+ service: Option.getOrUndefined(envService),
246
+ storeRoot: Option.getOrUndefined(envStoreRoot),
247
+ outputFormat: Option.getOrUndefined(envFormat),
248
+ identifier: Option.getOrUndefined(envIdentifier)
249
+ });
250
+
251
+ const merged = {
252
+ service: defaultService,
253
+ storeRoot: defaultRoot,
254
+ outputFormat: defaultOutputFormat,
255
+ ...fileConfig,
256
+ ...envConfig,
257
+ ...pickDefined(overrides as Record<string, unknown>)
258
+ };
259
+
260
+ const resolvedStoreRoot = merged.storeRoot ?? defaultRoot;
261
+ const normalized = {
262
+ ...merged,
263
+ storeRoot: normalizeStoreRoot(path, resolvedStoreRoot)
264
+ };
265
+
266
+ const decoded = yield* Schema.decodeUnknown(AppConfig)(normalized).pipe(
267
+ Effect.mapError((error) =>
268
+ ConfigError.make({
269
+ message: `Invalid config: ${formatSchemaError(error)}`,
270
+ path: configPath,
271
+ cause: error
272
+ })
273
+ )
274
+ );
275
+ return AppConfigService.of(decoded);
276
+ })
277
+ );
278
+ }