btca-server 1.0.962 → 2.0.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 (43) hide show
  1. package/package.json +3 -3
  2. package/src/agent/agent.test.ts +31 -24
  3. package/src/agent/index.ts +8 -2
  4. package/src/agent/loop.ts +303 -346
  5. package/src/agent/service.ts +252 -233
  6. package/src/agent/types.ts +2 -2
  7. package/src/collections/index.ts +2 -1
  8. package/src/collections/service.ts +352 -345
  9. package/src/config/config.test.ts +3 -1
  10. package/src/config/index.ts +615 -727
  11. package/src/config/remote.ts +214 -369
  12. package/src/context/index.ts +6 -12
  13. package/src/context/transaction.ts +23 -30
  14. package/src/effect/errors.ts +45 -0
  15. package/src/effect/layers.ts +26 -0
  16. package/src/effect/runtime.ts +19 -0
  17. package/src/effect/services.ts +154 -0
  18. package/src/index.ts +291 -369
  19. package/src/metrics/index.ts +46 -46
  20. package/src/pricing/models-dev.ts +104 -106
  21. package/src/providers/auth.ts +159 -200
  22. package/src/providers/index.ts +19 -2
  23. package/src/providers/model.ts +115 -135
  24. package/src/providers/openai.ts +3 -3
  25. package/src/resources/impls/git.ts +123 -146
  26. package/src/resources/impls/npm.test.ts +16 -5
  27. package/src/resources/impls/npm.ts +66 -75
  28. package/src/resources/index.ts +6 -1
  29. package/src/resources/schema.ts +7 -6
  30. package/src/resources/service.test.ts +13 -12
  31. package/src/resources/service.ts +153 -112
  32. package/src/stream/index.ts +1 -1
  33. package/src/stream/service.test.ts +5 -5
  34. package/src/stream/service.ts +282 -293
  35. package/src/tools/glob.ts +126 -141
  36. package/src/tools/grep.ts +205 -210
  37. package/src/tools/index.ts +8 -4
  38. package/src/tools/list.ts +118 -140
  39. package/src/tools/read.ts +209 -235
  40. package/src/tools/virtual-sandbox.ts +91 -83
  41. package/src/validation/index.ts +18 -22
  42. package/src/vfs/virtual-fs.test.ts +37 -25
  43. package/src/vfs/virtual-fs.ts +218 -216
@@ -1,11 +1,12 @@
1
1
  import { promises as fs } from 'node:fs';
2
2
  import path from 'node:path';
3
3
 
4
- import { Result } from 'better-result';
4
+ import { parseJsonc } from '@btca/shared';
5
+ import { Effect } from 'effect';
5
6
  import { z } from 'zod';
6
7
 
7
8
  import { CommonHints, type TaggedErrorOptions } from '../errors.ts';
8
- import { Metrics } from '../metrics/index.ts';
9
+ import { metricsError, metricsInfo } from '../metrics/index.ts';
9
10
  import { getSupportedProviders, isProviderSupported } from '../providers/index.ts';
10
11
  import { ResourceDefinitionSchema, type ResourceDefinition } from '../resources/schema.ts';
11
12
 
@@ -114,811 +115,698 @@ type LegacyReposConfig = z.infer<typeof LegacyReposConfigSchema>;
114
115
  type LegacyResourcesConfig = z.infer<typeof LegacyResourcesConfigSchema>;
115
116
  type LegacyRepo = z.infer<typeof LegacyRepoSchema>;
116
117
 
