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.
- package/package.json +3 -3
- package/src/agent/agent.test.ts +31 -24
- package/src/agent/index.ts +8 -2
- package/src/agent/loop.ts +303 -346
- package/src/agent/service.ts +252 -233
- package/src/agent/types.ts +2 -2
- package/src/collections/index.ts +2 -1
- package/src/collections/service.ts +352 -345
- package/src/config/config.test.ts +3 -1
- package/src/config/index.ts +615 -727
- package/src/config/remote.ts +214 -369
- package/src/context/index.ts +6 -12
- package/src/context/transaction.ts +23 -30
- package/src/effect/errors.ts +45 -0
- package/src/effect/layers.ts +26 -0
- package/src/effect/runtime.ts +19 -0
- package/src/effect/services.ts +154 -0
- package/src/index.ts +291 -369
- package/src/metrics/index.ts +46 -46
- package/src/pricing/models-dev.ts +104 -106
- package/src/providers/auth.ts +159 -200
- package/src/providers/index.ts +19 -2
- package/src/providers/model.ts +115 -135
- package/src/providers/openai.ts +3 -3
- package/src/resources/impls/git.ts +123 -146
- package/src/resources/impls/npm.test.ts +16 -5
- package/src/resources/impls/npm.ts +66 -75
- package/src/resources/index.ts +6 -1
- package/src/resources/schema.ts +7 -6
- package/src/resources/service.test.ts +13 -12
- package/src/resources/service.ts +153 -112
- package/src/stream/index.ts +1 -1
- package/src/stream/service.test.ts +5 -5
- package/src/stream/service.ts +282 -293
- package/src/tools/glob.ts +126 -141
- package/src/tools/grep.ts +205 -210
- package/src/tools/index.ts +8 -4
- package/src/tools/list.ts +118 -140
- package/src/tools/read.ts +209 -235
- package/src/tools/virtual-sandbox.ts +91 -83
- package/src/validation/index.ts +18 -22
- package/src/vfs/virtual-fs.test.ts +37 -25
- package/src/vfs/virtual-fs.ts +218 -216
package/src/config/index.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { promises as fs } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
|
|
4
|
-
import {
|
|
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 {
|
|
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
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
const
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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
|
-
|
|
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
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
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
|
-
|
|
411
|
-
|
|
320
|
+
return finalizeMigration(migrated, legacyPath, newConfigPath, migratedResources.length);
|
|
321
|
+
}
|
|
412
322
|
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
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
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
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
|
-
|
|
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
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
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
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
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
|
-
|
|
472
|
-
|
|
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
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
migratedCount
|
|
478
|
-
});
|
|
465
|
+
for (const [providerId, options] of Object.entries(globalOptions)) {
|
|
466
|
+
merged[providerId] = { ...options };
|
|
467
|
+
}
|
|
479
468
|
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
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
|
|
473
|
+
return merged;
|
|
491
474
|
};
|
|
492
475
|
|
|
493
|
-
const
|
|
494
|
-
|
|
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
|
-
|
|
502
|
-
|
|
479
|
+
// Get the config that should be used for model/provider
|
|
480
|
+
const getActiveConfig = (): StoredConfig => {
|
|
481
|
+
return currentProjectConfig ?? currentGlobalConfig;
|
|
503
482
|
};
|
|
504
483
|
|
|
505
|
-
|
|
506
|
-
|
|
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
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
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
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
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
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
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
|
-
|
|
600
|
-
merged
|
|
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
|
-
|
|
604
|
-
|
|
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
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
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
|
-
|
|
619
|
-
|
|
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
|
-
|
|
627
|
-
|
|
628
|
-
|
|
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
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
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: `
|
|
662
|
-
hint: `
|
|
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
|
-
|
|
666
|
-
|
|
667
|
-
|
|
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 "${
|
|
713
|
-
hint:
|
|
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:
|
|
646
|
+
resources: mutableConfig.resources.filter((r) => r.name !== name)
|
|
722
647
|
};
|
|
723
648
|
setMutableConfig(updated);
|
|
724
649
|
await saveConfig(configPath, updated);
|
|
725
|
-
|
|
726
|
-
|
|
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
|
-
|
|
789
|
-
|
|
790
|
-
|
|
654
|
+
clearResources: async () => {
|
|
655
|
+
// Clear the resources directory
|
|
656
|
+
let clearedCount = 0;
|
|
791
657
|
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
658
|
+
let resourcesDir: string[] = [];
|
|
659
|
+
try {
|
|
660
|
+
resourcesDir = await fs.readdir(resourcesDirectory);
|
|
661
|
+
} catch {
|
|
662
|
+
resourcesDir = [];
|
|
663
|
+
}
|
|
797
664
|
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
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
|
-
|
|
811
|
-
|
|
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
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
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
|
-
|
|
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
|
-
|
|
828
|
-
if (currentProjectConfig !== null) {
|
|
829
|
-
currentProjectConfig = reloaded;
|
|
830
|
-
} else {
|
|
831
|
-
currentGlobalConfig = reloaded;
|
|
832
|
-
}
|
|
689
|
+
const reloaded = await loadConfigFromPath(configPath);
|
|
833
690
|
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
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
|
-
|
|
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
|
-
|
|
845
|
-
|
|
846
|
-
Metrics.info('config.load.start', { cwd });
|
|
730
|
+
return service;
|
|
731
|
+
};
|
|
847
732
|
|
|
848
|
-
|
|
849
|
-
|
|
733
|
+
export const load = async (): Promise<ConfigService> => {
|
|
734
|
+
const cwd = process.cwd();
|
|
735
|
+
metricsInfo('config.load.start', { cwd });
|
|
850
736
|
|
|
851
|
-
|
|
852
|
-
|
|
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
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
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
|
-
|
|
868
|
-
globalConfig = await
|
|
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
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
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
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
766
|
+
metricsInfo('config.load.merged', {
|
|
767
|
+
globalResources: globalConfig.resources.length,
|
|
768
|
+
projectResources: projectConfig.resources.length
|
|
769
|
+
});
|
|
881
770
|
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
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
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
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
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
798
|
+
const resolvedProjectDataDir = resolveDataDirectory(projectDataDir, cwd);
|
|
799
|
+
return makeService(
|
|
800
|
+
globalConfig,
|
|
801
|
+
projectConfig,
|
|
802
|
+
`${resolvedProjectDataDir}/resources`,
|
|
803
|
+
projectConfigPath
|
|
921
804
|
);
|
|
922
|
-
|
|
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
|
+
};
|