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.
@@ -1,6 +1,8 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { appDataDir, backupFile, expandHome, readJsonIfExists, writeFileAtomic } from "../fsutil.js";
4
+ import { isJsonObject } from "../jsonc.js";
5
+ import { transactionalTarget } from "../target-transaction.js";
4
6
  const customDir = process.env.OPENCODE_CONFIG_DIR?.trim();
5
7
  const configFile = customDir
6
8
  ? path.join(expandHome(customDir), "opencode.json")
@@ -9,7 +11,7 @@ const configFile = customDir
9
11
  * opencode: custom providers in ~/.config/opencode/opencode.json "provider" map,
10
12
  * backed by @ai-sdk/openai-compatible or @ai-sdk/anthropic npm loaders.
11
13
  */
12
- export const opencode = {
14
+ export const opencode = transactionalTarget({
13
15
  id: "opencode",
14
16
  name: "opencode",
15
17
  protocols: ["openai", "anthropic"],
@@ -17,20 +19,32 @@ export const opencode = {
17
19
  detect: () => fs.existsSync(path.dirname(configFile)),
18
20
  async apply(provider) {
19
21
  const notes = [];
20
- const config = readJsonIfExists(configFile) ?? {
21
- $schema: "https://opencode.ai/config.json",
22
- };
22
+ const configValue = readJsonIfExists(configFile);
23
+ const config = configValue === undefined ? { $schema: "https://opencode.ai/config.json" } : configValue;
24
+ if (!isJsonObject(config) || (config.provider !== undefined && !isJsonObject(config.provider))) {
25
+ throw new Error(`${configFile}: expected config and provider to be JSON objects`);
26
+ }
23
27
  const anthropic = provider.protocol === "anthropic";
24
28
  const providerMap = { ...config.provider };
25
29
  // Keys agentsw does not model (custom options, per-model extras) are the user's,
26
30
  // but a key it owns and no longer emits is cleared rather than inherited from last sync.
27
31
  const existing = providerMap[provider.id];
32
+ if (existing !== undefined && !isJsonObject(existing)) {
33
+ throw new Error(`${configFile}: expected the selected provider to be a JSON object`);
34
+ }
28
35
  const prev = (existing && typeof existing === "object" && !Array.isArray(existing)
29
36
  ? existing
30
37
  : {});
38
+ if ((prev.models !== undefined && !isJsonObject(prev.models)) ||
39
+ (prev.options !== undefined && !isJsonObject(prev.options))) {
40
+ throw new Error(`${configFile}: expected provider models and options to be JSON objects`);
41
+ }
31
42
  const prevModels = (prev.models ?? {});
32
43
  const models = {};
33
44
  for (const m of provider.models) {
45
+ if (prevModels[m.id] !== undefined && !isJsonObject(prevModels[m.id])) {
46
+ throw new Error(`${configFile}: expected each model to be a JSON object`);
47
+ }
34
48
  const old = { ...prevModels[m.id] };
35
49
  for (const key of ["name", "reasoning", "attachment", "cost", "limit"])
36
50
  delete old[key];
@@ -61,7 +75,9 @@ export const opencode = {
61
75
  }
62
76
  providerMap[provider.id] = {
63
77
  ...prev,
64
- npm: anthropic ? "@ai-sdk/anthropic" : provider.openaiApi === "responses" ? "@ai-sdk/openai" : "@ai-sdk/openai-compatible",
78
+ npm: anthropic ? "@ai-sdk/anthropic" :
79
+ (provider.openaiApi ?? (prev.npm === "@ai-sdk/openai" ? "responses" : "completions")) === "responses"
80
+ ? "@ai-sdk/openai" : "@ai-sdk/openai-compatible",
65
81
  name: provider.name,
66
82
  options: {
67
83
  ...prev.options,
@@ -82,6 +98,9 @@ export const opencode = {
82
98
  },
83
99
  async prune(provider) {
84
100
  const config = readJsonIfExists(configFile);
101
+ if (config !== undefined && (!isJsonObject(config) || (config.provider !== undefined && !isJsonObject(config.provider)))) {
102
+ throw new Error(`${configFile}: expected config and provider to be JSON objects`);
103
+ }
85
104
  const providerMap = config?.provider;
86
105
  if (!config || !providerMap?.[provider.id]) {
87
106
  return { app: this.id, changed: [], notes: [], skipped: `no provider.${provider.id} entry` };
@@ -129,6 +148,8 @@ export const opencode = {
129
148
  keyEnv = ref[1];
130
149
  apiKey = process.env[ref[1]];
131
150
  }
151
+ if (apiKey?.startsWith("{file:"))
152
+ apiKey = undefined;
132
153
  const models = Object.keys(entry.models ?? {});
133
154
  const defaultModel = activeProvider === id ? activeModel : undefined;
134
155
  if (defaultModel && !models.includes(defaultModel))
@@ -136,4 +157,4 @@ export const opencode = {
136
157
  return [{ id, name: entry.name ?? id, protocol, openaiApi, baseUrl, apiKey, keyEnv, models, defaultModel, source: self }];
137
158
  });
138
159
  },
139
- };
160
+ });
@@ -3,6 +3,7 @@ import path from "node:path";
3
3
  import { backupFile, home, writeFileAtomic } from "../fsutil.js";
4
4
  import { editJsoncObject, isJsonObject, readJsoncObject } from "../jsonc.js";
5
5
  import { looksLikeEnvName } from "../slug.js";
6
+ import { transactionalTarget } from "../target-transaction.js";
6
7
  import { apiValue, classifyApi, entryApi, mergeModels, sdkBaseUrl, stripConflictingOverrides } from "./wire.js";
7
8
  /** Per-model keys this adapter writes; one that stops being emitted is cleared, not inherited. */
8
9
  const OWNED_MODEL_KEYS = [
@@ -62,7 +63,7 @@ export function piStyleTarget(opts) {
62
63
  return env.startsWith("~") ? path.join(home, env.slice(1)) : env;
63
64
  return path.join(home, opts.configDirName);
64
65
  };
65
- return {
66
+ return transactionalTarget({
66
67
  id: opts.id,
67
68
  name: opts.name,
68
69
  protocols: ["openai", "anthropic"],
@@ -81,7 +82,7 @@ export function piStyleTarget(opts) {
81
82
  // Keys agentsw does not model (headers, authHeader, oauth, ...) and per-model extras
82
83
  // are the user's; a re-sync overwrites only the fields it owns.
83
84
  const prev = providers[provider.id] ?? {};
84
- const api = apiValue(provider.protocol, provider.openaiApi, entryApi(prev));
85
+ const api = apiValue(provider.protocol, provider.openaiApi, prev.api ?? entryApi(prev));
85
86
  const baseUrl = sdkBaseUrl(provider.protocol, provider.baseUrl);
86
87
  const models = mergeModels(prev.models, provider.models.map((m) => ({
87
88
  id: m.id,
@@ -193,16 +194,19 @@ export function piStyleTarget(opts) {
193
194
  let keyEnv;
194
195
  const raw = typeof entry.apiKey === "string" && entry.apiKey ? entry.apiKey : undefined;
195
196
  if (raw) {
196
- if (raw.startsWith("$") && raw.length > 1) {
197
- keyEnv = raw.slice(1);
198
- apiKey = process.env[keyEnv];
197
+ const envRef = raw.match(/^\$(?:\{([A-Za-z_][A-Za-z0-9_]*)\}|([A-Za-z_][A-Za-z0-9_]*))$/)
198
+ ?? raw.match(/^\{env:([A-Za-z_][A-Za-z0-9_]*)\}$/)
199
+ ?? raw.match(/^env:([A-Za-z_][A-Za-z0-9_]*)$/);
200
+ if (envRef) {
201
+ keyEnv = envRef[1] ?? envRef[2];
202
+ apiKey = keyEnv ? process.env[keyEnv] : undefined;
199
203
  }
200
204
  else if (opts.id === "prime" && looksLikeEnvName(raw)) {
201
- // prime resolves bare env names; falls back to the literal string
202
205
  keyEnv = raw;
203
206
  apiKey = process.env[raw] ?? raw;
204
207
  }
205
- else {
208
+ else if (!/^(?:!|\$|\{(?:file|env):|file:|env:|~\/|\/)/.test(raw)) {
209
+ // Commands and file references are never executed or exported as literal credentials.
206
210
  apiKey = raw;
207
211
  }
208
212
  }
@@ -226,7 +230,7 @@ export function piStyleTarget(opts) {
226
230
  ];
227
231
  });
228
232
  },
229
- };
233
+ });
230
234
  }
231
235
  export const pi = piStyleTarget({
232
236
  id: "pi",
@@ -18,7 +18,7 @@ export function classifyApi(api) {
18
18
  return { protocol: "anthropic" };
19
19
  if (api === "openai-completions")
20
20
  return { protocol: "openai", openaiApi: "completions" };
21
- if (RESPONSES_APIS[api])
21
+ if (RESPONSES_APIS[api] === true)
22
22
  return { protocol: "openai", openaiApi: "responses" };
23
23
  return undefined;
24
24
  }
@@ -44,19 +44,25 @@ export function apiValue(protocol, openaiApi, existing) {
44
44
  * provider cannot represent it — so importers skip it rather than guess.
45
45
  */
46
46
  export function entryApi(entry) {
47
- if (typeof entry.api === "string")
47
+ if (!Array.isArray(entry.models) || entry.models.length === 0)
48
48
  return entry.api;
49
- if (!Array.isArray(entry.models))
50
- return undefined;
51
49
  let single;
50
+ let singleWire;
52
51
  for (const model of entry.models) {
53
- const api = model?.api;
54
- if (typeof api !== "string")
55
- continue;
56
- if (single === undefined)
52
+ // A provider default applies only to models without their own override.
53
+ if (!model || typeof model !== "object" || Array.isArray(model))
54
+ return undefined;
55
+ const api = model.api === undefined ? entry.api : model.api;
56
+ const wire = classifyApi(api);
57
+ if (!wire)
58
+ return undefined;
59
+ if (single === undefined) {
57
60
  single = api;
58
- else if (single !== api)
61
+ singleWire = wire;
62
+ }
63
+ else if (singleWire?.protocol !== wire.protocol || singleWire?.openaiApi !== wire.openaiApi) {
59
64
  return undefined;
65
+ }
60
66
  }
61
67
  return single;
62
68
  }
@@ -1,35 +1,76 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { appDataDir, backupFile, home, readJsonIfExists, writeFileAtomic } from "../fsutil.js";
4
+ import { isJsonObject } from "../jsonc.js";
5
+ import { localProviderId } from "../provider-identity.js";
4
6
  import { providerIdFromBaseUrl, providerNameFromBaseUrl } from "../slug.js";
7
+ import { transactionalTarget } from "../target-transaction.js";
5
8
  function configDir() {
6
- return process.env.WORKBUDDY_CONFIG_DIR?.trim() ?? process.env.CODEBUDDY_CONFIG_DIR?.trim() ??
9
+ return process.env.WORKBUDDY_CONFIG_DIR?.trim() || process.env.CODEBUDDY_CONFIG_DIR?.trim() ||
7
10
  (process.platform === "win32" ? appDataDir("workbuddy") : path.join(home, ".workbuddy"));
8
11
  }
9
- /**
10
- * models.json accepts a raw array (desktop writes) or {models, availableModels} (CLI writes).
11
- * Runtime-narrow both shapes; entries without a string id are dropped (WorkBuddy skips them too).
12
- */
12
+ function isModel(row) {
13
+ return isJsonObject(row) && typeof row.id === "string";
14
+ }
15
+ /** Preserve unknown rows and container fields even though WorkBuddy skips those rows. */
13
16
  function readWorkbuddyModels(file) {
14
17
  const raw = readJsonIfExists(file);
15
- const rows = Array.isArray(raw)
16
- ? raw
17
- : raw && typeof raw === "object" && "models" in raw && Array.isArray(raw.models)
18
- ? raw.models
19
- : [];
20
- const models = rows.filter((m) => !!m && typeof m === "object" && "id" in m && typeof m.id === "string");
21
- const availableModels = !Array.isArray(raw) && raw && typeof raw === "object" && "availableModels" in raw && Array.isArray(raw.availableModels)
22
- ? raw.availableModels.filter((id) => typeof id === "string")
23
- : [];
24
- return { models, availableModels, existed: raw !== undefined };
18
+ if (raw !== undefined && !Array.isArray(raw) && !isJsonObject(raw)) {
19
+ throw new Error(`${file}: expected a model array or JSON object`);
20
+ }
21
+ const container = isJsonObject(raw) ? raw : {};
22
+ if (container.models !== undefined && !Array.isArray(container.models)) {
23
+ throw new Error(`${file}: expected models to be an array`);
24
+ }
25
+ if (container.availableModels !== undefined &&
26
+ (!Array.isArray(container.availableModels) || container.availableModels.some((id) => typeof id !== "string"))) {
27
+ throw new Error(`${file}: expected availableModels to be an array of strings`);
28
+ }
29
+ const rows = Array.isArray(raw) ? raw : container.models ?? [];
30
+ const availableModels = container.availableModels ?? [];
31
+ return { rows, models: rows.filter(isModel), container, availableModels, existed: raw !== undefined };
32
+ }
33
+ /** Root endpoints retain WorkBuddy's /v1 convention; custom base paths never gain /v1. */
34
+ function completionsUrl(baseUrl) {
35
+ const url = new URL(baseUrl);
36
+ if (url.protocol !== "https:" && url.protocol !== "http:")
37
+ throw new Error("WorkBuddy requires an HTTP(S) endpoint");
38
+ const pathname = url.pathname.replace(/\/+$/, "");
39
+ url.pathname = pathname.endsWith("/chat/completions") ? pathname : `${pathname || "/v1"}/chat/completions`;
40
+ return url.toString();
41
+ }
42
+ function ownsModel(row, provider, endpoint) {
43
+ if (row.agentswProviderId !== undefined)
44
+ return row.agentswProviderId === provider.id;
45
+ if (typeof row.url !== "string" || row.apiKey !== provider.apiKey)
46
+ return false;
47
+ try {
48
+ return completionsUrl(row.url) === endpoint;
49
+ }
50
+ catch {
51
+ return false;
52
+ }
53
+ }
54
+ const OWNED_MODEL_KEYS = [
55
+ "id", "name", "vendor", "url", "apiKey", "agentswProviderId", "maxInputTokens", "maxOutputTokens",
56
+ "supportsToolCall", "supportsImages", "supportsReasoning",
57
+ ];
58
+ /** Import and management must identify the same endpoint without rewriting query values. */
59
+ export function workbuddyBaseUrl(raw) {
60
+ try {
61
+ const url = new URL(raw);
62
+ if (url.protocol !== "https:" && url.protocol !== "http:")
63
+ return undefined;
64
+ const pathname = url.pathname.replace(/\/+$/, "");
65
+ url.pathname = pathname === "/chat/completions" ? pathname : pathname.replace(/\/chat\/completions$/, "");
66
+ return url.toString();
67
+ }
68
+ catch {
69
+ return undefined;
70
+ }
25
71
  }
26
- /**
27
- * WorkBuddy (Tencent CodeBuddy family): custom models live in ~/.workbuddy/models.json.
28
- * Each entry carries its own full OpenAI chat-completions `url` + `apiKey`; entries merge
29
- * into the model selector ("custom" section). Default model via settings.json "model".
30
- * Custom entries speak the OpenAI wire only — anthropic-protocol providers are skipped.
31
- */
32
- export const workbuddy = {
72
+ /** WorkBuddy stores per-model full chat-completions URLs and credentials. */
73
+ export const workbuddy = transactionalTarget({
33
74
  id: "workbuddy",
34
75
  name: "WorkBuddy",
35
76
  protocols: ["openai"],
@@ -40,31 +81,51 @@ export const workbuddy = {
40
81
  const modelsFile = path.join(dir, "models.json");
41
82
  const settingsFile = path.join(dir, "settings.json");
42
83
  const notes = [];
43
- const base = provider.baseUrl.replace(/\/+$/, "");
44
- const url = /\/v\d+$/.test(base) ? `${base}/chat/completions` : `${base}/v1/chat/completions`;
45
- // models.json accepts a raw array (desktop) or {models, availableModels} (CLI); write the object shape.
46
- const { models: existingModels, availableModels: existingAvailable } = readWorkbuddyModels(modelsFile);
47
- const ourIds = provider.models.map((m) => m.id);
48
- const kept = existingModels.filter((m) => !ourIds.includes(m.id));
49
- const ours = provider.models.map((m) => ({
50
- id: m.id,
51
- name: m.name ?? m.id,
52
- vendor: provider.name,
53
- url,
54
- apiKey: provider.apiKey,
55
- ...(m.maxInput ?? m.contextWindow ? { maxInputTokens: m.maxInput ?? m.contextWindow } : {}),
56
- ...(m.maxOutput ? { maxOutputTokens: m.maxOutput } : {}),
57
- supportsToolCall: true,
58
- ...(m.imageInput !== undefined ? { supportsImages: !!m.imageInput } : {}),
59
- ...(m.reasoning !== undefined ? { supportsReasoning: !!m.reasoning } : {}),
60
- }));
61
- const availableModels = [...new Set([...existingAvailable, ...ourIds])];
84
+ const url = completionsUrl(provider.baseUrl);
85
+ const { rows, models, container, availableModels: existingAvailable } = readWorkbuddyModels(modelsFile);
86
+ const settingsValue = readJsonIfExists(settingsFile);
87
+ const settings = settingsValue === undefined ? {} : settingsValue;
88
+ if (!isJsonObject(settings))
89
+ throw new Error(`${settingsFile}: expected a JSON object`);
90
+ const owned = models.filter((row) => ownsModel(row, provider, url));
91
+ const ownedRows = new Set(owned);
92
+ const ourIds = new Set(provider.models.map((model) => model.id));
93
+ const kept = rows.filter((row) => !isModel(row) || !ownedRows.has(row));
94
+ for (const row of kept) {
95
+ if (isModel(row) && ourIds.has(row.id)) {
96
+ throw new Error(`${modelsFile}: model ${row.id} belongs to another account; refusing to overwrite it`);
97
+ }
98
+ }
99
+ const previous = new Map(owned.map((row) => [row.id, row]));
100
+ const ours = provider.models.map((model) => {
101
+ const custom = { ...previous.get(model.id) };
102
+ for (const key of OWNED_MODEL_KEYS)
103
+ delete custom[key];
104
+ return {
105
+ ...custom,
106
+ id: model.id,
107
+ name: model.name ?? model.id,
108
+ vendor: provider.name,
109
+ url,
110
+ apiKey: provider.apiKey,
111
+ agentswProviderId: provider.id,
112
+ ...(model.maxInput ?? model.contextWindow ? { maxInputTokens: model.maxInput ?? model.contextWindow } : {}),
113
+ ...(model.maxOutput ? { maxOutputTokens: model.maxOutput } : {}),
114
+ supportsToolCall: true,
115
+ ...(model.imageInput !== undefined ? { supportsImages: !!model.imageInput } : {}),
116
+ ...(model.reasoning !== undefined ? { supportsReasoning: !!model.reasoning } : {}),
117
+ };
118
+ });
119
+ const removedIds = new Set(owned.map((row) => row.id));
120
+ const keptIds = new Set(kept.filter(isModel).map((row) => row.id));
121
+ const availableModels = [...new Set([
122
+ ...existingAvailable.filter((id) => !removedIds.has(id) || keptIds.has(id)), ...ourIds,
123
+ ])];
124
+ settings.model = provider.defaultModel;
62
125
  const modelsBackup = backupFile(modelsFile);
63
126
  if (modelsBackup)
64
127
  notes.push(`backup: ${modelsBackup}`);
65
- writeFileAtomic(modelsFile, JSON.stringify({ models: [...kept, ...ours], availableModels }, null, 2) + "\n");
66
- const settings = readJsonIfExists(settingsFile) ?? {};
67
- settings.model = provider.defaultModel;
128
+ writeFileAtomic(modelsFile, JSON.stringify({ ...container, models: [...kept, ...ours], availableModels }, null, 2) + "\n", 0o600);
68
129
  const settingsBackup = backupFile(settingsFile);
69
130
  if (settingsBackup)
70
131
  notes.push(`backup: ${settingsBackup}`);
@@ -76,24 +137,30 @@ export const workbuddy = {
76
137
  const dir = configDir();
77
138
  const modelsFile = path.join(dir, "models.json");
78
139
  const settingsFile = path.join(dir, "settings.json");
79
- const { models, availableModels: available, existed } = readWorkbuddyModels(modelsFile);
140
+ const { rows, models, container, availableModels: available, existed } = readWorkbuddyModels(modelsFile);
80
141
  if (!existed)
81
142
  return { app: this.id, changed: [], notes: [], skipped: "no models.json" };
82
- const ourIds = provider.models.map((m) => m.id);
83
- // ours = matching id AND vendor (id alone could collide with entries from another provider)
84
- const kept = models.filter((m) => !(ourIds.includes(m.id) && m.vendor === provider.name));
85
- if (kept.length === models.length) {
143
+ const endpoint = completionsUrl(provider.baseUrl);
144
+ const owned = new Set(models.filter((row) => ownsModel(row, provider, endpoint)));
145
+ if (!owned.size)
86
146
  return { app: this.id, changed: [], notes: [], skipped: "no entries from this provider" };
87
- }
88
- const keptIds = kept.map((m) => m.id);
147
+ const kept = rows.filter((row) => !isModel(row) || !owned.has(row));
148
+ const keptIds = new Set(kept.filter(isModel).map((row) => row.id));
149
+ const removedIds = new Set([...owned].map((row) => row.id));
150
+ const settings = readJsonIfExists(settingsFile);
151
+ if (settings !== undefined && !isJsonObject(settings))
152
+ throw new Error(`${settingsFile}: expected a JSON object`);
89
153
  const notes = [];
90
154
  const modelsBackup = backupFile(modelsFile);
91
155
  if (modelsBackup)
92
156
  notes.push(`backup: ${modelsBackup}`);
93
- writeFileAtomic(modelsFile, JSON.stringify({ models: kept, availableModels: available.filter((id) => keptIds.includes(id)) }, null, 2) + "\n");
157
+ writeFileAtomic(modelsFile, JSON.stringify({
158
+ ...container,
159
+ models: kept,
160
+ availableModels: available.filter((id) => !removedIds.has(id) || keptIds.has(id)),
161
+ }, null, 2) + "\n", 0o600);
94
162
  const changed = [modelsFile];
95
- const settings = readJsonIfExists(settingsFile);
96
- if (settings && typeof settings.model === "string" && ourIds.includes(settings.model)) {
163
+ if (settings && typeof settings.model === "string" && removedIds.has(settings.model) && !keptIds.has(settings.model)) {
97
164
  delete settings.model;
98
165
  const settingsBackup = backupFile(settingsFile);
99
166
  if (settingsBackup)
@@ -109,37 +176,45 @@ export const workbuddy = {
109
176
  return settings?.model;
110
177
  },
111
178
  candidates() {
112
- // Each model has its own endpoint and credential; different accounts must stay separate.
113
179
  const { models } = readWorkbuddyModels(path.join(configDir(), "models.json"));
114
180
  if (models.length === 0)
115
181
  return [];
116
182
  const settings = readJsonIfExists(path.join(configDir(), "settings.json"));
117
183
  const groups = new Map();
118
- for (const m of models) {
119
- const row = m;
184
+ for (const row of models) {
120
185
  if (typeof row.url !== "string" || !row.url)
121
186
  continue;
122
- const baseUrl = row.url.replace(/\/+$/, "").replace(/\/chat\/completions$/, "");
123
- if (!/^https?:\/\//.test(baseUrl))
187
+ const baseUrl = workbuddyBaseUrl(row.url);
188
+ if (!baseUrl)
124
189
  continue;
125
- const apiKey = typeof row.apiKey === "string" && row.apiKey ? row.apiKey : undefined;
190
+ const apiKey = typeof row.apiKey === "string" ? row.apiKey : undefined;
126
191
  const key = JSON.stringify([baseUrl, apiKey ?? null]);
127
- let g = groups.get(key);
128
- if (!g) {
129
- g = { baseUrl, apiKey, ids: [] };
130
- groups.set(key, g);
192
+ let group = groups.get(key);
193
+ if (!group) {
194
+ const id = providerIdFromBaseUrl(baseUrl, "openai");
195
+ const candidate = {
196
+ id,
197
+ generatedId: true,
198
+ name: providerNameFromBaseUrl(baseUrl, "openai"),
199
+ protocol: "openai",
200
+ baseUrl,
201
+ apiKey,
202
+ models: [],
203
+ source: this.id,
204
+ };
205
+ candidate.localId = localProviderId(candidate);
206
+ group = { candidate };
207
+ groups.set(key, group);
208
+ }
209
+ if (!group.vendor && typeof row.vendor === "string" && row.vendor) {
210
+ group.vendor = row.vendor;
211
+ group.candidate.name = row.vendor;
131
212
  }
132
- if (!g.ids.includes(m.id))
133
- g.ids.push(m.id);
134
- if (!g.vendor && typeof row.vendor === "string")
135
- g.vendor = row.vendor;
213
+ if (!group.candidate.models.includes(row.id))
214
+ group.candidate.models.push(row.id);
215
+ if (settings?.model === row.id)
216
+ group.candidate.defaultModel = row.id;
136
217
  }
137
- const self = this.id;
138
- return [...groups.values()].map((g) => {
139
- const { baseUrl } = g;
140
- const id = providerIdFromBaseUrl(baseUrl, "openai");
141
- const defaultModel = settings?.model && g.ids.includes(settings.model) ? settings.model : undefined;
142
- return { id, generatedId: true, name: g.vendor ?? providerNameFromBaseUrl(baseUrl, "openai"), protocol: "openai", baseUrl, apiKey: g.apiKey, models: g.ids, defaultModel, source: self };
143
- });
218
+ return [...groups.values()].map((group) => group.candidate);
144
219
  },
145
- };
220
+ });
package/dist/yaml.js ADDED
@@ -0,0 +1,39 @@
1
+ import YAML from "yaml";
2
+ /** Resolve aliases in document context before edits can mutate or remove anchors. */
3
+ export function parseYamlMapping(file, text) {
4
+ try {
5
+ const doc = text ? YAML.parseDocument(text) : new YAML.Document({});
6
+ if (doc.errors.length)
7
+ throw new Error("parse error");
8
+ if (doc.contents == null)
9
+ doc.contents = doc.createNode({});
10
+ // YAML permits cycles; application configuration does not. The document
11
+ // conversion also enforces YAML's alias expansion limit before we detach.
12
+ JSON.stringify(doc.toJS());
13
+ const expanded = new Map();
14
+ YAML.visit(doc, {
15
+ Alias(_key, alias) {
16
+ const node = doc.createNode(alias.toJS(doc), { aliasDuplicateObjects: false });
17
+ node.comment = alias.comment;
18
+ node.commentBefore = alias.commentBefore;
19
+ node.spaceBefore = alias.spaceBefore;
20
+ expanded.set(alias, node);
21
+ },
22
+ });
23
+ // Resolve every alias before replacing any, since anchors may be reused.
24
+ YAML.visit(doc, { Alias: (_key, alias) => expanded.get(alias) });
25
+ if (!YAML.isMap(doc.contents))
26
+ throw new Error("expected a configuration mapping");
27
+ return doc;
28
+ }
29
+ catch {
30
+ // Parser messages include source lines, which may contain credentials.
31
+ throw new Error(`${file}: invalid YAML configuration (expected an acyclic mapping with valid aliases)`);
32
+ }
33
+ }
34
+ /** Validate the emitted configuration before any backup or write. */
35
+ export function serializeYamlMapping(file, doc) {
36
+ const text = doc.toString();
37
+ parseYamlMapping(file, text);
38
+ return text;
39
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentsw",
3
- "version": "0.7.1",
3
+ "version": "0.7.2",
4
4
  "description": "CLI provider switcher for coding agents: manage OpenAI/Anthropic-protocol providers in one place and sync them, with models.dev metadata, into Claude Code, Codex, omp, pi, prime-agent, opencode, Hermes, WorkBuddy and DeepSeek Harness",
5
5
  "type": "module",
6
6
  "repository": {
@@ -30,7 +30,7 @@
30
30
  "dist"
31
31
  ],
32
32
  "engines": {
33
- "node": ">=22.12.0"
33
+ "node": ">=22.13.0"
34
34
  },
35
35
  "scripts": {
36
36
  "build": "tsc -p tsconfig.json",
@@ -44,12 +44,14 @@
44
44
  "jsonc-parser": "^3.3.1",
45
45
  "picocolors": "^1.1.1",
46
46
  "prompts": "^2.4.2",
47
+ "semver": "^7.8.5",
47
48
  "smol-toml": "^1.8.0",
48
49
  "yaml": "^2.9.0"
49
50
  },
50
51
  "devDependencies": {
51
52
  "@types/node": "^26.4.0",
52
53
  "@types/prompts": "^2.4.9",
54
+ "@types/semver": "^7.8.0",
53
55
  "tsx": "^4.23.12",
54
56
  "typescript": "^7.0.2"
55
57
  }