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/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.find((candidate) => candidate.id === id);
403
- if (local) {
404
- if (stored)
405
- assertIdentity(target.id, stored, local);
406
- return candidateProvider(local);
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.find((candidate) => candidate.protocol === stored.protocol &&
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
- return matching ? candidateProvider(matching) : undefined;
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 managedKey = credentialRef(id);
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
- if (target.id === "hermes" && found) {
525
- const stillUsed = files.maps.some(({ document, at }) => Object.values(providerMap(document, at)).some((entry) => isJsonObject(entry) && entry.key_env === managedKey));
526
- if (!stillUsed) {
527
- for (const [file, before] of files.extras) {
528
- if (before === undefined)
529
- continue;
530
- const edits = envAssignments(file, before).filter((assignment) => assignment.name === managedKey)
531
- .map((assignment) => ({ offset: assignment.start, length: assignment.length, content: "" }));
532
- const after = applyEdits(before, edits);
533
- if (after !== before)
534
- addPlan(plans, { file, before, after, mode: 0o600 });
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
- if (target.id === "dsh" && (found || stored)) {
539
- const stillUsed = files.maps.some(({ document, at }) => Object.values(providerMap(document, at)).some((entry) => isJsonObject(entry) && entry.apiKeyEnv === managedKey));
540
- if (!stillUsed) {
541
- for (const document of files.documents.values()) {
542
- if (path.basename(document.file) !== ".credentials.yaml")
543
- continue;
544
- removeAt(document, ["refs", managedKey]);
545
- removeAt(document, [managedKey]);
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 dir = process.env.WORKBUDDY_CONFIG_DIR?.trim() ?? process.env.CODEBUDDY_CONFIG_DIR?.trim() ??
559
- (process.platform === "win32" ? appDataDir("workbuddy") : path.join(home, ".workbuddy"));
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
- const endpoint = entry.url.replace(/\/chat\/completions\/?$/, "");
582
- if (!sameEndpoint(endpoint, provider.baseUrl) || entry.apiKey !== provider.apiKey ||
583
- !provider.models.some((model) => model.id === entry.id))
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 oldKey = managedKey(oldId);
229
- const newKey = managedKey(newId);
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" && rawKey) {
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 : /^[A-Z][A-Z0-9_]*$/.test(rawKey) ? process.env[rawKey] : rawKey;
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
- assertConnection(editor, object(entries[oldId], editor.file));
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
- const migrateEnv = oldKey !== newKey && assignments.some((assignment) => assignment.name === oldKey);
379
- if (migrateEnv) {
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
- if (migrateEnv)
395
- for (const [id, entry] of entries(editor, ["providers"])) {
396
- if (get(entry, ["key_env"]) === newKey)
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 migrateCredential = false;
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
- const refs = object(get(editor.value, at), credentialFile);
413
- credentialRefs = refs;
414
- if (oldKey === newKey || !Object.hasOwn(refs, oldKey))
415
- return;
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 name of ["settings.yaml", "settings.yml", "settings.json"])
424
- visit(path.join(dshDir, name), name.endsWith("json") ? "json" : "yaml", (editor) => {
425
- const keyRef = get(editor.value, ["llm-pi-ai", "providers", oldId, "apiKeyEnv"]);
426
- const key = typeof keyRef === "string" ? process.env[keyRef] ?? credentialRefs[keyRef] : undefined;
427
- if (key !== undefined && provider.apiKey !== undefined && key !== provider.apiKey)
428
- throw new Error(`${editor.file}: source provider credentials conflict with the store`);
429
- if (migrateCredential)
430
- for (const [id, entry] of entries(editor, ["llm-pi-ai", "providers"])) {
431
- if (get(entry, ["apiKeyEnv"]) === newKey)
432
- throw new Error(`${editor.file}: destination credential reference already exists`);
433
- exact(editor, ["llm-pi-ai", "providers", id, "apiKeyEnv"], oldKey, newKey);
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) && row.url === url && row.apiKey === provider.apiKey)
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, readJsonIfExists, writeFileAtomic } from "./fsutil.js";
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 store = readJsonIfExists(configFile);
7
- if (!store)
8
- return { version: 1, providers: {} };
9
- if (!store.providers)
10
- store.providers = {};
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
- writeFileAtomic(configFile, JSON.stringify(store, null, 2) + "\n", 0o600);
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 settings = readJsonIfExists(settingsFile) ?? {};
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
+ });