agentsw 0.6.2 → 0.7.1
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/README.md +22 -0
- package/README.zh-CN.md +21 -0
- package/dist/commands.js +30 -40
- package/dist/config-transaction.js +138 -0
- package/dist/envfile.js +27 -0
- package/dist/i18n.js +74 -31
- package/dist/import.js +35 -21
- package/dist/index.js +16 -3
- package/dist/menu.js +111 -24
- package/dist/provider-actions.js +37 -0
- package/dist/remove.js +651 -0
- package/dist/rename.js +458 -0
- package/dist/slug.js +17 -8
- package/dist/sources/ccswitch.js +4 -2
- package/dist/targets/claudecode.js +4 -3
- package/dist/targets/workbuddy.js +11 -10
- package/package.json +1 -1
package/dist/rename.js
ADDED
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import YAML from "yaml";
|
|
4
|
+
import { applyEdits, findNodeAtLocation, getNodeValue, parseTree } from "jsonc-parser";
|
|
5
|
+
import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
|
|
6
|
+
import { appDataDir, expandHome, home, localAppDataDir } from "./fsutil.js";
|
|
7
|
+
import { isJsonObject, readJsoncObject } from "./jsonc.js";
|
|
8
|
+
import { configFile } from "./store.js";
|
|
9
|
+
import { commitFileChanges } from "./config-transaction.js";
|
|
10
|
+
import { endpointKey } from "./import.js";
|
|
11
|
+
import { classifyApi, entryApi } from "./targets/wire.js";
|
|
12
|
+
import { envAssignments } from "./envfile.js";
|
|
13
|
+
function readExisting(file) {
|
|
14
|
+
try {
|
|
15
|
+
return fs.readFileSync(file, "utf8");
|
|
16
|
+
}
|
|
17
|
+
catch (error) {
|
|
18
|
+
if (error.code === "ENOENT")
|
|
19
|
+
return undefined;
|
|
20
|
+
throw new Error(`${file}: cannot read configuration`);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
function get(value, at) {
|
|
24
|
+
for (const key of at) {
|
|
25
|
+
if (!isJsonObject(value) && !Array.isArray(value))
|
|
26
|
+
return undefined;
|
|
27
|
+
value = value[key];
|
|
28
|
+
}
|
|
29
|
+
return value;
|
|
30
|
+
}
|
|
31
|
+
function object(value, file) {
|
|
32
|
+
if (!isJsonObject(value))
|
|
33
|
+
throw new Error(`${file}: expected a configuration mapping`);
|
|
34
|
+
return value;
|
|
35
|
+
}
|
|
36
|
+
function jsonEditor(file, before, allowArray = false) {
|
|
37
|
+
const bom = before.startsWith("\uFEFF") ? "\uFEFF" : "";
|
|
38
|
+
const source = bom ? before.slice(1) : before;
|
|
39
|
+
const errors = [];
|
|
40
|
+
const root = parseTree(source, errors, { allowTrailingComma: true });
|
|
41
|
+
if (!root || errors.length || (root.type !== "object" && !(allowArray && root.type === "array"))) {
|
|
42
|
+
throw new Error(`${file}: invalid JSONC configuration`);
|
|
43
|
+
}
|
|
44
|
+
if (root.type === "object") {
|
|
45
|
+
if (readJsoncObject(file).text !== before)
|
|
46
|
+
throw new Error(`${file}: configuration changed since it was read`);
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
const check = (node) => {
|
|
50
|
+
const names = new Set();
|
|
51
|
+
for (const child of node.children ?? []) {
|
|
52
|
+
if (node.type === "object") {
|
|
53
|
+
const name = String(child.children?.[0]?.value);
|
|
54
|
+
if (names.has(name))
|
|
55
|
+
throw new Error(`${file}: duplicate JSONC property`);
|
|
56
|
+
names.add(name);
|
|
57
|
+
}
|
|
58
|
+
check(child);
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
check(root);
|
|
62
|
+
}
|
|
63
|
+
const edits = [];
|
|
64
|
+
return {
|
|
65
|
+
file, value: getNodeValue(root), changed: false,
|
|
66
|
+
set(at, next) {
|
|
67
|
+
const node = findNodeAtLocation(root, at);
|
|
68
|
+
if (!node)
|
|
69
|
+
throw new Error(`${file}: missing configuration reference`);
|
|
70
|
+
edits.push({ offset: node.offset, length: node.length, content: JSON.stringify(next) });
|
|
71
|
+
this.changed = true;
|
|
72
|
+
},
|
|
73
|
+
move(at, oldKey, newKey) {
|
|
74
|
+
const map = findNodeAtLocation(root, at);
|
|
75
|
+
const property = map?.children?.find((node) => node.children?.[0]?.value === oldKey);
|
|
76
|
+
const key = property?.children?.[0];
|
|
77
|
+
if (!key)
|
|
78
|
+
throw new Error(`${file}: missing provider key`);
|
|
79
|
+
edits.push({ offset: key.offset, length: key.length, content: JSON.stringify(newKey) });
|
|
80
|
+
this.changed = true;
|
|
81
|
+
},
|
|
82
|
+
render() {
|
|
83
|
+
const result = applyEdits(source, edits);
|
|
84
|
+
const errors = [];
|
|
85
|
+
parseTree(result, errors, { allowTrailingComma: true });
|
|
86
|
+
if (errors.length)
|
|
87
|
+
throw new Error(`${file}: invalid edited JSONC configuration`);
|
|
88
|
+
return bom + result;
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
function yamlEditor(file, before) {
|
|
93
|
+
let doc;
|
|
94
|
+
let value;
|
|
95
|
+
try {
|
|
96
|
+
doc = YAML.parseDocument(before);
|
|
97
|
+
if (doc.contents === null)
|
|
98
|
+
doc.contents = doc.createNode({});
|
|
99
|
+
if (doc.errors.length || !YAML.isMap(doc.contents))
|
|
100
|
+
throw new Error("invalid YAML mapping");
|
|
101
|
+
value = doc.toJS();
|
|
102
|
+
JSON.stringify(value);
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
throw new Error(`${file}: invalid YAML configuration or references`);
|
|
106
|
+
}
|
|
107
|
+
const writableParent = (at) => {
|
|
108
|
+
let node = doc.contents;
|
|
109
|
+
for (const key of at) {
|
|
110
|
+
if (!YAML.isMap(node) && !YAML.isSeq(node))
|
|
111
|
+
throw new Error(`${file}: invalid configuration mapping`);
|
|
112
|
+
let child = node.get(key, true);
|
|
113
|
+
// Editing through an alias must not mutate an unrelated template/provider.
|
|
114
|
+
if (YAML.isAlias(child)) {
|
|
115
|
+
const expanded = doc.createNode(child.toJS(doc), { aliasDuplicateObjects: false });
|
|
116
|
+
expanded.comment = child.comment;
|
|
117
|
+
expanded.commentBefore = child.commentBefore;
|
|
118
|
+
expanded.spaceBefore = child.spaceBefore;
|
|
119
|
+
node.set(key, expanded);
|
|
120
|
+
child = expanded;
|
|
121
|
+
}
|
|
122
|
+
node = child;
|
|
123
|
+
}
|
|
124
|
+
if (!YAML.isMap(node) && !YAML.isSeq(node))
|
|
125
|
+
throw new Error(`${file}: invalid configuration mapping`);
|
|
126
|
+
return node;
|
|
127
|
+
};
|
|
128
|
+
return {
|
|
129
|
+
file, value, changed: false,
|
|
130
|
+
set(at, next) {
|
|
131
|
+
const parent = writableParent(at.slice(0, -1));
|
|
132
|
+
const key = at.at(-1);
|
|
133
|
+
const node = parent.get(key, true);
|
|
134
|
+
if (YAML.isScalar(node))
|
|
135
|
+
node.value = next;
|
|
136
|
+
else {
|
|
137
|
+
const replacement = doc.createNode(next);
|
|
138
|
+
if (YAML.isNode(node)) {
|
|
139
|
+
replacement.comment = node.comment;
|
|
140
|
+
replacement.commentBefore = node.commentBefore;
|
|
141
|
+
}
|
|
142
|
+
parent.set(key, replacement);
|
|
143
|
+
}
|
|
144
|
+
this.changed = true;
|
|
145
|
+
},
|
|
146
|
+
move(at, oldKey, newKey) {
|
|
147
|
+
// Rename the Pair's key in place: preserve value, comments, anchors and position.
|
|
148
|
+
const parent = writableParent(at);
|
|
149
|
+
if (!YAML.isMap(parent))
|
|
150
|
+
throw new Error(`${file}: expected provider mapping`);
|
|
151
|
+
const pair = parent.items.find((entry) => {
|
|
152
|
+
const key = YAML.isAlias(entry.key) ? entry.key.resolve(doc) : entry.key;
|
|
153
|
+
return YAML.isScalar(key) ? key.value === oldKey : key === oldKey;
|
|
154
|
+
});
|
|
155
|
+
if (!pair)
|
|
156
|
+
throw new Error(`${file}: missing provider key`);
|
|
157
|
+
if (YAML.isScalar(pair.key))
|
|
158
|
+
pair.key.value = newKey;
|
|
159
|
+
else
|
|
160
|
+
pair.key = doc.createNode(newKey);
|
|
161
|
+
this.changed = true;
|
|
162
|
+
},
|
|
163
|
+
render() {
|
|
164
|
+
try {
|
|
165
|
+
const text = doc.toString();
|
|
166
|
+
const parsed = YAML.parseDocument(text);
|
|
167
|
+
if (parsed.errors.length)
|
|
168
|
+
throw new Error("invalid YAML");
|
|
169
|
+
JSON.stringify(parsed.toJS());
|
|
170
|
+
return text;
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
throw new Error(`${file}: invalid edited YAML configuration or references`);
|
|
174
|
+
}
|
|
175
|
+
},
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
function tomlEditor(file, before) {
|
|
179
|
+
let value;
|
|
180
|
+
try {
|
|
181
|
+
value = parseToml(before);
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
throw new Error(`${file}: invalid TOML configuration`);
|
|
185
|
+
}
|
|
186
|
+
return {
|
|
187
|
+
file, value, changed: false,
|
|
188
|
+
set(at, next) {
|
|
189
|
+
const parent = object(get(value, at.slice(0, -1)), file);
|
|
190
|
+
parent[String(at.at(-1))] = next;
|
|
191
|
+
this.changed = true;
|
|
192
|
+
},
|
|
193
|
+
move(at, oldKey, newKey) {
|
|
194
|
+
const parent = object(get(value, at), file);
|
|
195
|
+
Object.defineProperty(parent, newKey, { value: parent[oldKey], enumerable: true, writable: true, configurable: true });
|
|
196
|
+
delete parent[oldKey];
|
|
197
|
+
this.changed = true;
|
|
198
|
+
},
|
|
199
|
+
render() {
|
|
200
|
+
try {
|
|
201
|
+
return stringifyToml(value) + "\n";
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
throw new Error(`${file}: cannot serialize TOML configuration`);
|
|
205
|
+
}
|
|
206
|
+
},
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
const managedKey = (id) => `AGENTSW_${id.toUpperCase().replace(/[^A-Z0-9]/g, "_")}_API_KEY`;
|
|
210
|
+
/** Explicit provider ID migration; never recreates providers or edits session history. */
|
|
211
|
+
export async function renameProvider(oldId, newId, opts = {}) {
|
|
212
|
+
if (!/^[a-z0-9][a-z0-9_-]*$/.test(newId))
|
|
213
|
+
throw new Error("new provider ID must be a lowercase slug (letters, digits, hyphens or underscores)");
|
|
214
|
+
const beforeStore = readExisting(configFile);
|
|
215
|
+
if (beforeStore === undefined)
|
|
216
|
+
throw new Error(`${configFile}: provider store does not exist`);
|
|
217
|
+
const store = jsonEditor(configFile, beforeStore);
|
|
218
|
+
const providers = object(get(store.value, ["providers"]), configFile);
|
|
219
|
+
if (!Object.hasOwn(providers, oldId))
|
|
220
|
+
throw new Error(`${configFile}: source provider does not exist`);
|
|
221
|
+
if (oldId === newId)
|
|
222
|
+
return { files: [] };
|
|
223
|
+
if (Object.hasOwn(providers, newId))
|
|
224
|
+
throw new Error(`${configFile}: destination provider ID already exists`);
|
|
225
|
+
const provider = object(providers[oldId], configFile);
|
|
226
|
+
const changes = [];
|
|
227
|
+
const visited = new Set();
|
|
228
|
+
const oldKey = managedKey(oldId);
|
|
229
|
+
const newKey = managedKey(newId);
|
|
230
|
+
const assertConnection = (editor, entry) => {
|
|
231
|
+
const options = isJsonObject(entry.options) ? entry.options : {};
|
|
232
|
+
const endpoint = entry.baseUrl ?? entry.baseURL ?? entry.base_url ?? options.baseURL ?? (entry.transport ? entry.api : undefined);
|
|
233
|
+
if (typeof endpoint === "string" && /^https?:\/\//i.test(endpoint) && typeof provider.baseUrl === "string" && endpointKey(endpoint) !== endpointKey(provider.baseUrl)) {
|
|
234
|
+
throw new Error(`${editor.file}: source provider endpoint conflicts with the store`);
|
|
235
|
+
}
|
|
236
|
+
const protocol = classifyApi(entryApi(entry))?.protocol ??
|
|
237
|
+
(entry.wire_api ? "openai" : entry.transport === "anthropic_messages" ? "anthropic" :
|
|
238
|
+
["chat_completions", "codex_responses"].includes(String(entry.transport)) ? "openai" :
|
|
239
|
+
typeof entry.npm === "string" && entry.npm.includes("anthropic") ? "anthropic" :
|
|
240
|
+
typeof entry.npm === "string" && entry.npm.includes("openai") ? "openai" : undefined);
|
|
241
|
+
if (protocol && provider.protocol && protocol !== provider.protocol)
|
|
242
|
+
throw new Error(`${editor.file}: source provider protocol conflicts with the store`);
|
|
243
|
+
const rawKey = entry.apiKey ?? options.apiKey;
|
|
244
|
+
if (typeof rawKey === "string" && rawKey) {
|
|
245
|
+
const ref = rawKey.match(/^\$([A-Za-z_][A-Za-z0-9_]*)$/)?.[1] ?? rawKey.match(/^\{env:([A-Za-z_][A-Za-z0-9_]*)\}$/)?.[1];
|
|
246
|
+
// 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
|
+
if (key !== undefined && typeof provider.apiKey === "string" && key !== provider.apiKey)
|
|
249
|
+
throw new Error(`${editor.file}: source provider credentials conflict with the store`);
|
|
250
|
+
}
|
|
251
|
+
};
|
|
252
|
+
const exact = (editor, at, oldValue = oldId, newValue = newId) => {
|
|
253
|
+
if (get(editor.value, at) === oldValue)
|
|
254
|
+
editor.set(at, newValue);
|
|
255
|
+
};
|
|
256
|
+
const compound = (editor, at) => {
|
|
257
|
+
const value = get(editor.value, at);
|
|
258
|
+
if (typeof value === "string" && value.startsWith(`${oldId}/`))
|
|
259
|
+
editor.set(at, newId + value.slice(oldId.length));
|
|
260
|
+
};
|
|
261
|
+
const entries = (editor, at) => {
|
|
262
|
+
const value = get(editor.value, at);
|
|
263
|
+
return value === undefined ? [] : Object.entries(object(value, editor.file));
|
|
264
|
+
};
|
|
265
|
+
const renameMap = (editor, at, names = ["id", "name"]) => {
|
|
266
|
+
const map = get(editor.value, at);
|
|
267
|
+
if (map === undefined)
|
|
268
|
+
return;
|
|
269
|
+
const entries = object(map, editor.file);
|
|
270
|
+
if (Object.hasOwn(entries, newId))
|
|
271
|
+
throw new Error(`${editor.file}: destination provider ID already exists`);
|
|
272
|
+
if (!Object.hasOwn(entries, oldId))
|
|
273
|
+
return;
|
|
274
|
+
assertConnection(editor, object(entries[oldId], editor.file));
|
|
275
|
+
for (const key of names)
|
|
276
|
+
exact(editor, [...at, oldId, key]);
|
|
277
|
+
editor.move(at, oldId, newId);
|
|
278
|
+
};
|
|
279
|
+
const finish = (editor, before, mode) => {
|
|
280
|
+
changes.push({ file: editor.file, before, after: editor.changed ? editor.render() : before, mode });
|
|
281
|
+
};
|
|
282
|
+
const visit = (file, format, edit, mode, allowArray = false) => {
|
|
283
|
+
file = path.resolve(file);
|
|
284
|
+
if (visited.has(file))
|
|
285
|
+
return;
|
|
286
|
+
visited.add(file);
|
|
287
|
+
const before = readExisting(file);
|
|
288
|
+
if (before === undefined) {
|
|
289
|
+
changes.push({ file, before, after: undefined });
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
const editor = format === "yaml" ? yamlEditor(file, before) : format === "toml" ? tomlEditor(file, before) : jsonEditor(file, before, allowArray);
|
|
293
|
+
edit(editor);
|
|
294
|
+
finish(editor, before, mode);
|
|
295
|
+
};
|
|
296
|
+
exact(store, ["providers", oldId, "id"]);
|
|
297
|
+
exact(store, ["providers", oldId, "name"]);
|
|
298
|
+
store.move(["providers"], oldId, newId);
|
|
299
|
+
exact(store, ["active"]);
|
|
300
|
+
finish(store, beforeStore, 0o600);
|
|
301
|
+
visit(path.join(home, ".codex", "config.toml"), "toml", (editor) => {
|
|
302
|
+
const entry = get(editor.value, ["model_providers", oldId]);
|
|
303
|
+
if (isJsonObject(entry)) {
|
|
304
|
+
let key = typeof entry.env_key === "string" ? process.env[entry.env_key] : undefined;
|
|
305
|
+
if (!entry.env_key && entry.requires_openai_auth && get(editor.value, ["model_provider"]) === oldId) {
|
|
306
|
+
const authFile = path.join(home, ".codex", "auth.json");
|
|
307
|
+
const authText = readExisting(authFile);
|
|
308
|
+
changes.push({ file: authFile, before: authText, after: authText });
|
|
309
|
+
if (authText !== undefined) {
|
|
310
|
+
const auth = jsonEditor(authFile, authText);
|
|
311
|
+
const value = get(auth.value, ["OPENAI_API_KEY"]);
|
|
312
|
+
if (typeof value === "string")
|
|
313
|
+
key = value;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
if (key !== undefined && provider.apiKey !== undefined && key !== provider.apiKey)
|
|
317
|
+
throw new Error(`${editor.file}: source provider credentials conflict with the store`);
|
|
318
|
+
}
|
|
319
|
+
exact(editor, ["model_provider"]);
|
|
320
|
+
for (const [profile] of entries(editor, ["profiles"]))
|
|
321
|
+
exact(editor, ["profiles", profile, "model_provider"]);
|
|
322
|
+
renameMap(editor, ["model_providers"]);
|
|
323
|
+
});
|
|
324
|
+
const ompDir = path.join(home, ".omp", "agent");
|
|
325
|
+
for (const name of ["models.yml", "models.yaml"])
|
|
326
|
+
visit(path.join(ompDir, name), "yaml", (editor) => renameMap(editor, ["providers"]));
|
|
327
|
+
for (const name of ["config.yml", "config.yaml"])
|
|
328
|
+
visit(path.join(ompDir, name), "yaml", (editor) => {
|
|
329
|
+
compound(editor, ["model"]);
|
|
330
|
+
for (const [role] of entries(editor, ["modelRoles"]))
|
|
331
|
+
compound(editor, ["modelRoles", role]);
|
|
332
|
+
});
|
|
333
|
+
for (const [env, fallback] of [["PI_CODING_AGENT_DIR", ".pi/agent"], ["PRIME_AGENT_CODING_AGENT_DIR", ".prime/agent"]]) {
|
|
334
|
+
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"]));
|
|
336
|
+
visit(path.join(dir, "settings.json"), "json", (editor) => {
|
|
337
|
+
exact(editor, ["defaultProvider"]);
|
|
338
|
+
const recent = get(editor.value, ["recentModels"]);
|
|
339
|
+
if (Array.isArray(recent))
|
|
340
|
+
recent.forEach((item, index) => {
|
|
341
|
+
if (typeof item === "string")
|
|
342
|
+
compound(editor, ["recentModels", index]);
|
|
343
|
+
else
|
|
344
|
+
exact(editor, ["recentModels", index, "provider"]);
|
|
345
|
+
});
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
const opencodeFiles = new Set(["config.json", "opencode.json", "opencode.jsonc"].map((name) => path.join(appDataDir("opencode"), name)));
|
|
349
|
+
const customOpenCode = process.env.OPENCODE_CONFIG_DIR?.trim();
|
|
350
|
+
if (customOpenCode)
|
|
351
|
+
for (const name of ["opencode.json", "opencode.jsonc"])
|
|
352
|
+
opencodeFiles.add(path.join(expandHome(customOpenCode), name));
|
|
353
|
+
const sharedOpenCode = process.env.OPENCODE_CONFIG?.trim();
|
|
354
|
+
if (sharedOpenCode)
|
|
355
|
+
opencodeFiles.add(expandHome(sharedOpenCode));
|
|
356
|
+
for (const file of opencodeFiles)
|
|
357
|
+
visit(file, "json", (editor) => {
|
|
358
|
+
renameMap(editor, ["provider"]);
|
|
359
|
+
for (const key of ["model", "small_model"])
|
|
360
|
+
compound(editor, [key]);
|
|
361
|
+
for (const key of ["agent", "mode", "command"]) {
|
|
362
|
+
for (const [name] of entries(editor, [key]))
|
|
363
|
+
compound(editor, [key, name, "model"]);
|
|
364
|
+
}
|
|
365
|
+
for (const key of ["enabled_providers", "disabled_providers"]) {
|
|
366
|
+
const list = get(editor.value, [key]);
|
|
367
|
+
if (list !== undefined && !Array.isArray(list))
|
|
368
|
+
throw new Error(`${file}: expected provider list`);
|
|
369
|
+
if (Array.isArray(list))
|
|
370
|
+
list.forEach((_value, index) => exact(editor, [key, index]));
|
|
371
|
+
}
|
|
372
|
+
});
|
|
373
|
+
const hermesDir = process.env.HERMES_HOME?.trim() ? expandHome(process.env.HERMES_HOME.trim()) : localAppDataDir("hermes");
|
|
374
|
+
const envFile = path.join(hermesDir, ".env");
|
|
375
|
+
const envBefore = readExisting(envFile);
|
|
376
|
+
const assignments = envBefore === undefined ? [] : envAssignments(envFile, envBefore);
|
|
377
|
+
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 });
|
|
389
|
+
visit(path.join(hermesDir, "config.yaml"), "yaml", (editor) => {
|
|
390
|
+
const keyRef = get(editor.value, ["providers", oldId, "key_env"]);
|
|
391
|
+
const key = typeof keyRef === "string" ? process.env[keyRef] ?? envValues.get(keyRef) : undefined;
|
|
392
|
+
if (key !== undefined && provider.apiKey !== undefined && key !== provider.apiKey)
|
|
393
|
+
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
|
+
}
|
|
400
|
+
renameMap(editor, ["providers"]);
|
|
401
|
+
exact(editor, ["model", "provider"]);
|
|
402
|
+
});
|
|
403
|
+
const dshDir = process.env.DSH_HOME?.trim() ? expandHome(process.env.DSH_HOME.trim()) : localAppDataDir("dsh");
|
|
404
|
+
const credentialFile = path.join(dshDir, ".credentials.yaml");
|
|
405
|
+
let migrateCredential = false;
|
|
406
|
+
let credentialRefs = {};
|
|
407
|
+
visit(credentialFile, "yaml", (editor) => {
|
|
408
|
+
const version = get(editor.value, ["version"]);
|
|
409
|
+
if (version !== undefined && version !== 1)
|
|
410
|
+
throw new Error(`${credentialFile}: unsupported credential layout version`);
|
|
411
|
+
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;
|
|
422
|
+
}, 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
|
+
});
|
|
438
|
+
const workbuddyEnv = process.env.WORKBUDDY_CONFIG_DIR?.trim() ?? process.env.CODEBUDDY_CONFIG_DIR?.trim();
|
|
439
|
+
const workbuddyDir = workbuddyEnv ? expandHome(workbuddyEnv) : process.platform === "win32" ? appDataDir("workbuddy") : path.join(home, ".workbuddy");
|
|
440
|
+
visit(path.join(workbuddyDir, "models.json"), "json", (editor) => {
|
|
441
|
+
const at = Array.isArray(editor.value) ? [] : ["models"];
|
|
442
|
+
const rows = get(editor.value, at);
|
|
443
|
+
if (rows === undefined)
|
|
444
|
+
return;
|
|
445
|
+
if (!Array.isArray(rows))
|
|
446
|
+
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
|
+
rows.forEach((row, index) => {
|
|
452
|
+
if (isJsonObject(row) && row.url === url && row.apiKey === provider.apiKey)
|
|
453
|
+
exact(editor, [...at, index, "vendor"]);
|
|
454
|
+
});
|
|
455
|
+
}, undefined, true);
|
|
456
|
+
// Claude and WorkBuddy settings contain model IDs, not provider IDs; leave them intact.
|
|
457
|
+
return commitFileChanges(changes, opts);
|
|
458
|
+
}
|
package/dist/slug.js
CHANGED
|
@@ -1,16 +1,25 @@
|
|
|
1
|
-
|
|
2
|
-
export function slugFromBaseUrl(baseUrl) {
|
|
3
|
-
let host = "";
|
|
1
|
+
function hostnameFromBaseUrl(baseUrl) {
|
|
4
2
|
try {
|
|
5
|
-
|
|
3
|
+
return new URL(baseUrl).hostname.toLowerCase();
|
|
6
4
|
}
|
|
7
5
|
catch {
|
|
8
6
|
return "imported";
|
|
9
7
|
}
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
8
|
+
}
|
|
9
|
+
/** Autogenerated IDs retain every hostname label and always identify the wire protocol. */
|
|
10
|
+
export function providerIdFromBaseUrl(baseUrl, protocol) {
|
|
11
|
+
const slug = hostnameFromBaseUrl(baseUrl).replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
12
|
+
return `${slug || "imported"}-${protocol}`;
|
|
13
|
+
}
|
|
14
|
+
export function providerNameFromBaseUrl(baseUrl, protocol) {
|
|
15
|
+
return `${hostnameFromBaseUrl(baseUrl)} (${protocol})`;
|
|
16
|
+
}
|
|
17
|
+
/** Explicit IDs can update in place; automatically allocated IDs must never overwrite. */
|
|
18
|
+
export function availableProviderId(baseId, providers) {
|
|
19
|
+
let id = baseId;
|
|
20
|
+
for (let n = 2; Object.hasOwn(providers, id); n++)
|
|
21
|
+
id = `${baseId}-${n}`;
|
|
22
|
+
return id;
|
|
14
23
|
}
|
|
15
24
|
/** True for bare UPPER_SNAKE names that apps may treat as env-var references (omp, prime). */
|
|
16
25
|
export function looksLikeEnvName(v) {
|
package/dist/sources/ccswitch.js
CHANGED
|
@@ -3,7 +3,7 @@ import { createRequire } from "node:module";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { parse as parseToml } from "smol-toml";
|
|
5
5
|
import { appDataDir, home } from "../fsutil.js";
|
|
6
|
-
import {
|
|
6
|
+
import { providerIdFromBaseUrl } from "../slug.js";
|
|
7
7
|
import { classifyApi } from "../targets/wire.js";
|
|
8
8
|
/** cc-switch (the Tauri desktop switcher) keeps every managed app's providers in one SQLite file. */
|
|
9
9
|
const storeFile = process.platform === "win32" && !process.env.AGENTSW_HOME
|
|
@@ -137,8 +137,10 @@ export function ccSwitchCandidates() {
|
|
|
137
137
|
const name = row.name || row.app_type;
|
|
138
138
|
// cc-switch ids are UUIDs; the display name is what the user recognizes.
|
|
139
139
|
const slug = name.toLowerCase().replace(/[^a-z0-9_-]+/g, "-").replace(/^[-_]+|[-_]+$/g, "");
|
|
140
|
+
const generatedId = !/^[a-z0-9]/.test(slug);
|
|
140
141
|
out.push({
|
|
141
|
-
id:
|
|
142
|
+
id: generatedId ? providerIdFromBaseUrl(parsed.baseUrl, parsed.protocol) : slug,
|
|
143
|
+
...(generatedId ? { generatedId: true } : {}),
|
|
142
144
|
name,
|
|
143
145
|
protocol: parsed.protocol,
|
|
144
146
|
...(parsed.openaiApi ? { openaiApi: parsed.openaiApi } : {}),
|
|
@@ -1,7 +1,7 @@
|
|
|
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 {
|
|
4
|
+
import { providerIdFromBaseUrl, providerNameFromBaseUrl } from "../slug.js";
|
|
5
5
|
import { stripApiVersion } from "./wire.js";
|
|
6
6
|
const dir = path.join(home, ".claude");
|
|
7
7
|
const settingsFile = path.join(dir, "settings.json");
|
|
@@ -80,11 +80,12 @@ export const claudecode = {
|
|
|
80
80
|
env.ANTHROPIC_REASONING_MODEL,
|
|
81
81
|
env.ANTHROPIC_SMALL_FAST_MODEL,
|
|
82
82
|
].filter((m) => !!m);
|
|
83
|
-
const id =
|
|
83
|
+
const id = providerIdFromBaseUrl(baseUrl, "anthropic");
|
|
84
84
|
return [
|
|
85
85
|
{
|
|
86
86
|
id,
|
|
87
|
-
|
|
87
|
+
generatedId: true,
|
|
88
|
+
name: providerNameFromBaseUrl(baseUrl, "anthropic"),
|
|
88
89
|
protocol: "anthropic",
|
|
89
90
|
baseUrl,
|
|
90
91
|
apiKey: env.ANTHROPIC_AUTH_TOKEN || env.ANTHROPIC_API_KEY || undefined,
|
|
@@ -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 {
|
|
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
|
-
//
|
|
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
|
-
|
|
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(
|
|
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.
|
|
139
|
-
const
|
|
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 ??
|
|
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.
|
|
3
|
+
"version": "0.7.1",
|
|
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": {
|