agentsw 0.6.2 → 0.7.0

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 ADDED
@@ -0,0 +1,651 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { applyEdits, parse as parseJsonc } from "jsonc-parser";
4
+ import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
5
+ import YAML from "yaml";
6
+ import { commitFileChanges } from "./config-transaction.js";
7
+ import { appDataDir, drainPendingWrites, expandHome, home, localAppDataDir, setDryRun } from "./fsutil.js";
8
+ import { editJsoncObject, isJsonObject, readJsoncObject } from "./jsonc.js";
9
+ import { configFile } from "./store.js";
10
+ import { endpointKey } from "./import.js";
11
+ import { envAssignments } from "./envfile.js";
12
+ import { resolveTargets, targets } from "./targets/index.js";
13
+ import { classifyApi, entryApi } from "./targets/wire.js";
14
+ function readText(file) {
15
+ try {
16
+ return fs.readFileSync(file, "utf8");
17
+ }
18
+ catch (error) {
19
+ if (error.code === "ENOENT")
20
+ return undefined;
21
+ throw new Error(`${file}: unable to read configuration`);
22
+ }
23
+ }
24
+ /** Validate before planning, and resolve aliases before deleting their anchors. */
25
+ function parseDocument(file, text, before = text) {
26
+ let yaml;
27
+ let original;
28
+ try {
29
+ if (/\.ya?ml$/i.test(file)) {
30
+ yaml = YAML.parseDocument(text);
31
+ if (yaml.errors.length)
32
+ throw new Error("invalid YAML");
33
+ if (yaml.contents == null)
34
+ yaml.contents = yaml.createNode({});
35
+ original = yaml.toJS();
36
+ JSON.stringify(original);
37
+ const expanded = new Map();
38
+ YAML.visit(yaml, {
39
+ Alias(_key, alias) {
40
+ const node = yaml.createNode(alias.toJS(yaml), { aliasDuplicateObjects: false });
41
+ node.comment = alias.comment;
42
+ node.commentBefore = alias.commentBefore;
43
+ node.spaceBefore = alias.spaceBefore;
44
+ expanded.set(alias, node);
45
+ },
46
+ });
47
+ YAML.visit(yaml, { Alias: (_key, alias) => expanded.get(alias) });
48
+ original = yaml.toJS();
49
+ }
50
+ else if (file.endsWith(".toml")) {
51
+ original = parseToml(text);
52
+ }
53
+ else {
54
+ // Reuse strict parsing (including duplicate-key checks) for originals.
55
+ if (text === before) {
56
+ const parsed = readJsoncObject(file);
57
+ if (parsed.text !== text)
58
+ throw new Error("configuration changed during planning");
59
+ original = parsed.value;
60
+ }
61
+ else {
62
+ const errors = [];
63
+ original = parseJsonc(text, errors, { allowTrailingComma: true });
64
+ if (errors.length)
65
+ throw new Error("invalid JSONC");
66
+ }
67
+ }
68
+ if (!isJsonObject(original))
69
+ throw new Error("expected a configuration mapping");
70
+ }
71
+ catch {
72
+ // Parser messages can contain credential-bearing source lines.
73
+ throw new Error(`${file}: invalid configuration; refusing to modify it`);
74
+ }
75
+ return { file, before, text, original, value: structuredClone(original), yaml, changed: text !== before };
76
+ }
77
+ function valueAt(document, at) {
78
+ let value = document.value;
79
+ for (const key of at) {
80
+ if (!isJsonObject(value) || !Object.hasOwn(value, key))
81
+ return undefined;
82
+ value = value[key];
83
+ }
84
+ return value;
85
+ }
86
+ function removeAt(document, at) {
87
+ const parent = valueAt(document, at.slice(0, -1));
88
+ const key = at[at.length - 1];
89
+ if (!isJsonObject(parent) || !Object.hasOwn(parent, key))
90
+ return false;
91
+ delete parent[key];
92
+ document.yaml?.deleteIn(at);
93
+ document.changed = true;
94
+ return true;
95
+ }
96
+ function replaceAt(document, at, value) {
97
+ const parent = valueAt(document, at.slice(0, -1));
98
+ if (!isJsonObject(parent))
99
+ return;
100
+ parent[at[at.length - 1]] = value;
101
+ document.yaml?.setIn(at, document.yaml.createNode(value));
102
+ document.changed = true;
103
+ }
104
+ function removeEmpty(document, at) {
105
+ const value = valueAt(document, at);
106
+ if (isJsonObject(value) && Object.keys(value).length === 0)
107
+ removeAt(document, at);
108
+ }
109
+ function providerMap(document, at) {
110
+ for (let size = 1; size <= at.length; size++) {
111
+ const value = valueAt(document, at.slice(0, size));
112
+ if (value !== undefined && !isJsonObject(value)) {
113
+ throw new Error(`${document.file}: expected ${at.slice(0, size).join(".")} to be a mapping`);
114
+ }
115
+ }
116
+ const map = valueAt(document, at);
117
+ if (map && Object.values(map).some((entry) => !isJsonObject(entry))) {
118
+ throw new Error(`${document.file}: expected provider entries to be mappings`);
119
+ }
120
+ return map ?? {};
121
+ }
122
+ function render(document) {
123
+ if (document.yaml) {
124
+ const text = document.yaml.toString();
125
+ try {
126
+ YAML.parse(text);
127
+ }
128
+ catch {
129
+ throw new Error(`${document.file}: cannot serialize configuration safely`);
130
+ }
131
+ return text;
132
+ }
133
+ if (document.file.endsWith(".toml"))
134
+ return stringifyToml(document.value) + "\n";
135
+ return editJsoncObject({ file: document.file, text: document.text, value: document.original }, document.value);
136
+ }
137
+ function targetFiles(target, listing = false) {
138
+ const result = { documents: new Map(), maps: [], refs: [], extras: new Map() };
139
+ const add = (file, at) => {
140
+ file = path.resolve(file);
141
+ let document = result.documents.get(file);
142
+ if (!document) {
143
+ const text = readText(file);
144
+ if (text === undefined)
145
+ return undefined;
146
+ document = parseDocument(file, text);
147
+ result.documents.set(file, document);
148
+ }
149
+ if (at) {
150
+ providerMap(document, at);
151
+ if (!result.maps.some((map) => map.document === document))
152
+ result.maps.push({ document, at });
153
+ }
154
+ return document;
155
+ };
156
+ const refs = (file) => {
157
+ if (listing && target.id !== "claude")
158
+ return;
159
+ const document = add(file);
160
+ if (document)
161
+ result.refs.push(document);
162
+ };
163
+ switch (target.id) {
164
+ case "omp": {
165
+ const dir = path.join(home, ".omp", "agent");
166
+ for (const name of ["models.yml", "models.yaml"])
167
+ add(path.join(dir, name), ["providers"]);
168
+ for (const name of ["config.yml", "config.yaml"])
169
+ refs(path.join(dir, name));
170
+ break;
171
+ }
172
+ case "pi":
173
+ case "prime": {
174
+ const env = process.env[target.id === "pi" ? "PI_CODING_AGENT_DIR" : "PRIME_AGENT_CODING_AGENT_DIR"];
175
+ const dir = env ? expandHome(env) : path.join(home, target.id === "pi" ? ".pi/agent" : ".prime/agent");
176
+ add(path.join(dir, "models.json"), ["providers"]);
177
+ refs(path.join(dir, "settings.json"));
178
+ break;
179
+ }
180
+ case "opencode": {
181
+ const dirs = new Set([appDataDir("opencode")]);
182
+ if (process.env.OPENCODE_CONFIG_DIR?.trim())
183
+ dirs.add(expandHome(process.env.OPENCODE_CONFIG_DIR.trim()));
184
+ const files = new Set();
185
+ for (const dir of dirs) {
186
+ for (const name of ["config.json", "opencode.json", "opencode.jsonc"])
187
+ files.add(path.join(dir, name));
188
+ }
189
+ if (process.env.OPENCODE_CONFIG?.trim())
190
+ files.add(expandHome(process.env.OPENCODE_CONFIG.trim()));
191
+ for (const file of files)
192
+ add(file, ["provider"]);
193
+ break;
194
+ }
195
+ case "codex":
196
+ add(target.configPaths[0], ["model_providers"]);
197
+ break;
198
+ case "hermes": {
199
+ const dir = process.env.HERMES_HOME?.trim() || localAppDataDir("hermes");
200
+ add(path.join(dir, "config.yaml"), ["providers"]);
201
+ const file = path.resolve(dir, ".env");
202
+ if (!listing)
203
+ result.extras.set(file, readText(file));
204
+ break;
205
+ }
206
+ case "dsh": {
207
+ const dir = process.env.DSH_HOME?.trim() ? expandHome(process.env.DSH_HOME.trim()) : localAppDataDir("dsh");
208
+ const file = ["settings.yaml", "settings.yml", "settings.json"].map((name) => path.join(dir, name)).find((file) => fs.existsSync(file));
209
+ if (file)
210
+ add(file, ["llm-pi-ai", "providers"]);
211
+ if (!listing) {
212
+ const credentials = add(path.join(dir, ".credentials.yaml"));
213
+ if (credentials) {
214
+ const { version, refs } = credentials.value;
215
+ const flat = version === undefined && refs === undefined && Object.values(credentials.value).every((value) => typeof value === "string");
216
+ if (!flat && (version !== 1 || (refs !== undefined && !isJsonObject(refs)))) {
217
+ throw new Error(`${credentials.file}: unsupported credential layout; refusing to modify it`);
218
+ }
219
+ }
220
+ }
221
+ break;
222
+ }
223
+ case "claude":
224
+ refs(target.configPaths[0]);
225
+ break;
226
+ // WorkBuddy has a model array, not a named provider map; its adapter supplies candidates.
227
+ case "workbuddy":
228
+ break;
229
+ default:
230
+ throw new Error(`unsupported app "${target.id}"`);
231
+ }
232
+ return result;
233
+ }
234
+ function hasModelPrefix(value, id) {
235
+ return typeof value === "string" && value.startsWith(`${id}/`);
236
+ }
237
+ function clearModelRefs(document, at, id) {
238
+ const value = valueAt(document, at);
239
+ if (hasModelPrefix(value, id)) {
240
+ removeAt(document, at);
241
+ }
242
+ else if (isJsonObject(value)) {
243
+ for (const key of Object.keys(value)) {
244
+ if (hasModelPrefix(value[key], id))
245
+ removeAt(document, [...at, key]);
246
+ }
247
+ }
248
+ else if (Array.isArray(value)) {
249
+ const kept = value.filter((model) => !hasModelPrefix(model, id) && !(isJsonObject(model) && model.provider === id));
250
+ if (kept.length !== value.length)
251
+ replaceAt(document, at, kept);
252
+ }
253
+ }
254
+ function clearSelection(document, providerAt, modelKeys, id) {
255
+ if (valueAt(document, providerAt) !== id)
256
+ return false;
257
+ removeAt(document, providerAt);
258
+ for (const key of modelKeys)
259
+ removeAt(document, [...providerAt.slice(0, -1), key]);
260
+ return true;
261
+ }
262
+ /** Adapter planning is isolated from mutation, including failures after a queued write. */
263
+ async function previewPrune(target, provider, files) {
264
+ let writes;
265
+ try {
266
+ setDryRun(true);
267
+ await target.prune(provider);
268
+ writes = drainPendingWrites();
269
+ }
270
+ catch {
271
+ throw new Error(`${target.name}: unable to prepare provider removal; no files changed`);
272
+ }
273
+ finally {
274
+ setDryRun(false);
275
+ }
276
+ for (const write of writes) {
277
+ const file = path.resolve(write.file);
278
+ const old = files.documents.get(file);
279
+ if (!old)
280
+ throw new Error(`${file}: adapter attempted an unplanned configuration write`);
281
+ const document = parseDocument(file, write.content, old.before);
282
+ files.documents.set(file, document);
283
+ for (const map of files.maps)
284
+ if (map.document === old)
285
+ map.document = document;
286
+ files.refs = files.refs.map((ref) => ref === old ? document : ref);
287
+ }
288
+ }
289
+ function namedProvider(id) {
290
+ // Named-map prune methods only consume id, not endpoint/model/credential metadata.
291
+ return { id, name: id, protocol: "openai", baseUrl: "", apiKey: "", models: [], defaultModel: "" };
292
+ }
293
+ function candidateProvider(candidate) {
294
+ return {
295
+ ...candidate,
296
+ apiKey: candidate.apiKey ?? "",
297
+ models: candidate.models.map((id) => ({ id })),
298
+ defaultModel: candidate.defaultModel ?? candidate.models[0] ?? "",
299
+ };
300
+ }
301
+ function credentialRef(id) {
302
+ return `AGENTSW_${id.toUpperCase().replace(/[^A-Z0-9]/g, "_")}_API_KEY`;
303
+ }
304
+ function addPlan(plans, change) {
305
+ const file = path.resolve(change.file);
306
+ const previous = plans.get(file);
307
+ if (previous && (previous.before !== change.before || previous.after !== change.after)) {
308
+ throw new Error(`${file}: conflicting removal plans; no files changed`);
309
+ }
310
+ plans.set(file, { ...change, file });
311
+ }
312
+ function sameEndpoint(a, b) {
313
+ return endpointKey(a) === endpointKey(b);
314
+ }
315
+ function assertIdentity(app, stored, local) {
316
+ const conflict = typeof local.baseUrl === "string" && local.baseUrl && !sameEndpoint(local.baseUrl, stored.baseUrl) ? "endpoint" :
317
+ typeof local.protocol === "string" && local.protocol !== stored.protocol ? "protocol" :
318
+ typeof local.apiKey === "string" && local.apiKey !== stored.apiKey ? "credential" : undefined;
319
+ if (conflict)
320
+ throw new Error(`${app}: provider "${stored.id}" has a different ${conflict}; use --apps to remove that app-local entry explicitly`);
321
+ }
322
+ function assertNamedIdentities(target, files, stored) {
323
+ const resolveKey = (raw) => {
324
+ if (typeof raw !== "string")
325
+ return undefined;
326
+ if (raw.startsWith("!"))
327
+ return undefined;
328
+ if (raw.startsWith("$"))
329
+ return process.env[raw.slice(1)];
330
+ const envRef = raw.match(/^\{env:([^}]+)\}$/);
331
+ if (envRef)
332
+ return process.env[envRef[1]];
333
+ if (raw.startsWith("{"))
334
+ return undefined;
335
+ return target.id === "omp" || target.id === "prime" ? process.env[raw] ?? raw : raw;
336
+ };
337
+ for (const { document, at } of files.maps) {
338
+ const entry = providerMap(document, at)[stored.id];
339
+ if (!isJsonObject(entry))
340
+ continue;
341
+ let baseUrl;
342
+ let protocol;
343
+ let apiKey;
344
+ if (["omp", "pi", "prime", "dsh"].includes(target.id)) {
345
+ baseUrl = target.id === "dsh" ? entry.baseURL : entry.baseUrl;
346
+ protocol = classifyApi(entryApi(entry))?.protocol;
347
+ apiKey = resolveKey(entry.apiKey);
348
+ if (Array.isArray(entry.models)) {
349
+ for (const model of entry.models) {
350
+ if (!isJsonObject(model))
351
+ continue;
352
+ assertIdentity(target.id, stored, { baseUrl: model.baseUrl ?? model.baseURL, protocol: classifyApi(model.api)?.protocol, apiKey: resolveKey(model.apiKey) });
353
+ }
354
+ }
355
+ if (target.id === "dsh" && typeof entry.apiKeyEnv === "string") {
356
+ apiKey = process.env[entry.apiKeyEnv];
357
+ for (const doc of files.documents.values()) {
358
+ if (path.basename(doc.file) === ".credentials.yaml")
359
+ apiKey ??= valueAt(doc, ["refs", entry.apiKeyEnv]) ?? valueAt(doc, [entry.apiKeyEnv]);
360
+ }
361
+ }
362
+ }
363
+ else if (target.id === "opencode") {
364
+ const options = isJsonObject(entry.options) ? entry.options : {};
365
+ baseUrl = typeof options.baseURL === "string" && !options.baseURL.startsWith("{") ? options.baseURL : undefined;
366
+ protocol = typeof entry.npm === "string" ? entry.npm.includes("anthropic") ? "anthropic" : entry.npm.includes("openai") ? "openai" : undefined : undefined;
367
+ apiKey = resolveKey(options.apiKey);
368
+ }
369
+ else if (target.id === "hermes") {
370
+ baseUrl = entry.api;
371
+ protocol = entry.transport === "anthropic_messages" ? "anthropic" : ["chat_completions", "codex_responses"].includes(String(entry.transport)) ? "openai" : undefined;
372
+ if (typeof entry.key_env === "string") {
373
+ apiKey = process.env[entry.key_env];
374
+ for (const [file, text] of files.extras) {
375
+ if (apiKey !== undefined || text === undefined)
376
+ continue;
377
+ apiKey = envAssignments(file, text).filter((assignment) => assignment.name === entry.key_env).at(-1)?.value;
378
+ }
379
+ }
380
+ }
381
+ else if (target.id === "codex") {
382
+ baseUrl = entry.base_url;
383
+ protocol = "openai";
384
+ if (typeof entry.env_key === "string")
385
+ apiKey = process.env[entry.env_key];
386
+ }
387
+ assertIdentity(target.id, stored, { baseUrl, protocol, apiKey });
388
+ }
389
+ }
390
+ function candidatesFor(target) {
391
+ try {
392
+ return target.candidates?.() ?? [];
393
+ }
394
+ catch {
395
+ throw new Error(`${target.name}: invalid configuration; refusing to modify it`);
396
+ }
397
+ }
398
+ function matchingCandidate(target, id, stored) {
399
+ const candidates = candidatesFor(target);
400
+ if (!candidates.length)
401
+ 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);
407
+ }
408
+ // Single-endpoint apps have no persisted provider ID. A custom store ID can
409
+ // still select them, but only when both endpoint and credential match.
410
+ if (!stored) {
411
+ const before = readText(configFile);
412
+ if (before !== undefined) {
413
+ const store = parseDocument(configFile, before);
414
+ const providers = providerMap(store, ["providers"]);
415
+ if (Object.hasOwn(providers, id))
416
+ stored = providers[id];
417
+ }
418
+ }
419
+ if (!stored || typeof stored.baseUrl !== "string")
420
+ return undefined;
421
+ const matching = candidates.find((candidate) => candidate.protocol === stored.protocol &&
422
+ sameEndpoint(candidate.baseUrl, stored.baseUrl) && candidate.apiKey === stored.apiKey);
423
+ return matching ? candidateProvider(matching) : undefined;
424
+ }
425
+ /** One row per store ID and per app-local ID, never endpoint or credential values. */
426
+ export function listRemovableProviders(apps) {
427
+ const rows = [];
428
+ const storeText = apps === undefined ? readText(configFile) : undefined;
429
+ if (storeText !== undefined) {
430
+ const store = parseDocument(configFile, storeText);
431
+ for (const [id, entry] of Object.entries(providerMap(store, ["providers"]))) {
432
+ rows.push({ id, ...(isJsonObject(entry) && typeof entry.name === "string" ? { name: entry.name } : {}) });
433
+ }
434
+ }
435
+ for (const target of apps === undefined ? targets : resolveTargets(apps)) {
436
+ const files = targetFiles(target, true);
437
+ const entries = new Map();
438
+ if (target.id === "claude" || target.id === "workbuddy") {
439
+ for (const candidate of candidatesFor(target))
440
+ entries.set(candidate.id, candidate.name);
441
+ }
442
+ else {
443
+ for (const { document, at } of files.maps) {
444
+ for (const [id, entry] of Object.entries(providerMap(document, at))) {
445
+ const value = entry;
446
+ const name = typeof value.name === "string" ? value.name : typeof value.displayName === "string" ? value.displayName : undefined;
447
+ entries.set(id, name ?? entries.get(id));
448
+ }
449
+ }
450
+ }
451
+ for (const [id, name] of entries)
452
+ rows.push({ id, app: target.id, ...(name ? { name } : {}) });
453
+ }
454
+ return rows;
455
+ }
456
+ async function planTargetRemoval(target, id, plans, stored) {
457
+ const files = targetFiles(target);
458
+ if (stored)
459
+ assertNamedIdentities(target, files, stored);
460
+ let found = files.maps.some(({ document, at }) => Object.hasOwn(providerMap(document, at), id));
461
+ if (target.id === "workbuddy")
462
+ return planWorkbuddyRemoval(target, id, plans, stored);
463
+ if (target.id === "claude") {
464
+ const provider = matchingCandidate(target, id, stored);
465
+ if (!provider)
466
+ return false;
467
+ found = true;
468
+ await previewPrune(target, provider, files);
469
+ for (const document of files.refs)
470
+ removeAt(document, ["env", "ANTHROPIC_API_KEY"]);
471
+ }
472
+ else if (["omp", "pi", "prime", "codex"].includes(target.id)) {
473
+ await previewPrune(target, namedProvider(id), files);
474
+ }
475
+ const managedKey = credentialRef(id);
476
+ for (const { document, at } of files.maps) {
477
+ const removed = removeAt(document, [...at, id]);
478
+ if (removed && ["opencode", "codex", "hermes", "dsh"].includes(target.id))
479
+ removeEmpty(document, at);
480
+ switch (target.id) {
481
+ case "codex":
482
+ clearSelection(document, ["model_provider"], ["model", "model_reasoning_effort"], id);
483
+ if (isJsonObject(document.value.profiles)) {
484
+ for (const name of Object.keys(document.value.profiles)) {
485
+ clearSelection(document, ["profiles", name, "model_provider"], ["model", "model_reasoning_effort"], id);
486
+ }
487
+ }
488
+ break;
489
+ case "opencode":
490
+ for (const key of ["model", "small_model"])
491
+ clearModelRefs(document, [key], id);
492
+ for (const key of ["enabled_providers", "disabled_providers"]) {
493
+ const values = document.value[key];
494
+ if (Array.isArray(values) && values.includes(id))
495
+ replaceAt(document, [key], values.filter((value) => value !== id));
496
+ }
497
+ for (const key of ["agent", "mode", "command"]) {
498
+ const entries = document.value[key];
499
+ if (!isJsonObject(entries))
500
+ continue;
501
+ for (const name of Object.keys(entries))
502
+ clearModelRefs(document, [key, name, "model"], id);
503
+ }
504
+ break;
505
+ case "hermes":
506
+ if (clearSelection(document, ["model", "provider"], ["default", "model"], id))
507
+ removeEmpty(document, ["model"]);
508
+ break;
509
+ case "dsh":
510
+ if (valueAt(document, ["agent-default-model", "provider"]) === id)
511
+ removeAt(document, ["agent-default-model"]);
512
+ if (removed)
513
+ removeEmpty(document, ["llm-pi-ai"]);
514
+ break;
515
+ }
516
+ }
517
+ for (const document of files.refs) {
518
+ if (target.id === "pi" || target.id === "prime" || target.id === "omp") {
519
+ clearSelection(document, ["defaultProvider"], ["defaultModel"], id);
520
+ for (const key of ["model", "defaultModel", "models", "modelRoles", "enabledModels", "recentModels"])
521
+ clearModelRefs(document, [key], id);
522
+ }
523
+ }
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 });
535
+ }
536
+ }
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]);
546
+ }
547
+ }
548
+ }
549
+ for (const document of files.documents.values()) {
550
+ if (!document.changed)
551
+ continue;
552
+ addPlan(plans, { file: document.file, before: document.before, after: render(document),
553
+ ...(path.basename(document.file) === ".credentials.yaml" ? { mode: 0o600 } : {}) });
554
+ }
555
+ return found;
556
+ }
557
+ 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"));
560
+ const file = path.join(dir, "models.json");
561
+ const before = readText(file);
562
+ if (before === undefined)
563
+ return false;
564
+ let raw;
565
+ try {
566
+ raw = JSON.parse(before);
567
+ }
568
+ catch {
569
+ throw new Error(`${file}: invalid configuration; refusing to modify it`);
570
+ }
571
+ const models = Array.isArray(raw) ? raw : isJsonObject(raw) && Array.isArray(raw.models) ? raw.models : undefined;
572
+ if (!models)
573
+ throw new Error(`${file}: expected a model array`);
574
+ const provider = matchingCandidate(target, id, stored);
575
+ if (!provider)
576
+ return false;
577
+ const removed = new Set();
578
+ const kept = models.filter((entry) => {
579
+ if (!isJsonObject(entry) || typeof entry.id !== "string" || typeof entry.url !== "string")
580
+ 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))
584
+ return true;
585
+ removed.add(entry.id);
586
+ return false;
587
+ });
588
+ if (!removed.size)
589
+ return false;
590
+ for (const entry of kept)
591
+ if (isJsonObject(entry) && typeof entry.id === "string")
592
+ removed.delete(entry.id);
593
+ if (Array.isArray(raw)) {
594
+ addPlan(plans, { file, before, after: JSON.stringify(kept, null, 2) + "\n" });
595
+ }
596
+ else {
597
+ const document = parseDocument(file, before);
598
+ replaceAt(document, ["models"], kept);
599
+ if (Array.isArray(document.value.availableModels)) {
600
+ replaceAt(document, ["availableModels"], document.value.availableModels.filter((model) => typeof model !== "string" || !removed.has(model)));
601
+ }
602
+ addPlan(plans, { file, before, after: render(document) });
603
+ }
604
+ const settingsFile = path.join(dir, "settings.json");
605
+ const settingsText = readText(settingsFile);
606
+ if (settingsText !== undefined) {
607
+ const settings = parseDocument(settingsFile, settingsText);
608
+ if (typeof settings.value.model === "string" && removed.has(settings.value.model)) {
609
+ removeAt(settings, ["model"]);
610
+ addPlan(plans, { file: settingsFile, before: settingsText, after: render(settings) });
611
+ }
612
+ }
613
+ return true;
614
+ }
615
+ /** Plan every scoped change first; commit performs the single backed-up mutation phase. */
616
+ export async function removeProvider(id, opts = {}) {
617
+ if (opts.apps !== undefined && opts.prune)
618
+ throw new Error("--apps and --prune cannot be combined");
619
+ if (opts.apps !== undefined && !opts.apps.trim())
620
+ throw new Error("--apps must select at least one app");
621
+ const appOnly = opts.apps !== undefined;
622
+ const selected = appOnly || opts.prune ? [...new Set(resolveTargets(opts.apps))] : [];
623
+ if (appOnly && !selected.length)
624
+ throw new Error("--apps must select at least one app");
625
+ const plans = new Map();
626
+ let stored;
627
+ let store;
628
+ if (!appOnly) {
629
+ const before = readText(configFile);
630
+ if (before !== undefined) {
631
+ store = parseDocument(configFile, before);
632
+ const providers = providerMap(store, ["providers"]);
633
+ if (Object.hasOwn(providers, id))
634
+ stored = providers[id];
635
+ }
636
+ if (!stored)
637
+ throw new Error(`provider "${id}" not found in agentsw store; use --apps for agent-local removal`);
638
+ }
639
+ let found = stored !== undefined;
640
+ for (const target of selected)
641
+ found = await planTargetRemoval(target, id, plans, stored) || found;
642
+ if (!found)
643
+ throw new Error(`provider "${id}" not found${appOnly ? " in selected apps" : " in agentsw store or app configurations"}`);
644
+ if (store && stored) {
645
+ removeAt(store, ["providers", id]);
646
+ if (store.value.active === id)
647
+ removeAt(store, ["active"]);
648
+ addPlan(plans, { file: configFile, before: store.before, after: render(store), mode: 0o600 });
649
+ }
650
+ return commitFileChanges([...plans.values()], { dryRun: opts.dryRun });
651
+ }