agentsw 0.6.0 → 0.6.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/dist/jsonc.js ADDED
@@ -0,0 +1,111 @@
1
+ import fs from "node:fs";
2
+ import { applyEdits, createScanner, getNodeValue, modify, parseTree, printParseErrorCode, } from "jsonc-parser";
3
+ export function isJsonObject(value) {
4
+ return value !== null && typeof value === "object" && !Array.isArray(value);
5
+ }
6
+ function parseJsoncObject(file, text) {
7
+ const source = text.startsWith("\uFEFF") ? text.slice(1) : text;
8
+ const errors = [];
9
+ const root = parseTree(source, errors, { allowTrailingComma: true });
10
+ const error = errors[0];
11
+ if (error) {
12
+ // Do not quote source text: config files can contain credentials.
13
+ throw new Error(`${file}: invalid JSONC (${printParseErrorCode(error.error)} at offset ${error.offset})`);
14
+ }
15
+ if (root?.type !== "object")
16
+ throw new Error(`${file}: expected a JSON object at the document root`);
17
+ // Parsing resolves duplicate keys last-wins, but modify edits the first occurrence.
18
+ // Reject that ambiguity at any depth rather than syncing a value the agent will not read.
19
+ const checkDuplicates = (node) => {
20
+ const names = node.type === "object" ? new Set() : undefined;
21
+ for (const child of node.children ?? []) {
22
+ if (names) {
23
+ const name = child.children[0].value;
24
+ if (names.has(name)) {
25
+ throw new Error(`${file}: invalid JSONC (duplicate object property at offset ${child.offset})`);
26
+ }
27
+ names.add(name);
28
+ }
29
+ checkDuplicates(child);
30
+ }
31
+ };
32
+ checkDuplicates(root);
33
+ return getNodeValue(root);
34
+ }
35
+ /** Only missing files initialize to an empty object; unreadable or invalid files must not be replaced. */
36
+ export function readJsoncObject(file) {
37
+ let text;
38
+ try {
39
+ text = fs.readFileSync(file, "utf8");
40
+ }
41
+ catch (error) {
42
+ if (error.code === "ENOENT")
43
+ return { file, text: "{}\n", value: {} };
44
+ throw error;
45
+ }
46
+ return { file, text, value: parseJsoncObject(file, text) };
47
+ }
48
+ function comments(text) {
49
+ const result = [];
50
+ const scanner = createScanner(text);
51
+ for (let token = scanner.scan(); token !== 17 /* SyntaxKind.EOF */; token = scanner.scan()) {
52
+ if (token === 12 /* SyntaxKind.LineCommentTrivia */ || token === 13 /* SyntaxKind.BlockCommentTrivia */) {
53
+ result.push(text.slice(scanner.getTokenOffset(), scanner.getTokenOffset() + scanner.getTokenLength()));
54
+ }
55
+ }
56
+ return result;
57
+ }
58
+ /** modify() may include adjacent comments in deletion ranges; retain them as detached comments. */
59
+ function retainComments(text, edit, eol) {
60
+ const remaining = new Map();
61
+ for (const comment of comments(edit.content))
62
+ remaining.set(comment, (remaining.get(comment) ?? 0) + 1);
63
+ const removed = [];
64
+ for (const comment of comments(text.slice(edit.offset, edit.offset + edit.length))) {
65
+ const count = remaining.get(comment) ?? 0;
66
+ if (count)
67
+ remaining.set(comment, count - 1);
68
+ else
69
+ removed.push(comment);
70
+ }
71
+ return removed.length ? { ...edit, content: eol + removed.join(eol) + eol + edit.content } : edit;
72
+ }
73
+ /** Change only differing values, leaving unrelated fields, comments, and string literals intact. */
74
+ export function editJsoncObject(document, value) {
75
+ const bom = document.text.startsWith("\uFEFF") ? "\uFEFF" : "";
76
+ let text = bom ? document.text.slice(1) : document.text;
77
+ const eol = text.includes("\r\n") ? "\r\n" : "\n";
78
+ const update = (location, previous, next) => {
79
+ if (previous === next)
80
+ return;
81
+ if (isJsonObject(previous) && isJsonObject(next)) {
82
+ const keys = Object.keys(next);
83
+ if (keys.length || !Object.keys(previous).length) {
84
+ // Add before deleting, so a trailing-comma object never temporarily loses its last property.
85
+ for (const key of keys)
86
+ update([...location, key], previous[key], next[key]);
87
+ for (const key of Object.keys(previous)) {
88
+ if (!Object.hasOwn(next, key))
89
+ update([...location, key], previous[key], undefined);
90
+ }
91
+ return;
92
+ }
93
+ // Replacing an emptying object also removes its trailing comma (modify's last-property
94
+ // deletion otherwise leaves `{,}`). retainComments keeps comments from the removed subtree.
95
+ }
96
+ // Replace shrinking arrays whole: modify's last-element deletion can truncate a compact
97
+ // value incorrectly. Equal-length/growing arrays still get incremental, comment-safe edits.
98
+ if (Array.isArray(previous) && Array.isArray(next) && next.length >= previous.length) {
99
+ for (let index = 0; index < next.length; index++) {
100
+ update([...location, index], previous[index], next[index]);
101
+ }
102
+ return;
103
+ }
104
+ const edits = modify(text, location, next, { formattingOptions: { insertSpaces: true, tabSize: 2, eol } });
105
+ text = applyEdits(text, edits.map((edit) => retainComments(text, edit, eol)));
106
+ };
107
+ update([], document.value, value);
108
+ // Never let a library edit boundary or comment relocation write malformed output.
109
+ parseJsoncObject(document.file, text);
110
+ return bom + text;
111
+ }
@@ -2,7 +2,7 @@ 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 { apiValue, classifyApi, mergeModels, stripApiVersion } from "./wire.js";
5
+ import { apiValue, classifyApi, mergeModels, sdkBaseUrl } from "./wire.js";
6
6
  /** `$DSH_HOME`, or `~/.dsh` (packages/util/home-paths `resolveDshHome`). */
