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/codex.js
CHANGED
|
@@ -2,6 +2,8 @@ import fs from "node:fs";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
|
|
4
4
|
import { backupFile, home, readJsonIfExists, readTextIfExists, writeFileAtomic } from "../fsutil.js";
|
|
5
|
+
import { isJsonObject } from "../jsonc.js";
|
|
6
|
+
import { transactionalTarget } from "../target-transaction.js";
|
|
5
7
|
const dir = path.join(home, ".codex");
|
|
6
8
|
const configFile = path.join(dir, "config.toml");
|
|
7
9
|
const authFile = path.join(dir, "auth.json");
|
|
@@ -10,7 +12,7 @@ const authFile = path.join(dir, "auth.json");
|
|
|
10
12
|
* OpenAI-protocol endpoint; the key ships via ~/.codex/auth.json OPENAI_API_KEY.
|
|
11
13
|
* TOML round-trip via smol-toml preserves data but not comments (backed up first).
|
|
12
14
|
*/
|
|
13
|
-
export const codex = {
|
|
15
|
+
export const codex = transactionalTarget({
|
|
14
16
|
id: "codex",
|
|
15
17
|
name: "Codex CLI",
|
|
16
18
|
protocols: ["openai"],
|
|
@@ -20,6 +22,13 @@ export const codex = {
|
|
|
20
22
|
const notes = [];
|
|
21
23
|
const text = readTextIfExists(configFile);
|
|
22
24
|
const config = (text ? parseToml(text) : {});
|
|
25
|
+
const authValue = readJsonIfExists(authFile);
|
|
26
|
+
const auth = authValue === undefined ? {} : authValue;
|
|
27
|
+
if (!isJsonObject(auth))
|
|
28
|
+
throw new Error(`${authFile}: expected a JSON object`);
|
|
29
|
+
if (config.model_providers !== undefined && !isJsonObject(config.model_providers)) {
|
|
30
|
+
throw new Error(`${configFile}: expected model_providers to be a table`);
|
|
31
|
+
}
|
|
23
32
|
config.model_provider = provider.id;
|
|
24
33
|
config.model = provider.defaultModel;
|
|
25
34
|
const model = provider.models.find((m) => m.id === provider.defaultModel);
|
|
@@ -29,7 +38,27 @@ export const codex = {
|
|
|
29
38
|
// Codex only speaks the OpenAI Responses API (wire_api = "chat" was removed Feb 2026).
|
|
30
39
|
// requires_openai_auth = true makes Codex take the key from auth.json instead of an env var.
|
|
31
40
|
const providers = (config.model_providers ?? {});
|
|
41
|
+
const previous = providers[provider.id];
|
|
42
|
+
if (previous !== undefined && !isJsonObject(previous)) {
|
|
43
|
+
throw new Error(`${configFile}: expected the selected model provider to be a table`);
|
|
44
|
+
}
|
|
45
|
+
const kept = { ...previous };
|
|
46
|
+
for (const key of ["env_key", "env_key_instructions", "experimental_bearer_token"])
|
|
47
|
+
delete kept[key];
|
|
48
|
+
for (const key of ["http_headers", "env_http_headers"]) {
|
|
49
|
+
if (kept[key] === undefined)
|
|
50
|
+
continue;
|
|
51
|
+
if (!isJsonObject(kept[key]))
|
|
52
|
+
throw new Error(`${configFile}: expected ${key} to be a table`);
|
|
53
|
+
const headers = { ...kept[key] };
|
|
54
|
+
for (const name of Object.keys(headers)) {
|
|
55
|
+
if (["authorization", "x-api-key", "api-key"].includes(name.toLowerCase()))
|
|
56
|
+
delete headers[name];
|
|
57
|
+
}
|
|
58
|
+
kept[key] = headers;
|
|
59
|
+
}
|
|
32
60
|
providers[provider.id] = {
|
|
61
|
+
...kept,
|
|
33
62
|
name: provider.name,
|
|
34
63
|
base_url: provider.baseUrl.replace(/\/$/, ""),
|
|
35
64
|
wire_api: "responses",
|
|
@@ -43,7 +72,6 @@ export const codex = {
|
|
|
43
72
|
if (text?.includes("#"))
|
|
44
73
|
notes.push("config.toml comments are not preserved by TOML round-trip");
|
|
45
74
|
writeFileAtomic(configFile, stringifyToml(config) + "\n");
|
|
46
|
-
const auth = readJsonIfExists(authFile) ?? {};
|
|
47
75
|
auth.auth_mode = "apikey";
|
|
48
76
|
auth.OPENAI_API_KEY = provider.apiKey;
|
|
49
77
|
const authBackup = backupFile(authFile);
|
|
@@ -57,6 +85,9 @@ export const codex = {
|
|
|
57
85
|
if (!text)
|
|
58
86
|
return { app: this.id, changed: [], notes: [], skipped: "no config.toml" };
|
|
59
87
|
const config = parseToml(text);
|
|
88
|
+
if (config.model_providers !== undefined && !isJsonObject(config.model_providers)) {
|
|
89
|
+
throw new Error(`${configFile}: expected model_providers to be a table`);
|
|
90
|
+
}
|
|
60
91
|
const providers = config.model_providers;
|
|
61
92
|
if (!providers?.[provider.id]) {
|
|
62
93
|
return { app: this.id, changed: [], notes: [], skipped: `no model_providers.${provider.id} entry` };
|
|
@@ -133,4 +164,4 @@ export const codex = {
|
|
|
133
164
|
];
|
|
134
165
|
});
|
|
135
166
|
},
|
|
136
|
-
};
|
|
167
|
+
});
|
package/dist/targets/dsh.js
CHANGED
|
@@ -2,6 +2,9 @@ import fs from "node:fs";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import YAML from "yaml";
|
|
4
4
|
import { backupFile, expandHome, home, localAppDataDir, readTextIfExists, writeFileAtomic } from "../fsutil.js";
|
|
5
|
+
import { isManagedCredentialRef, legacyManagedCredentialRef, managedCredentialRef } from "../provider-identity.js";
|
|
6
|
+
import { transactionalTarget } from "../target-transaction.js";
|
|
7
|
+
import { parseYamlMapping, serializeYamlMapping } from "../yaml.js";
|
|
5
8
|
import { apiValue, classifyApi, mergeModels, sdkBaseUrl } from "./wire.js";
|
|
6
9
|
/** `$DSH_HOME`, or `~/.dsh` (packages/util/home-paths `resolveDshHome`). */
|
|
7
10
|
function dshHome() {
|
|
@@ -24,8 +27,6 @@ function settingsFile() {
|
|
|
24
27
|
const credentialsFile = () => path.join(dshHome(), ".credentials.yaml");
|
|
25
28
|
/** Layout version of `.credentials.yaml` this build reads and writes. */
|
|
26
29
|
const CREDENTIALS_VERSION = 1;
|
|
27
|
-
/** Credential reference: a POSIX identifier (`/^[A-Za-z_][A-Za-z0-9_]*$/`). */
|
|
28
|
-
const credentialRef = (id) => `AGENTSW_${id.toUpperCase().replace(/[^A-Z0-9]/g, "_")}_API_KEY`;
|
|
29
30
|
/** Thinking levels a `reasoningEfforts` entry may offer (llm-pi-ai THINKING_LEVEL_GATE). */
|
|
30
31
|
const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
|
|
31
32
|
/** Per-model keys this adapter writes; one that stops being emitted is cleared, not inherited. */
|
|
@@ -56,25 +57,28 @@ function modelEntry(m) {
|
|
|
56
57
|
};
|
|
57
58
|
}
|
|
58
59
|
function parseSettings(file) {
|
|
59
|
-
const
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
60
|
+
const doc = parseYamlMapping(file, readTextIfExists(file));
|
|
61
|
+
for (const at of [["llm-pi-ai"], ["llm-pi-ai", "providers"], ["agent-default-model"]]) {
|
|
62
|
+
if (doc.hasIn(at) && !YAML.isMap(doc.getIn(at)))
|
|
63
|
+
throw new Error(`${file}: expected ${at.join(".")} to be a mapping`);
|
|
64
|
+
}
|
|
65
|
+
const providers = doc.getIn(["llm-pi-ai", "providers"]);
|
|
66
|
+
if (YAML.isMap(providers) && providers.items.some((pair) => !YAML.isMap(pair.value))) {
|
|
67
|
+
throw new Error(`${file}: expected provider mappings`);
|
|
63
68
|
}
|
|
64
|
-
if (doc.contents == null)
|
|
65
|
-
doc.contents = doc.createNode({});
|
|
66
69
|
return doc;
|
|
67
70
|
}
|
|
68
71
|
/** JSON documents re-serialize without comments; YAML keeps them (dsh reads both). */
|
|
69
72
|
function renderSettings(file, doc) {
|
|
70
|
-
|
|
73
|
+
const text = serializeYamlMapping(file, doc);
|
|
74
|
+
return file.endsWith(".json") ? JSON.stringify(doc.toJS(), null, 2) + "\n" : text;
|
|
71
75
|
}
|
|
72
76
|
/**
|
|
73
77
|
* DeepSeek Harness (dsh): provider routes live in the `llm-pi-ai` section of
|
|
74
78
|
* `$DSH_HOME/settings.yaml`, the picked default in `agent-default-model`, and
|
|
75
79
|
* the key in `$DSH_HOME/.credentials.yaml` — settings carry only a reference.
|
|
76
80
|
*/
|
|
77
|
-
export const dsh = {
|
|
81
|
+
export const dsh = transactionalTarget({
|
|
78
82
|
id: "dsh",
|
|
79
83
|
name: "DeepSeek Harness",
|
|
80
84
|
protocols: ["openai", "anthropic"],
|
|
@@ -86,22 +90,42 @@ export const dsh = {
|
|
|
86
90
|
const doc = parseSettings(file);
|
|
87
91
|
const at = ["llm-pi-ai", "providers", provider.id];
|
|
88
92
|
const prev = doc.getIn(at)?.toJSON?.();
|
|
89
|
-
const ref =
|
|
93
|
+
const ref = managedCredentialRef(provider.id);
|
|
90
94
|
const entry = {
|
|
91
95
|
displayName: provider.name,
|
|
92
96
|
apiKeyEnv: ref, // credential reference, never the secret
|
|
93
97
|
api: apiValue(provider.protocol, provider.openaiApi, prev?.api),
|
|
94
98
|
baseURL: sdkBaseUrl(provider.protocol, provider.baseUrl),
|
|
95
|
-
models: mergeModels(prev?.models, provider.models.map(modelEntry), OWNED_MODEL_KEYS),
|
|
96
99
|
};
|
|
97
100
|
if (YAML.isMap(doc.getIn(at))) {
|
|
98
101
|
// Key-by-key: compat, headers, defaultInput, retryPolicy and their comments are the user's.
|
|
99
102
|
for (const [key, value] of Object.entries(entry))
|
|
100
|
-
doc.setIn([...at, key],
|
|
103
|
+
doc.setIn([...at, key], value);
|
|
101
104
|
}
|
|
102
105
|
else {
|
|
103
106
|
doc.setIn(at, doc.createNode(entry));
|
|
104
107
|
}
|
|
108
|
+
const modelsAt = [...at, "models"];
|
|
109
|
+
const previousModels = doc.getIn(modelsAt);
|
|
110
|
+
const models = YAML.isSeq(previousModels) ? previousModels : doc.createNode([]);
|
|
111
|
+
const previousById = new Map();
|
|
112
|
+
for (const node of models.items) {
|
|
113
|
+
if (YAML.isMap(node))
|
|
114
|
+
previousById.set(node.get("id"), node);
|
|
115
|
+
}
|
|
116
|
+
models.items = mergeModels(prev?.models, provider.models.map(modelEntry), OWNED_MODEL_KEYS).map((model) => {
|
|
117
|
+
const node = previousById.get(model.id);
|
|
118
|
+
if (!node)
|
|
119
|
+
return doc.createNode(model);
|
|
120
|
+
for (const key of OWNED_MODEL_KEYS) {
|
|
121
|
+
if (!(key in model))
|
|
122
|
+
node.delete(key);
|
|
123
|
+
else
|
|
124
|
+
node.set(key, typeof model[key] === "object" ? doc.createNode(model[key]) : model[key]);
|
|
125
|
+
}
|
|
126
|
+
return node;
|
|
127
|
+
});
|
|
128
|
+
doc.setIn(modelsAt, models);
|
|
105
129
|
if (doc.hasIn([...at, "modelOverrides"])) {
|
|
106
130
|
// llm-pi-ai refuses modelOverrides beside an explicit models list.
|
|
107
131
|
doc.deleteIn([...at, "modelOverrides"]);
|
|
@@ -109,30 +133,37 @@ export const dsh = {
|
|
|
109
133
|
}
|
|
110
134
|
// The picker writes this section whole; a partial write would keep a stale reasoningEffort.
|
|
111
135
|
doc.set("agent-default-model", doc.createNode({ provider: provider.id, model: provider.defaultModel }));
|
|
136
|
+
const referenced = credentialReferences(doc, provider.id);
|
|
137
|
+
const oldRef = typeof prev?.apiKeyEnv === "string" && isManagedCredentialRef(prev.apiKeyEnv, provider.id) && !referenced.has(prev.apiKeyEnv)
|
|
138
|
+
? prev.apiKeyEnv : undefined;
|
|
139
|
+
const credential = writeCredential(ref, provider.apiKey, notes, oldRef, referenced);
|
|
112
140
|
const backup = backupFile(file);
|
|
113
141
|
if (backup)
|
|
114
142
|
notes.push(`backup: ${backup}`);
|
|
115
143
|
writeFileAtomic(file, renderSettings(file, doc));
|
|
116
|
-
const changed = [file,
|
|
144
|
+
const changed = [file, credential];
|
|
117
145
|
notes.push(`select in dsh with the model picker, or run: dsh web`);
|
|
118
146
|
return { app: this.id, changed, notes };
|
|
119
147
|
},
|
|
120
148
|
async prune(provider) {
|
|
121
149
|
const notes = [];
|
|
122
|
-
// The stored key is removed even when the route is already gone, so a hand-deleted
|
|
123
|
-
// route never leaves its secret behind.
|
|
124
|
-
const droppedCredential = deleteCredential(credentialRef(provider.id), notes);
|
|
125
150
|
const file = settingsFile();
|
|
126
|
-
|
|
151
|
+
// Settings must be understood before any credential can be removed.
|
|
152
|
+
const doc = parseSettings(file);
|
|
127
153
|
const at = ["llm-pi-ai", "providers", provider.id];
|
|
128
|
-
|
|
129
|
-
|
|
154
|
+
const hasProvider = doc.hasIn(at);
|
|
155
|
+
if (hasProvider)
|
|
156
|
+
doc.deleteIn(at);
|
|
157
|
+
const referenced = credentialReferences(doc);
|
|
158
|
+
const removable = new Set([managedCredentialRef(provider.id), legacyManagedCredentialRef(provider.id)].filter((ref) => !referenced.has(ref)));
|
|
159
|
+
const droppedCredential = deleteCredentials(removable, notes);
|
|
160
|
+
if (!hasProvider) {
|
|
161
|
+
const why = `no llm-pi-ai.providers.${provider.id} entry`;
|
|
130
162
|
if (!droppedCredential)
|
|
131
163
|
return { app: this.id, changed: [], notes, skipped: why };
|
|
132
164
|
notes.push(why);
|
|
133
165
|
return { app: this.id, changed: [droppedCredential], notes };
|
|
134
166
|
}
|
|
135
|
-
doc.deleteIn(at);
|
|
136
167
|
if (doc.getIn(["agent-default-model", "provider"]) === provider.id) {
|
|
137
168
|
doc.delete("agent-default-model");
|
|
138
169
|
notes.push("default model selection reset (was pointing at this provider)");
|
|
@@ -147,10 +178,7 @@ export const dsh = {
|
|
|
147
178
|
if (backup)
|
|
148
179
|
notes.push(`backup: ${backup}`);
|
|
149
180
|
writeFileAtomic(file, renderSettings(file, doc));
|
|
150
|
-
|
|
151
|
-
if (droppedCredential)
|
|
152
|
-
changed.push(droppedCredential);
|
|
153
|
-
return { app: this.id, changed, notes };
|
|
181
|
+
return { app: this.id, changed: droppedCredential ? [file, droppedCredential] : [file], notes };
|
|
154
182
|
},
|
|
155
183
|
current() {
|
|
156
184
|
const text = readTextIfExists(settingsFile());
|
|
@@ -215,34 +243,38 @@ export const dsh = {
|
|
|
215
243
|
];
|
|
216
244
|
});
|
|
217
245
|
},
|
|
218
|
-
};
|
|
246
|
+
});
|
|
219
247
|
/** Read the managed store's `refs` section; an unreadable document yields no keys. */
|
|
220
248
|
function readCredentialRefs() {
|
|
221
|
-
const text = readTextIfExists(credentialsFile());
|
|
222
|
-
if (!text)
|
|
223
|
-
return {};
|
|
224
249
|
try {
|
|
225
|
-
const
|
|
226
|
-
const
|
|
227
|
-
|
|
228
|
-
return refs;
|
|
229
|
-
// pre-release flat layout: top-level reference-to-secret mapping
|
|
230
|
-
if (parsed && !("version" in parsed))
|
|
231
|
-
return parsed;
|
|
232
|
-
return {};
|
|
250
|
+
const doc = parseCredentials(credentialsFile());
|
|
251
|
+
const parsed = doc.toJS();
|
|
252
|
+
return (doc.get("version") === undefined ? parsed : parsed.refs ?? {});
|
|
233
253
|
}
|
|
234
254
|
catch {
|
|
235
255
|
return {};
|
|
236
256
|
}
|
|
237
257
|
}
|
|
238
258
|
function parseCredentials(file) {
|
|
239
|
-
const
|
|
240
|
-
const
|
|
241
|
-
if (
|
|
242
|
-
|
|
259
|
+
const doc = parseYamlMapping(file, readTextIfExists(file));
|
|
260
|
+
const version = doc.get("version");
|
|
261
|
+
if (version !== undefined && version !== CREDENTIALS_VERSION) {
|
|
262
|
+
const label = typeof version === "number" ? String(version) : "an unsupported value";
|
|
263
|
+
throw new Error(`${file} declares version ${label}; agentsw writes version ${CREDENTIALS_VERSION}`);
|
|
264
|
+
}
|
|
265
|
+
if (version === undefined) {
|
|
266
|
+
const flat = Object.entries(doc.toJS()).every(([key, value]) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(key) && typeof value === "string" && value.length > 0);
|
|
267
|
+
if (!flat)
|
|
268
|
+
throw new Error(`${file} is neither the version ${CREDENTIALS_VERSION} layout nor a pre-release flat document; refusing to rewrite it`);
|
|
269
|
+
}
|
|
270
|
+
else if (doc.has("refs") && !YAML.isMap(doc.get("refs"))) {
|
|
271
|
+
throw new Error(`${file}: expected refs to be a mapping`);
|
|
272
|
+
}
|
|
273
|
+
const refs = doc.get("refs");
|
|
274
|
+
if (version !== undefined && YAML.isMap(refs) && refs.items.some((pair) => !YAML.isScalar(pair.key) || typeof pair.key.value !== "string" || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(pair.key.value) ||
|
|
275
|
+
!YAML.isScalar(pair.value) || typeof pair.value.value !== "string" || pair.value.value.length === 0)) {
|
|
276
|
+
throw new Error(`${file}: expected non-empty credential values with valid reference names`);
|
|
243
277
|
}
|
|
244
|
-
if (doc.contents == null)
|
|
245
|
-
doc.contents = doc.createNode({});
|
|
246
278
|
return doc;
|
|
247
279
|
}
|
|
248
280
|
/**
|
|
@@ -252,45 +284,65 @@ function parseCredentials(file) {
|
|
|
252
284
|
* looks unversioned (a hand-written `refs:` block, a future layout) is refused
|
|
253
285
|
* instead of being re-rooted one level deeper. Returns the file.
|
|
254
286
|
*/
|
|
255
|
-
function writeCredential(ref, apiKey, notes) {
|
|
287
|
+
function writeCredential(ref, apiKey, notes, oldRef, referenced) {
|
|
256
288
|
const file = credentialsFile();
|
|
257
289
|
const doc = parseCredentials(file);
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
290
|
+
if (doc.get("version") === undefined) {
|
|
291
|
+
const refs = doc.contents;
|
|
292
|
+
const migrating = YAML.isMap(refs) && refs.items.length > 0;
|
|
293
|
+
doc.contents = doc.createNode({ version: CREDENTIALS_VERSION });
|
|
294
|
+
doc.set("refs", refs);
|
|
295
|
+
if (migrating)
|
|
296
|
+
notes.push(`migrated ${file} to the version ${CREDENTIALS_VERSION} layout`);
|
|
262
297
|
}
|
|
263
|
-
if (
|
|
264
|
-
|
|
265
|
-
if (!flat) {
|
|
266
|
-
throw new Error(`${file} is neither the version ${CREDENTIALS_VERSION} layout nor a pre-release flat document; refusing to rewrite it`);
|
|
267
|
-
}
|
|
268
|
-
doc.contents = doc.createNode({ version: CREDENTIALS_VERSION, refs: root });
|
|
269
|
-
notes.push(`migrated ${file} to the version ${CREDENTIALS_VERSION} layout`);
|
|
298
|
+
if (referenced.has(ref) && doc.getIn(["refs", ref]) !== apiKey) {
|
|
299
|
+
throw new Error(`${file}: credential reference is shared by another provider; refusing to replace it`);
|
|
270
300
|
}
|
|
271
|
-
doc.set("version", CREDENTIALS_VERSION);
|
|
272
301
|
doc.setIn(["refs", ref], apiKey);
|
|
302
|
+
if (oldRef && oldRef !== ref)
|
|
303
|
+
doc.deleteIn(["refs", oldRef]);
|
|
304
|
+
const text = serializeYamlMapping(file, doc);
|
|
273
305
|
const backup = backupFile(file);
|
|
274
306
|
if (backup)
|
|
275
307
|
notes.push(`backup: ${backup}`);
|
|
276
308
|
// dsh refuses a credentials document readable beyond its owner.
|
|
277
|
-
writeFileAtomic(file,
|
|
309
|
+
writeFileAtomic(file, text, 0o600);
|
|
278
310
|
return file;
|
|
279
311
|
}
|
|
280
|
-
/** Drop
|
|
281
|
-
function
|
|
312
|
+
/** Drop only managed references that no surviving route uses. */
|
|
313
|
+
function deleteCredentials(refs, notes) {
|
|
282
314
|
const file = credentialsFile();
|
|
283
|
-
|
|
284
|
-
if (!text)
|
|
315
|
+
if (readTextIfExists(file) === undefined)
|
|
285
316
|
return undefined;
|
|
286
317
|
const doc = parseCredentials(file);
|
|
287
|
-
|
|
288
|
-
|
|
318
|
+
let changed = false;
|
|
319
|
+
for (const ref of refs) {
|
|
320
|
+
const at = doc.get("version") === undefined ? [ref] : ["refs", ref];
|
|
321
|
+
if (doc.hasIn(at)) {
|
|
322
|
+
doc.deleteIn(at);
|
|
323
|
+
changed = true;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
if (!changed)
|
|
289
327
|
return undefined;
|
|
290
|
-
doc
|
|
328
|
+
const text = serializeYamlMapping(file, doc);
|
|
291
329
|
const backup = backupFile(file);
|
|
292
330
|
if (backup)
|
|
293
331
|
notes.push(`backup: ${backup}`);
|
|
294
|
-
writeFileAtomic(file,
|
|
332
|
+
writeFileAtomic(file, text, 0o600);
|
|
295
333
|
return file;
|
|
296
334
|
}
|
|
335
|
+
function credentialReferences(doc, exceptId) {
|
|
336
|
+
const providers = doc.getIn(["llm-pi-ai", "providers"]);
|
|
337
|
+
const refs = new Set();
|
|
338
|
+
if (YAML.isMap(providers)) {
|
|
339
|
+
for (const pair of providers.items) {
|
|
340
|
+
if ((YAML.isScalar(pair.key) ? pair.key.value : pair.key) === exceptId)
|
|
341
|
+
continue;
|
|
342
|
+
const ref = YAML.isMap(pair.value) ? pair.value.get("apiKeyEnv") : undefined;
|
|
343
|
+
if (typeof ref === "string")
|
|
344
|
+
refs.add(ref);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
return refs;
|
|
348
|
+
}
|
package/dist/targets/hermes.js
CHANGED
|
@@ -2,6 +2,10 @@ import fs from "node:fs";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import YAML from "yaml";
|
|
4
4
|
import { backupFile, home, localAppDataDir, readTextIfExists, writeFileAtomic } from "../fsutil.js";
|
|
5
|
+
import { envAssignments, removeEnvAssignments, upsertEnvAssignment } from "../envfile.js";
|
|
6
|
+
import { isManagedCredentialRef, legacyManagedCredentialRef, managedCredentialRef } from "../provider-identity.js";
|
|
7
|
+
import { transactionalTarget } from "../target-transaction.js";
|
|
8
|
+
import { parseYamlMapping, serializeYamlMapping } from "../yaml.js";
|
|
5
9
|
import { sdkBaseUrl } from "./wire.js";
|
|
6
10
|
function hermesHome() {
|
|
7
11
|
const env = process.env.HERMES_HOME?.trim();
|
|
@@ -12,12 +16,45 @@ function hermesHome() {
|
|
|
12
16
|
}
|
|
13
17
|
return path.join(home, ".hermes");
|
|
14
18
|
}
|
|
19
|
+
function parseConfig(file, text) {
|
|
20
|
+
const doc = parseYamlMapping(file, text);
|
|
21
|
+
for (const key of ["providers", "model"]) {
|
|
22
|
+
if (doc.has(key) && !YAML.isMap(doc.get(key)))
|
|
23
|
+
throw new Error(`${file}: expected ${key} to be a mapping`);
|
|
24
|
+
}
|
|
25
|
+
const providers = doc.get("providers");
|
|
26
|
+
if (YAML.isMap(providers)) {
|
|
27
|
+
for (const pair of providers.items) {
|
|
28
|
+
if (!YAML.isMap(pair.value))
|
|
29
|
+
throw new Error(`${file}: expected provider mappings`);
|
|
30
|
+
const models = pair.value.get("models");
|
|
31
|
+
if (models !== undefined && (!YAML.isMap(models) || models.items.some((model) => !YAML.isMap(model.value)))) {
|
|
32
|
+
throw new Error(`${file}: expected model mappings`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return doc;
|
|
37
|
+
}
|
|
38
|
+
function credentialReferences(doc, exceptId) {
|
|
39
|
+
const refs = new Set();
|
|
40
|
+
const providers = doc.get("providers");
|
|
41
|
+
if (YAML.isMap(providers)) {
|
|
42
|
+
for (const pair of providers.items) {
|
|
43
|
+
if ((YAML.isScalar(pair.key) ? pair.key.value : pair.key) === exceptId)
|
|
44
|
+
continue;
|
|
45
|
+
const ref = YAML.isMap(pair.value) ? pair.value.get("key_env") : undefined;
|
|
46
|
+
if (typeof ref === "string")
|
|
47
|
+
refs.add(ref);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return refs;
|
|
51
|
+
}
|
|
15
52
|
/**
|
|
16
53
|
* hermes (NousResearch): providers dict + model section in ~/.hermes/config.yaml,
|
|
17
54
|
* API key in ~/.hermes/.env referenced via key_env. hermes refuses to load an
|
|
18
55
|
* unparseable config.yaml, so we round-trip through YAML Document (comments kept).
|
|
19
56
|
*/
|
|
20
|
-
export const hermes = {
|
|
57
|
+
export const hermes = transactionalTarget({
|
|
21
58
|
id: "hermes",
|
|
22
59
|
name: "Hermes",
|
|
23
60
|
protocols: ["openai", "anthropic"],
|
|
@@ -29,56 +66,66 @@ export const hermes = {
|
|
|
29
66
|
const envFile = path.join(home, ".env");
|
|
30
67
|
const notes = [];
|
|
31
68
|
const text = readTextIfExists(configFile);
|
|
32
|
-
const doc =
|
|
33
|
-
|
|
34
|
-
throw new Error(`${configFile} has YAML errors; refusing to rewrite: ${doc.errors[0]?.message}`);
|
|
35
|
-
}
|
|
36
|
-
if (doc.contents == null)
|
|
37
|
-
doc.contents = doc.createNode({});
|
|
38
|
-
const keyVar = `AGENTSW_${provider.id.toUpperCase().replace(/[^A-Z0-9]/g, "_")}_API_KEY`;
|
|
69
|
+
const doc = parseConfig(configFile, text);
|
|
70
|
+
const keyVar = managedCredentialRef(provider.id);
|
|
39
71
|
const at = ["providers", provider.id];
|
|
40
72
|
const prev = doc.getIn(at)?.toJSON?.();
|
|
41
|
-
const prevModels = (prev?.models ?? {});
|
|
42
|
-
const models = {};
|
|
43
|
-
for (const m of provider.models) {
|
|
44
|
-
const old = { ...prevModels[m.id] };
|
|
45
|
-
delete old.context_length; // owned: a model that loses its size must not keep the old one
|
|
46
|
-
models[m.id] = { ...old, ...(m.contextWindow ? { context_length: m.contextWindow } : {}) };
|
|
47
|
-
}
|
|
48
73
|
const entry = {
|
|
49
74
|
name: provider.name,
|
|
50
75
|
api: sdkBaseUrl(provider.protocol, provider.baseUrl),
|
|
51
76
|
key_env: keyVar,
|
|
52
77
|
transport: provider.protocol === "anthropic"
|
|
53
78
|
? "anthropic_messages"
|
|
54
|
-
: provider.openaiApi === "responses"
|
|
79
|
+
: provider.openaiApi === "responses" || (provider.openaiApi === undefined && prev?.transport === "codex_responses")
|
|
55
80
|
? "codex_responses"
|
|
56
81
|
: "chat_completions",
|
|
57
82
|
default_model: provider.defaultModel,
|
|
58
|
-
models,
|
|
59
83
|
};
|
|
60
84
|
// Key-by-key when the route exists: provider-level keys agentsw does not model
|
|
61
85
|
// (and their comments) belong to the user.
|
|
62
86
|
if (YAML.isMap(doc.getIn(at))) {
|
|
63
87
|
for (const [key, value] of Object.entries(entry))
|
|
64
|
-
doc.setIn([...at, key],
|
|
88
|
+
doc.setIn([...at, key], value);
|
|
65
89
|
}
|
|
66
90
|
else {
|
|
67
91
|
doc.setIn(at, doc.createNode(entry));
|
|
68
92
|
}
|
|
93
|
+
const modelsAt = [...at, "models"];
|
|
94
|
+
if (!doc.hasIn(modelsAt))
|
|
95
|
+
doc.setIn(modelsAt, doc.createNode({}));
|
|
96
|
+
const models = doc.getIn(modelsAt);
|
|
97
|
+
const modelIds = new Set(provider.models.map((model) => model.id));
|
|
98
|
+
for (const pair of [...models.items]) {
|
|
99
|
+
if (YAML.isScalar(pair.key) && !modelIds.has(String(pair.key.value)))
|
|
100
|
+
models.delete(pair.key.value);
|
|
101
|
+
}
|
|
102
|
+
for (const model of provider.models) {
|
|
103
|
+
const modelAt = [...modelsAt, model.id];
|
|
104
|
+
if (!doc.hasIn(modelAt))
|
|
105
|
+
doc.setIn(modelAt, doc.createNode({}));
|
|
106
|
+
if (model.contextWindow)
|
|
107
|
+
doc.setIn([...modelAt, "context_length"], model.contextWindow);
|
|
108
|
+
else
|
|
109
|
+
doc.deleteIn([...modelAt, "context_length"]);
|
|
110
|
+
}
|
|
69
111
|
doc.setIn(["model", "provider"], provider.id);
|
|
70
112
|
doc.setIn(["model", "default"], provider.defaultModel);
|
|
113
|
+
const envText = readTextIfExists(envFile) ?? "";
|
|
114
|
+
const assignments = envAssignments(envFile, envText);
|
|
115
|
+
const referenced = credentialReferences(doc, provider.id);
|
|
116
|
+
if (referenced.has(keyVar) && assignments.filter((assignment) => assignment.name === keyVar).at(-1)?.value !== provider.apiKey) {
|
|
117
|
+
throw new Error(`${envFile}: credential reference is shared by another provider; refusing to replace it`);
|
|
118
|
+
}
|
|
119
|
+
let nextEnv = upsertEnvAssignment(envFile, envText, keyVar, provider.apiKey);
|
|
120
|
+
const oldRef = prev?.key_env;
|
|
121
|
+
if (typeof oldRef === "string" && oldRef !== keyVar && isManagedCredentialRef(oldRef, provider.id) && !referenced.has(oldRef)) {
|
|
122
|
+
nextEnv = removeEnvAssignments(envFile, nextEnv, new Set([oldRef]));
|
|
123
|
+
}
|
|
124
|
+
const nextConfig = serializeYamlMapping(configFile, doc);
|
|
71
125
|
const configBackup = backupFile(configFile);
|
|
72
126
|
if (configBackup)
|
|
73
127
|
notes.push(`backup: ${configBackup}`);
|
|
74
|
-
writeFileAtomic(configFile,
|
|
75
|
-
// upsert the key into ~/.hermes/.env
|
|
76
|
-
const envText = readTextIfExists(envFile) ?? "";
|
|
77
|
-
const line = `${keyVar}=${provider.apiKey}`;
|
|
78
|
-
const pattern = new RegExp(`^${keyVar}=.*$`, "m");
|
|
79
|
-
const nextEnv = pattern.test(envText)
|
|
80
|
-
? envText.replace(pattern, line)
|
|
81
|
-
: envText + (envText.endsWith("\n") || envText === "" ? "" : "\n") + line + "\n";
|
|
128
|
+
writeFileAtomic(configFile, nextConfig);
|
|
82
129
|
const envBackup = backupFile(envFile);
|
|
83
130
|
if (envBackup)
|
|
84
131
|
notes.push(`backup: ${envBackup}`);
|
|
@@ -89,18 +136,12 @@ export const hermes = {
|
|
|
89
136
|
const home = hermesHome();
|
|
90
137
|
const configFile = path.join(home, "config.yaml");
|
|
91
138
|
const envFile = path.join(home, ".env");
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
const doc = YAML.parseDocument(text);
|
|
96
|
-
if (doc.errors.length) {
|
|
97
|
-
throw new Error(`${configFile} has YAML errors; refusing to rewrite: ${doc.errors[0]?.message}`);
|
|
98
|
-
}
|
|
99
|
-
if (!doc.hasIn(["providers", provider.id])) {
|
|
100
|
-
return { app: this.id, changed: [], notes: [], skipped: `no providers.${provider.id} entry` };
|
|
101
|
-
}
|
|
102
|
-
doc.deleteIn(["providers", provider.id]);
|
|
139
|
+
const doc = parseConfig(configFile, readTextIfExists(configFile));
|
|
140
|
+
const at = ["providers", provider.id];
|
|
141
|
+
const hasProvider = doc.hasIn(at);
|
|
103
142
|
const notes = [];
|
|
143
|
+
if (hasProvider)
|
|
144
|
+
doc.deleteIn(at);
|
|
104
145
|
if (doc.getIn(["model", "provider"]) === provider.id) {
|
|
105
146
|
doc.deleteIn(["model", "provider"]);
|
|
106
147
|
doc.deleteIn(["model", "default"]);
|
|
@@ -112,21 +153,27 @@ export const hermes = {
|
|
|
112
153
|
if (YAML.isMap(node) && node.items.length === 0)
|
|
113
154
|
doc.delete(key);
|
|
114
155
|
}
|
|
115
|
-
const
|
|
116
|
-
const
|
|
117
|
-
if (configBackup)
|
|
118
|
-
notes.push(`backup: ${configBackup}`);
|
|
119
|
-
writeFileAtomic(configFile, doc.toString());
|
|
120
|
-
const keyVar = `AGENTSW_${provider.id.toUpperCase().replace(/[^A-Z0-9]/g, "_")}_API_KEY`;
|
|
156
|
+
const referenced = credentialReferences(doc);
|
|
157
|
+
const removable = new Set([managedCredentialRef(provider.id), legacyManagedCredentialRef(provider.id)].filter((ref) => !referenced.has(ref)));
|
|
121
158
|
const envText = readTextIfExists(envFile);
|
|
122
|
-
|
|
159
|
+
const nextEnv = envText === undefined ? undefined : removeEnvAssignments(envFile, envText, removable);
|
|
160
|
+
const changed = [];
|
|
161
|
+
if (hasProvider) {
|
|
162
|
+
const config = serializeYamlMapping(configFile, doc);
|
|
163
|
+
const configBackup = backupFile(configFile);
|
|
164
|
+
if (configBackup)
|
|
165
|
+
notes.push(`backup: ${configBackup}`);
|
|
166
|
+
writeFileAtomic(configFile, config);
|
|
167
|
+
changed.push(configFile);
|
|
168
|
+
}
|
|
169
|
+
if (nextEnv !== undefined && nextEnv !== envText) {
|
|
123
170
|
const envBackup = backupFile(envFile);
|
|
124
171
|
if (envBackup)
|
|
125
172
|
notes.push(`backup: ${envBackup}`);
|
|
126
|
-
writeFileAtomic(envFile,
|
|
173
|
+
writeFileAtomic(envFile, nextEnv, 0o600);
|
|
127
174
|
changed.push(envFile);
|
|
128
175
|
}
|
|
129
|
-
return { app: this.id, changed, notes };
|
|
176
|
+
return changed.length ? { app: this.id, changed, notes } : { app: this.id, changed, notes, skipped: `no providers.${provider.id} entry or managed credential` };
|
|
130
177
|
},
|
|
131
178
|
current() {
|
|
132
179
|
const text = readTextIfExists(path.join(hermesHome(), "config.yaml"));
|
|
@@ -156,8 +203,10 @@ export const hermes = {
|
|
|
156
203
|
}
|
|
157
204
|
if (!parsed?.providers)
|
|
158
205
|
return [];
|
|
159
|
-
const
|
|
160
|
-
const
|
|
206
|
+
const envFile = path.join(hh, ".env");
|
|
207
|
+
const envText = readTextIfExists(envFile) ?? "";
|
|
208
|
+
const values = new Map(envAssignments(envFile, envText).map((assignment) => [assignment.name, assignment.value]));
|
|
209
|
+
const readEnv = (key) => process.env[key] ?? values.get(key);
|
|
161
210
|
const self = this.id;
|
|
162
211
|
return Object.entries(parsed.providers).flatMap(([id, entry]) => {
|
|
163
212
|
if (!entry || typeof entry.api !== "string" || !entry.api)
|
|
@@ -180,4 +229,4 @@ export const hermes = {
|
|
|
180
229
|
return [{ id, name: entry.name ?? id, protocol, openaiApi, baseUrl: entry.api, apiKey, keyEnv, models, defaultModel, source: self }];
|
|
181
230
|
});
|
|
182
231
|
},
|
|
183
|
-
};
|
|
232
|
+
});
|