agentsw 0.7.1 → 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/fsutil.js CHANGED
@@ -1,6 +1,8 @@
1
1
  import fs from "node:fs";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
+ import { AsyncLocalStorage } from "node:async_hooks";
5
+ import { randomUUID } from "node:crypto";
4
6
  // AGENTSW_HOME is an explicit portable/test override. Native Windows otherwise
5
7
  // uses USERPROFILE/os.homedir(), while Unix follows HOME as expected.
6
8
  export const home = process.env.AGENTSW_HOME?.trim() ||
@@ -24,14 +26,62 @@ export function expandHome(p) {
24
26
  return p.startsWith("~") ? path.join(home, p.slice(1)) : p;
25
27
  }
26
28
  export function ensureDir(dir) {
27
- fs.mkdirSync(dir, { recursive: true });
29
+ if (isDryRun() || stagedFiles.getStore())
30
+ return;
31
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
32
+ }
33
+ /** Read exactly one regular file. A broken path or unreadable file is not an empty config. */
34
+ export function readFileSnapshot(file) {
35
+ try {
36
+ const stat = fs.lstatSync(file, { bigint: true });
37
+ if (!stat.isFile())
38
+ throw new Error(`${file}: not a regular configuration file`);
39
+ return { text: fs.readFileSync(file, "utf8"), mode: Number(stat.mode & 511n), identity: `${stat.dev}:${stat.ino}` };
40
+ }
41
+ catch (error) {
42
+ if (error.code === "ENOENT")
43
+ return { text: undefined };
44
+ throw new Error(`${file}: cannot read a regular configuration file`);
45
+ }
46
+ }
47
+ const stagedFiles = new AsyncLocalStorage();
48
+ function stagedSnapshot(scope, file) {
49
+ if (scope.closed)
50
+ throw new Error("configuration transaction already finished");
51
+ let snapshot = scope.reads.get(file);
52
+ if (!snapshot) {
53
+ snapshot = readFileSnapshot(file);
54
+ scope.reads.set(file, snapshot);
55
+ }
56
+ return snapshot;
57
+ }
58
+ /** Scope only this asynchronous adapter invocation, never unrelated filesystem calls. */
59
+ export async function stageFileWrites(operation) {
60
+ if (stagedFiles.getStore())
61
+ throw new Error("nested configuration transaction is not supported");
62
+ const scope = { reads: new Map(), writes: new Map(), dryRun, closed: false };
63
+ try {
64
+ const result = await stagedFiles.run(scope, operation);
65
+ return { result, changes: [...scope.writes.values()], reads: scope.reads, dryRun: scope.dryRun };
66
+ }
67
+ finally {
68
+ scope.closed = true;
69
+ }
28
70
  }
