agentsw 0.7.0 → 0.7.2
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/README.md +30 -11
- package/README.zh-CN.md +26 -11
- package/dist/apps.js +48 -44
- package/dist/commands.js +221 -74
- package/dist/config-transaction.js +169 -84
- package/dist/discover.js +55 -17
- package/dist/envfile.js +101 -20
- package/dist/fsutil.js +109 -14
- package/dist/i18n.js +62 -39
- package/dist/import.js +2 -2
- package/dist/index.js +16 -1
- package/dist/jsonc.js +4 -10
- package/dist/menu.js +28 -22
- package/dist/modelsdev.js +7 -3
- package/dist/provider-identity.js +25 -0
- package/dist/remove.js +84 -38
- package/dist/rename.js +91 -58
- package/dist/store.js +38 -7
- package/dist/target-transaction.js +25 -0
- package/dist/targets/claudecode.js +12 -3
- package/dist/targets/codex.js +34 -3
- package/dist/targets/dsh.js +117 -65
- package/dist/targets/hermes.js +98 -49
- package/dist/targets/omp.js +11 -47
- package/dist/targets/opencode.js +27 -6
- package/dist/targets/pistyle.js +12 -8
- package/dist/targets/wire.js +15 -9
- package/dist/targets/workbuddy.js +151 -76
- package/dist/yaml.js +39 -0
- package/package.json +4 -2
package/dist/remove.js
CHANGED
|
@@ -11,6 +11,8 @@ import { endpointKey } from "./import.js";
|
|
|
11
11
|
import { envAssignments } from "./envfile.js";
|
|
12
12
|
import { resolveTargets, targets } from "./targets/index.js";
|
|
13
13
|
import { classifyApi, entryApi } from "./targets/wire.js";
|
|
14
|
+
import { isManagedCredentialRef, legacyManagedCredentialRef, managedCredentialRef } from "./provider-identity.js";
|
|
15
|
+
import { workbuddyBaseUrl } from "./targets/workbuddy.js";
|
|
14
16
|
function readText(file) {
|
|
15
17
|
try {
|
|
16
18
|
return fs.readFileSync(file, "utf8");
|
|
@@ -298,9 +300,6 @@ function candidateProvider(candidate) {
|
|
|
298
300
|
defaultModel: candidate.defaultModel ?? candidate.models[0] ?? "",
|
|
299
301
|
};
|
|
300
302
|
}
|
|
301
|
-
function credentialRef(id) {
|
|
302
|
-
return `AGENTSW_${id.toUpperCase().replace(/[^A-Z0-9]/g, "_")}_API_KEY`;
|
|
303
|
-
}
|
|
304
303
|
function addPlan(plans, change) {
|
|
305
304
|
const file = path.resolve(change.file);
|
|
306
305
|
const previous = plans.get(file);
|
|
@@ -383,6 +382,13 @@ function assertNamedIdentities(target, files, stored) {
|
|
|
383
382
|
protocol = "openai";
|
|
384
383
|
if (typeof entry.env_key === "string")
|
|
385
384
|
apiKey = process.env[entry.env_key];
|
|
385
|
+
else if (entry.requires_openai_auth === true && valueAt(document, ["model_provider"]) === stored.id) {
|
|
386
|
+
const authFile = path.join(path.dirname(document.file), "auth.json");
|
|
387
|
+
const authText = readText(authFile);
|
|
388
|
+
files.extras.set(authFile, authText);
|
|
389
|
+
if (authText !== undefined)
|
|
390
|
+
apiKey = valueAt(parseDocument(authFile, authText), ["OPENAI_API_KEY"]);
|
|
391
|
+
}
|
|
386
392
|
}
|
|
387
393
|
assertIdentity(target.id, stored, { baseUrl, protocol, apiKey });
|
|
388
394
|
}
|
|
@@ -399,11 +405,12 @@ function matchingCandidate(target, id, stored) {
|
|
|
399
405
|
const candidates = candidatesFor(target);
|
|
400
406
|
if (!candidates.length)
|
|
401
407
|
return undefined;
|
|
402
|
-
const local = candidates.
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
408
|
+
const local = candidates.filter((candidate) => (candidate.localId ?? candidate.id) === id);
|
|
409
|
+
const legacy = local.length ? local : candidates.filter((candidate) => candidate.id === id);
|
|
410
|
+
if (!stored && legacy.length) {
|
|
411
|
+
if (legacy.length !== 1)
|
|
412
|
+
throw new Error(`${target.name}: ambiguous provider ID; use an account-qualified ID from list --apps`);
|
|
413
|
+
return legacy[0];
|
|
407
414
|
}
|
|
408
415
|
// Single-endpoint apps have no persisted provider ID. A custom store ID can
|
|
409
416
|
// still select them, but only when both endpoint and credential match.
|
|
@@ -418,9 +425,15 @@ function matchingCandidate(target, id, stored) {
|
|
|
418
425
|
}
|
|
419
426
|
if (!stored || typeof stored.baseUrl !== "string")
|
|
420
427
|
return undefined;
|
|
421
|
-
const matching = candidates.
|
|
428
|
+
const matching = (legacy.length ? legacy : candidates).filter((candidate) => candidate.protocol === stored.protocol &&
|
|
422
429
|
sameEndpoint(candidate.baseUrl, stored.baseUrl) && candidate.apiKey === stored.apiKey);
|
|
423
|
-
|
|
430
|
+
if (matching.length > 1)
|
|
431
|
+
throw new Error(`${target.name}: ambiguous provider identity; use an account-qualified ID from list --apps`);
|
|
432
|
+
if (matching.length === 1)
|
|
433
|
+
return matching[0];
|
|
434
|
+
if (legacy.length)
|
|
435
|
+
throw new Error(`${target.name}: provider "${id}" has a different account; use --apps with an account-qualified ID`);
|
|
436
|
+
return undefined;
|
|
424
437
|
}
|
|
425
438
|
/** One row per store ID and per app-local ID, never endpoint or credential values. */
|
|
426
439
|
export function listRemovableProviders(apps) {
|
|
@@ -437,7 +450,7 @@ export function listRemovableProviders(apps) {
|
|
|
437
450
|
const entries = new Map();
|
|
438
451
|
if (target.id === "claude" || target.id === "workbuddy") {
|
|
439
452
|
for (const candidate of candidatesFor(target))
|
|
440
|
-
entries.set(candidate.id, candidate.name);
|
|
453
|
+
entries.set(candidate.localId ?? candidate.id, candidate.name);
|
|
441
454
|
}
|
|
442
455
|
else {
|
|
443
456
|
for (const { document, at } of files.maps) {
|
|
@@ -465,14 +478,27 @@ async function planTargetRemoval(target, id, plans, stored) {
|
|
|
465
478
|
if (!provider)
|
|
466
479
|
return false;
|
|
467
480
|
found = true;
|
|
468
|
-
await previewPrune(target, provider, files);
|
|
481
|
+
await previewPrune(target, candidateProvider(provider), files);
|
|
469
482
|
for (const document of files.refs)
|
|
470
483
|
removeAt(document, ["env", "ANTHROPIC_API_KEY"]);
|
|
471
484
|
}
|
|
472
485
|
else if (["omp", "pi", "prime", "codex"].includes(target.id)) {
|
|
473
486
|
await previewPrune(target, namedProvider(id), files);
|
|
474
487
|
}
|
|
475
|
-
const
|
|
488
|
+
const managedKeys = [managedCredentialRef(id), legacyManagedCredentialRef(id)];
|
|
489
|
+
const ownedRefs = new Set();
|
|
490
|
+
for (const { document, at } of files.maps) {
|
|
491
|
+
const entry = providerMap(document, at)[id];
|
|
492
|
+
if (!isJsonObject(entry))
|
|
493
|
+
continue;
|
|
494
|
+
const ref = target.id === "hermes" ? entry.key_env : entry.apiKeyEnv;
|
|
495
|
+
if (typeof ref === "string" && isManagedCredentialRef(ref, id))
|
|
496
|
+
ownedRefs.add(ref);
|
|
497
|
+
// No explicit reference: older releases may have left an unused managed key.
|
|
498
|
+
else if (ref === undefined)
|
|
499
|
+
for (const key of managedKeys)
|
|
500
|
+
ownedRefs.add(key);
|
|
501
|
+
}
|
|
476
502
|
for (const { document, at } of files.maps) {
|
|
477
503
|
const removed = removeAt(document, [...at, id]);
|
|
478
504
|
if (removed && ["opencode", "codex", "hermes", "dsh"].includes(target.id))
|
|
@@ -521,28 +547,32 @@ async function planTargetRemoval(target, id, plans, stored) {
|
|
|
521
547
|
clearModelRefs(document, [key], id);
|
|
522
548
|
}
|
|
523
549
|
}
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
550
|
+
for (const managedKey of ownedRefs) {
|
|
551
|
+
if (target.id === "hermes" && found) {
|
|
552
|
+
const stillUsed = files.maps.some(({ document, at }) => Object.entries(providerMap(document, at)).some(([otherId, entry]) => isJsonObject(entry) && (entry.key_env === managedKey || isManagedCredentialRef(managedKey, otherId))));
|
|
553
|
+
if (!stillUsed) {
|
|
554
|
+
for (const [file, before] of files.extras) {
|
|
555
|
+
if (before === undefined)
|
|
556
|
+
continue;
|
|
557
|
+
const prior = plans.get(path.resolve(file));
|
|
558
|
+
const source = prior?.after ?? before;
|
|
559
|
+
const edits = envAssignments(file, source).filter((assignment) => assignment.name === managedKey)
|
|
560
|
+
.map((assignment) => ({ offset: assignment.start, length: assignment.length, content: "" }));
|
|
561
|
+
const after = applyEdits(source, edits);
|
|
562
|
+
if (after !== source)
|
|
563
|
+
plans.set(path.resolve(file), { file, before, after, mode: 0o600 });
|
|
564
|
+
}
|
|
535
565
|
}
|
|
536
566
|
}
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
567
|
+
if (target.id === "dsh" && found) {
|
|
568
|
+
const stillUsed = files.maps.some(({ document, at }) => Object.entries(providerMap(document, at)).some(([otherId, entry]) => isJsonObject(entry) && (entry.apiKeyEnv === managedKey || isManagedCredentialRef(managedKey, otherId))));
|
|
569
|
+
if (!stillUsed) {
|
|
570
|
+
for (const document of files.documents.values()) {
|
|
571
|
+
if (path.basename(document.file) !== ".credentials.yaml")
|
|
572
|
+
continue;
|
|
573
|
+
removeAt(document, ["refs", managedKey]);
|
|
574
|
+
removeAt(document, [managedKey]);
|
|
575
|
+
}
|
|
546
576
|
}
|
|
547
577
|
}
|
|
548
578
|
}
|
|
@@ -552,11 +582,15 @@ async function planTargetRemoval(target, id, plans, stored) {
|
|
|
552
582
|
addPlan(plans, { file: document.file, before: document.before, after: render(document),
|
|
553
583
|
...(path.basename(document.file) === ".credentials.yaml" ? { mode: 0o600 } : {}) });
|
|
554
584
|
}
|
|
585
|
+
for (const [file, before] of files.extras) {
|
|
586
|
+
if (!plans.has(path.resolve(file)))
|
|
587
|
+
addPlan(plans, { file, before, after: before });
|
|
588
|
+
}
|
|
555
589
|
return found;
|
|
556
590
|
}
|
|
557
591
|
function planWorkbuddyRemoval(target, id, plans, stored) {
|
|
558
|
-
const
|
|
559
|
-
|
|
592
|
+
const override = process.env.WORKBUDDY_CONFIG_DIR?.trim() || process.env.CODEBUDDY_CONFIG_DIR?.trim();
|
|
593
|
+
const dir = override ? expandHome(override) : (process.platform === "win32" ? appDataDir("workbuddy") : path.join(home, ".workbuddy"));
|
|
560
594
|
const file = path.join(dir, "models.json");
|
|
561
595
|
const before = readText(file);
|
|
562
596
|
if (before === undefined)
|
|
@@ -571,6 +605,16 @@ function planWorkbuddyRemoval(target, id, plans, stored) {
|
|
|
571
605
|
const models = Array.isArray(raw) ? raw : isJsonObject(raw) && Array.isArray(raw.models) ? raw.models : undefined;
|
|
572
606
|
if (!models)
|
|
573
607
|
throw new Error(`${file}: expected a model array`);
|
|
608
|
+
if (stored)
|
|
609
|
+
for (const entry of models) {
|
|
610
|
+
if (!isJsonObject(entry) || entry.agentswProviderId !== stored.id)
|
|
611
|
+
continue;
|
|
612
|
+
assertIdentity(target.id, stored, {
|
|
613
|
+
protocol: "openai",
|
|
614
|
+
baseUrl: typeof entry.url === "string" ? workbuddyBaseUrl(entry.url) : undefined,
|
|
615
|
+
apiKey: entry.apiKey,
|
|
616
|
+
});
|
|
617
|
+
}
|
|
574
618
|
const provider = matchingCandidate(target, id, stored);
|
|
575
619
|
if (!provider)
|
|
576
620
|
return false;
|
|
@@ -578,9 +622,11 @@ function planWorkbuddyRemoval(target, id, plans, stored) {
|
|
|
578
622
|
const kept = models.filter((entry) => {
|
|
579
623
|
if (!isJsonObject(entry) || typeof entry.id !== "string" || typeof entry.url !== "string")
|
|
580
624
|
return true;
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
625
|
+
if (stored && typeof entry.agentswProviderId === "string" && entry.agentswProviderId !== stored.id)
|
|
626
|
+
return true;
|
|
627
|
+
const endpoint = workbuddyBaseUrl(entry.url);
|
|
628
|
+
if (endpoint === undefined || !sameEndpoint(endpoint, provider.baseUrl) || entry.apiKey !== provider.apiKey ||
|
|
629
|
+
!provider.models.includes(entry.id))
|
|
584
630
|
return true;
|
|
585
631
|
removed.add(entry.id);
|
|
586
632
|
return false;
|
package/dist/rename.js
CHANGED
|
@@ -10,6 +10,8 @@ import { commitFileChanges } from "./config-transaction.js";
|
|
|
10
10
|
import { endpointKey } from "./import.js";
|
|
11
11
|
import { classifyApi, entryApi } from "./targets/wire.js";
|
|
12
12
|
import { envAssignments } from "./envfile.js";
|
|
13
|
+
import { isManagedCredentialRef, legacyManagedCredentialRef, managedCredentialRef } from "./provider-identity.js";
|
|
14
|
+
import { workbuddyBaseUrl } from "./targets/workbuddy.js";
|
|
13
15
|
function readExisting(file) {
|
|
14
16
|
try {
|
|
15
17
|
return fs.readFileSync(file, "utf8");
|
|
@@ -206,7 +208,6 @@ function tomlEditor(file, before) {
|
|
|
206
208
|
},
|
|
207
209
|
};
|
|
208
210
|
}
|
|
209
|
-
const managedKey = (id) => `AGENTSW_${id.toUpperCase().replace(/[^A-Z0-9]/g, "_")}_API_KEY`;
|
|
210
211
|
/** Explicit provider ID migration; never recreates providers or edits session history. */
|
|
211
212
|
export async function renameProvider(oldId, newId, opts = {}) {
|
|
212
213
|
if (!/^[a-z0-9][a-z0-9_-]*$/.test(newId))
|
|
@@ -225,9 +226,9 @@ export async function renameProvider(oldId, newId, opts = {}) {
|
|
|
225
226
|
const provider = object(providers[oldId], configFile);
|
|
226
227
|
const changes = [];
|
|
227
228
|
const visited = new Set();
|
|
228
|
-
const
|
|
229
|
-
const newKey =
|
|
230
|
-
const assertConnection = (editor, entry) => {
|
|
229
|
+
const oldKeys = [managedCredentialRef(oldId), legacyManagedCredentialRef(oldId)];
|
|
230
|
+
const newKey = managedCredentialRef(newId);
|
|
231
|
+
const assertConnection = (editor, entry, envLookup = false) => {
|
|
231
232
|
const options = isJsonObject(entry.options) ? entry.options : {};
|
|
232
233
|
const endpoint = entry.baseUrl ?? entry.baseURL ?? entry.base_url ?? options.baseURL ?? (entry.transport ? entry.api : undefined);
|
|
233
234
|
if (typeof endpoint === "string" && /^https?:\/\//i.test(endpoint) && typeof provider.baseUrl === "string" && endpointKey(endpoint) !== endpointKey(provider.baseUrl)) {
|
|
@@ -241,10 +242,10 @@ export async function renameProvider(oldId, newId, opts = {}) {
|
|
|
241
242
|
if (protocol && provider.protocol && protocol !== provider.protocol)
|
|
242
243
|
throw new Error(`${editor.file}: source provider protocol conflicts with the store`);
|
|
243
244
|
const rawKey = entry.apiKey ?? options.apiKey;
|
|
244
|
-
if (typeof rawKey === "string"
|
|
245
|
+
if (typeof rawKey === "string") {
|
|
245
246
|
const ref = rawKey.match(/^\$([A-Za-z_][A-Za-z0-9_]*)$/)?.[1] ?? rawKey.match(/^\{env:([A-Za-z_][A-Za-z0-9_]*)\}$/)?.[1];
|
|
246
247
|
// Command/file references remain unresolved; no shell execution or secret-file reads.
|
|
247
|
-
const key = ref ? process.env[ref] : /^(?:!|\{file:)/.test(rawKey) ? undefined :
|
|
248
|
+
const key = ref ? process.env[ref] : /^(?:!|\{file:)/.test(rawKey) ? undefined : envLookup ? process.env[rawKey] ?? rawKey : rawKey;
|
|
248
249
|
if (key !== undefined && typeof provider.apiKey === "string" && key !== provider.apiKey)
|
|
249
250
|
throw new Error(`${editor.file}: source provider credentials conflict with the store`);
|
|
250
251
|
}
|
|
@@ -262,7 +263,7 @@ export async function renameProvider(oldId, newId, opts = {}) {
|
|
|
262
263
|
const value = get(editor.value, at);
|
|
263
264
|
return value === undefined ? [] : Object.entries(object(value, editor.file));
|
|
264
265
|
};
|
|
265
|
-
const renameMap = (editor, at, names = ["id", "name"]) => {
|
|
266
|
+
const renameMap = (editor, at, names = ["id", "name"], envLookup = false) => {
|
|
266
267
|
const map = get(editor.value, at);
|
|
267
268
|
if (map === undefined)
|
|
268
269
|
return;
|
|
@@ -271,7 +272,13 @@ export async function renameProvider(oldId, newId, opts = {}) {
|
|
|
271
272
|
throw new Error(`${editor.file}: destination provider ID already exists`);
|
|
272
273
|
if (!Object.hasOwn(entries, oldId))
|
|
273
274
|
return;
|
|
274
|
-
|
|
275
|
+
const entry = object(entries[oldId], editor.file);
|
|
276
|
+
assertConnection(editor, entry, envLookup);
|
|
277
|
+
if (Array.isArray(entry.models))
|
|
278
|
+
for (const model of entry.models) {
|
|
279
|
+
if (isJsonObject(model))
|
|
280
|
+
assertConnection(editor, model, envLookup);
|
|
281
|
+
}
|
|
275
282
|
for (const key of names)
|
|
276
283
|
exact(editor, [...at, oldId, key]);
|
|
277
284
|
editor.move(at, oldId, newId);
|
|
@@ -323,7 +330,7 @@ export async function renameProvider(oldId, newId, opts = {}) {
|
|
|
323
330
|
});
|
|
324
331
|
const ompDir = path.join(home, ".omp", "agent");
|
|
325
332
|
for (const name of ["models.yml", "models.yaml"])
|
|
326
|
-
visit(path.join(ompDir, name), "yaml", (editor) => renameMap(editor, ["providers"]));
|
|
333
|
+
visit(path.join(ompDir, name), "yaml", (editor) => renameMap(editor, ["providers"], undefined, true));
|
|
327
334
|
for (const name of ["config.yml", "config.yaml"])
|
|
328
335
|
visit(path.join(ompDir, name), "yaml", (editor) => {
|
|
329
336
|
compound(editor, ["model"]);
|
|
@@ -332,7 +339,7 @@ export async function renameProvider(oldId, newId, opts = {}) {
|
|
|
332
339
|
});
|
|
333
340
|
for (const [env, fallback] of [["PI_CODING_AGENT_DIR", ".pi/agent"], ["PRIME_AGENT_CODING_AGENT_DIR", ".prime/agent"]]) {
|
|
334
341
|
const dir = process.env[env] ? expandHome(process.env[env]) : path.join(home, fallback);
|
|
335
|
-
visit(path.join(dir, "models.json"), "json", (editor) => renameMap(editor, ["providers"]));
|
|
342
|
+
visit(path.join(dir, "models.json"), "json", (editor) => renameMap(editor, ["providers"], undefined, env === "PRIME_AGENT_CODING_AGENT_DIR"));
|
|
336
343
|
visit(path.join(dir, "settings.json"), "json", (editor) => {
|
|
337
344
|
exact(editor, ["defaultProvider"]);
|
|
338
345
|
const recent = get(editor.value, ["recentModels"]);
|
|
@@ -370,71 +377,87 @@ export async function renameProvider(oldId, newId, opts = {}) {
|
|
|
370
377
|
list.forEach((_value, index) => exact(editor, [key, index]));
|
|
371
378
|
}
|
|
372
379
|
});
|
|
380
|
+
const migrationKey = (file, local, maps, field) => {
|
|
381
|
+
const sourceRefs = maps.flatMap((map) => map.filter(([id]) => id === oldId).map(([, entry]) => get(entry, [field])));
|
|
382
|
+
const eligible = oldKeys.filter((key) => local.has(key) &&
|
|
383
|
+
(sourceRefs.includes(key) || sourceRefs.every((ref) => ref === undefined)) &&
|
|
384
|
+
!Object.keys(providers).some((id) => id !== oldId && isManagedCredentialRef(key, id)) &&
|
|
385
|
+
!maps.some((map) => map.some(([id, entry]) => id !== oldId &&
|
|
386
|
+
(get(entry, [field]) === key || isManagedCredentialRef(key, id)))));
|
|
387
|
+
if (eligible.length > 1)
|
|
388
|
+
throw new Error(`${file}: ambiguous managed credential references`);
|
|
389
|
+
const key = eligible[0];
|
|
390
|
+
if (!key)
|
|
391
|
+
return undefined;
|
|
392
|
+
if (local.has(newKey) || process.env[newKey] !== undefined ||
|
|
393
|
+
maps.some((map) => map.some(([, entry]) => get(entry, [field]) === newKey))) {
|
|
394
|
+
throw new Error(`${file}: destination credential reference already exists`);
|
|
395
|
+
}
|
|
396
|
+
if (process.env[key] !== undefined && process.env[key] !== local.get(key))
|
|
397
|
+
throw new Error(`${file}: inherited credential overrides the local definition`);
|
|
398
|
+
if (typeof provider.apiKey === "string" && local.get(key) !== provider.apiKey)
|
|
399
|
+
throw new Error(`${file}: source provider credentials conflict with the store`);
|
|
400
|
+
return key;
|
|
401
|
+
};
|
|
373
402
|
const hermesDir = process.env.HERMES_HOME?.trim() ? expandHome(process.env.HERMES_HOME.trim()) : localAppDataDir("hermes");
|
|
374
403
|
const envFile = path.join(hermesDir, ".env");
|
|
375
404
|
const envBefore = readExisting(envFile);
|
|
376
405
|
const assignments = envBefore === undefined ? [] : envAssignments(envFile, envBefore);
|
|
377
406
|
const envValues = new Map(assignments.map((assignment) => [assignment.name, assignment.value]));
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
if (assignments.some((assignment) => assignment.name === newKey) || process.env[newKey] !== undefined)
|
|
381
|
-
throw new Error(`${envFile}: destination credential reference already exists`);
|
|
382
|
-
if (process.env[oldKey] !== undefined && process.env[oldKey] !== envValues.get(oldKey))
|
|
383
|
-
throw new Error(`${envFile}: inherited credential overrides the local definition`);
|
|
384
|
-
const after = applyEdits(envBefore, assignments.filter((assignment) => assignment.name === oldKey).map((assignment) => ({ offset: assignment.offset, length: oldKey.length, content: newKey })));
|
|
385
|
-
changes.push({ file: envFile, before: envBefore, after, mode: 0o600 });
|
|
386
|
-
}
|
|
387
|
-
if (!migrateEnv)
|
|
388
|
-
changes.push({ file: envFile, before: envBefore, after: envBefore });
|
|
407
|
+
let envKey;
|
|
408
|
+
let hermesPresent = false;
|
|
389
409
|
visit(path.join(hermesDir, "config.yaml"), "yaml", (editor) => {
|
|
410
|
+
hermesPresent = true;
|
|
390
411
|
const keyRef = get(editor.value, ["providers", oldId, "key_env"]);
|
|
391
412
|
const key = typeof keyRef === "string" ? process.env[keyRef] ?? envValues.get(keyRef) : undefined;
|
|
392
413
|
if (key !== undefined && provider.apiKey !== undefined && key !== provider.apiKey)
|
|
393
414
|
throw new Error(`${editor.file}: source provider credentials conflict with the store`);
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
throw new Error(`${editor.file}: destination credential reference already exists`);
|
|
398
|
-
exact(editor, ["providers", id, "key_env"], oldKey, newKey);
|
|
399
|
-
}
|
|
415
|
+
envKey = migrationKey(envFile, envValues, [entries(editor, ["providers"])], "key_env");
|
|
416
|
+
if (envKey)
|
|
417
|
+
exact(editor, ["providers", oldId, "key_env"], envKey, newKey);
|
|
400
418
|
renameMap(editor, ["providers"]);
|
|
401
419
|
exact(editor, ["model", "provider"]);
|
|
402
420
|
});
|
|
421
|
+
if (!hermesPresent)
|
|
422
|
+
envKey = migrationKey(envFile, envValues, [], "key_env");
|
|
423
|
+
const envAfter = envKey ? applyEdits(envBefore, assignments.filter((assignment) => assignment.name === envKey)
|
|
424
|
+
.map((assignment) => ({ offset: assignment.offset, length: envKey.length, content: newKey }))) : envBefore;
|
|
425
|
+
changes.push({ file: envFile, before: envBefore, after: envAfter, mode: 0o600 });
|
|
403
426
|
const dshDir = process.env.DSH_HOME?.trim() ? expandHome(process.env.DSH_HOME.trim()) : localAppDataDir("dsh");
|
|
427
|
+
const settings = [];
|
|
428
|
+
for (const name of ["settings.yaml", "settings.yml", "settings.json"]) {
|
|
429
|
+
const file = path.join(dshDir, name);
|
|
430
|
+
const before = readExisting(file);
|
|
431
|
+
if (before === undefined)
|
|
432
|
+
changes.push({ file, before, after: before });
|
|
433
|
+
else
|
|
434
|
+
settings.push({ editor: name.endsWith("json") ? jsonEditor(file, before) : yamlEditor(file, before), before });
|
|
435
|
+
}
|
|
436
|
+
const dshMaps = settings.map(({ editor }) => entries(editor, ["llm-pi-ai", "providers"]));
|
|
404
437
|
const credentialFile = path.join(dshDir, ".credentials.yaml");
|
|
405
|
-
let
|
|
438
|
+
let credentialKey;
|
|
406
439
|
let credentialRefs = {};
|
|
407
440
|
visit(credentialFile, "yaml", (editor) => {
|
|
408
441
|
const version = get(editor.value, ["version"]);
|
|
409
442
|
if (version !== undefined && version !== 1)
|
|
410
443
|
throw new Error(`${credentialFile}: unsupported credential layout version`);
|
|
411
444
|
const at = version === undefined && get(editor.value, ["refs"]) === undefined ? [] : ["refs"];
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
if (
|
|
415
|
-
|
|
416
|
-
if (Object.hasOwn(refs, newKey) || process.env[newKey] !== undefined)
|
|
417
|
-
throw new Error(`${credentialFile}: destination credential reference already exists`);
|
|
418
|
-
if (process.env[oldKey] !== undefined && process.env[oldKey] !== refs[oldKey])
|
|
419
|
-
throw new Error(`${credentialFile}: inherited credential overrides the local definition`);
|
|
420
|
-
editor.move(at, oldKey, newKey);
|
|
421
|
-
migrateCredential = true;
|
|
445
|
+
credentialRefs = object(get(editor.value, at), credentialFile);
|
|
446
|
+
credentialKey = migrationKey(credentialFile, new Map(Object.entries(credentialRefs)), dshMaps, "apiKeyEnv");
|
|
447
|
+
if (credentialKey)
|
|
448
|
+
editor.move(at, credentialKey, newKey);
|
|
422
449
|
}, 0o600);
|
|
423
|
-
for (const
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
}
|
|
435
|
-
renameMap(editor, ["llm-pi-ai", "providers"], ["id", "displayName"]);
|
|
436
|
-
exact(editor, ["agent-default-model", "provider"]);
|
|
437
|
-
});
|
|
450
|
+
for (const { editor, before } of settings) {
|
|
451
|
+
const keyRef = get(editor.value, ["llm-pi-ai", "providers", oldId, "apiKeyEnv"]);
|
|
452
|
+
const key = typeof keyRef === "string" ? process.env[keyRef] ?? credentialRefs[keyRef] : undefined;
|
|
453
|
+
if (key !== undefined && provider.apiKey !== undefined && key !== provider.apiKey)
|
|
454
|
+
throw new Error(`${editor.file}: source provider credentials conflict with the store`);
|
|
455
|
+
if (credentialKey)
|
|
456
|
+
exact(editor, ["llm-pi-ai", "providers", oldId, "apiKeyEnv"], credentialKey, newKey);
|
|
457
|
+
renameMap(editor, ["llm-pi-ai", "providers"], ["id", "displayName"]);
|
|
458
|
+
exact(editor, ["agent-default-model", "provider"]);
|
|
459
|
+
finish(editor, before);
|
|
460
|
+
}
|
|
438
461
|
const workbuddyEnv = process.env.WORKBUDDY_CONFIG_DIR?.trim() ?? process.env.CODEBUDDY_CONFIG_DIR?.trim();
|
|
439
462
|
const workbuddyDir = workbuddyEnv ? expandHome(workbuddyEnv) : process.platform === "win32" ? appDataDir("workbuddy") : path.join(home, ".workbuddy");
|
|
440
463
|
visit(path.join(workbuddyDir, "models.json"), "json", (editor) => {
|
|
@@ -444,12 +467,22 @@ export async function renameProvider(oldId, newId, opts = {}) {
|
|
|
444
467
|
return;
|
|
445
468
|
if (!Array.isArray(rows))
|
|
446
469
|
throw new Error(`${editor.file}: expected WorkBuddy models array`);
|
|
447
|
-
const base = typeof provider.baseUrl === "string" ? provider.baseUrl.replace(/\/+$/, "") : "";
|
|
448
|
-
const url = /\/v\d+$/.test(base) ? `${base}/chat/completions` : `${base}/v1/chat/completions`;
|
|
449
|
-
if (provider.name !== oldId)
|
|
450
|
-
return;
|
|
451
470
|
rows.forEach((row, index) => {
|
|
452
|
-
if (isJsonObject(row)
|
|
471
|
+
if (!isJsonObject(row))
|
|
472
|
+
return;
|
|
473
|
+
if (row.agentswProviderId === newId)
|
|
474
|
+
throw new Error(`${editor.file}: destination provider ID already exists`);
|
|
475
|
+
const owned = row.agentswProviderId === oldId;
|
|
476
|
+
const endpoint = typeof row.url === "string" ? workbuddyBaseUrl(row.url) : undefined;
|
|
477
|
+
const matches = typeof endpoint === "string" && typeof provider.baseUrl === "string" &&
|
|
478
|
+
endpointKey(endpoint) === endpointKey(provider.baseUrl) && row.apiKey === provider.apiKey;
|
|
479
|
+
if (owned && !matches)
|
|
480
|
+
throw new Error(`${editor.file}: source provider connection conflicts with the store`);
|
|
481
|
+
if (!matches || (row.agentswProviderId !== undefined && !owned))
|
|
482
|
+
return;
|
|
483
|
+
if (owned)
|
|
484
|
+
exact(editor, [...at, index, "agentswProviderId"]);
|
|
485
|
+
if (provider.name === oldId)
|
|
453
486
|
exact(editor, [...at, index, "vendor"]);
|
|
454
487
|
});
|
|
455
488
|
}, undefined, true);
|
package/dist/store.js
CHANGED
|
@@ -1,17 +1,48 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
|
-
import { appDataDir,
|
|
2
|
+
import { appDataDir, isDryRun, readFileSnapshot, recordPendingWrites } from "./fsutil.js";
|
|
3
|
+
import { commitFileChanges } from "./config-transaction.js";
|
|
3
4
|
export const configDir = appDataDir("agentsw");
|
|
4
5
|
export const configFile = path.join(configDir, "config.json");
|
|
6
|
+
const loadedSnapshots = new WeakMap();
|
|
5
7
|
export function loadStore() {
|
|
6
|
-
const
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
const snapshot = readFileSnapshot(configFile);
|
|
9
|
+
let store;
|
|
10
|
+
if (snapshot.text === undefined)
|
|
11
|
+
store = { version: 1, providers: {} };
|
|
12
|
+
else {
|
|
13
|
+
try {
|
|
14
|
+
store = JSON.parse(snapshot.text);
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
throw new Error(`${configFile}: invalid provider store JSON`);
|
|
18
|
+
}
|
|
19
|
+
if (!store || typeof store !== "object" || Array.isArray(store)) {
|
|
20
|
+
throw new Error(`${configFile}: expected a provider store object`);
|
|
21
|
+
}
|
|
22
|
+
if (store.providers === undefined)
|
|
23
|
+
store.providers = {};
|
|
24
|
+
if (!store.providers || typeof store.providers !== "object" || Array.isArray(store.providers)) {
|
|
25
|
+
throw new Error(`${configFile}: expected a providers object`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
loadedSnapshots.set(store, snapshot);
|
|
11
29
|
return store;
|
|
12
30
|
}
|
|
13
31
|
export function saveStore(store) {
|
|
14
|
-
|
|
32
|
+
// A snapshot stays associated with this object across asynchronous discovery and repeated saves.
|
|
33
|
+
const expected = loadedSnapshots.get(store) ?? readFileSnapshot(configFile);
|
|
34
|
+
const after = JSON.stringify(store, null, 2) + "\n";
|
|
35
|
+
const changes = [{ file: configFile, before: expected.text, after, mode: 0o600, expected }];
|
|
36
|
+
const dryRun = isDryRun();
|
|
37
|
+
commitFileChanges(changes, { dryRun });
|
|
38
|
+
if (dryRun) {
|
|
39
|
+
recordPendingWrites(changes);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
const saved = readFileSnapshot(configFile);
|
|
43
|
+
if (saved.text !== after)
|
|
44
|
+
throw new Error(`${configFile}: configuration changed during store save`);
|
|
45
|
+
loadedSnapshots.set(store, saved);
|
|
15
46
|
}
|
|
16
47
|
export function getProvider(store, id) {
|
|
17
48
|
const p = store.providers[id];
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { commitFileChanges } from "./config-transaction.js";
|
|
2
|
+
import { recordPendingWrites, stageFileWrites } from "./fsutil.js";
|
|
3
|
+
const wrappedTargets = new WeakSet();
|
|
4
|
+
/** Validate every adapter input before committing any of its configuration writes. */
|
|
5
|
+
export function transactionalTarget(target) {
|
|
6
|
+
if (wrappedTargets.has(target))
|
|
7
|
+
return target;
|
|
8
|
+
const wrapped = Object.create(Object.getPrototypeOf(target), Object.getOwnPropertyDescriptors(target));
|
|
9
|
+
for (const method of ["apply", "prune"]) {
|
|
10
|
+
const operation = target[method];
|
|
11
|
+
wrapped[method] = async function (provider) {
|
|
12
|
+
const staged = await stageFileWrites(() => operation.call(this, provider));
|
|
13
|
+
const committed = commitFileChanges(staged.changes, { dryRun: staged.dryRun, reads: staged.reads });
|
|
14
|
+
if (staged.dryRun)
|
|
15
|
+
recordPendingWrites(staged.changes);
|
|
16
|
+
return {
|
|
17
|
+
...staged.result,
|
|
18
|
+
changed: committed.files,
|
|
19
|
+
notes: committed.backupDir ? [...staged.result.notes, `backup: ${committed.backupDir}`] : staged.result.notes,
|
|
20
|
+
};
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
wrappedTargets.add(wrapped);
|
|
24
|
+
return wrapped;
|
|
25
|
+
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { backupFile, home, readJsonIfExists, writeFileAtomic } from "../fsutil.js";
|
|
4
|
+
import { isJsonObject } from "../jsonc.js";
|
|
5
|
+
import { transactionalTarget } from "../target-transaction.js";
|
|
4
6
|
import { providerIdFromBaseUrl, providerNameFromBaseUrl } from "../slug.js";
|
|
5
7
|
import { stripApiVersion } from "./wire.js";
|
|
6
8
|
const dir = path.join(home, ".claude");
|
|
@@ -9,7 +11,7 @@ const settingsFile = path.join(dir, "settings.json");
|
|
|
9
11
|
* Claude Code reads Anthropic-protocol endpoints via env vars in ~/.claude/settings.json.
|
|
10
12
|
* We merge the "env" block and leave every other setting untouched.
|
|
11
13
|
*/
|
|
12
|
-
export const claudecode = {
|
|
14
|
+
export const claudecode = transactionalTarget({
|
|
13
15
|
id: "claude",
|
|
14
16
|
name: "Claude Code",
|
|
15
17
|
protocols: ["anthropic"],
|
|
@@ -17,7 +19,11 @@ export const claudecode = {
|
|
|
17
19
|
detect: () => fs.existsSync(dir),
|
|
18
20
|
async apply(provider) {
|
|
19
21
|
const notes = [];
|
|
20
|
-
const
|
|
22
|
+
const settingsValue = readJsonIfExists(settingsFile);
|
|
23
|
+
const settings = settingsValue === undefined ? {} : settingsValue;
|
|
24
|
+
if (!isJsonObject(settings) || (settings.env !== undefined && !isJsonObject(settings.env))) {
|
|
25
|
+
throw new Error(`${settingsFile}: expected settings and env to be JSON objects`);
|
|
26
|
+
}
|
|
21
27
|
const env = { ...settings.env };
|
|
22
28
|
env.ANTHROPIC_BASE_URL = stripApiVersion(provider.baseUrl);
|
|
23
29
|
env.ANTHROPIC_AUTH_TOKEN = provider.apiKey;
|
|
@@ -38,6 +44,9 @@ export const claudecode = {
|
|
|
38
44
|
},
|
|
39
45
|
async prune(provider) {
|
|
40
46
|
const settings = readJsonIfExists(settingsFile);
|
|
47
|
+
if (settings !== undefined && (!isJsonObject(settings) || (settings.env !== undefined && !isJsonObject(settings.env)))) {
|
|
48
|
+
throw new Error(`${settingsFile}: expected settings and env to be JSON objects`);
|
|
49
|
+
}
|
|
41
50
|
const env = settings?.env;
|
|
42
51
|
if (!settings || !env || env.ANTHROPIC_BASE_URL !== stripApiVersion(provider.baseUrl)) {
|
|
43
52
|
return { app: this.id, changed: [], notes: [], skipped: "claude env does not point at this provider" };
|
|
@@ -95,4 +104,4 @@ export const claudecode = {
|
|
|
95
104
|
},
|
|
96
105
|
];
|
|
97
106
|
},
|
|
98
|
-
};
|
|
107
|
+
});
|