agentsw 0.6.1 → 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.
@@ -1,6 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
- import { backupFile, home, readJsonIfExists, writeFileAtomic } from "../fsutil.js";
3
+ import { backupFile, home, writeFileAtomic } from "../fsutil.js";
4
+ import { editJsoncObject, isJsonObject, readJsoncObject } from "../jsonc.js";
4
5
  import { looksLikeEnvName } from "../slug.js";
5
6
  import { apiValue, classifyApi, entryApi, mergeModels, sdkBaseUrl, stripConflictingOverrides } from "./wire.js";
6
7
  /** Per-model keys this adapter writes; one that stops being emitted is cleared, not inherited. */
@@ -14,6 +15,28 @@ const OWNED_MODEL_KEYS = [
14
15
  "maxTokens",
15
16
  "cost",
16
17
  ];
18
+ function readModels(file) {
19
+ const document = readJsoncObject(file);
20
+ const providers = document.value.providers;
21
+ if (providers !== undefined) {
22
+ if (!isJsonObject(providers))
23
+ throw new Error(`${file}: expected providers to be a JSON object`);
24
+ for (const entry of Object.values(providers)) {
25
+ if (!isJsonObject(entry))
26
+ throw new Error(`${file}: expected every provider entry to be a JSON object`);
27
+ }
28
+ }
29
+ return { ...document, value: document.value };
30
+ }
31
+ function readSettings(file) {
32
+ const document = readJsoncObject(file);
33
+ for (const key of ["defaultProvider", "defaultModel"]) {
34
+ if (document.value[key] !== undefined && typeof document.value[key] !== "string") {
35
+ throw new Error(`${file}: expected ${key} to be a string`);
36
+ }
37
+ }
38
+ return { ...document, value: document.value };
39
+ }
17
40
  /**
18
41
  * pi-family agents (pi, prime-agent) share the same config layout:
19
42
  * <dir>/models.json — { providers: { <id>: { baseUrl, apiKey, api, models[] } } }
@@ -50,14 +73,14 @@ export function piStyleTarget(opts) {
50
73
  const modelsFile = path.join(dir, "models.json");
51
74
  const settingsFile = path.join(dir, "settings.json");
52
75
  const notes = [];
53
- const modelsConfig = readJsonIfExists(modelsFile) ?? {};
76
+ // Validate both files before preparing any writes, including a malformed settings file.
77
+ const modelsDocument = readModels(modelsFile);
78
+ const settingsDocument = readSettings(settingsFile);
79
+ const modelsConfig = { ...modelsDocument.value };
54
80
  const providers = { ...modelsConfig.providers };
55
81
  // Keys agentsw does not model (headers, authHeader, oauth, ...) and per-model extras
56
82
  // are the user's; a re-sync overwrites only the fields it owns.
57
- const existing = providers[provider.id];
58
- const prev = (existing && typeof existing === "object" && !Array.isArray(existing)
59
- ? existing
60
- : {});
83
+ const prev = providers[provider.id] ?? {};
61
84
  const api = apiValue(provider.protocol, provider.openaiApi, entryApi(prev));
62
85
  const baseUrl = sdkBaseUrl(provider.protocol, provider.baseUrl);
63
86
  const models = mergeModels(prev.models, provider.models.map((m) => ({
@@ -93,60 +116,70 @@ export function piStyleTarget(opts) {
93
116
  models,
94
117
  };
95
118
  modelsConfig.providers = providers;
119
+ const settings = {
120
+ ...settingsDocument.value,
121
+ defaultProvider: provider.id,
122
+ defaultModel: provider.defaultModel,
123
+ };
124
+ const modelsText = editJsoncObject(modelsDocument, modelsConfig);
125
+ const settingsText = editJsoncObject(settingsDocument, settings);
96
126
  const modelsBackup = backupFile(modelsFile);
97
127
  if (modelsBackup)
98
128
  notes.push(`backup: ${modelsBackup}`);
99
- writeFileAtomic(modelsFile, JSON.stringify(modelsConfig, null, 2) + "\n");
100
- const settings = readJsonIfExists(settingsFile) ?? {};
101
- settings.defaultProvider = provider.id;
102
- settings.defaultModel = provider.defaultModel;
129
+ writeFileAtomic(modelsFile, modelsText);
103
130
  const settingsBackup = backupFile(settingsFile);
104
131
  if (settingsBackup)
105
132
  notes.push(`backup: ${settingsBackup}`);
106
- writeFileAtomic(settingsFile, JSON.stringify(settings, null, 2) + "\n");
133
+ writeFileAtomic(settingsFile, settingsText);
107
134
  return { app: opts.id, changed: [modelsFile, settingsFile], notes };
108
135
  },
109
136
  async prune(provider) {
110
137
  const dir = resolveDir();
111
138
  const modelsFile = path.join(dir, "models.json");
112
139
  const settingsFile = path.join(dir, "settings.json");
113
- const modelsConfig = readJsonIfExists(modelsFile);
114
- const providers = modelsConfig?.providers;
115
- if (!modelsConfig || !providers?.[provider.id]) {
140
+ const modelsDocument = readModels(modelsFile);
141
+ const settingsDocument = readSettings(settingsFile);
142
+ const providers = { ...modelsDocument.value.providers };
143
+ if (!Object.hasOwn(providers, provider.id)) {
116
144
  return { app: opts.id, changed: [], notes: [], skipped: `no providers.${provider.id} entry` };
117
145
  }
118
146
  delete providers[provider.id];
147
+ const settings = { ...settingsDocument.value };
148
+ const resetDefault = settings.defaultProvider === provider.id;
149
+ if (resetDefault) {
150
+ delete settings.defaultProvider;
151
+ delete settings.defaultModel;
152
+ }
153
+ const modelsText = editJsoncObject(modelsDocument, { ...modelsDocument.value, providers });
154
+ const settingsText = resetDefault ? editJsoncObject(settingsDocument, settings) : undefined;
119
155
  const notes = [];
120
156
  const changed = [modelsFile];
121
157
  const modelsBackup = backupFile(modelsFile);
122
158
  if (modelsBackup)
123
159
  notes.push(`backup: ${modelsBackup}`);
124
- writeFileAtomic(modelsFile, JSON.stringify(modelsConfig, null, 2) + "\n");
125
- const settings = readJsonIfExists(settingsFile);
126
- if (settings?.defaultProvider === provider.id) {
127
- delete settings.defaultProvider;
128
- delete settings.defaultModel;
160
+ writeFileAtomic(modelsFile, modelsText);
161
+ if (settingsText !== undefined) {
129
162
  const settingsBackup = backupFile(settingsFile);
130
163
  if (settingsBackup)
131
164
  notes.push(`backup: ${settingsBackup}`);
132
- writeFileAtomic(settingsFile, JSON.stringify(settings, null, 2) + "\n");
165
+ writeFileAtomic(settingsFile, settingsText);
133
166
  changed.push(settingsFile);
134
167
  notes.push("default model reset (was pointing at this provider)");
135
168
  }
136
169
  return { app: opts.id, changed, notes };
137
170
  },
138
171
  current() {
139
- const settings = readJsonIfExists(path.join(resolveDir(), "settings.json"));
172
+ const settings = readSettings(path.join(resolveDir(), "settings.json")).value;
140
173
  if (!settings?.defaultProvider)
141
174
  return undefined;
142
175
  return `${settings.defaultProvider} · ${settings.defaultModel ?? "?"}`;
143
176
  },
144
177
  candidates() {
145
178
  const dir = resolveDir();
146
- const mc = readJsonIfExists(path.join(dir, "models.json"));
147
- if (!mc?.providers)
179
+ const mc = readModels(path.join(dir, "models.json")).value;
180
+ const settings = readSettings(path.join(dir, "settings.json")).value;
181
+ if (!mc.providers)
148
182
  return [];
149
- const settings = readJsonIfExists(path.join(dir, "settings.json"));
150
183
  const self = opts.id;
151
184
  return Object.entries(mc.providers).flatMap(([id, entry]) => {
152
185
  if (!entry || typeof entry.baseUrl !== "string")
@@ -1,7 +1,7 @@
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 { slugFromBaseUrl } from "../slug.js";
4
+ import { providerIdFromBaseUrl, providerNameFromBaseUrl } from "../slug.js";
5
5
  function configDir() {
6
6
  return process.env.WORKBUDDY_CONFIG_DIR?.trim() ?? process.env.CODEBUDDY_CONFIG_DIR?.trim() ??
7
7
  (process.platform === "win32" ? appDataDir("workbuddy") : path.join(home, ".workbuddy"));
@@ -109,7 +109,7 @@ export const workbuddy = {
109
109
  return settings?.model;
110
110
  },
111
111
  candidates() {
112
- // every entry carries its own full chat/completions url + key; group by the underlying base URL
112
+ // Each model has its own endpoint and credential; different accounts must stay separate.
113
113
  const { models } = readWorkbuddyModels(path.join(configDir(), "models.json"));
114
114
  if (models.length === 0)
115
115
  return [];
@@ -122,23 +122,24 @@ export const workbuddy = {
122
122
  const baseUrl = row.url.replace(/\/+$/, "").replace(/\/chat\/completions$/, "");
123
123
  if (!/^https?:\/\//.test(baseUrl))
124
124
  continue;
125
- let g = groups.get(baseUrl);
125
+ const apiKey = typeof row.apiKey === "string" && row.apiKey ? row.apiKey : undefined;
126
+ const key = JSON.stringify([baseUrl, apiKey ?? null]);
127
+ let g = groups.get(key);
126
128
  if (!g) {
127
- g = { ids: [] };
128
- groups.set(baseUrl, g);
129
+ g = { baseUrl, apiKey, ids: [] };
130
+ groups.set(key, g);
129
131
  }
130
132
  if (!g.ids.includes(m.id))
131
133
  g.ids.push(m.id);
132
- if (!g.apiKey && typeof row.apiKey === "string")
133
- g.apiKey = row.apiKey;
134
134
  if (!g.vendor && typeof row.vendor === "string")
135
135
  g.vendor = row.vendor;
136
136
  }
137
137
  const self = this.id;
138
- return [...groups.entries()].map(([baseUrl, g]) => {
139
- const id = slugFromBaseUrl(baseUrl);
138
+ return [...groups.values()].map((g) => {
139
+ const { baseUrl } = g;
140
+ const id = providerIdFromBaseUrl(baseUrl, "openai");
140
141
  const defaultModel = settings?.model && g.ids.includes(settings.model) ? settings.model : undefined;
141
- return { id, name: g.vendor ?? id, protocol: "openai", baseUrl, apiKey: g.apiKey, models: g.ids, defaultModel, source: self };
142
+ return { id, generatedId: true, name: g.vendor ?? providerNameFromBaseUrl(baseUrl, "openai"), protocol: "openai", baseUrl, apiKey: g.apiKey, models: g.ids, defaultModel, source: self };
142
143
  });
143
144
  },
144
145
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentsw",
3
- "version": "0.6.1",
3
+ "version": "0.7.0",
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": {
@@ -41,6 +41,7 @@
41
41
  },
42
42
  "dependencies": {
43
43
  "commander": "^15.0.0",
44
+ "jsonc-parser": "^3.3.1",
44
45
  "picocolors": "^1.1.1",
45
46
  "prompts": "^2.4.2",
46
47
  "smol-toml": "^1.8.0",