29
71
  export function readTextIfExists(file) {
72
+ const scope = stagedFiles.getStore();
73
+ if (scope) {
74
+ file = path.resolve(file);
75
+ const snapshot = stagedSnapshot(scope, file);
76
+ return scope.writes.has(file) ? scope.writes.get(file).after : snapshot.text;
77
+ }
30
78
  try {
31
79
  return fs.readFileSync(file, "utf8");
32
80
  }
33
- catch {
34
- return undefined;
81
+ catch (error) {
82
+ if (error.code === "ENOENT")
83
+ return undefined;
84
+ throw error;
35
85
  }
36
86
  }
37
87
  export function readJsonIfExists(file) {
@@ -40,9 +90,12 @@ export function readJsonIfExists(file) {
40
90
  return undefined;
41
91
  return JSON.parse(text);
42
92
  }
43
- /** dry-run mode: writeFileAtomic records intents instead of touching disk; backups are skipped */
93
+ /** Compatibility preview switch. Each target captures its value when its scoped work starts. */
44
94
  let dryRun = false;
45
95
  const pendingWrites = [];
96
+ export function isDryRun() {
97
+ return stagedFiles.getStore()?.dryRun ?? dryRun;
98
+ }
46
99
  export function setDryRun(on) {
47
100
  dryRun = on;
48
101
  pendingWrites.length = 0;
@@ -50,25 +103,67 @@ export function setDryRun(on) {
50
103
  export function drainPendingWrites() {
51
104
  return pendingWrites.splice(0);
52
105
  }
53
- /** Atomic write: tmp file + rename. Creates parent dirs. In dry-run mode, records the intent instead. */
106
+ /** Publish previews only after the adapter has validated all of its input files. */
107
+ export function recordPendingWrites(changes) {
108
+ for (const change of changes) {
109
+ if (change.before !== change.after && change.after !== undefined) {
110
+ pendingWrites.push({ file: change.file, content: change.after });
111
+ }
112
+ }
113
+ }
114
+ /** Atomic replacement, preserving existing permissions and defaulting new secret files to 0600. */
54
115
  export function writeFileAtomic(file, content, mode) {
116
+ const scope = stagedFiles.getStore();
117
+ if (scope) {
118
+ file = path.resolve(file);
119
+ const expected = stagedSnapshot(scope, file);
120
+ scope.writes.set(file, { file, before: expected.text, after: content, mode, expected });
121
+ return;
122
+ }
55
123
  if (dryRun) {
56
124
  pendingWrites.push({ file, content });
57
125
  return;
58
126
  }
127
+ const existing = readFileSnapshot(file);
128
+ const permissions = mode ?? existing.mode ?? 0o600;
129
+ if (!Number.isInteger(permissions) || permissions < 0 || permissions > 0o777) {
130
+ throw new Error(`${file}: invalid file permissions`);
131
+ }
59
132
  ensureDir(path.dirname(file));
60
- const tmp = `${file}.tmp-${process.pid}`;
61
- fs.writeFileSync(tmp, content, mode !== undefined ? { mode } : undefined);
62
- fs.renameSync(tmp, file);
133
+ const tmp = path.join(path.dirname(file), `.${path.basename(file)}.agentsw-${randomUUID()}.tmp`);
134
+ try {
135
+ fs.writeFileSync(tmp, content, { flag: "wx", mode: permissions });
136
+ fs.chmodSync(tmp, permissions);
137
+ fs.renameSync(tmp, file);
138
+ }
139
+ finally {
140
+ fs.rmSync(tmp, { force: true });
141
+ }
63
142
  }
64
143
  export const backupsDir = path.join(appDataDir("agentsw"), "backups");
65
- /** Copy `file` into the backups dir with a timestamp suffix. No-op if file missing. */
144
+ /** Each original gets a private, unique directory even for equal basenames and timestamps. */
66
145
  export function backupFile(file) {
67
- if (!fs.existsSync(file) || dryRun)
146
+ const scope = stagedFiles.getStore();
147
+ if (scope) {
148
+ stagedSnapshot(scope, path.resolve(file));
149
+ return undefined;
150
+ }
151
+ if (dryRun)
152
+ return undefined;
153
+ const snapshot = readFileSnapshot(file);
154
+ if (snapshot.text === undefined)
68
155
  return undefined;
69
- ensureDir(backupsDir);
70
- const stamp = new Date().toISOString().replace(/[:.]/g, "-");
71
- const dest = path.join(backupsDir, `${path.basename(file)}.${stamp}`);
72
- fs.copyFileSync(file, dest);
156
+ fs.mkdirSync(backupsDir, { recursive: true, mode: 0o700 });
157
+ const dir = fs.mkdtempSync(path.join(backupsDir, "file-"));
158
+ const dest = path.join(dir, path.basename(file));
159
+ try {
160
+ fs.chmodSync(dir, 0o700);
161
+ fs.copyFileSync(file, dest, fs.constants.COPYFILE_EXCL);
162
+ fs.chmodSync(dest, 0o600);
163
+ }
164
+ catch (error) {
165
+ fs.rmSync(dir, { recursive: true, force: true });
166
+ throw error;
167
+ }
73
168
  return dest;
74
169
  }
package/dist/i18n.js CHANGED
@@ -38,6 +38,7 @@ const messages = {
38
38
  "opt.discover": { en: "list model ids from the provider's /v1/models", "zh-CN": "从供应商 /v1/models 获取模型 id" },
39
39
  "opt.include": { en: "keep only models matching comma-separated globs", "zh-CN": "仅保留匹配逗号分隔 glob 的模型" },
40
40
  "opt.exclude": { en: "drop models matching comma-separated globs", "zh-CN": "排除匹配逗号分隔 glob 的模型" },
41
+ "opt.dedup": { en: "deduplicate model snapshot aliases", "zh-CN": "合并重复的模型快照别名" },
41
42
  "opt.noDedup": { en: "keep snapshot duplicates (-latest/date suffixes)", "zh-CN": "保留快照重复项(-latest/日期后缀)" },
42
43
  "opt.modelsDev": { en: "models.dev provider id for metadata matching", "zh-CN": "用于元数据匹配的 models.dev 供应商 id" },
43
44
  "opt.yes": { en: "non-interactive; require all flags", "zh-CN": "非交互模式;要求提供全部参数" },
@@ -45,7 +46,7 @@ const messages = {
45
46
  "opt.apps": { en: "comma-separated apps or 'all'", "zh-CN": "逗号分隔的应用或 'all'" },
46
47
  "opt.removeApps": { en: "delete only from these apps; keep the agentsw store and other apps unchanged", "zh-CN": "仅从这些智能体删除,保留 agentsw 中央配置和其他智能体配置" },
47
48
  "opt.manageDryRun": { en: "preview affected paths without changing files or creating backups", "zh-CN": "仅预览受影响路径,不修改文件或创建备份" },
48
- "opt.appsDetailed": { en: "comma-separated apps (claude,codex,omp,pi,prime,opencode,hermes,workbuddy) or 'all'", "zh-CN": "逗号分隔的应用(claude,codex,omp,pi,prime,opencode,hermes,workbuddy)或 'all'" },
49
+ "opt.appsDetailed": { en: "comma-separated apps (claude,codex,omp,pi,prime,opencode,hermes,workbuddy,dsh) or 'all'", "zh-CN": "逗号分隔的应用(claude,codex,omp,pi,prime,opencode,hermes,workbuddy,dsh)或 'all'" },
49
50
  "opt.model": { en: "override default model while switching", "zh-CN": "切换时覆盖默认模型" },
50
51
  "opt.dryRun": { en: "preview config diff without writing", "zh-CN": "仅预览配置差异,不写入" },
51
52
  "opt.provider": { en: "sync this provider instead of the active one", "zh-CN": "同步指定供应商而非当前供应商" },
package/dist/import.js CHANGED
@@ -12,12 +12,12 @@ export function normalizeUrl(u) {
12
12
  }
13
13
  /** True when the path already names the API version, e.g. `.../v1`. */
14
14
  const hasApiVersion = (baseUrl) => /\/v\d+(?:beta\d*)?$/.test(baseUrl.split(/[?#]/, 1)[0]);
15
- /** Apps may append their own API version; all other URL components remain significant. */
15
+ /** Apps may append /v1; other API versions and URL components remain significant. */
16
16
  export function endpointKey(baseUrl) {
17
17
  const normalized = normalizeUrl(baseUrl);
18
18
  const suffixAt = normalized.search(/[?#]/);
19
19
  const path = suffixAt < 0 ? normalized : normalized.slice(0, suffixAt);
20
- return path.replace(/\/v\d+(?:beta\d*)?$/, "") + (suffixAt < 0 ? "" : normalized.slice(suffixAt));
20
+ return path.replace(/\/v1$/, "") + (suffixAt < 0 ? "" : normalized.slice(suffixAt));
21
21
  }
22
22
  /** An unresolved credential is not evidence that two configurations use the same account. */
23
23
  export function findMatchingProvider(existing, candidate) {
package/dist/index.js CHANGED
@@ -6,8 +6,17 @@ import { detectSystemLocale, extractCliLocale, normalizeLocale, setLocale, t } f
6
6
  import { cmdMenu } from "./menu.js";
7
7
  import { loadStore } from "./store.js";
8
8
  import { cmdListLocalProviders, cmdRemoveProvider, cmdRename } from "./provider-actions.js";
9
+ // Locale lookup is best effort: help/version and app-local commands do not need the store.
10
+ function savedLocale() {
11
+ try {
12
+ return loadStore().language;
13
+ }
14
+ catch {
15
+ return undefined;
16
+ }
17
+ }
9
18
  // Locale precedence: CLI flag > environment > saved preference > system locale.
10
- setLocale(extractCliLocale() ?? process.env.AGENTSW_LANG ?? loadStore().language ?? detectSystemLocale());
19
+ setLocale(extractCliLocale() ?? process.env.AGENTSW_LANG ?? savedLocale() ?? detectSystemLocale());
11
20
  function parseLocale(value) {
12
21
  const locale = normalizeLocale(value);
13
22
  if (!locale)
@@ -51,6 +60,7 @@ program
51
60
  .option("-d, --discover", t("opt.discover"))
52
61
  .option("--include <globs>", t("opt.include"))
53
62
  .option("--exclude <globs>", t("opt.exclude"))
63
+ .option("--dedup", t("opt.dedup"))
54
64
  .option("--no-dedup", t("opt.noDedup"))
55
65
  .option("--models-dev <id>", t("opt.modelsDev"))
56
66
  .option("-y, --yes", t("opt.yes"))
@@ -59,6 +69,9 @@ program
59
69
  .command("quick")
60
70
  .description(t("cmd.quick"))
61
71
  .option("--id <slug>", t("opt.id"))
72
+ .option("--name <name>", t("opt.name"))
73
+ .option("--openai-api <flavor>", t("opt.openaiApi"))
74
+ .option("--models-dev <id>", t("opt.modelsDev"))
62
75
  .option("--base-url <url>", t("opt.baseUrl"))
63
76
  .option("--api-key <key>", t("opt.apiKey"))
64
77
  .option("--default-model <id>", t("opt.defaultModel"))
@@ -66,6 +79,7 @@ program
66
79
  .option("--reasoning-effort <level>", t("opt.reasoning"))
67
80
  .option("--include <globs>", t("opt.include"))
68
81
  .option("--exclude <globs>", t("opt.exclude"))
82
+ .option("--dedup", t("opt.dedup"))
69
83
  .option("--no-dedup", t("opt.noDedup"))
70
84
  .option("-y, --yes", t("opt.yes"))
71
85
  .action(cmdQuickAdd);
@@ -122,6 +136,7 @@ program
122
136
  .option("-a, --apps <apps>", t("opt.appsSync"))
123
137
  .option("--include <globs>", t("opt.setInclude"))
124
138
  .option("--exclude <globs>", t("opt.setExclude"))
139
+ .option("--dedup", t("opt.dedup"))
125
140
  .option("--no-dedup", t("opt.noDedup"))
126
141
  .option("--no-filter", t("opt.noFilter"))
127
142
  .action(cmdDiscover);
package/dist/jsonc.js CHANGED
@@ -1,4 +1,4 @@
1
- import fs from "node:fs";
1
+ import { readTextIfExists } from "./fsutil.js";
2
2
  import { applyEdits, createScanner, getNodeValue, modify, parseTree, printParseErrorCode, } from "jsonc-parser";
3
3
  export function isJsonObject(value) {
4
4
  return value !== null && typeof value === "object" && !Array.isArray(value);
@@ -34,15 +34,9 @@ function parseJsoncObject(file, text) {
34
34
  }
35
35
  /** Only missing files initialize to an empty object; unreadable or invalid files must not be replaced. */
36
36
  export function readJsoncObject(file) {
37
- let text;
38
- try {
39
- text = fs.readFileSync(file, "utf8");
40
- }
41
- catch (error) {
42
- if (error.code === "ENOENT")
43
- return { file, text: "{}\n", value: {} };
44
- throw error;
45
- }
37
+ const text = readTextIfExists(file);
38
+ if (text === undefined)
39
+ return { file, text: "{}\n", value: {} };
46
40
  return { file, text, value: parseJsoncObject(file, text) };
47
41
  }
48
42
  function comments(text) {
package/dist/modelsdev.js CHANGED
@@ -75,9 +75,13 @@ export function findModelMeta(catalog, modelId, providerHint) {
75
75
  if (m)
76
76
  return { spec: toModelSpec(modelId, m), provider: hinted.id };
77
77
  }
78
- for (const p of Object.values(catalog)) {
79
- if (p === hinted)
80
- continue;
78
+ const providers = Object.values(catalog).filter((p) => p !== hinted);
79
+ for (const p of providers) {
80
+ const m = p.models[modelId];
81
+ if (m)
82
+ return { spec: toModelSpec(modelId, m), provider: p.id };
83
+ }
84
+ for (const p of providers) {
81
85
  const m = scan(p);
82
86
  if (m)
83
87
  return { spec: toModelSpec(modelId, m), provider: p.id };
@@ -0,0 +1,25 @@
1
+ import { createHash } from "node:crypto";
2
+ /** Previous releases used this lossy encoding; recognize it, never generate it. */
3
+ export function legacyManagedCredentialRef(id) {
4
+ return `AGENTSW_${id.toUpperCase().replace(/[^A-Z0-9]/g, "_")}_API_KEY`;
5
+ }
6
+ /** Human-readable prefix plus an exact, case-sensitive provider identity digest. */
7
+ export function managedCredentialRef(id) {
8
+ const prefix = id.toUpperCase().replace(/[^A-Z0-9]/g, "_").slice(0, 48) || "PROVIDER";
9
+ const digest = createHash("sha256").update(id).digest("hex").slice(0, 24).toUpperCase();
10
+ return `AGENTSW_${prefix}_${digest}_API_KEY`;
11
+ }
12
+ export function isManagedCredentialRef(ref, id) {
13
+ return ref === managedCredentialRef(id) || ref === legacyManagedCredentialRef(id);
14
+ }
15
+ /** Account-qualified app-local selector; never embeds endpoints or credentials. */
16
+ export function localProviderId(candidate) {
17
+ const identity = JSON.stringify([
18
+ candidate.id,
19
+ candidate.protocol,
20
+ candidate.baseUrl,
21
+ candidate.apiKey ?? null,
22
+ candidate.keyEnv ?? null,
23
+ ]);
24
+ return `local-${createHash("sha256").update(identity).digest("hex").slice(0, 32)}`;
25
+ }
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;