agentsw 0.7.0 → 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.
- package/README.md +30 -11
- package/README.zh-CN.md +26 -11
- package/dist/apps.js +48 -44
- package/dist/commands.js +221 -74
- package/dist/config-transaction.js +169 -84
- package/dist/discover.js +55 -17
- package/dist/envfile.js +101 -20
- package/dist/fsutil.js +109 -14
- package/dist/i18n.js +62 -39
- package/dist/import.js +2 -2
- package/dist/index.js +16 -1
- package/dist/jsonc.js +4 -10
- package/dist/menu.js +28 -22
- package/dist/modelsdev.js +7 -3
- package/dist/provider-identity.js +25 -0
- package/dist/remove.js +84 -38
- package/dist/rename.js +91 -58
- package/dist/store.js +38 -7
- package/dist/target-transaction.js +25 -0
- package/dist/targets/claudecode.js +12 -3
- package/dist/targets/codex.js +34 -3
- package/dist/targets/dsh.js +117 -65
- package/dist/targets/hermes.js +98 -49
- package/dist/targets/omp.js +11 -47
- package/dist/targets/opencode.js +27 -6
- package/dist/targets/pistyle.js +12 -8
- package/dist/targets/wire.js +15 -9
- package/dist/targets/workbuddy.js +151 -76
- package/dist/yaml.js +39 -0
- package/package.json +4 -2
package/dist/targets/omp.js
CHANGED
|
@@ -3,62 +3,26 @@ import path from "node:path";
|
|
|
3
3
|
import YAML from "yaml";
|
|
4
4
|
import { backupFile, home, readTextIfExists, writeFileAtomic } from "../fsutil.js";
|
|
5
5
|
import { looksLikeEnvName } from "../slug.js";
|
|
6
|
+
import { transactionalTarget } from "../target-transaction.js";
|
|
7
|
+
import { parseYamlMapping, serializeYamlMapping } from "../yaml.js";
|
|
6
8
|
import { apiValue, classifyApi, entryApi, mergeModels, sdkBaseUrl, stripConflictingOverrides } from "./wire.js";
|
|
7
9
|
/** Per-model keys this adapter writes; one that stops being emitted is cleared, not inherited. */
|
|
8
10
|
const OWNED_MODEL_KEYS = ["id", "name", "reasoning", "input", "contextWindow", "maxTokens", "cost"];
|
|
9
11
|
const agentDir = path.join(home, ".omp", "agent");
|
|
10
12
|
const modelsYml = path.join(agentDir, "models.yml");
|
|
11
13
|
const modelsYaml = path.join(agentDir, "models.yaml");
|
|
12
|
-
/** Resolve references before edits can remove their anchors or mutate shared providers. */
|
|
13
14
|
function parseModelsDocument(file, text) {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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)}`);
|
|
15
|
+
const doc = parseYamlMapping(file, text);
|
|
16
|
+
if (doc.hasIn(["providers"]) && !YAML.isMap(doc.getIn(["providers"]))) {
|
|
17
|
+
throw new Error(`${file}: expected providers to be a mapping`);
|
|
55
18
|
}
|
|
19
|
+
return doc;
|
|
56
20
|
}
|
|
57
21
|
/**
|
|
58
22
|
* Oh My Pi: providers live in ~/.omp/agent/models.yml (models.yaml fallback).
|
|
59
23
|
* YAML Document round-trip preserves existing comments and unrelated providers.
|
|
60
24
|
*/
|
|
61
|
-
export const omp = {
|
|
25
|
+
export const omp = transactionalTarget({
|
|
62
26
|
id: "omp",
|
|
63
27
|
name: "Oh My Pi",
|
|
64
28
|
protocols: ["openai", "anthropic"],
|
|
@@ -72,7 +36,7 @@ export const omp = {
|
|
|
72
36
|
const doc = parseModelsDocument(file, text);
|
|
73
37
|
const prev = doc.getIn(["providers", provider.id])?.toJS(doc);
|
|
74
38
|
const anthropic = provider.protocol === "anthropic";
|
|
75
|
-
const api = apiValue(provider.protocol, provider.openaiApi, entryApi(prev ?? {}));
|
|
39
|
+
const api = apiValue(provider.protocol, provider.openaiApi, prev?.api ?? entryApi(prev ?? {}));
|
|
76
40
|
const baseUrl = sdkBaseUrl(provider.protocol, provider.baseUrl);
|
|
77
41
|
const models = mergeModels(prev?.models, provider.models.map((m) => ({
|
|
78
42
|
id: m.id,
|
|
@@ -119,7 +83,7 @@ export const omp = {
|
|
|
119
83
|
else {
|
|
120
84
|
doc.setIn(at, doc.createNode(entry));
|
|
121
85
|
}
|
|
122
|
-
const output =
|
|
86
|
+
const output = serializeYamlMapping(file, doc);
|
|
123
87
|
const backup = backupFile(file);
|
|
124
88
|
if (backup)
|
|
125
89
|
notes.push(`backup: ${backup}`);
|
|
@@ -137,7 +101,7 @@ export const omp = {
|
|
|
137
101
|
}
|
|
138
102
|
doc.deleteIn(["providers", provider.id]);
|
|
139
103
|
const notes = [];
|
|
140
|
-
const output =
|
|
104
|
+
const output = serializeYamlMapping(file, doc);
|
|
141
105
|
const backup = backupFile(file);
|
|
142
106
|
if (backup)
|
|
143
107
|
notes.push(`backup: ${backup}`);
|
|
@@ -207,4 +171,4 @@ export const omp = {
|
|
|
207
171
|
];
|
|
208
172
|
});
|
|
209
173
|
},
|
|
210
|
-
};
|
|
174
|
+
});
|
package/dist/targets/opencode.js
CHANGED
|
@@ -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
|
|
21
|
-
|
|
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" :
|
|
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
|
+
});
|
package/dist/targets/pistyle.js
CHANGED
|
@@ -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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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",
|
package/dist/targets/wire.js
CHANGED
|
@@ -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 (
|
|
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
|
-
|
|
54
|
-
if (typeof
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
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()
|
|
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
|
-
|
|
11
|
-
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
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
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
|
83
|
-
|
|
84
|
-
|
|
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((
|
|
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({
|
|
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
|
-
|
|
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
|
|
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
|
|
123
|
-
if (
|
|
187
|
+
const baseUrl = workbuddyBaseUrl(row.url);
|
|
188
|
+
if (!baseUrl)
|
|
124
189
|
continue;
|
|
125
|
-
const apiKey = typeof row.apiKey === "string"
|
|
190
|
+
const apiKey = typeof row.apiKey === "string" ? row.apiKey : undefined;
|
|
126
191
|
const key = JSON.stringify([baseUrl, apiKey ?? null]);
|
|
127
|
-
let
|
|
128
|
-
if (!
|
|
129
|
-
|
|
130
|
-
|
|
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 (!
|
|
133
|
-
|
|
134
|
-
if (
|
|
135
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
}
|