117
- export namespace Config {
118
- export class ConfigError extends Error {
119
- readonly _tag = 'ConfigError';
120
- override readonly cause?: unknown;
121
- readonly hint?: string;
118
+ export class ConfigError extends Error {
119
+ readonly _tag = 'ConfigError';
120
+ override readonly cause?: unknown;
121
+ readonly hint?: string;
122
122
 
123
- constructor(args: TaggedErrorOptions) {
124
- super(args.message);
125
- this.cause = args.cause;
126
- this.hint = args.hint;
127
- }
123
+ constructor(args: TaggedErrorOptions) {
124
+ super(args.message);
125
+ this.cause = args.cause;
126
+ this.hint = args.hint;
128
127
  }
128
+ }
129
129
 
130
- export type Service = {
131
- resourcesDirectory: string;
132
- resources: readonly ResourceDefinition[];
133
- model: string;
134
- provider: string;
135
- providerTimeoutMs?: number;
136
- maxSteps: number;
137
- configPath: string;
138
- getProviderOptions: (providerId: string) => ProviderOptionsConfig | undefined;
139
- getResource: (name: string) => ResourceDefinition | undefined;
140
- updateModel: (
141
- provider: string,
142
- model: string,
143
- providerOptions?: ProviderOptionsConfig
144
- ) => Promise<{ provider: string; model: string; savedTo: ConfigScope }>;
145
- addResource: (resource: ResourceDefinition) => Promise<ResourceDefinition>;
146
- removeResource: (name: string) => Promise<void>;
147
- clearResources: () => Promise<{ cleared: number }>;
148
- reload: () => Promise<void>;
149
- };
150
-
151
- const expandHome = (path: string): string => {
152
- const home = process.env.HOME ?? process.env.USERPROFILE ?? '';
153
- if (path.startsWith('~/')) return home + path.slice(1);
154
- return path;
155
- };
156
-
157
- const resolveDataDirectory = (rawPath: string, baseDir: string): string => {
158
- const expanded = expandHome(rawPath);
159
- if (path.isAbsolute(expanded)) return expanded;
160
- return path.resolve(baseDir, expanded);
161
- };
162
-
163
- const stripJsonc = (content: string): string => {
164
- // Remove // and /* */ comments without touching strings.
165
- let out = '';
166
- let i = 0;
167
- let inString = false;
168
- let quote: '"' | "'" | null = null;
169
- let escaped = false;
170
-
171
- while (i < content.length) {
172
- const ch = content[i] ?? '';
173
- const next = content[i + 1] ?? '';
174
-
175
- if (inString) {
176
- out += ch;
177
- if (escaped) escaped = false;
178
- else if (ch === '\\') escaped = true;
179
- else if (quote && ch === quote) {
180
- inString = false;
181
- quote = null;
182
- }
183
- i += 1;
184
- continue;
185
- }
186
-
187
- if (ch === '/' && next === '/') {
188
- i += 2;
189
- while (i < content.length && content[i] !== '\n') i += 1;
190
- continue;
191
- }
192
-
193
- if (ch === '/' && next === '*') {
194
- i += 2;
195
- while (i < content.length) {
196
- if (content[i] === '*' && content[i + 1] === '/') {
197
- i += 2;
198
- break;
199
- }
200
- i += 1;
201
- }
202
- continue;
203
- }
204
-
205
- if (ch === '"' || ch === "'") {
206
- inString = true;
207
- quote = ch;
208
- out += ch;
209
- i += 1;
210
- continue;
211
- }
212
-
213
- out += ch;
214
- i += 1;
215
- }
216
-
217
- // Remove trailing commas (outside strings).
218
- let normalized = '';
219
- inString = false;
220
- quote = null;
221
- escaped = false;
222
- i = 0;
223
-
224
- while (i < out.length) {
225
- const ch = out[i] ?? '';
226
-
227
- if (inString) {
228
- normalized += ch;
229
- if (escaped) escaped = false;
230
- else if (ch === '\\') escaped = true;
231
- else if (quote && ch === quote) {
232
- inString = false;
233
- quote = null;
234
- }
235
- i += 1;
236
- continue;
237
- }
238
-
239
- if (ch === '"' || ch === "'") {
240
- inString = true;
241
- quote = ch;
242
- normalized += ch;
243
- i += 1;
244
- continue;
245
- }
246
-
247
- if (ch === ',') {
248
- let j = i + 1;
249
- while (j < out.length && /\s/.test(out[j] ?? '')) j += 1;
250
- const nextNonWs = out[j] ?? '';
251
- if (nextNonWs === ']' || nextNonWs === '}') {
252
- i += 1;
253
- continue;
254
- }
255
- }
256
-
257
- normalized += ch;
258
- i += 1;
259
- }
260
-
261
- return normalized.trim();
262
- };
263
-
264
- const parseJsonc = (content: string): unknown => JSON.parse(stripJsonc(content));
265
-
266
- const readConfigText = (configPath: string) =>
267
- Result.tryPromise({
268
- try: () => Bun.file(configPath).text(),
269
- catch: (cause) =>
270
- new ConfigError({
271
- message: `Failed to read config file: "${configPath}"`,
272
- hint: 'Check that the file exists and you have read permissions.',
273
- cause
274
- })
130
+ export type ConfigService = {
131
+ resourcesDirectory: string;
132
+ resources: readonly ResourceDefinition[];
133
+ model: string;
134
+ provider: string;
135
+ providerTimeoutMs?: number;
136
+ maxSteps: number;
137
+ configPath: string;
138
+ getProviderOptions: (providerId: string) => ProviderOptionsConfig | undefined;
139
+ getResource: (name: string) => ResourceDefinition | undefined;
140
+ updateModel: (
141
+ provider: string,
142
+ model: string,
143
+ providerOptions?: ProviderOptionsConfig
144
+ ) => Promise<{ provider: string; model: string; savedTo: ConfigScope }>;
145
+ updateModelEffect: (
146
+ provider: string,
147
+ model: string,
148
+ providerOptions?: ProviderOptionsConfig
149
+ ) => Effect.Effect<{ provider: string; model: string; savedTo: ConfigScope }, unknown>;
150
+ addResource: (resource: ResourceDefinition) => Promise<ResourceDefinition>;
151
+ addResourceEffect: (resource: ResourceDefinition) => Effect.Effect<ResourceDefinition, unknown>;
152
+ removeResource: (name: string) => Promise<void>;
153
+ removeResourceEffect: (name: string) => Effect.Effect<void, unknown>;
154
+ clearResources: () => Promise<{ cleared: number }>;
155
+ clearResourcesEffect: () => Effect.Effect<{ cleared: number }, unknown>;
156
+ reload: () => Promise<void>;
157
+ reloadEffect: () => Effect.Effect<void, unknown>;
158
+ };
159
+
160
+ export type Service = ConfigService;
161
+
162
+ const expandHome = (path: string): string => {
163
+ const home = process.env.HOME ?? process.env.USERPROFILE ?? '';
164
+ if (path.startsWith('~/')) return home + path.slice(1);
165
+ return path;
166
+ };
167
+
168
+ const resolveDataDirectory = (rawPath: string, baseDir: string): string => {
169
+ const expanded = expandHome(rawPath);
170
+ if (path.isAbsolute(expanded)) return expanded;
171
+ return path.resolve(baseDir, expanded);
172
+ };
173
+
174
+ const readConfigText = async (configPath: string) => {
175
+ try {
176
+ return await Bun.file(configPath).text();
177
+ } catch (cause) {
178
+ throw new ConfigError({
179
+ message: `Failed to read config file: "${configPath}"`,
180
+ hint: 'Check that the file exists and you have read permissions.',
181
+ cause
275
182
  });
276
-
277
- const parseConfigText = (configPath: string, content: string) =>
278
- Result.try({
279
- try: () => parseJsonc(content),
280
- catch: (cause) =>
281
- new ConfigError({
282
- message: 'Failed to parse config file - invalid JSON syntax',
283
- hint: `Check "${configPath}" for syntax errors like missing commas, brackets, or quotes.`,
284
- cause
285
- })
183
+ }
184
+ };
185
+
186
+ const parseConfigText = (configPath: string, content: string) => {
187
+ try {
188
+ return parseJsonc(content);
189
+ } catch (cause) {
190
+ throw new ConfigError({
191
+ message: 'Failed to parse config file - invalid JSON syntax',
192
+ hint: `Check "${configPath}" for syntax errors like missing commas, brackets, or quotes.`,
193
+ cause
286
194
  });
195
+ }
196
+ };
197
+
198
+ const validateStoredConfig = (parsed: unknown): StoredConfig => {
199
+ const result = StoredConfigSchema.safeParse(parsed);
200
+ if (result.success) return result.data;
201
+ const issues = result.error.issues.map((i) => ` - ${i.path.join('.')}: ${i.message}`).join('\n');
202
+ throw new ConfigError({
203
+ message: `Invalid config structure:\n${issues}`,
204
+ hint: `${CommonHints.CHECK_CONFIG} Required fields: "resources" (array), "model" (string), "provider" (string).`,
205
+ cause: result.error
206
+ });
207
+ };
208
+
209
+ const writeConfigFile = async (
210
+ configPath: string,
211
+ stored: StoredConfig,
212
+ message: string,
213
+ hint: string
214
+ ) => {
215
+ try {
216
+ await Bun.write(configPath, JSON.stringify(stored, null, 2));
217
+ } catch (cause) {
218
+ throw new ConfigError({
219
+ message,
220
+ hint,
221
+ cause
222
+ });
223
+ }
224
+ };
225
+
226
+ /**
227
+ * Convert a legacy repo to a git resource
228
+ */
229
+ const legacyRepoToResource = (repo: LegacyRepo): ResourceDefinition => ({
230
+ type: 'git',
231
+ name: repo.name,
232
+ url: repo.url,
233
+ branch: repo.branch,
234
+ ...(repo.specialNotes && { specialNotes: repo.specialNotes }),
235
+ ...(repo.searchPath && { searchPath: repo.searchPath })
236
+ });
287
237
 
288
- const validateStoredConfig = (parsed: unknown) => {
289
- const result = StoredConfigSchema.safeParse(parsed);
290
- if (result.success) return Result.ok(result.data);
291
- const issues = result.error.issues
292
- .map((i) => ` - ${i.path.join('.')}: ${i.message}`)
293
- .join('\n');
294
- return Result.err(
295
- new ConfigError({
296
- message: `Invalid config structure:\n${issues}`,
297
- hint: `${CommonHints.CHECK_CONFIG} Required fields: "resources" (array), "model" (string), "provider" (string).`,
298
- cause: result.error
299
- })
300
- );
301
- };
238
+ /**
239
+ * Check for and migrate legacy config (btca.json) to new format
240
+ * Supports two legacy formats:
241
+ * 1. Very old: has "repos" array with git repos only
242
+ * 2. Intermediate: has "resources" array (already migrated repos->resources)
243
+ *
244
+ * Returns migrated config if legacy exists, null otherwise
245
+ */
246
+ const migrateLegacyConfig = async (
247
+ legacyPath: string,
248
+ newConfigPath: string
249
+ ): Promise<StoredConfig | null> => {
250
+ const legacyExists = await Bun.file(legacyPath).exists();
251
+ if (!legacyExists) return null;
252
+
253
+ metricsInfo('config.legacy.found', { path: legacyPath });
254
+
255
+ let content: string;
256
+ try {
257
+ content = await Bun.file(legacyPath).text();
258
+ } catch (cause) {
259
+ metricsError('config.legacy.read_failed', { path: legacyPath, error: String(cause) });
260
+ return null;
261
+ }
262
+ let parsed: unknown;
263
+ try {
264
+ parsed = JSON.parse(content);
265
+ } catch (cause) {
266
+ metricsError('config.legacy.parse_failed', { path: legacyPath, error: String(cause) });
267
+ return null;
268
+ }
302
269
 
303
- const writeConfigFile = (
304
- configPath: string,
305
- stored: StoredConfig,
306
- message: string,
307
- hint: string
308
- ) =>
309
- Result.tryPromise({
310
- try: () => Bun.write(configPath, JSON.stringify(stored, null, 2)),
311
- catch: (cause) =>
312
- new ConfigError({
313
- message,
314
- hint,
315
- cause
316
- })
270
+ if (!parsed) return null;
271
+
272
+ // Try the intermediate format first (has "resources" array)
273
+ const resourcesResult = LegacyResourcesConfigSchema.safeParse(parsed);
274
+ if (resourcesResult.success) {
275
+ const legacy = resourcesResult.data;
276
+ metricsInfo('config.legacy.parsed', {
277
+ format: 'resources',
278
+ resourceCount: legacy.resources.length,
279
+ model: legacy.model,
280
+ provider: legacy.provider
317
281
  });
318
282
 
319
- /**
320
- * Convert a legacy repo to a git resource
321
- */
322
- const legacyRepoToResource = (repo: LegacyRepo): ResourceDefinition => ({
323
- type: 'git',
324
- name: repo.name,
325
- url: repo.url,
326
- branch: repo.branch,
327
- ...(repo.specialNotes && { specialNotes: repo.specialNotes }),
328
- ...(repo.searchPath && { searchPath: repo.searchPath })
329
- });
283
+ // Resources are already in the right format, just copy them over
284
+ const migrated: StoredConfig = {
285
+ $schema: CONFIG_SCHEMA_URL,
286
+ resources: legacy.resources,
287
+ model: legacy.model,
288
+ provider: legacy.provider
289
+ };
330
290
 
331
- /**
332
- * Check for and migrate legacy config (btca.json) to new format
333
- * Supports two legacy formats:
334
- * 1. Very old: has "repos" array with git repos only
335
- * 2. Intermediate: has "resources" array (already migrated repos->resources)
336
- *
337
- * Returns migrated config if legacy exists, null otherwise
338
- */
339
- const migrateLegacyConfig = async (
340
- legacyPath: string,
341
- newConfigPath: string
342
- ): Promise<StoredConfig | null> => {
343
- const legacyExists = await Bun.file(legacyPath).exists();
344
- if (!legacyExists) return null;
345
-
346
- Metrics.info('config.legacy.found', { path: legacyPath });
347
-
348
- const legacyResult = await Result.gen(async function* () {
349
- const content = yield* Result.await(
350
- Result.tryPromise({
351
- try: () => Bun.file(legacyPath).text(),
352
- catch: (cause) => ({
353
- stage: 'read' as const,
354
- cause
355
- })
356
- })
357
- );
358
- const parsed = yield* Result.try(() => JSON.parse(content)).mapError((cause) => ({
359
- stage: 'parse' as const,
360
- cause
361
- }));
362
- return Result.ok(parsed);
363
- });
291
+ return finalizeMigration(migrated, legacyPath, newConfigPath, legacy.resources.length);
292
+ }
364
293
 
365
- const parsed = legacyResult.match({
366
- ok: (value) => value,
367
- err: (error) => {
368
- const event =
369
- error.stage === 'read' ? 'config.legacy.read_failed' : 'config.legacy.parse_failed';
370
- Metrics.error(event, { path: legacyPath, error: String(error.cause) });
371
- return null;
372
- }
294
+ // Try the very old format (has "repos" array)
295
+ const reposResult = LegacyReposConfigSchema.safeParse(parsed);
296
+ if (reposResult.success) {
297
+ const legacy = reposResult.data;
298
+ metricsInfo('config.legacy.parsed', {
299
+ format: 'repos',
300
+ repoCount: legacy.repos.length,
301
+ model: legacy.model,
302
+ provider: legacy.provider
373
303
  });
374
304
 
375
- if (!parsed) return null;
376
-
377
- // Try the intermediate format first (has "resources" array)
378
- const resourcesResult = LegacyResourcesConfigSchema.safeParse(parsed);
379
- if (resourcesResult.success) {
380
- const legacy = resourcesResult.data;
381
- Metrics.info('config.legacy.parsed', {
382
- format: 'resources',
383
- resourceCount: legacy.resources.length,
384
- model: legacy.model,
385
- provider: legacy.provider
386
- });
387
-
388
- // Resources are already in the right format, just copy them over
389
- const migrated: StoredConfig = {
390
- $schema: CONFIG_SCHEMA_URL,
391
- resources: legacy.resources,
392
- model: legacy.model,
393
- provider: legacy.provider
394
- };
305
+ // Convert legacy repos to resources
306
+ const migratedResources = legacy.repos.map(legacyRepoToResource);
395
307
 
396
- return finalizeMigration(migrated, legacyPath, newConfigPath, legacy.resources.length);
397
- }
308
+ // Merge with default resources (legacy resources take precedence by name)
309
+ const migratedNames = new Set(migratedResources.map((r) => r.name));
310
+ const defaultsToAdd = DEFAULT_RESOURCES.filter((r) => !migratedNames.has(r.name));
311
+ const allResources = [...migratedResources, ...defaultsToAdd];
398
312
 
399
- // Try the very old format (has "repos" array)
400
- const reposResult = LegacyReposConfigSchema.safeParse(parsed);
401
- if (reposResult.success) {
402
- const legacy = reposResult.data;
403
- Metrics.info('config.legacy.parsed', {
404
- format: 'repos',
405
- repoCount: legacy.repos.length,
406
- model: legacy.model,
407
- provider: legacy.provider
408
- });
313
+ const migrated: StoredConfig = {
314
+ $schema: CONFIG_SCHEMA_URL,
315
+ resources: allResources,
316
+ model: legacy.model,
317
+ provider: legacy.provider
318
+ };
409
319
 
410
- // Convert legacy repos to resources
411
- const migratedResources = legacy.repos.map(legacyRepoToResource);
320
+ return finalizeMigration(migrated, legacyPath, newConfigPath, migratedResources.length);
321
+ }
412
322
 
413
- // Merge with default resources (legacy resources take precedence by name)
414
- const migratedNames = new Set(migratedResources.map((r) => r.name));
415
- const defaultsToAdd = DEFAULT_RESOURCES.filter((r) => !migratedNames.has(r.name));
416
- const allResources = [...migratedResources, ...defaultsToAdd];
323
+ // Neither format matched
324
+ metricsError('config.legacy.invalid', {
325
+ path: legacyPath,
326
+ error: 'Config does not match any known legacy format'
327
+ });
328
+ return null;
329
+ };
330
+
331
+ /**
332
+ * Write migrated config and rename legacy file
333
+ */
334
+ const finalizeMigration = async (
335
+ migrated: StoredConfig,
336
+ legacyPath: string,
337
+ newConfigPath: string,
338
+ migratedCount: number
339
+ ): Promise<StoredConfig> => {
340
+ const configDir = newConfigPath.slice(0, newConfigPath.lastIndexOf('/'));
341
+ try {
342
+ await fs.mkdir(configDir, { recursive: true });
343
+ } catch (cause) {
344
+ throw new ConfigError({
345
+ message: 'Failed to write migrated config',
346
+ hint: `Check that you have write permissions to "${configDir}".`,
347
+ cause
348
+ });
349
+ }
417
350
 
418
- const migrated: StoredConfig = {
419
- $schema: CONFIG_SCHEMA_URL,
420
- resources: allResources,
421
- model: legacy.model,
422
- provider: legacy.provider
423
- };
351
+ await writeConfigFile(
352
+ newConfigPath,
353
+ migrated,
354
+ 'Failed to write migrated config',
355
+ `Check that you have write permissions to "${configDir}".`
356
+ );
357
+
358
+ metricsInfo('config.legacy.migrated', {
359
+ newPath: newConfigPath,
360
+ resourceCount: migrated.resources.length,
361
+ migratedCount
362
+ });
424
363
 
425
- return finalizeMigration(migrated, legacyPath, newConfigPath, migratedResources.length);
426
- }
364
+ // Rename the legacy file to mark it as migrated
365
+ try {
366
+ await fs.rename(legacyPath, `${legacyPath}.migrated`);
367
+ metricsInfo('config.legacy.renamed', { from: legacyPath, to: `${legacyPath}.migrated` });
368
+ } catch {
369
+ metricsInfo('config.legacy.rename_skipped', { path: legacyPath });
370
+ }
427
371
 
428
- // Neither format matched
429
- Metrics.error('config.legacy.invalid', {
430
- path: legacyPath,
431
- error: 'Config does not match any known legacy format'
432
- });
433
- return null;
372
+ return migrated;
373
+ };
374
+
375
+ const loadConfigFromPath = async (configPath: string): Promise<StoredConfig> => {
376
+ const content = await readConfigText(configPath);
377
+ const parsed = parseConfigText(configPath, content);
378
+ return validateStoredConfig(parsed);
379
+ };
380
+
381
+ const createDefaultConfig = async (configPath: string): Promise<StoredConfig> => {
382
+ const configDir = configPath.slice(0, configPath.lastIndexOf('/'));
383
+
384
+ const defaultStored: StoredConfig = {
385
+ $schema: CONFIG_SCHEMA_URL,
386
+ resources: DEFAULT_RESOURCES,
387
+ model: DEFAULT_MODEL,
388
+ provider: DEFAULT_PROVIDER,
389
+ providerTimeoutMs: DEFAULT_PROVIDER_TIMEOUT_MS,
390
+ maxSteps: DEFAULT_MAX_STEPS
434
391
  };
435
392
 
436
- /**
437
- * Write migrated config and rename legacy file
438
- */
439
- const finalizeMigration = async (
440
- migrated: StoredConfig,
441
- legacyPath: string,
442
- newConfigPath: string,
443
- migratedCount: number
444
- ): Promise<StoredConfig> => {
445
- const configDir = newConfigPath.slice(0, newConfigPath.lastIndexOf('/'));
446
- const result = await Result.gen(async function* () {
447
- yield* Result.await(
448
- Result.tryPromise({
449
- try: () => fs.mkdir(configDir, { recursive: true }),
450
- catch: (cause) =>
451
- new ConfigError({
452
- message: 'Failed to write migrated config',
453
- hint: `Check that you have write permissions to "${configDir}".`,
454
- cause
455
- })
456
- })
457
- );
458
-
459
- yield* Result.await(
460
- writeConfigFile(
461
- newConfigPath,
462
- migrated,
463
- 'Failed to write migrated config',
464
- `Check that you have write permissions to "${configDir}".`
465
- )
466
- );
467
-
468
- return Result.ok(migrated);
393
+ try {
394
+ await fs.mkdir(configDir, { recursive: true });
395
+ } catch (cause) {
396
+ throw new ConfigError({
397
+ message: `Failed to create config directory: "${configDir}"`,
398
+ hint: 'Check that you have write permissions to the parent directory.',
399
+ cause
469
400
  });
401
+ }
402
+ await writeConfigFile(
403
+ configPath,
404
+ defaultStored,
405
+ `Failed to write default config to: "${configPath}"`,
406
+ 'Check that you have write permissions to the config directory.'
407
+ );
408
+ return defaultStored;
409
+ };
410
+
411
+ const saveConfig = async (configPath: string, stored: StoredConfig): Promise<void> => {
412
+ await writeConfigFile(
413
+ configPath,
414
+ stored,
415
+ `Failed to save config to: "${configPath}"`,
416
+ 'Check that you have write permissions and the disk is not full.'
417
+ );
418
+ };
419
+
420
+ /**
421
+ * Create a config service.
422
+ *
423
+ * When both global and project configs exist, mutations (add/remove resource, update model)
424
+ * only modify the project config. The merged view is computed on-the-fly for reads.
425
+ *
426
+ * @param globalConfig - The global config (always present)
427
+ * @param projectConfig - The project config (null if not using project-level config)
428
+ * @param resourcesDirectory - Directory for resource data
429
+ * @param configPath - Path to the config file to save (project if exists, else global)
430
+ */
431
+ const makeService = (
432
+ globalConfig: StoredConfig,
433
+ projectConfig: StoredConfig | null,
434
+ resourcesDirectory: string,
435
+ configPath: string
436
+ ): ConfigService => {
437
+ // Track configs separately to avoid resource leakage
438
+ let currentGlobalConfig = globalConfig;
439
+ let currentProjectConfig = projectConfig;
440
+
441
+ // Compute merged resources on-the-fly
442
+ const getMergedResources = (): readonly ResourceDefinition[] => {
443
+ if (!currentProjectConfig) {
444
+ return currentGlobalConfig.resources;
445
+ }
446
+ // Merge: global first, then project overrides by name
447
+ const resourceMap = new Map<string, ResourceDefinition>();
448
+ for (const resource of currentGlobalConfig.resources) {
449
+ resourceMap.set(resource.name, resource);
450
+ }
451
+ for (const resource of currentProjectConfig.resources) {
452
+ resourceMap.set(resource.name, resource);
453
+ }
454
+ return Array.from(resourceMap.values());
455
+ };
470
456
 
471
- if (!Result.isOk(result)) throw result.error;
472
- const saved = result.value;
457
+ const mergeProviderOptions = (
458
+ globalConfigValue: StoredConfig,
459
+ projectConfigValue: StoredConfig | null
460
+ ): ProviderOptionsMap => {
461
+ const merged: ProviderOptionsMap = {};
462
+ const globalOptions = globalConfigValue.providerOptions ?? {};
463
+ const projectOptions = projectConfigValue?.providerOptions ?? {};
473
464
 
474
- Metrics.info('config.legacy.migrated', {
475
- newPath: newConfigPath,
476
- resourceCount: migrated.resources.length,
477
- migratedCount
478
- });
465
+ for (const [providerId, options] of Object.entries(globalOptions)) {
466
+ merged[providerId] = { ...options };
467
+ }
479
468
 
480
- // Rename the legacy file to mark it as migrated
481
- const renameResult = await Result.tryPromise(() =>
482
- fs.rename(legacyPath, `${legacyPath}.migrated`)
483
- );
484
- renameResult.match({
485
- ok: () =>
486
- Metrics.info('config.legacy.renamed', { from: legacyPath, to: `${legacyPath}.migrated` }),
487
- err: () => Metrics.info('config.legacy.rename_skipped', { path: legacyPath })
488
- });
469
+ for (const [providerId, options] of Object.entries(projectOptions)) {
470
+ merged[providerId] = { ...(merged[providerId] ?? {}), ...options };
471
+ }
489
472
 
490
- return saved;
473
+ return merged;
491
474
  };
492
475
 
493
- const loadConfigFromPath = async (configPath: string): Promise<StoredConfig> => {
494
- const result = await Result.gen(async function* () {
495
- const content = yield* Result.await(readConfigText(configPath));
496
- const parsed = yield* parseConfigText(configPath, content);
497
- const stored = yield* validateStoredConfig(parsed);
498
- return Result.ok(stored);
499
- });
476
+ const getMergedProviderOptions = (): ProviderOptionsMap =>
477
+ mergeProviderOptions(currentGlobalConfig, currentProjectConfig);
500
478
 
501
- if (!Result.isOk(result)) throw result.error;
502
- return result.value;
479
+ // Get the config that should be used for model/provider
480
+ const getActiveConfig = (): StoredConfig => {
481
+ return currentProjectConfig ?? currentGlobalConfig;
503
482
  };
504
483
 
505
- const createDefaultConfig = async (configPath: string): Promise<StoredConfig> => {
506
- const configDir = configPath.slice(0, configPath.lastIndexOf('/'));
507
-
508
- const defaultStored: StoredConfig = {
509
- $schema: CONFIG_SCHEMA_URL,
510
- resources: DEFAULT_RESOURCES,
511
- model: DEFAULT_MODEL,
512
- provider: DEFAULT_PROVIDER,
513
- providerTimeoutMs: DEFAULT_PROVIDER_TIMEOUT_MS,
514
- maxSteps: DEFAULT_MAX_STEPS
515
- };
516
-
517
- const result = await Result.gen(async function* () {
518
- yield* Result.await(
519
- Result.tryPromise({
520
- try: () => fs.mkdir(configDir, { recursive: true }),
521
- catch: (cause) =>
522
- new ConfigError({
523
- message: `Failed to create config directory: "${configDir}"`,
524
- hint: 'Check that you have write permissions to the parent directory.',
525
- cause
526
- })
527
- })
528
- );
529
- yield* Result.await(
530
- writeConfigFile(
531
- configPath,
532
- defaultStored,
533
- `Failed to write default config to: "${configPath}"`,
534
- 'Check that you have write permissions to the config directory.'
535
- )
536
- );
537
- return Result.ok(defaultStored);
538
- });
539
-
540
- if (!Result.isOk(result)) throw result.error;
541
- return result.value;
484
+ // Get the config that should be mutated
485
+ const getMutableConfig = (): StoredConfig => {
486
+ return currentProjectConfig ?? currentGlobalConfig;
542
487
  };
543
488
 
544
- const saveConfig = async (configPath: string, stored: StoredConfig): Promise<void> => {
545
- const result = await writeConfigFile(
546
- configPath,
547
- stored,
548
- `Failed to save config to: "${configPath}"`,
549
- 'Check that you have write permissions and the disk is not full.'
550
- );
551
- if (!Result.isOk(result)) throw result.error;
489
+ // Update the mutable config
490
+ const setMutableConfig = (config: StoredConfig): void => {
491
+ if (currentProjectConfig) {
492
+ currentProjectConfig = config;
493
+ } else {
494
+ currentGlobalConfig = config;
495
+ }
552
496
  };
553
497
 
554
- /**
555
- * Create a config service.
556
- *
557
- * When both global and project configs exist, mutations (add/remove resource, update model)
558
- * only modify the project config. The merged view is computed on-the-fly for reads.
559
- *
560
- * @param globalConfig - The global config (always present)
561
- * @param projectConfig - The project config (null if not using project-level config)
562
- * @param resourcesDirectory - Directory for resource data
563
- * @param configPath - Path to the config file to save (project if exists, else global)
564
- */
565
- const makeService = (
566
- globalConfig: StoredConfig,
567
- projectConfig: StoredConfig | null,
568
- resourcesDirectory: string,
569
- configPath: string
570
- ): Service => {
571
- // Track configs separately to avoid resource leakage
572
- let currentGlobalConfig = globalConfig;
573
- let currentProjectConfig = projectConfig;
574
-
575
- // Compute merged resources on-the-fly
576
- const getMergedResources = (): readonly ResourceDefinition[] => {
577
- if (!currentProjectConfig) {
578
- return currentGlobalConfig.resources;
579
- }
580
- // Merge: global first, then project overrides by name
581
- const resourceMap = new Map<string, ResourceDefinition>();
582
- for (const resource of currentGlobalConfig.resources) {
583
- resourceMap.set(resource.name, resource);
584
- }
585
- for (const resource of currentProjectConfig.resources) {
586
- resourceMap.set(resource.name, resource);
498
+ const service: ConfigService = {
499
+ resourcesDirectory,
500
+ configPath,
501
+ get resources() {
502
+ return getMergedResources();
503
+ },
504
+ get model() {
505
+ return getActiveConfig().model ?? DEFAULT_MODEL;
506
+ },
507
+ get provider() {
508
+ return getActiveConfig().provider ?? DEFAULT_PROVIDER;
509
+ },
510
+ get providerTimeoutMs() {
511
+ return getActiveConfig().providerTimeoutMs;
512
+ },
513
+ get maxSteps() {
514
+ return getActiveConfig().maxSteps ?? DEFAULT_MAX_STEPS;
515
+ },
516
+ getProviderOptions: (providerId: string) => getMergedProviderOptions()[providerId],
517
+ getResource: (name: string) => getMergedResources().find((r) => r.name === name),
518
+
519
+ updateModel: async (
520
+ provider: string,
521
+ model: string,
522
+ providerOptions?: ProviderOptionsConfig
523
+ ) => {
524
+ if (!isProviderSupported(provider)) {
525
+ const available = getSupportedProviders();
526
+ throw new ConfigError({
527
+ message: `Provider "${provider}" is not supported`,
528
+ hint: `Available providers: ${available.join(', ')}. Open an issue to request this provider: https://github.com/davis7dotsh/better-context/issues.`
529
+ });
587
530
  }
588
- return Array.from(resourceMap.values());
589
- };
590
-
591
- const mergeProviderOptions = (
592
- globalConfigValue: StoredConfig,
593
- projectConfigValue: StoredConfig | null
594
- ): ProviderOptionsMap => {
595
- const merged: ProviderOptionsMap = {};
596
- const globalOptions = globalConfigValue.providerOptions ?? {};
597
- const projectOptions = projectConfigValue?.providerOptions ?? {};
531
+ const mutableConfig = getMutableConfig();
532
+ const existingProviderOptions = mutableConfig.providerOptions ?? {};
533
+ const nextProviderOptions = providerOptions
534
+ ? {
535
+ ...existingProviderOptions,
536
+ [provider]: {
537
+ ...(existingProviderOptions[provider] ?? {}),
538
+ ...providerOptions
539
+ }
540
+ }
541
+ : existingProviderOptions;
542
+ const updated = {
543
+ ...mutableConfig,
544
+ provider,
545
+ model,
546
+ ...(providerOptions ? { providerOptions: nextProviderOptions } : {})
547
+ };
598
548
 
599
- for (const [providerId, options] of Object.entries(globalOptions)) {
600
- merged[providerId] = { ...options };
549
+ if (provider === 'openai-compat') {
550
+ const merged = currentProjectConfig
551
+ ? mergeProviderOptions(currentGlobalConfig, updated)
552
+ : mergeProviderOptions(updated, null);
553
+ const compat = merged['openai-compat'];
554
+ const baseURL = compat?.baseURL?.trim();
555
+ const name = compat?.name?.trim();
556
+ if (!baseURL || !name) {
557
+ throw new ConfigError({
558
+ message: 'openai-compat requires baseURL and name',
559
+ hint: 'Run "btca connect -p openai-compat" to configure baseURL and name.'
560
+ });
561
+ }
601
562
  }
602
-
603
- for (const [providerId, options] of Object.entries(projectOptions)) {
604
- merged[providerId] = { ...(merged[providerId] ?? {}), ...options };
563
+ setMutableConfig(updated);
564
+ await saveConfig(configPath, updated);
565
+ metricsInfo('config.model.updated', { provider, model });
566
+ return {
567
+ provider,
568
+ model,
569
+ savedTo: currentProjectConfig ? 'project' : 'global'
570
+ };
571
+ },
572
+
573
+ addResource: async (resource: ResourceDefinition) => {
574
+ // Check for duplicate name in merged resources
575
+ const mergedResources = getMergedResources();
576
+ if (mergedResources.some((r) => r.name === resource.name)) {
577
+ throw new ConfigError({
578
+ message: `Resource "${resource.name}" already exists`,
579
+ hint: `Choose a different name or remove the existing resource first with "btca remove ${resource.name}".`
580
+ });
605
581
  }
606
582
 
607
- return merged;
608
- };
609
-
610
- const getMergedProviderOptions = (): ProviderOptionsMap =>
611
- mergeProviderOptions(currentGlobalConfig, currentProjectConfig);
612
-
613
- // Get the config that should be used for model/provider
614
- const getActiveConfig = (): StoredConfig => {
615
- return currentProjectConfig ?? currentGlobalConfig;
616
- };
583
+ // Add only to the mutable config (project if exists, else global)
584
+ const mutableConfig = getMutableConfig();
585
+ const updated = {
586
+ ...mutableConfig,
587
+ resources: [...mutableConfig.resources, resource]
588
+ };
589
+ setMutableConfig(updated);
590
+ await saveConfig(configPath, updated);
591
+ metricsInfo('config.resource.added', { name: resource.name, type: resource.type });
592
+ return resource;
593
+ },
594
+
595
+ removeResource: async (name: string) => {
596
+ const mergedResources = getMergedResources();
597
+ const exists = mergedResources.some((r) => r.name === name);
598
+ if (!exists) {
599
+ const available = mergedResources.map((r) => r.name);
600
+ throw new ConfigError({
601
+ message: `Resource "${name}" not found`,
602
+ hint:
603
+ available.length > 0
604
+ ? `Available resources: ${available.join(', ')}. ${CommonHints.LIST_RESOURCES}`
605
+ : `No resources configured. ${CommonHints.ADD_RESOURCE}`
606
+ });
607
+ }
617
608
 
618
- // Get the config that should be mutated
619
- const getMutableConfig = (): StoredConfig => {
620
- return currentProjectConfig ?? currentGlobalConfig;
621
- };
609
+ const mutableConfig = getMutableConfig();
610
+ const isInMutableConfig = mutableConfig.resources.some((r) => r.name === name);
622
611
 
623
- // Update the mutable config
624
- const setMutableConfig = (config: StoredConfig): void => {
625
612
  if (currentProjectConfig) {
626
- currentProjectConfig = config;
627
- } else {
628
- currentGlobalConfig = config;
629
- }
630
- };
613
+ // We have a project config
614
+ const isInGlobal = currentGlobalConfig.resources.some((r) => r.name === name);
615
+ const isInProject = currentProjectConfig.resources.some((r) => r.name === name);
631
616
 
632
- const service: Service = {
633
- resourcesDirectory,
634
- configPath,
635
- get resources() {
636
- return getMergedResources();
637
- },
638
- get model() {
639
- return getActiveConfig().model ?? DEFAULT_MODEL;
640
- },
641
- get provider() {
642
- return getActiveConfig().provider ?? DEFAULT_PROVIDER;
643
- },
644
- get providerTimeoutMs() {
645
- return getActiveConfig().providerTimeoutMs;
646
- },
647
- get maxSteps() {
648
- return getActiveConfig().maxSteps ?? DEFAULT_MAX_STEPS;
649
- },
650
- getProviderOptions: (providerId: string) => getMergedProviderOptions()[providerId],
651
- getResource: (name: string) => getMergedResources().find((r) => r.name === name),
652
-
653
- updateModel: async (
654
- provider: string,
655
- model: string,
656
- providerOptions?: ProviderOptionsConfig
657
- ) => {
658
- if (!isProviderSupported(provider)) {
659
- const available = getSupportedProviders();
617
+ if (isInProject) {
618
+ // Resource is in project config - just remove it
619
+ const updated = {
620
+ ...currentProjectConfig,
621
+ resources: currentProjectConfig.resources.filter((r) => r.name !== name)
622
+ };
623
+ currentProjectConfig = updated;
624
+ await saveConfig(configPath, updated);
625
+ metricsInfo('config.resource.removed', { name, from: 'project' });
626
+ } else if (isInGlobal) {
627
+ // Resource is only in global config
628
+ // User wants to remove a global resource from project context
629
+ // We can't modify global config from project context, so throw an error
660
630
  throw new ConfigError({
661
- message: `Provider "${provider}" is not supported`,
662
- hint: `Available providers: ${available.join(', ')}. Open an issue to request this provider: https://github.com/davis7dotsh/better-context/issues.`
631
+ message: `Resource "${name}" is defined in the global config`,
632
+ hint: `To remove this resource globally, edit the global config at "${expandHome(GLOBAL_CONFIG_DIR)}/${GLOBAL_CONFIG_FILENAME}" or run the command without a project config present.`
663
633
  });
664
634
  }
665
- const mutableConfig = getMutableConfig();
666
- const existingProviderOptions = mutableConfig.providerOptions ?? {};
667
- const nextProviderOptions = providerOptions
668
- ? {
669
- ...existingProviderOptions,
670
- [provider]: {
671
- ...(existingProviderOptions[provider] ?? {}),
672
- ...providerOptions
673
- }
674
- }
675
- : existingProviderOptions;
676
- const updated = {
677
- ...mutableConfig,
678
- provider,
679
- model,
680
- ...(providerOptions ? { providerOptions: nextProviderOptions } : {})
681
- };
682
-
683
- if (provider === 'openai-compat') {
684
- const merged = currentProjectConfig
685
- ? mergeProviderOptions(currentGlobalConfig, updated)
686
- : mergeProviderOptions(updated, null);
687
- const compat = merged['openai-compat'];
688
- const baseURL = compat?.baseURL?.trim();
689
- const name = compat?.name?.trim();
690
- if (!baseURL || !name) {
691
- throw new ConfigError({
692
- message: 'openai-compat requires baseURL and name',
693
- hint: 'Run "btca connect -p openai-compat" to configure baseURL and name.'
694
- });
695
- }
696
- }
697
- setMutableConfig(updated);
698
- await saveConfig(configPath, updated);
699
- Metrics.info('config.model.updated', { provider, model });
700
- return {
701
- provider,
702
- model,
703
- savedTo: currentProjectConfig ? 'project' : 'global'
704
- };
705
- },
706
-
707
- addResource: async (resource: ResourceDefinition) => {
708
- // Check for duplicate name in merged resources
709
- const mergedResources = getMergedResources();
710
- if (mergedResources.some((r) => r.name === resource.name)) {
635
+ } else {
636
+ // No project config, modify global directly
637
+ if (!isInMutableConfig) {
638
+ // This shouldn't happen given the exists check above, but be safe
711
639
  throw new ConfigError({
712
- message: `Resource "${resource.name}" already exists`,
713
- hint: `Choose a different name or remove the existing resource first with "btca remove ${resource.name}".`
640
+ message: `Resource "${name}" not found in config`,
641
+ hint: CommonHints.LIST_RESOURCES
714
642
  });
715
643
  }
716
-
717
- // Add only to the mutable config (project if exists, else global)
718
- const mutableConfig = getMutableConfig();
719
644
  const updated = {
720
645
  ...mutableConfig,
721
- resources: [...mutableConfig.resources, resource]
646
+ resources: mutableConfig.resources.filter((r) => r.name !== name)
722
647
  };
723
648
  setMutableConfig(updated);
724
649
  await saveConfig(configPath, updated);
725
- Metrics.info('config.resource.added', { name: resource.name, type: resource.type });
726
- return resource;
727
- },
728
-
729
- removeResource: async (name: string) => {
730
- const mergedResources = getMergedResources();
731
- const exists = mergedResources.some((r) => r.name === name);
732
- if (!exists) {
733
- const available = mergedResources.map((r) => r.name);
734
- throw new ConfigError({
735
- message: `Resource "${name}" not found`,
736
- hint:
737
- available.length > 0
738
- ? `Available resources: ${available.join(', ')}. ${CommonHints.LIST_RESOURCES}`
739
- : `No resources configured. ${CommonHints.ADD_RESOURCE}`
740
- });
741
- }
742
-
743
- const mutableConfig = getMutableConfig();
744
- const isInMutableConfig = mutableConfig.resources.some((r) => r.name === name);
745
-
746
- if (currentProjectConfig) {
747
- // We have a project config
748
- const isInGlobal = currentGlobalConfig.resources.some((r) => r.name === name);
749
- const isInProject = currentProjectConfig.resources.some((r) => r.name === name);
750
-
751
- if (isInProject) {
752
- // Resource is in project config - just remove it
753
- const updated = {
754
- ...currentProjectConfig,
755
- resources: currentProjectConfig.resources.filter((r) => r.name !== name)
756
- };
757
- currentProjectConfig = updated;
758
- await saveConfig(configPath, updated);
759
- Metrics.info('config.resource.removed', { name, from: 'project' });
760
- } else if (isInGlobal) {
761
- // Resource is only in global config
762
- // User wants to remove a global resource from project context
763
- // We can't modify global config from project context, so throw an error
764
- throw new ConfigError({
765
- message: `Resource "${name}" is defined in the global config`,
766
- hint: `To remove this resource globally, edit the global config at "${expandHome(GLOBAL_CONFIG_DIR)}/${GLOBAL_CONFIG_FILENAME}" or run the command without a project config present.`
767
- });
768
- }
769
- } else {
770
- // No project config, modify global directly
771
- if (!isInMutableConfig) {
772
- // This shouldn't happen given the exists check above, but be safe
773
- throw new ConfigError({
774
- message: `Resource "${name}" not found in config`,
775
- hint: CommonHints.LIST_RESOURCES
776
- });
777
- }
778
- const updated = {
779
- ...mutableConfig,
780
- resources: mutableConfig.resources.filter((r) => r.name !== name)
781
- };
782
- setMutableConfig(updated);
783
- await saveConfig(configPath, updated);
784
- Metrics.info('config.resource.removed', { name, from: 'global' });
785
- }
786
- },
650
+ metricsInfo('config.resource.removed', { name, from: 'global' });
651
+ }
652
+ },
787
653
 
788
- clearResources: async () => {
789
- // Clear the resources directory
790
- let clearedCount = 0;
654
+ clearResources: async () => {
655
+ // Clear the resources directory
656
+ let clearedCount = 0;
791
657
 
792
- const resourcesResult = await Result.tryPromise(() => fs.readdir(resourcesDirectory));
793
- const resourcesDir = resourcesResult.match({
794
- ok: (value) => value,
795
- err: () => []
796
- });
658
+ let resourcesDir: string[] = [];
659
+ try {
660
+ resourcesDir = await fs.readdir(resourcesDirectory);
661
+ } catch {
662
+ resourcesDir = [];
663
+ }
797
664
 
798
- for (const item of resourcesDir) {
799
- const removeResult = await Result.tryPromise(() =>
800
- fs.rm(`${resourcesDirectory}/${item}`, { recursive: true, force: true })
801
- );
802
- const removed = removeResult.match({
803
- ok: () => true,
804
- err: () => false
805
- });
806
- if (!removed) break;
665
+ for (const item of resourcesDir) {
666
+ try {
667
+ await fs.rm(`${resourcesDirectory}/${item}`, { recursive: true, force: true });
807
668
  clearedCount++;
669
+ } catch {
670
+ break;
808
671
  }
672
+ }
809
673
 
810
- Metrics.info('config.resources.cleared', { count: clearedCount });
811
- return { cleared: clearedCount };
812
- },
813
-
814
- reload: async () => {
815
- // Reload the config file from disk
816
- // configPath points to either project config (if it existed at startup) or global config
817
- Metrics.info('config.reload.start', { configPath });
674
+ metricsInfo('config.resources.cleared', { count: clearedCount });
675
+ return { cleared: clearedCount };
676
+ },
818
677
 
819
- const configExists = await Bun.file(configPath).exists();
820
- if (!configExists) {
821
- Metrics.info('config.reload.skipped', { reason: 'file not found', configPath });
822
- return;
823
- }
678
+ reload: async () => {
679
+ // Reload the config file from disk
680
+ // configPath points to either project config (if it existed at startup) or global config
681
+ metricsInfo('config.reload.start', { configPath });
824
682
 
825
- const reloaded = await loadConfigFromPath(configPath);
683
+ const configExists = await Bun.file(configPath).exists();
684
+ if (!configExists) {
685
+ metricsInfo('config.reload.skipped', { reason: 'file not found', configPath });
686
+ return;
687
+ }
826
688
 
827
- // Update the appropriate config based on what we had at startup
828
- if (currentProjectConfig !== null) {
829
- currentProjectConfig = reloaded;
830
- } else {
831
- currentGlobalConfig = reloaded;
832
- }
689
+ const reloaded = await loadConfigFromPath(configPath);
833
690
 
834
- Metrics.info('config.reload.done', {
835
- resources: reloaded.resources.length,
836
- configPath
837
- });
691
+ // Update the appropriate config based on what we had at startup
692
+ if (currentProjectConfig !== null) {
693
+ currentProjectConfig = reloaded;
694
+ } else {
695
+ currentGlobalConfig = reloaded;
838
696
  }
839
- };
840
697
 
841
- return service;
698
+ metricsInfo('config.reload.done', {
699
+ resources: reloaded.resources.length,
700
+ configPath
701
+ });
702
+ },
703
+ updateModelEffect: (provider, model, providerOptions) =>
704
+ Effect.tryPromise({
705
+ try: () => service.updateModel(provider, model, providerOptions),
706
+ catch: (cause) => cause
707
+ }),
708
+ addResourceEffect: (resource) =>
709
+ Effect.tryPromise({
710
+ try: () => service.addResource(resource),
711
+ catch: (cause) => cause
712
+ }),
713
+ removeResourceEffect: (name) =>
714
+ Effect.tryPromise({
715
+ try: () => service.removeResource(name),
716
+ catch: (cause) => cause
717
+ }),
718
+ clearResourcesEffect: () =>
719
+ Effect.tryPromise({
720
+ try: () => service.clearResources(),
721
+ catch: (cause) => cause
722
+ }),
723
+ reloadEffect: () =>
724
+ Effect.tryPromise({
725
+ try: () => service.reload(),
726
+ catch: (cause) => cause
727
+ })
842
728
  };
843
729
 
844
- export const load = async (): Promise<Service> => {
845
- const cwd = process.cwd();
846
- Metrics.info('config.load.start', { cwd });
730
+ return service;
731
+ };
847
732
 
848
- const globalConfigPath = `${expandHome(GLOBAL_CONFIG_DIR)}/${GLOBAL_CONFIG_FILENAME}`;
849
- const projectConfigPath = `${cwd}/${PROJECT_CONFIG_FILENAME}`;
733
+ export const load = async (): Promise<ConfigService> => {
734
+ const cwd = process.cwd();
735
+ metricsInfo('config.load.start', { cwd });
850
736
 
851
- // First, load or create the global config
852
- let globalConfig: StoredConfig;
853
- const globalExists = await Bun.file(globalConfigPath).exists();
737
+ const globalConfigPath = `${expandHome(GLOBAL_CONFIG_DIR)}/${GLOBAL_CONFIG_FILENAME}`;
738
+ const projectConfigPath = `${cwd}/${PROJECT_CONFIG_FILENAME}`;
854
739
 
855
- if (!globalExists) {
856
- // Check for legacy config to migrate
857
- const legacyConfigPath = `${expandHome(GLOBAL_CONFIG_DIR)}/${LEGACY_CONFIG_FILENAME}`;
858
- const migrated = await migrateLegacyConfig(legacyConfigPath, globalConfigPath);
859
- if (migrated) {
860
- Metrics.info('config.load.global', { source: 'migrated', path: globalConfigPath });
861
- globalConfig = migrated;
862
- } else {
863
- Metrics.info('config.load.global', { source: 'default', path: globalConfigPath });
864
- globalConfig = await createDefaultConfig(globalConfigPath);
865
- }
740
+ // First, load or create the global config
741
+ let globalConfig: StoredConfig;
742
+ const globalExists = await Bun.file(globalConfigPath).exists();
743
+
744
+ if (!globalExists) {
745
+ // Check for legacy config to migrate
746
+ const legacyConfigPath = `${expandHome(GLOBAL_CONFIG_DIR)}/${LEGACY_CONFIG_FILENAME}`;
747
+ const migrated = await migrateLegacyConfig(legacyConfigPath, globalConfigPath);
748
+ if (migrated) {
749
+ metricsInfo('config.load.global', { source: 'migrated', path: globalConfigPath });
750
+ globalConfig = migrated;
866
751
  } else {
867
- Metrics.info('config.load.global', { source: 'existing', path: globalConfigPath });
868
- globalConfig = await loadConfigFromPath(globalConfigPath);
752
+ metricsInfo('config.load.global', { source: 'default', path: globalConfigPath });
753
+ globalConfig = await createDefaultConfig(globalConfigPath);
869
754
  }
755
+ } else {
756
+ metricsInfo('config.load.global', { source: 'existing', path: globalConfigPath });
757
+ globalConfig = await loadConfigFromPath(globalConfigPath);
758
+ }
870
759
 
871
- // Now check for project config and merge if it exists
872
- const projectExists = await Bun.file(projectConfigPath).exists();
873
- if (projectExists) {
874
- Metrics.info('config.load.project', { source: 'project', path: projectConfigPath });
875
- let projectConfig = await loadConfigFromPath(projectConfigPath);
760
+ // Now check for project config and merge if it exists
761
+ const projectExists = await Bun.file(projectConfigPath).exists();
762
+ if (projectExists) {
763
+ metricsInfo('config.load.project', { source: 'project', path: projectConfigPath });
764
+ let projectConfig = await loadConfigFromPath(projectConfigPath);
876
765
 
877
- Metrics.info('config.load.merged', {
878
- globalResources: globalConfig.resources.length,
879
- projectResources: projectConfig.resources.length
880
- });
766
+ metricsInfo('config.load.merged', {
767
+ globalResources: globalConfig.resources.length,
768
+ projectResources: projectConfig.resources.length
769
+ });
881
770
 
882
- // Use project paths for data storage when project config exists
883
- // Pass both configs separately to avoid resource leakage on mutations
884
- let projectDataDir =
885
- projectConfig.dataDirectory ?? globalConfig.dataDirectory ?? expandHome(GLOBAL_DATA_DIR);
886
-
887
- // Migration: if no dataDirectory is set and legacy .btca exists, use it and update config
888
- if (!projectConfig.dataDirectory) {
889
- const legacyProjectDataDir = `${cwd}/.btca`;
890
- const legacyExists = (await Result.tryPromise(() => fs.stat(legacyProjectDataDir))).match({
891
- ok: () => true,
892
- err: () => false
771
+ // Use project paths for data storage when project config exists
772
+ // Pass both configs separately to avoid resource leakage on mutations
773
+ let projectDataDir =
774
+ projectConfig.dataDirectory ?? globalConfig.dataDirectory ?? expandHome(GLOBAL_DATA_DIR);
775
+
776
+ // Migration: if no dataDirectory is set and legacy .btca exists, use it and update config
777
+ if (!projectConfig.dataDirectory) {
778
+ const legacyProjectDataDir = `${cwd}/.btca`;
779
+ let legacyExists = false;
780
+ try {
781
+ await fs.stat(legacyProjectDataDir);
782
+ legacyExists = true;
783
+ } catch {
784
+ legacyExists = false;
785
+ }
786
+ if (legacyExists) {
787
+ metricsInfo('config.project.legacy_data_dir', {
788
+ path: legacyProjectDataDir,
789
+ action: 'migrating'
893
790
  });
894
- if (legacyExists) {
895
- Metrics.info('config.project.legacy_data_dir', {
896
- path: legacyProjectDataDir,
897
- action: 'migrating'
898
- });
899
- projectDataDir = '.btca';
900
- const updatedProjectConfig = { ...projectConfig, dataDirectory: '.btca' };
901
- await saveConfig(projectConfigPath, updatedProjectConfig);
902
- projectConfig = updatedProjectConfig;
903
- }
791
+ projectDataDir = '.btca';
792
+ const updatedProjectConfig = { ...projectConfig, dataDirectory: '.btca' };
793
+ await saveConfig(projectConfigPath, updatedProjectConfig);
794
+ projectConfig = updatedProjectConfig;
904
795
  }
905
-
906
- const resolvedProjectDataDir = resolveDataDirectory(projectDataDir, cwd);
907
- return makeService(
908
- globalConfig,
909
- projectConfig,
910
- `${resolvedProjectDataDir}/resources`,
911
- projectConfigPath
912
- );
913
796
  }
914
797
 
915
- // No project config, use global only
916
- Metrics.info('config.load.source', { source: 'global', path: globalConfigPath });
917
- const globalDataDir = globalConfig.dataDirectory ?? expandHome(GLOBAL_DATA_DIR);
918
- const resolvedGlobalDataDir = resolveDataDirectory(
919
- globalDataDir,
920
- expandHome(GLOBAL_CONFIG_DIR)
798
+ const resolvedProjectDataDir = resolveDataDirectory(projectDataDir, cwd);
799
+ return makeService(
800
+ globalConfig,
801
+ projectConfig,
802
+ `${resolvedProjectDataDir}/resources`,
803
+ projectConfigPath
921
804
  );
922
- return makeService(globalConfig, null, `${resolvedGlobalDataDir}/resources`, globalConfigPath);
923
- };
924
- }
805
+ }
806
+
807
+ // No project config, use global only
808
+ metricsInfo('config.load.source', { source: 'global', path: globalConfigPath });
809
+ const globalDataDir = globalConfig.dataDirectory ?? expandHome(GLOBAL_DATA_DIR);
810
+ const resolvedGlobalDataDir = resolveDataDirectory(globalDataDir, expandHome(GLOBAL_CONFIG_DIR));
811
+ return makeService(globalConfig, null, `${resolvedGlobalDataDir}/resources`, globalConfigPath);
812
+ };