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.
- 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 +24 -3
- package/dist/import.js +35 -21
- package/dist/index.js +16 -3
- package/dist/jsonc.js +111 -0
- package/dist/menu.js +90 -9
- 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/omp.js +52 -13
- package/dist/targets/pistyle.js +57 -24
- package/dist/targets/workbuddy.js +11 -10
- package/package.json +2 -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,
|
package/dist/targets/omp.js
CHANGED
|
@@ -9,6 +9,51 @@ const OWNED_MODEL_KEYS = ["id", "name", "reasoning", "input", "contextWindow", "
|
|
|
9
9
|
const agentDir = path.join(home, ".omp", "agent");
|
|
10
10
|
const modelsYml = path.join(agentDir, "models.yml");
|
|
11
11
|
const modelsYaml = path.join(agentDir, "models.yaml");
|
|
12
|
+
/** Resolve references before edits can remove their anchors or mutate shared providers. */
|
|
13
|
+
function parseModelsDocument(file, text) {
|
|
14
|
+
try {
|
|
15
|
+
const doc = text ? YAML.parseDocument(text) : new YAML.Document({});
|
|
16
|
+
if (doc.errors.length)
|
|
17
|
+
throw doc.errors[0];
|
|
18
|
+
if (doc.contents == null)
|
|
19
|
+
doc.contents = doc.createNode({});
|
|
20
|
+
// Validate references with the full document context and the default alias limit.
|
|
21
|
+
// Cycles are valid YAML, but not a usable model configuration; reject before expansion.
|
|
22
|
+
JSON.stringify(doc.toJS());
|
|
23
|
+
const expanded = new Map();
|
|
24
|
+
YAML.visit(doc, {
|
|
25
|
+
Alias(_key, alias) {
|
|
26
|
+
const node = doc.createNode(alias.toJS(doc), { aliasDuplicateObjects: false });
|
|
27
|
+
node.comment = alias.comment;
|
|
28
|
+
node.commentBefore = alias.commentBefore;
|
|
29
|
+
node.spaceBefore = alias.spaceBefore;
|
|
30
|
+
expanded.set(alias, node);
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
// Resolve all references before replacing any nodes (anchors may be reused).
|
|
34
|
+
YAML.visit(doc, { Alias: (_key, alias) => expanded.get(alias) });
|
|
35
|
+
if (!YAML.isMap(doc.contents))
|
|
36
|
+
throw new Error("expected a configuration mapping");
|
|
37
|
+
if (doc.hasIn(["providers"]) && !YAML.isMap(doc.getIn(["providers"]))) {
|
|
38
|
+
throw new Error("expected providers to be a mapping");
|
|
39
|
+
}
|
|
40
|
+
return doc;
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
throw new Error(`${file} has invalid YAML: ${error instanceof Error ? error.message : String(error)}`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/** Validate the emitted document before any backup or write. */
|
|
47
|
+
function serializeModelsDocument(file, doc) {
|
|
48
|
+
try {
|
|
49
|
+
const text = doc.toString();
|
|
50
|
+
YAML.parse(text);
|
|
51
|
+
return text;
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
throw new Error(`${file} has invalid YAML: ${error instanceof Error ? error.message : String(error)}`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
12
57
|
/**
|
|
13
58
|
* Oh My Pi: providers live in ~/.omp/agent/models.yml (models.yaml fallback).
|
|
14
59
|
* YAML Document round-trip preserves existing comments and unrelated providers.
|
|
@@ -24,13 +69,8 @@ export const omp = {
|
|
|
24
69
|
// Respect an existing models.yaml if models.yml is absent (omp precedence: yml then yaml).
|
|
25
70
|
const file = !fs.existsSync(modelsYml) && fs.existsSync(modelsYaml) ? modelsYaml : modelsYml;
|
|
26
71
|
const text = readTextIfExists(file);
|
|
27
|
-
const doc =
|
|
28
|
-
|
|
29
|
-
throw new Error(`${file} has YAML errors; refusing to rewrite: ${doc.errors[0]?.message}`);
|
|
30
|
-
}
|
|
31
|
-
if (doc.contents == null)
|
|
32
|
-
doc.contents = doc.createNode({});
|
|
33
|
-
const prev = doc.getIn(["providers", provider.id])?.toJSON?.();
|
|
72
|
+
const doc = parseModelsDocument(file, text);
|
|
73
|
+
const prev = doc.getIn(["providers", provider.id])?.toJS(doc);
|
|
34
74
|
const anthropic = provider.protocol === "anthropic";
|
|
35
75
|
const api = apiValue(provider.protocol, provider.openaiApi, entryApi(prev ?? {}));
|
|
36
76
|
const baseUrl = sdkBaseUrl(provider.protocol, provider.baseUrl);
|
|
@@ -79,10 +119,11 @@ export const omp = {
|
|
|
79
119
|
else {
|
|
80
120
|
doc.setIn(at, doc.createNode(entry));
|
|
81
121
|
}
|
|
122
|
+
const output = serializeModelsDocument(file, doc);
|
|
82
123
|
const backup = backupFile(file);
|
|
83
124
|
if (backup)
|
|
84
125
|
notes.push(`backup: ${backup}`);
|
|
85
|
-
writeFileAtomic(file,
|
|
126
|
+
writeFileAtomic(file, output);
|
|
86
127
|
notes.push(`select in omp with: omp --model ${provider.id}/${provider.defaultModel}`);
|
|
87
128
|
return { app: this.id, changed: [file], notes };
|
|
88
129
|
},
|
|
@@ -90,19 +131,17 @@ export const omp = {
|
|
|
90
131
|
const file = fs.existsSync(modelsYml) ? modelsYml : fs.existsSync(modelsYaml) ? modelsYaml : undefined;
|
|
91
132
|
if (!file)
|
|
92
133
|
return { app: this.id, changed: [], notes: [], skipped: "no models.yml" };
|
|
93
|
-
const doc =
|
|
94
|
-
if (doc.errors.length) {
|
|
95
|
-
throw new Error(`${file} has YAML errors; refusing to rewrite: ${doc.errors[0]?.message}`);
|
|
96
|
-
}
|
|
134
|
+
const doc = parseModelsDocument(file, readTextIfExists(file));
|
|
97
135
|
if (!doc.hasIn(["providers", provider.id])) {
|
|
98
136
|
return { app: this.id, changed: [], notes: [], skipped: `no providers.${provider.id} entry` };
|
|
99
137
|
}
|
|
100
138
|
doc.deleteIn(["providers", provider.id]);
|
|
101
139
|
const notes = [];
|
|
140
|
+
const output = serializeModelsDocument(file, doc);
|
|
102
141
|
const backup = backupFile(file);
|
|
103
142
|
if (backup)
|
|
104
143
|
notes.push(`backup: ${backup}`);
|
|
105
|
-
writeFileAtomic(file,
|
|
144
|
+
writeFileAtomic(file, output);
|
|
106
145
|
return { app: this.id, changed: [file], notes };
|
|
107
146
|
},
|
|
108
147
|
current() {
|