agentsw 0.7.1 → 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.
@@ -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 text = readTextIfExists(file);
60
- const doc = text ? YAML.parseDocument(text) : new YAML.Document({});
61
- if (doc.errors.length) {
62
- throw new Error(`${file} has settings errors; refusing to rewrite: ${doc.errors[0]?.message}`);
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
- return file.endsWith(".json") ? JSON.stringify(doc.toJSON(), null, 2) + "\n" : doc.toString();
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 = credentialRef(provider.id);
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], doc.createNode(value));
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, writeCredential(ref, provider.apiKey, notes)];
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
- const doc = fs.existsSync(file) ? parseSettings(file) : undefined;
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
- if (!doc?.hasIn(at)) {
129
- const why = doc ? `no llm-pi-ai.providers.${provider.id} entry` : "no settings document";
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
- const changed = [file];
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 parsed = YAML.parse(text);
226
- const refs = parsed?.refs;
227
- if (refs && typeof refs === "object")
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 text = readTextIfExists(file);
240
- const doc = text ? YAML.parseDocument(text) : new YAML.Document({});
241
- if (doc.errors.length) {
242
- throw new Error(`${file} has YAML errors; refusing to rewrite: ${doc.errors[0]?.message}`);
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
- const root = (doc.toJSON() ?? {});
259
- const version = root.version;
260
- if (version !== undefined && version !== CREDENTIALS_VERSION) {
261
- throw new Error(`${file} declares version ${JSON.stringify(version)}; agentsw writes version ${CREDENTIALS_VERSION}`);
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 (version === undefined && Object.keys(root).length > 0) {
264
- const flat = Object.entries(root).every(([key, value]) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(key) && typeof value === "string" && value.length > 0);
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, doc.toString(), 0o600);
309
+ writeFileAtomic(file, text, 0o600);
278
310
  return file;
279
311
  }
280
- /** Drop this provider's reference. Returns the file when it was rewritten. */
281
- function deleteCredential(ref, notes) {
312
+ /** Drop only managed references that no surviving route uses. */
313
+ function deleteCredentials(refs, notes) {
282
314
  const file = credentialsFile();
283
- const text = readTextIfExists(file);
284
- if (!text)
315
+ if (readTextIfExists(file) === undefined)
285
316
  return undefined;
286
317
  const doc = parseCredentials(file);
287
- const at = doc.hasIn(["refs", ref]) ? ["refs", ref] : doc.has(ref) ? [ref] : undefined;
288
- if (!at)
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.deleteIn(at);
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, doc.toString(), 0o600);
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
+ }
@@ -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 = text ? YAML.parseDocument(text) : new YAML.Document({});
33
- if (doc.errors.length) {
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], doc.createNode(value));
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, doc.toString());
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 text = readTextIfExists(configFile);
93
- if (!text)
94
- return { app: this.id, changed: [], notes: [], skipped: "no config.yaml" };
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 changed = [configFile];
116
- const configBackup = backupFile(configFile);
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
- if (envText && new RegExp(`^${keyVar}=`, "m").test(envText)) {
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, envText.replace(new RegExp(`^${keyVar}=.*\\n?`, "m"), ""), 0o600);
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 envText = readTextIfExists(path.join(hh, ".env")) ?? "";
160
- const readEnv = (key) => process.env[key] ?? (envText.match(new RegExp(`^${key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}=(.*)$`, "m"))?.[1]?.trim() || undefined);
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
+ });
@@ -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
- 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)}`);
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 = serializeModelsDocument(file, doc);
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 = serializeModelsDocument(file, doc);
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
+ });