7
7
  function dshHome() {
8
8
  const env = process.env.DSH_HOME?.trim();
@@ -91,7 +91,7 @@ export const dsh = {
91
91
  displayName: provider.name,
92
92
  apiKeyEnv: ref, // credential reference, never the secret
93
93
  api: apiValue(provider.protocol, provider.openaiApi, prev?.api),
94
- baseURL: stripApiVersion(provider.baseUrl),
94
+ baseURL: sdkBaseUrl(provider.protocol, provider.baseUrl),
95
95
  models: mergeModels(prev?.models, provider.models.map(modelEntry), OWNED_MODEL_KEYS),
96
96
  };
97
97
  if (YAML.isMap(doc.getIn(at))) {
@@ -2,7 +2,7 @@ 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 { stripApiVersion } from "./wire.js";
5
+ import { sdkBaseUrl } from "./wire.js";
6
6
  function hermesHome() {
7
7
  const env = process.env.HERMES_HOME?.trim();
8
8
  if (env)
@@ -47,9 +47,13 @@ export const hermes = {
47
47
  }
48
48
  const entry = {
49
49
  name: provider.name,
50
- api: stripApiVersion(provider.baseUrl),
50
+ api: sdkBaseUrl(provider.protocol, provider.baseUrl),
51
51
  key_env: keyVar,
52
- transport: provider.protocol === "anthropic" ? "anthropic_messages" : "chat_completions",
52
+ transport: provider.protocol === "anthropic"
53
+ ? "anthropic_messages"
54
+ : provider.openaiApi === "responses"
55
+ ? "codex_responses"
56
+ : "chat_completions",
53
57
  default_model: provider.defaultModel,
54
58
  models,
55
59
  };
@@ -158,9 +162,14 @@ export const hermes = {
158
162
  return Object.entries(parsed.providers).flatMap(([id, entry]) => {
159
163
  if (!entry || typeof entry.api !== "string" || !entry.api)
160
164
  return [];
161
- const protocol = entry.transport === "anthropic_messages" ? "anthropic" : entry.transport === "chat_completions" ? "openai" : undefined;
165
+ const protocol = entry.transport === "anthropic_messages"
166
+ ? "anthropic"
167
+ : ["chat_completions", "codex_responses"].includes(entry.transport ?? "")
168
+ ? "openai"
169
+ : undefined;
162
170
  if (!protocol)
163
171
  return [];
172
+ const openaiApi = protocol === "openai" ? (entry.transport === "codex_responses" ? "responses" : "completions") : undefined;
164
173
  const keyEnv = typeof entry.key_env === "string" && entry.key_env ? entry.key_env : undefined;
165
174
  const apiKey = keyEnv ? readEnv(keyEnv) : undefined;
166
175
  const models = Object.keys(entry.models ?? {});
@@ -168,7 +177,7 @@ export const hermes = {
168
177
  const defaultModel = entry.default_model ?? active;
169
178
  if (defaultModel && !models.includes(defaultModel))
170
179
  models.unshift(defaultModel);
171
- return [{ id, name: entry.name ?? id, protocol, baseUrl: entry.api, apiKey, keyEnv, models, defaultModel, source: self }];
180
+ return [{ id, name: entry.name ?? id, protocol, openaiApi, baseUrl: entry.api, apiKey, keyEnv, models, defaultModel, source: self }];
172
181
  });
173
182
  },
174
183
  };
@@ -3,13 +3,57 @@ 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 { stripApiVersion } from "./wire.js";
7
- import { apiValue, classifyApi, entryApi, mergeModels, stripConflictingOverrides } from "./wire.js";
6
+ import { apiValue, classifyApi, entryApi, mergeModels, sdkBaseUrl, stripConflictingOverrides } from "./wire.js";
8
7
  /** Per-model keys this adapter writes; one that stops being emitted is cleared, not inherited. */
9
8
  const OWNED_MODEL_KEYS = ["id", "name", "reasoning", "input", "contextWindow", "maxTokens", "cost"];
10
9
  const agentDir = path.join(home, ".omp", "agent");
11
10
  const modelsYml = path.join(agentDir, "models.yml");
12
11
  const modelsYaml = path.join(agentDir, "models.yaml");
12
+ /** Resolve references before edits can remove their anchors or mutate shared providers. */
13
+ function parseModelsDocument(file, text) {
14
+ try {
15
+ const doc = text ? YAML.parseDocument(text) : new YAML.Document({});
16
+ if (doc.errors.length)
17
+ throw doc.errors[0];
18
+ if (doc.contents == null)
19
+ doc.contents = doc.createNode({});
20
+ // Validate references with the full document context and the default alias limit.
21
+ // Cycles are valid YAML, but not a usable model configuration; reject before expansion.
22
+ JSON.stringify(doc.toJS());
23
+ const expanded = new Map();
24
+ YAML.visit(doc, {
25
+ Alias(_key, alias) {
26
+ const node = doc.createNode(alias.toJS(doc), { aliasDuplicateObjects: false });
27
+ node.comment = alias.comment;
28
+ node.commentBefore = alias.commentBefore;
29
+ node.spaceBefore = alias.spaceBefore;
30
+ expanded.set(alias, node);
31
+ },
32
+ });
33
+ // Resolve all references before replacing any nodes (anchors may be reused).
34
+ YAML.visit(doc, { Alias: (_key, alias) => expanded.get(alias) });
35
+ if (!YAML.isMap(doc.contents))
36
+ throw new Error("expected a configuration mapping");
37
+ if (doc.hasIn(["providers"]) && !YAML.isMap(doc.getIn(["providers"]))) {
38
+ throw new Error("expected providers to be a mapping");
39
+ }
40
+ return doc;
41
+ }
42
+ catch (error) {
43
+ throw new Error(`${file} has invalid YAML: ${error instanceof Error ? error.message : String(error)}`);
44
+ }
45
+ }
46
+ /** Validate the emitted document before any backup or write. */
47
+ function serializeModelsDocument(file, doc) {
48
+ try {
49
+ const text = doc.toString();
50
+ YAML.parse(text);
51
+ return text;
52
+ }
53
+ catch (error) {
54
+ throw new Error(`${file} has invalid YAML: ${error instanceof Error ? error.message : String(error)}`);
55
+ }
56
+ }
13
57
  /**
14
58
  * Oh My Pi: providers live in ~/.omp/agent/models.yml (models.yaml fallback).
15
59
  * YAML Document round-trip preserves existing comments and unrelated providers.
@@ -25,15 +69,11 @@ export const omp = {
25
69
  // Respect an existing models.yaml if models.yml is absent (omp precedence: yml then yaml).
26
70
  const file = !fs.existsSync(modelsYml) && fs.existsSync(modelsYaml) ? modelsYaml : modelsYml;
27
71
  const text = readTextIfExists(file);
28
- const doc = text ? YAML.parseDocument(text) : new YAML.Document({});
29
- if (doc.errors.length) {
30
- throw new Error(`${file} has YAML errors; refusing to rewrite: ${doc.errors[0]?.message}`);
31
- }
32
- if (doc.contents == null)
33
- doc.contents = doc.createNode({});
34
- const prev = doc.getIn(["providers", provider.id])?.toJSON?.();
72
+ const doc = parseModelsDocument(file, text);
73
+ const prev = doc.getIn(["providers", provider.id])?.toJS(doc);
35
74
  const anthropic = provider.protocol === "anthropic";
36
75
  const api = apiValue(provider.protocol, provider.openaiApi, entryApi(prev ?? {}));
76
+ const baseUrl = sdkBaseUrl(provider.protocol, provider.baseUrl);
37
77
  const models = mergeModels(prev?.models, provider.models.map((m) => ({
38
78
  id: m.id,
39
79
  ...(m.name ? { name: m.name } : {}),
@@ -52,11 +92,11 @@ export const omp = {
52
92
  }
53
93
  : {}),
54
94
  })), OWNED_MODEL_KEYS);
55
- const conflicts = stripConflictingOverrides(models, api, provider.baseUrl);
95
+ const conflicts = stripConflictingOverrides(models, api, baseUrl);
56
96
  if (conflicts.length)
57
97
  notes.push(`dropped model overrides pointing elsewhere: ${conflicts.join(", ")}`);
58
98
  const entry = {
59
- baseUrl: stripApiVersion(provider.baseUrl),
99
+ baseUrl,
60
100
  apiKey: provider.apiKey, // omp treats value as env-var name first, then literal
61
101
  api,
62
102
  models,
@@ -79,10 +119,11 @@ export const omp = {
79
119
  else {
80
120
  doc.setIn(at, doc.createNode(entry));
81
121
  }
122
+ const output = serializeModelsDocument(file, doc);
82
123
  const backup = backupFile(file);
83
124
  if (backup)
84
125
  notes.push(`backup: ${backup}`);
85
- writeFileAtomic(file, doc.toString());
126
+ writeFileAtomic(file, output);
86
127
  notes.push(`select in omp with: omp --model ${provider.id}/${provider.defaultModel}`);
87
128
  return { app: this.id, changed: [file], notes };
88
129
  },
@@ -90,19 +131,17 @@ export const omp = {
90
131
  const file = fs.existsSync(modelsYml) ? modelsYml : fs.existsSync(modelsYaml) ? modelsYaml : undefined;
91
132
  if (!file)
92
133
  return { app: this.id, changed: [], notes: [], skipped: "no models.yml" };
93
- const doc = YAML.parseDocument(readTextIfExists(file) ?? "");
94
- if (doc.errors.length) {
95
- throw new Error(`${file} has YAML errors; refusing to rewrite: ${doc.errors[0]?.message}`);
96
- }
134
+ const doc = parseModelsDocument(file, readTextIfExists(file));
97
135
  if (!doc.hasIn(["providers", provider.id])) {
98
136
  return { app: this.id, changed: [], notes: [], skipped: `no providers.${provider.id} entry` };
99
137
  }
100
138
  doc.deleteIn(["providers", provider.id]);
101
139
  const notes = [];
140
+ const output = serializeModelsDocument(file, doc);
102
141
  const backup = backupFile(file);
103
142
  if (backup)
104
143
  notes.push(`backup: ${backup}`);
105
- writeFileAtomic(file, doc.toString());
144
+ writeFileAtomic(file, output);
106
145
  return { app: this.id, changed: [file], notes };
107
146
  },
108
147
  current() {
@@ -1,9 +1,10 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
- import { appDataDir, backupFile, readJsonIfExists, writeFileAtomic } from "../fsutil.js";
4
- import { stripApiVersion } from "./wire.js";
5
- const configDir = process.env.OPENCODE_CONFIG_DIR?.trim() || appDataDir("opencode");
6
- const configFile = path.join(configDir, "opencode.json");
3
+ import { appDataDir, backupFile, expandHome, readJsonIfExists, writeFileAtomic } from "../fsutil.js";
4
+ const customDir = process.env.OPENCODE_CONFIG_DIR?.trim();
5
+ const configFile = customDir
6
+ ? path.join(expandHome(customDir), "opencode.json")
7
+ : path.join(appDataDir("opencode"), "opencode.json");
7
8
  /**
8
9
  * opencode: custom providers in ~/.config/opencode/opencode.json "provider" map,
9
10
  * backed by @ai-sdk/openai-compatible or @ai-sdk/anthropic npm loaders.
@@ -48,11 +49,11 @@ export const opencode = {
48
49
  },
49
50
  }
50
51
  : {}),
51
- ...(m.contextWindow || m.maxOutput
52
+ ...(m.contextWindow && m.maxOutput
52
53
  ? {
53
54
  limit: {
54
- ...(m.contextWindow ? { context: m.contextWindow } : {}),
55
- ...(m.maxOutput ? { output: m.maxOutput } : {}),
55
+ context: m.contextWindow,
56
+ output: m.maxOutput,
56
57
  },
57
58
  }
58
59
  : {}),
@@ -60,11 +61,11 @@ export const opencode = {
60
61
  }
61
62
  providerMap[provider.id] = {
62
63
  ...prev,
63
- npm: anthropic ? "@ai-sdk/anthropic" : "@ai-sdk/openai-compatible",
64
+ npm: anthropic ? "@ai-sdk/anthropic" : provider.openaiApi === "responses" ? "@ai-sdk/openai" : "@ai-sdk/openai-compatible",
64
65
  name: provider.name,
65
66
  options: {
66
67
  ...prev.options,
67
- baseURL: stripApiVersion(provider.baseUrl),
68
+ baseURL: provider.baseUrl.replace(/\/+$/, ""),
68
69
  apiKey: provider.apiKey,
69
70
  },
70
71
  models,
@@ -120,6 +121,7 @@ export const opencode = {
120
121
  if (!entry.npm)
121
122
  return []; // built-in provider without a custom npm loader
122
123
  const protocol = entry.npm.includes("anthropic") ? "anthropic" : "openai";
124
+ const openaiApi = protocol === "openai" ? (entry.npm === "@ai-sdk/openai" ? "responses" : "completions") : undefined;
123
125
  let apiKey = entry.options?.apiKey;
124
126
  let keyEnv;
125
127
  const ref = apiKey?.match(/^\{env:([A-Za-z0-9_]+)\}$/);
@@ -131,7 +133,7 @@ export const opencode = {
131
133
  const defaultModel = activeProvider === id ? activeModel : undefined;
132
134
  if (defaultModel && !models.includes(defaultModel))
133
135
  models.unshift(defaultModel);
134
- return [{ id, name: entry.name ?? id, protocol, baseUrl, apiKey, keyEnv, models, defaultModel, source: self }];
136
+ return [{ id, name: entry.name ?? id, protocol, openaiApi, baseUrl, apiKey, keyEnv, models, defaultModel, source: self }];
135
137
  });
136
138
  },
137
139
  };
@@ -1,9 +1,9 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
- import { backupFile, home, readJsonIfExists, writeFileAtomic } from "../fsutil.js";
3
+ import { backupFile, home, writeFileAtomic } from "../fsutil.js";
4
+ import { editJsoncObject, isJsonObject, readJsoncObject } from "../jsonc.js";
4
5
  import { looksLikeEnvName } from "../slug.js";
5
- import { stripApiVersion } from "./wire.js";
6
- import { apiValue, classifyApi, entryApi, mergeModels, stripConflictingOverrides } from "./wire.js";
6
+ import { apiValue, classifyApi, entryApi, mergeModels, sdkBaseUrl, stripConflictingOverrides } from "./wire.js";
7
7
  /** Per-model keys this adapter writes; one that stops being emitted is cleared, not inherited. */
8
8
  const OWNED_MODEL_KEYS = [
9
9
  "id",
@@ -15,6 +15,28 @@ const OWNED_MODEL_KEYS = [
15
15
  "maxTokens",
16
16
  "cost",
17
17
  ];
18
+ function readModels(file) {
19
+ const document = readJsoncObject(file);
20
+ const providers = document.value.providers;
21
+ if (providers !== undefined) {
22
+ if (!isJsonObject(providers))
23
+ throw new Error(`${file}: expected providers to be a JSON object`);
24
+ for (const entry of Object.values(providers)) {
25
+ if (!isJsonObject(entry))
26
+ throw new Error(`${file}: expected every provider entry to be a JSON object`);
27
+ }
28
+ }
29
+ return { ...document, value: document.value };
30
+ }
31
+ function readSettings(file) {
32
+ const document = readJsoncObject(file);
33
+ for (const key of ["defaultProvider", "defaultModel"]) {
34
+ if (document.value[key] !== undefined && typeof document.value[key] !== "string") {
35
+ throw new Error(`${file}: expected ${key} to be a string`);
36
+ }
37
+ }
38
+ return { ...document, value: document.value };
39
+ }
18
40
  /**
19
41
  * pi-family agents (pi, prime-agent) share the same config layout:
20
42
  * <dir>/models.json — { providers: { <id>: { baseUrl, apiKey, api, models[] } } }
@@ -51,15 +73,16 @@ export function piStyleTarget(opts) {
51
73
  const modelsFile = path.join(dir, "models.json");
52
74
  const settingsFile = path.join(dir, "settings.json");
53
75
  const notes = [];
54
- const modelsConfig = readJsonIfExists(modelsFile) ?? {};
76
+ // Validate both files before preparing any writes, including a malformed settings file.
77
+ const modelsDocument = readModels(modelsFile);
78
+ const settingsDocument = readSettings(settingsFile);
79
+ const modelsConfig = { ...modelsDocument.value };
55
80
  const providers = { ...modelsConfig.providers };
56
81
  // Keys agentsw does not model (headers, authHeader, oauth, ...) and per-model extras
57
82
  // are the user's; a re-sync overwrites only the fields it owns.
58
- const existing = providers[provider.id];
59
- const prev = (existing && typeof existing === "object" && !Array.isArray(existing)
60
- ? existing
61
- : {});
83
+ const prev = providers[provider.id] ?? {};
62
84
  const api = apiValue(provider.protocol, provider.openaiApi, entryApi(prev));
85
+ const baseUrl = sdkBaseUrl(provider.protocol, provider.baseUrl);
63
86
  const models = mergeModels(prev.models, provider.models.map((m) => ({
64
87
  id: m.id,
65
88
  ...(m.name ? { name: m.name } : {}),
@@ -81,72 +104,82 @@ export function piStyleTarget(opts) {
81
104
  }
82
105
  : {}),
83
106
  })), OWNED_MODEL_KEYS);
84
- const conflicts = stripConflictingOverrides(models, api, provider.baseUrl);
107
+ const conflicts = stripConflictingOverrides(models, api, baseUrl);
85
108
  if (conflicts.length)
86
109
  notes.push(`dropped model overrides pointing elsewhere: ${conflicts.join(", ")}`);
87
110
  providers[provider.id] = {
88
111
  ...prev,
89
112
  name: provider.name,
90
- baseUrl: stripApiVersion(provider.baseUrl),
113
+ baseUrl,
91
114
  apiKey: provider.apiKey, // literal key; pi treats "$VAR"/"!cmd" as indirection, prime also accepts bare env names
92
115
  api,
93
116
  models,
94
117
  };
95
118
  modelsConfig.providers = providers;
119
+ const settings = {
120
+ ...settingsDocument.value,
121
+ defaultProvider: provider.id,
122
+ defaultModel: provider.defaultModel,
123
+ };
124
+ const modelsText = editJsoncObject(modelsDocument, modelsConfig);
125
+ const settingsText = editJsoncObject(settingsDocument, settings);
96
126
  const modelsBackup = backupFile(modelsFile);
97
127
  if (modelsBackup)
98
128
  notes.push(`backup: ${modelsBackup}`);
99
- writeFileAtomic(modelsFile, JSON.stringify(modelsConfig, null, 2) + "\n");
100
- const settings = readJsonIfExists(settingsFile) ?? {};
101
- settings.defaultProvider = provider.id;
102
- settings.defaultModel = provider.defaultModel;
129
+ writeFileAtomic(modelsFile, modelsText);
103
130
  const settingsBackup = backupFile(settingsFile);
104
131
  if (settingsBackup)
105
132
  notes.push(`backup: ${settingsBackup}`);
106
- writeFileAtomic(settingsFile, JSON.stringify(settings, null, 2) + "\n");
133
+ writeFileAtomic(settingsFile, settingsText);
107
134
  return { app: opts.id, changed: [modelsFile, settingsFile], notes };
108
135
  },
109
136
  async prune(provider) {
110
137
  const dir = resolveDir();
111
138
  const modelsFile = path.join(dir, "models.json");
112
139
  const settingsFile = path.join(dir, "settings.json");
113
- const modelsConfig = readJsonIfExists(modelsFile);
114
- const providers = modelsConfig?.providers;
115
- if (!modelsConfig || !providers?.[provider.id]) {
140
+ const modelsDocument = readModels(modelsFile);
141
+ const settingsDocument = readSettings(settingsFile);
142
+ const providers = { ...modelsDocument.value.providers };
143
+ if (!Object.hasOwn(providers, provider.id)) {
116
144
  return { app: opts.id, changed: [], notes: [], skipped: `no providers.${provider.id} entry` };
117
145
  }
118
146
  delete providers[provider.id];
147
+ const settings = { ...settingsDocument.value };
148
+ const resetDefault = settings.defaultProvider === provider.id;
149
+ if (resetDefault) {
150
+ delete settings.defaultProvider;
151
+ delete settings.defaultModel;
152
+ }
153
+ const modelsText = editJsoncObject(modelsDocument, { ...modelsDocument.value, providers });
154
+ const settingsText = resetDefault ? editJsoncObject(settingsDocument, settings) : undefined;
119
155
  const notes = [];
120
156
  const changed = [modelsFile];
121
157
  const modelsBackup = backupFile(modelsFile);
122
158
  if (modelsBackup)
123
159
  notes.push(`backup: ${modelsBackup}`);
124
- writeFileAtomic(modelsFile, JSON.stringify(modelsConfig, null, 2) + "\n");
125
- const settings = readJsonIfExists(settingsFile);
126
- if (settings?.defaultProvider === provider.id) {
127
- delete settings.defaultProvider;
128
- delete settings.defaultModel;
160
+ writeFileAtomic(modelsFile, modelsText);
161
+ if (settingsText !== undefined) {
129
162
  const settingsBackup = backupFile(settingsFile);
130
163
  if (settingsBackup)
131
164
  notes.push(`backup: ${settingsBackup}`);
132
- writeFileAtomic(settingsFile, JSON.stringify(settings, null, 2) + "\n");
165
+ writeFileAtomic(settingsFile, settingsText);
133
166
  changed.push(settingsFile);
134
167
  notes.push("default model reset (was pointing at this provider)");
135
168
  }
136
169
  return { app: opts.id, changed, notes };
137
170
  },
138
171
  current() {
139
- const settings = readJsonIfExists(path.join(resolveDir(), "settings.json"));
172
+ const settings = readSettings(path.join(resolveDir(), "settings.json")).value;
140
173
  if (!settings?.defaultProvider)
141
174
  return undefined;
142
175
  return `${settings.defaultProvider} · ${settings.defaultModel ?? "?"}`;
143
176
  },
144
177
  candidates() {
145
178
  const dir = resolveDir();
146
- const mc = readJsonIfExists(path.join(dir, "models.json"));
147
- if (!mc?.providers)
179
+ const mc = readModels(path.join(dir, "models.json")).value;
180
+ const settings = readSettings(path.join(dir, "settings.json")).value;
181
+ if (!mc.providers)
148
182
  return [];
149
- const settings = readJsonIfExists(path.join(dir, "settings.json"));
150
183
  const self = opts.id;
151
184
  return Object.entries(mc.providers).flatMap(([id, entry]) => {
152
185
  if (!entry || typeof entry.baseUrl !== "string")
@@ -108,11 +108,14 @@ export function stripConflictingOverrides(models, api, baseUrl) {
108
108
  return dropped;
109
109
  }
110
110
  /**
111
- * Strip a trailing API version segment (e.g. `/v1`, `/v2beta`) from a base URL.
112
- * Use this when writing to apps whose own SDK appends `/v1/...` to the base URL
113
- * (Anthropic SDK, OpenAI SDK, omp, pi, hermes, dsh). Codex is the exception —
114
- * it appends `/responses` directly, so it keeps the `/v1` segment.
111
+ * Return the base URL expected by SDK-backed clients. Anthropic SDK methods
112
+ * append `/v1/...`, while OpenAI-compatible clients append only the operation
113
+ * path (`/responses` or `/chat/completions`) and therefore keep `/v1`.
115
114
  */
115
+ export function sdkBaseUrl(protocol, baseUrl) {
116
+ return protocol === "anthropic" ? stripApiVersion(baseUrl) : baseUrl.replace(/\/+$/, "");
117
+ }
118
+ /** Strip a trailing API version segment (e.g. `/v1`, `/v2beta`). */
116
119
  export function stripApiVersion(baseUrl) {
117
120
  return baseUrl.replace(/\/v\d+(?:beta\d*)?\/?$/i, "");
118
121
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentsw",
3
- "version": "0.6.0",
3
+ "version": "0.6.2",
4
4
  "description": "CLI provider switcher for coding agents: manage OpenAI/Anthropic-protocol providers in one place and sync them, with models.dev metadata, into Claude Code, Codex, omp, pi, prime-agent, opencode, Hermes, WorkBuddy and DeepSeek Harness",
5
5
  "type": "module",
6
6
  "repository": {
@@ -41,6 +41,7 @@
41
41
  },
42
42
  "dependencies": {
43
43
  "commander": "^15.0.0",
44
+ "jsonc-parser": "^3.3.1",
44
45
  "picocolors": "^1.1.1",
45
46
  "prompts": "^2.4.2",
46
47
  "smol-toml": "^1.8.0",