agentsw 0.9.2 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/remove.js CHANGED
@@ -4,7 +4,8 @@ import { applyEdits, parse as parseJsonc } from "jsonc-parser";
4
4
  import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
5
5
  import YAML from "yaml";
6
6
  import { commitFileChanges } from "./config-transaction.js";
7
- import { appDataDir, drainPendingWrites, expandHome, home, localAppDataDir, setDryRun } from "./fsutil.js";
7
+ import { drainPendingWrites, setDryRun } from "./fsutil.js";
8
+ import { dshCredentialsFile, dshSettingsFiles, hermesConfigFile, hermesEnvFile, ompConfigFiles, ompModelsFiles, opencodeConfigFiles, piModelsFile, piSettingsFile, workbuddyDir, workbuddyModelsFile, } from "./app-paths.js";
8
9
  import { editJsoncObject, isJsonObject, readJsoncObject } from "./jsonc.js";
9
10
  import { configFile } from "./store.js";
10
11
  import { endpointKey } from "./import.js";
@@ -164,33 +165,20 @@ function targetFiles(target, listing = false) {
164
165
  };
165
166
  switch (target.id) {
166
167
  case "omp": {
167
- const dir = path.join(home, ".omp", "agent");
168
- for (const name of ["models.yml", "models.yaml"])
169
- add(path.join(dir, name), ["providers"]);
170
- for (const name of ["config.yml", "config.yaml"])
171
- refs(path.join(dir, name));
168
+ for (const file of ompModelsFiles())
169
+ add(file, ["providers"]);
170
+ for (const file of ompConfigFiles())
171
+ refs(file);
172
172
  break;
173
173
  }
174
174
  case "pi":
175
175
  case "prime": {
176
- const env = process.env[target.id === "pi" ? "PI_CODING_AGENT_DIR" : "PRIME_AGENT_CODING_AGENT_DIR"];
177
- const dir = env ? expandHome(env) : path.join(home, target.id === "pi" ? ".pi/agent" : ".prime/agent");
178
- add(path.join(dir, "models.json"), ["providers"]);
179
- refs(path.join(dir, "settings.json"));
176
+ add(piModelsFile(target.id), ["providers"]);
177
+ refs(piSettingsFile(target.id));
180
178
  break;
181
179
  }
182
180
  case "opencode": {
183
- const dirs = new Set([appDataDir("opencode")]);
184
- if (process.env.OPENCODE_CONFIG_DIR?.trim())
185
- dirs.add(expandHome(process.env.OPENCODE_CONFIG_DIR.trim()));
186
- const files = new Set();
187
- for (const dir of dirs) {
188
- for (const name of ["config.json", "opencode.json", "opencode.jsonc"])
189
- files.add(path.join(dir, name));
190
- }
191
- if (process.env.OPENCODE_CONFIG?.trim())
192
- files.add(expandHome(process.env.OPENCODE_CONFIG.trim()));
193
- for (const file of files)
181
+ for (const file of opencodeConfigFiles())
194
182
  add(file, ["provider"]);
195
183
  break;
196
184
  }
@@ -198,20 +186,18 @@ function targetFiles(target, listing = false) {
198
186
  add(target.configPaths[0], ["model_providers"]);
199
187
  break;
200
188
  case "hermes": {
201
- const dir = process.env.HERMES_HOME?.trim() || localAppDataDir("hermes");
202
- add(path.join(dir, "config.yaml"), ["providers"]);
203
- const file = path.resolve(dir, ".env");
189
+ add(hermesConfigFile(), ["providers"]);
190
+ const file = path.resolve(hermesEnvFile());
204
191
  if (!listing)
205
192
  result.extras.set(file, readText(file));
206
193
  break;
207
194
  }
208
195
  case "dsh": {
209
- const dir = process.env.DSH_HOME?.trim() ? expandHome(process.env.DSH_HOME.trim()) : localAppDataDir("dsh");
210
- const file = ["settings.yaml", "settings.yml", "settings.json"].map((name) => path.join(dir, name)).find((file) => fs.existsSync(file));
196
+ const file = dshSettingsFiles().find((candidate) => fs.existsSync(candidate));
211
197
  if (file)
212
198
  add(file, ["llm-pi-ai", "providers"]);
213
199
  if (!listing) {
214
- const credentials = add(path.join(dir, ".credentials.yaml"));
200
+ const credentials = add(dshCredentialsFile());
215
201
  if (credentials) {
216
202
  const { version, refs } = credentials.value;
217
203
  const flat = version === undefined && refs === undefined && Object.values(credentials.value).every((value) => typeof value === "string");
@@ -589,9 +575,8 @@ async function planTargetRemoval(target, id, plans, stored) {
589
575
  return found;
590
576
  }
591
577
  function planWorkbuddyRemoval(target, id, plans, stored) {
592
- const override = process.env.WORKBUDDY_CONFIG_DIR?.trim() || process.env.CODEBUDDY_CONFIG_DIR?.trim();
593
- const dir = override ? expandHome(override) : (process.platform === "win32" ? appDataDir("workbuddy") : path.join(home, ".workbuddy"));
594
- const file = path.join(dir, "models.json");
578
+ const dir = workbuddyDir();
579
+ const file = workbuddyModelsFile();
595
580
  const before = readText(file);
596
581
  if (before === undefined)
597
582
  return false;
package/dist/rename.js CHANGED
@@ -3,7 +3,7 @@ import path from "node:path";
3
3
  import YAML from "yaml";
4
4
  import { applyEdits, findNodeAtLocation, getNodeValue, parseTree } from "jsonc-parser";
5
5
  import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
6
- import { appDataDir, expandHome, home, localAppDataDir } from "./fsutil.js";
6
+ import { codexAuthFile, codexConfigFile, dshCredentialsFile, dshSettingsFiles, hermesConfigFile, hermesEnvFile, ompConfigFiles, ompModelsFiles, opencodeConfigFiles, piModelsFile, piSettingsFile, workbuddyModelsFile, } from "./app-paths.js";
7
7
  import { isJsonObject, readJsoncObject } from "./jsonc.js";
8
8
  import { configFile } from "./store.js";
9
9
  import { commitFileChanges } from "./config-transaction.js";
@@ -305,12 +305,12 @@ export async function renameProvider(oldId, newId, opts = {}) {
305
305
  store.move(["providers"], oldId, newId);
306
306
  exact(store, ["active"]);
307
307
  finish(store, beforeStore, 0o600);
308
- visit(path.join(home, ".codex", "config.toml"), "toml", (editor) => {
308
+ visit(codexConfigFile(), "toml", (editor) => {
309
309
  const entry = get(editor.value, ["model_providers", oldId]);
310
310
  if (isJsonObject(entry)) {
311
311
  let key = typeof entry.env_key === "string" ? process.env[entry.env_key] : undefined;
312
312
  if (!entry.env_key && entry.requires_openai_auth && get(editor.value, ["model_provider"]) === oldId) {
313
- const authFile = path.join(home, ".codex", "auth.json");
313
+ const authFile = codexAuthFile();
314
314
  const authText = readExisting(authFile);
315
315
  changes.push({ file: authFile, before: authText, after: authText });
316
316
  if (authText !== undefined) {
@@ -328,19 +328,17 @@ export async function renameProvider(oldId, newId, opts = {}) {
328
328
  exact(editor, ["profiles", profile, "model_provider"]);
329
329
  renameMap(editor, ["model_providers"]);
330
330
  });
331
- const ompDir = path.join(home, ".omp", "agent");
332
- for (const name of ["models.yml", "models.yaml"])
333
- visit(path.join(ompDir, name), "yaml", (editor) => renameMap(editor, ["providers"], undefined, true));
334
- for (const name of ["config.yml", "config.yaml"])
335
- visit(path.join(ompDir, name), "yaml", (editor) => {
331
+ for (const file of ompModelsFiles())
332
+ visit(file, "yaml", (editor) => renameMap(editor, ["providers"], undefined, true));
333
+ for (const file of ompConfigFiles())
334
+ visit(file, "yaml", (editor) => {
336
335
  compound(editor, ["model"]);
337
336
  for (const [role] of entries(editor, ["modelRoles"]))
338
337
  compound(editor, ["modelRoles", role]);
339
338
  });
340
- for (const [env, fallback] of [["PI_CODING_AGENT_DIR", ".pi/agent"], ["PRIME_AGENT_CODING_AGENT_DIR", ".prime/agent"]]) {
341
- const dir = process.env[env] ? expandHome(process.env[env]) : path.join(home, fallback);
342
- visit(path.join(dir, "models.json"), "json", (editor) => renameMap(editor, ["providers"], undefined, env === "PRIME_AGENT_CODING_AGENT_DIR"));
343
- visit(path.join(dir, "settings.json"), "json", (editor) => {
339
+ for (const id of ["pi", "prime"]) {
340
+ visit(piModelsFile(id), "json", (editor) => renameMap(editor, ["providers"], undefined, id === "prime"));
341
+ visit(piSettingsFile(id), "json", (editor) => {
344
342
  exact(editor, ["defaultProvider"]);
345
343
  const recent = get(editor.value, ["recentModels"]);
346
344
  if (Array.isArray(recent))
@@ -352,15 +350,7 @@ export async function renameProvider(oldId, newId, opts = {}) {
352
350
  });
353
351
  });
354
352
  }
355
- const opencodeFiles = new Set(["config.json", "opencode.json", "opencode.jsonc"].map((name) => path.join(appDataDir("opencode"), name)));
356
- const customOpenCode = process.env.OPENCODE_CONFIG_DIR?.trim();
357
- if (customOpenCode)
358
- for (const name of ["opencode.json", "opencode.jsonc"])
359
- opencodeFiles.add(path.join(expandHome(customOpenCode), name));
360
- const sharedOpenCode = process.env.OPENCODE_CONFIG?.trim();
361
- if (sharedOpenCode)
362
- opencodeFiles.add(expandHome(sharedOpenCode));
363
- for (const file of opencodeFiles)
353
+ for (const file of opencodeConfigFiles())
364
354
  visit(file, "json", (editor) => {
365
355
  renameMap(editor, ["provider"]);
366
356
  for (const key of ["model", "small_model"])
@@ -399,14 +389,13 @@ export async function renameProvider(oldId, newId, opts = {}) {
399
389
  throw new Error(`${file}: source provider credentials conflict with the store`);
400
390
  return key;
401
391
  };
402
- const hermesDir = process.env.HERMES_HOME?.trim() ? expandHome(process.env.HERMES_HOME.trim()) : localAppDataDir("hermes");
403
- const envFile = path.join(hermesDir, ".env");
392
+ const envFile = hermesEnvFile();
404
393
  const envBefore = readExisting(envFile);
405
394
  const assignments = envBefore === undefined ? [] : envAssignments(envFile, envBefore);
406
395
  const envValues = new Map(assignments.map((assignment) => [assignment.name, assignment.value]));
407
396
  let envKey;
408
397
  let hermesPresent = false;
409
- visit(path.join(hermesDir, "config.yaml"), "yaml", (editor) => {
398
+ visit(hermesConfigFile(), "yaml", (editor) => {
410
399
  hermesPresent = true;
411
400
  const keyRef = get(editor.value, ["providers", oldId, "key_env"]);
412
401
  const key = typeof keyRef === "string" ? process.env[keyRef] ?? envValues.get(keyRef) : undefined;
@@ -423,18 +412,16 @@ export async function renameProvider(oldId, newId, opts = {}) {
423
412
  const envAfter = envKey ? applyEdits(envBefore, assignments.filter((assignment) => assignment.name === envKey)
424
413
  .map((assignment) => ({ offset: assignment.offset, length: envKey.length, content: newKey }))) : envBefore;
425
414
  changes.push({ file: envFile, before: envBefore, after: envAfter, mode: 0o600 });
426
- const dshDir = process.env.DSH_HOME?.trim() ? expandHome(process.env.DSH_HOME.trim()) : localAppDataDir("dsh");
427
415
  const settings = [];
428
- for (const name of ["settings.yaml", "settings.yml", "settings.json"]) {
429
- const file = path.join(dshDir, name);
416
+ for (const file of dshSettingsFiles()) {
430
417
  const before = readExisting(file);
431
418
  if (before === undefined)
432
419
  changes.push({ file, before, after: before });
433
420
  else
434
- settings.push({ editor: name.endsWith("json") ? jsonEditor(file, before) : yamlEditor(file, before), before });
421
+ settings.push({ editor: file.endsWith("json") ? jsonEditor(file, before) : yamlEditor(file, before), before });
435
422
  }
436
423
  const dshMaps = settings.map(({ editor }) => entries(editor, ["llm-pi-ai", "providers"]));
437
- const credentialFile = path.join(dshDir, ".credentials.yaml");
424
+ const credentialFile = dshCredentialsFile();
438
425
  let credentialKey;
439
426
  let credentialRefs = {};
440
427
  visit(credentialFile, "yaml", (editor) => {
@@ -458,9 +445,7 @@ export async function renameProvider(oldId, newId, opts = {}) {
458
445
  exact(editor, ["agent-default-model", "provider"]);
459
446
  finish(editor, before);
460
447
  }
461
- const workbuddyEnv = process.env.WORKBUDDY_CONFIG_DIR?.trim() ?? process.env.CODEBUDDY_CONFIG_DIR?.trim();
462
- const workbuddyDir = workbuddyEnv ? expandHome(workbuddyEnv) : process.platform === "win32" ? appDataDir("workbuddy") : path.join(home, ".workbuddy");
463
- visit(path.join(workbuddyDir, "models.json"), "json", (editor) => {
448
+ visit(workbuddyModelsFile(), "json", (editor) => {
464
449
  const at = Array.isArray(editor.value) ? [] : ["models"];
465
450
  const rows = get(editor.value, at);
466
451
  if (rows === undefined)
package/dist/report.js ADDED
@@ -0,0 +1,67 @@
1
+ import { getMetadataMode } from "./metadata.js";
2
+ function modelSpecReport(spec) {
3
+ return {
4
+ id: spec.id,
5
+ name: spec.name ?? null,
6
+ contextWindow: spec.contextWindow ?? null,
7
+ maxInput: spec.maxInput ?? null,
8
+ maxOutput: spec.maxOutput ?? null,
9
+ reasoning: spec.reasoning ?? null,
10
+ reasoningEfforts: spec.reasoningEfforts ?? null,
11
+ imageInput: spec.imageInput ?? null,
12
+ cost: spec.cost ?? null,
13
+ };
14
+ }
15
+ export function providerSummary(provider) {
16
+ return {
17
+ id: provider.id,
18
+ name: provider.name,
19
+ protocol: provider.protocol,
20
+ baseUrl: provider.baseUrl,
21
+ defaultModel: provider.defaultModel,
22
+ modelCount: provider.models.length,
23
+ };
24
+ }
25
+ export function statusReport(configFile, store, apps) {
26
+ return { configFile, active: store.active ?? null, apps };
27
+ }
28
+ export function listReport(store) {
29
+ const providers = Object.keys(store.providers).map((id) => providerSummary(store.providers[id]));
30
+ return { active: store.active ?? null, providers };
31
+ }
32
+ export function listAppsReport(apps, entries) {
33
+ return {
34
+ apps,
35
+ entries: entries.map((entry) => ({ app: entry.app ?? null, id: entry.id, name: entry.name ?? null })),
36
+ };
37
+ }
38
+ export function appsReport(rows) {
39
+ return { apps: rows };
40
+ }
41
+ /** Mirrors the object `models --provider --metadata` printed before --json existed, for compatibility. */
42
+ export function providerMetadataReport(provider) {
43
+ return {
44
+ provider: provider.id,
45
+ gatewayMetadata: provider.gatewayMetadata ?? "auto",
46
+ metadataMode: getMetadataMode(provider),
47
+ gatewayModelAliases: provider.gatewayModelAliases ?? {},
48
+ models: provider.models.map((spec) => ({ id: spec.id, metadata: spec.metadata })),
49
+ };
50
+ }
51
+ export function providerModelsReport(provider, metadata) {
52
+ if (metadata)
53
+ return providerMetadataReport(provider);
54
+ return {
55
+ provider: provider.id,
56
+ protocol: provider.protocol,
57
+ baseUrl: provider.baseUrl,
58
+ defaultModel: provider.defaultModel,
59
+ models: provider.models.map(modelSpecReport),
60
+ };
61
+ }
62
+ export function modelsQueryReport(query, hits) {
63
+ return {
64
+ query,
65
+ models: hits.map((hit) => ({ provider: hit.provider, ...modelSpecReport(hit.spec) })),
66
+ };
67
+ }
package/dist/store.js CHANGED
@@ -1,9 +1,33 @@
1
1
  import path from "node:path";
2
2
  import { appDataDir, isDryRun, readFileSnapshot, recordPendingWrites } from "./fsutil.js";
3
+ import { isJsonObject } from "./jsonc.js";
3
4
  import { commitFileChanges } from "./config-transaction.js";
4
5
  export const configDir = appDataDir("agentsw");
5
6
  export const configFile = path.join(configDir, "config.json");
6
7
  const loadedSnapshots = new WeakMap();
8
+ /**
9
+ * Reject only shapes that would otherwise crash later with an opaque TypeError.
10
+ * Messages name the file and the provider id, never a field value, so no
11
+ * credential can leak through an error. Extra/unknown fields stay allowed.
12
+ */
13
+ function validateStore(store) {
14
+ const raw = store;
15
+ if (raw.version !== undefined && raw.version !== 1) {
16
+ const version = typeof raw.version === "number" ? ` ${raw.version}` : "";
17
+ throw new Error(`${configFile}: unsupported provider store version${version}`);
18
+ }
19
+ for (const [id, provider] of Object.entries(store.providers)) {
20
+ if (!isJsonObject(provider))
21
+ throw new Error(`${configFile}: provider "${id}" is not an object`);
22
+ if (!Array.isArray(provider.models)) {
23
+ throw new Error(`${configFile}: provider "${id}" has no models array`);
24
+ }
25
+ for (const model of provider.models) {
26
+ if (!isJsonObject(model))
27
+ throw new Error(`${configFile}: provider "${id}" has a model that is not an object`);
28
+ }
29
+ }
30
+ }
7
31
  export function loadStore() {
8
32
  const snapshot = readFileSnapshot(configFile);
9
33
  let store;
@@ -24,6 +48,7 @@ export function loadStore() {
24
48
  if (!store.providers || typeof store.providers !== "object" || Array.isArray(store.providers)) {
25
49
  throw new Error(`${configFile}: expected a providers object`);
26
50
  }
51
+ validateStore(store);
27
52
  }
28
53
  loadedSnapshots.set(store, snapshot);
29
54
  return store;
@@ -1,23 +1,25 @@
1
1
  import fs from "node:fs";
2
- import path from "node:path";
3
- import { backupFile, home, readJsonIfExists, writeFileAtomic } from "../fsutil.js";
2
+ import { backupFile, readJsonIfExists, writeFileAtomic } from "../fsutil.js";
4
3
  import { isJsonObject } from "../jsonc.js";
4
+ import { claudeDir, claudeSettingsFile } from "../app-paths.js";
5
5
  import { transactionalTarget } from "../target-transaction.js";
6
6
  import { providerIdFromBaseUrl, providerNameFromBaseUrl } from "../slug.js";
7
7
  import { stripApiVersion } from "./wire.js";
8
- const dir = path.join(home, ".claude");
9
- const settingsFile = path.join(dir, "settings.json");
10
8
  /**
11
- * Claude Code reads Anthropic-protocol endpoints via env vars in ~/.claude/settings.json.
9
+ * Claude Code reads Anthropic-protocol endpoints via env vars in
10
+ * $CLAUDE_CONFIG_DIR/settings.json (default ~/.claude/settings.json).
12
11
  * We merge the "env" block and leave every other setting untouched.
13
12
  */
14
13
  export const claudecode = transactionalTarget({
15
14
  id: "claude",
16
15
  name: "Claude Code",
17
16
  protocols: ["anthropic"],
18
- configPaths: [settingsFile],
19
- detect: () => fs.existsSync(dir),
17
+ get configPaths() {
18
+ return [claudeSettingsFile()];
19
+ },
20
+ detect: () => fs.existsSync(claudeDir()),
20
21
  async apply(provider) {
22
+ const settingsFile = claudeSettingsFile();
21
23
  const notes = [];
22
24
  const settingsValue = readJsonIfExists(settingsFile);
23
25
  const settings = settingsValue === undefined ? {} : settingsValue;
@@ -43,6 +45,7 @@ export const claudecode = transactionalTarget({
43
45
  return { app: this.id, changed: [settingsFile], notes };
44
46
  },
45
47
  async prune(provider) {
48
+ const settingsFile = claudeSettingsFile();
46
49
  const settings = readJsonIfExists(settingsFile);
47
50
  if (settings !== undefined && (!isJsonObject(settings) || (settings.env !== undefined && !isJsonObject(settings.env)))) {
48
51
  throw new Error(`${settingsFile}: expected settings and env to be JSON objects`);
@@ -71,13 +74,13 @@ export const claudecode = transactionalTarget({
71
74
  return { app: this.id, changed: [settingsFile], notes };
72
75
  },
73
76
  current() {
74
- const env = readJsonIfExists(settingsFile)?.env;
77
+ const env = readJsonIfExists(claudeSettingsFile())?.env;
75
78
  if (!env?.ANTHROPIC_BASE_URL)
76
79
  return undefined;
77
80
  return `${env.ANTHROPIC_BASE_URL} · ${env.ANTHROPIC_MODEL ?? "?"}`;
78
81
  },
79
82
  candidates() {
80
- const env = readJsonIfExists(settingsFile)?.env;
83
+ const env = readJsonIfExists(claudeSettingsFile())?.env;
81
84
  const baseUrl = env?.ANTHROPIC_BASE_URL;
82
85
  if (!baseUrl)
83
86
  return [];
@@ -1,12 +1,9 @@
1
1
  import fs from "node:fs";
2
- import path from "node:path";
3
2
  import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
4
- import { SafeConfigError, backupFile, home, readJsonIfExists, readTextIfExists, writeFileAtomic } from "../fsutil.js";
3
+ import { SafeConfigError, backupFile, readJsonIfExists, readTextIfExists, writeFileAtomic } from "../fsutil.js";
5
4
  import { isJsonObject } from "../jsonc.js";
5
+ import { codexAuthFile, codexConfigFile, codexDir } from "../app-paths.js";
6
6
  import { transactionalTarget } from "../target-transaction.js";
7
- const dir = path.join(home, ".codex");
8
- const configFile = path.join(dir, "config.toml");
9
- const authFile = path.join(dir, "auth.json");
10
7
  /** smol-toml quotes the offending source line, which may hold a key: report only the position. */
11
8
  function parseConfigToml(file, text) {
12
9
  try {
@@ -19,17 +16,22 @@ function parseConfigToml(file, text) {
19
16
  }
20
17
  }
21
18
  /**
22
- * Codex CLI: [model_providers.<id>] in ~/.codex/config.toml selects a custom
23
- * OpenAI-protocol endpoint; the key ships via ~/.codex/auth.json OPENAI_API_KEY.
24
- * TOML round-trip via smol-toml preserves data but not comments (backed up first).
19
+ * Codex CLI: [model_providers.<id>] in $CODEX_HOME/config.toml (default
20
+ * ~/.codex/config.toml) selects a custom OpenAI-protocol endpoint; the key ships
21
+ * via auth.json in the same directory. TOML round-trip via smol-toml preserves
22
+ * data but not comments (backed up first).
25
23
  */
26
24
  export const codex = transactionalTarget({
27
25
  id: "codex",
28
26
  name: "Codex CLI",
29
27
  protocols: ["openai"],
30
- configPaths: [configFile, authFile],
31
- detect: () => fs.existsSync(dir),
28
+ get configPaths() {
29
+ return [codexConfigFile(), codexAuthFile()];
30
+ },
31
+ detect: () => fs.existsSync(codexDir()),
32
32
  async apply(provider) {
33
+ const configFile = codexConfigFile();
34
+ const authFile = codexAuthFile();
33
35
  const notes = [];
34
36
  const text = readTextIfExists(configFile);
35
37
  const config = text ? parseConfigToml(configFile, text) : {};
@@ -46,6 +48,11 @@ export const codex = transactionalTarget({
46
48
  if (provider.reasoningEffort && model?.reasoning !== false) {
47
49
  config.model_reasoning_effort = provider.reasoningEffort;
48
50
  }
51
+ else {
52
+ // The effort belongs to the active model: switching providers must not
53
+ // inherit the previous provider's level (prune treats it as owned too).
54
+ delete config.model_reasoning_effort;
55
+ }
49
56
  // Codex only speaks the OpenAI Responses API (wire_api = "chat" was removed Feb 2026).
50
57
  // requires_openai_auth = true makes Codex take the key from auth.json instead of an env var.
51
58
  const providers = (config.model_providers ?? {});
@@ -92,6 +99,7 @@ export const codex = transactionalTarget({
92
99
  return { app: this.id, changed: [configFile, authFile], notes };
93
100
  },
94
101
  async prune(provider) {
102
+ const configFile = codexConfigFile();
95
103
  const text = readTextIfExists(configFile);
96
104
  if (!text)
97
105
  return { app: this.id, changed: [], notes: [], skipped: "no config.toml" };
@@ -121,11 +129,11 @@ export const codex = transactionalTarget({
121
129
  return { app: this.id, changed: [configFile], notes };
122
130
  },
123
131
  current() {
124
- const text = readTextIfExists(configFile);
132
+ const text = readTextIfExists(codexConfigFile());
125
133
  if (!text)
126
134
  return undefined;
127
135
  try {
128
- const config = parseConfigToml(configFile, text);
136
+ const config = parseConfigToml(codexConfigFile(), text);
129
137
  if (!config.model_provider)
130
138
  return undefined;
131
139
  return `${String(config.model_provider)} · ${String(config.model ?? "?")}`;
@@ -135,6 +143,7 @@ export const codex = transactionalTarget({
135
143
  }
136
144
  },
137
145
  candidates() {
146
+ const configFile = codexConfigFile();
138
147
  const text = readTextIfExists(configFile);
139
148
  if (!text)
140
149
  return [];
@@ -148,7 +157,7 @@ export const codex = transactionalTarget({
148
157
  const providers = config.model_providers;
149
158
  if (!providers)
150
159
  return [];
151
- const auth = readJsonIfExists(authFile);
160
+ const auth = readJsonIfExists(codexAuthFile());
152
161
  const authKey = auth?.OPENAI_API_KEY || auth?.tokens?.access_token;
153
162
  const active = typeof config.model_provider === "string" ? config.model_provider : undefined;
154
163
  const self = this.id;
@@ -1,30 +1,11 @@
1
1
  import fs from "node:fs";
2
- import path from "node:path";
3
2
  import YAML from "yaml";
4
- import { backupFile, expandHome, home, localAppDataDir, readTextIfExists, writeFileAtomic } from "../fsutil.js";
3
+ import { backupFile, readTextIfExists, writeFileAtomic } from "../fsutil.js";
4
+ import { dshCredentialsFile, dshDir, dshSettingsFile } from "../app-paths.js";
5
5
  import { isManagedCredentialRef, legacyManagedCredentialRef, managedCredentialRef } from "../provider-identity.js";
6
6
  import { transactionalTarget } from "../target-transaction.js";
7
7
  import { parseYamlMapping, serializeYamlMapping } from "../yaml.js";
8
- import { apiValue, classifyApi, mergeModels, sdkBaseUrl } from "./wire.js";
9
- /** `$DSH_HOME`, or `~/.dsh` (packages/util/home-paths `resolveDshHome`). */
10
- function dshHome() {
11
- const env = process.env.DSH_HOME?.trim();
12
- return env ? expandHome(env) : process.platform === "win32" ? localAppDataDir("dsh") : path.join(home, ".dsh");
13
- }
14
- /**
15
- * Settings document: `<harness home>/settings.yaml` by default; the extension
16
- * picks the format, so an existing `.yml`/`.json` document is written in place.
17
- */
18
- function settingsFile() {
19
- const dir = dshHome();
20
- for (const name of ["settings.yaml", "settings.yml", "settings.json"]) {
21
- const file = path.join(dir, name);
22
- if (fs.existsSync(file))
23
- return file;
24
- }
25
- return path.join(dir, "settings.yaml");
26
- }
27
- const credentialsFile = () => path.join(dshHome(), ".credentials.yaml");
8
+ import { apiValue, classifyApi, entryApi, mergeModels, sdkBaseUrl } from "./wire.js";
28
9
  /** Layout version of `.credentials.yaml` this build reads and writes. */
29
10
  const CREDENTIALS_VERSION = 1;
30
11
  /** Thinking levels a `reasoningEfforts` entry may offer (llm-pi-ai THINKING_LEVEL_GATE). */
@@ -82,20 +63,23 @@ export const dsh = transactionalTarget({
82
63
  id: "dsh",
83
64
  name: "DeepSeek Harness",
84
65
  protocols: ["openai", "anthropic"],
85
- configPaths: [settingsFile(), credentialsFile()],
86
- detect: () => fs.existsSync(dshHome()),
66
+ get configPaths() {
67
+ return [dshSettingsFile(), dshCredentialsFile()];
68
+ },
69
+ detect: () => fs.existsSync(dshDir()),
87
70
  async apply(provider) {
88
71
  const notes = [];
89
- const file = settingsFile();
72
+ const file = dshSettingsFile();
90
73
  const doc = parseSettings(file);
91
74
  const at = ["llm-pi-ai", "providers", provider.id];
92
75
  const prev = doc.getIn(at)?.toJSON?.();
93
76
  const ref = managedCredentialRef(provider.id);
77
+ const baseURL = sdkBaseUrl(provider.protocol, provider.baseUrl);
94
78
  const entry = {
95
79
  displayName: provider.name,
96
80
  apiKeyEnv: ref, // credential reference, never the secret
97
- api: apiValue(provider.protocol, provider.openaiApi, prev?.api),
98
- baseURL: sdkBaseUrl(provider.protocol, provider.baseUrl),
81
+ api: apiValue(provider.protocol, provider.openaiApi, prev?.api ?? entryApi(prev ?? {})),
82
+ baseURL,
99
83
  };
100
84
  if (YAML.isMap(doc.getIn(at))) {
101
85
  // Key-by-key: compat, headers, defaultInput, retryPolicy and their comments are the user's.
@@ -147,7 +131,7 @@ export const dsh = transactionalTarget({
147
131
  },
148
132
  async prune(provider) {
149
133
  const notes = [];
150
- const file = settingsFile();
134
+ const file = dshSettingsFile();
151
135
  // Settings must be understood before any credential can be removed.
152
136
  const doc = parseSettings(file);
153
137
  const at = ["llm-pi-ai", "providers", provider.id];
@@ -181,7 +165,7 @@ export const dsh = transactionalTarget({
181
165
  return { app: this.id, changed: droppedCredential ? [file, droppedCredential] : [file], notes };
182
166
  },
183
167
  current() {
184
- const text = readTextIfExists(settingsFile());
168
+ const text = readTextIfExists(dshSettingsFile());
185
169
  if (!text)
186
170
  return undefined;
187
171
  try {
@@ -196,7 +180,7 @@ export const dsh = transactionalTarget({
196
180
  }
197
181
  },
198
182
  candidates() {
199
- const text = readTextIfExists(settingsFile());
183
+ const text = readTextIfExists(dshSettingsFile());
200
184
  if (!text)
201
185
  return [];
202
186
  let parsed;
@@ -215,7 +199,8 @@ export const dsh = transactionalTarget({
215
199
  return Object.entries(providers).flatMap(([id, entry]) => {
216
200
  if (!entry || typeof entry.baseURL !== "string")
217
201
  return [];
218
- const wire = classifyApi(entry.api);
202
+ // llm-pi-ai declares the wire at provider level or, agreeing, on every model.
203
+ const wire = classifyApi(entryApi(entry));
219
204
  if (!wire)
220
205
  return [];
221
206
  const keyEnv = typeof entry.apiKeyEnv === "string" && entry.apiKeyEnv ? entry.apiKeyEnv : undefined;
@@ -247,7 +232,7 @@ export const dsh = transactionalTarget({
247
232
  /** Read the managed store's `refs` section; an unreadable document yields no keys. */
248
233
  function readCredentialRefs() {
249
234
  try {
250
- const doc = parseCredentials(credentialsFile());
235
+ const doc = parseCredentials(dshCredentialsFile());
251
236
  const parsed = doc.toJS();
252
237
  return (doc.get("version") === undefined ? parsed : parsed.refs ?? {});
253
238
  }
@@ -285,7 +270,7 @@ function parseCredentials(file) {
285
270
  * instead of being re-rooted one level deeper. Returns the file.
286
271
  */
287
272
  function writeCredential(ref, apiKey, notes, oldRef, referenced) {
288
- const file = credentialsFile();
273
+ const file = dshCredentialsFile();
289
274
  const doc = parseCredentials(file);
290
275
  if (doc.get("version") === undefined) {
291
276
  const refs = doc.contents;
@@ -311,7 +296,7 @@ function writeCredential(ref, apiKey, notes, oldRef, referenced) {
311
296
  }
312
297
  /** Drop only managed references that no surviving route uses. */
313
298
  function deleteCredentials(refs, notes) {
314
- const file = credentialsFile();
299
+ const file = dshCredentialsFile();
315
300
  if (readTextIfExists(file) === undefined)
316
301
  return undefined;
317
302
  const doc = parseCredentials(file);