agentsw 0.6.1 → 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
+ }
@@ -9,6 +9,51 @@ const OWNED_MODEL_KEYS = ["id", "name", "reasoning", "input", "contextWindow", "
9
9
  const agentDir = path.join(home, ".omp", "agent");
10
10
  const modelsYml = path.join(agentDir, "models.yml");
11
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
+ }
12
57
  /**
13
58
  * Oh My Pi: providers live in ~/.omp/agent/models.yml (models.yaml fallback).
14
59
  * YAML Document round-trip preserves existing comments and unrelated providers.
@@ -24,13 +69,8 @@ export const omp = {
24
69
  // Respect an existing models.yaml if models.yml is absent (omp precedence: yml then yaml).
25
70
  const file = !fs.existsSync(modelsYml) && fs.existsSync(modelsYaml) ? modelsYaml : modelsYml;
26
71
  const text = readTextIfExists(file);
27
- const doc = text ? YAML.parseDocument(text) : new YAML.Document({});
28
- if (doc.errors.length) {
29
- throw new Error(`${file} has YAML errors; refusing to rewrite: ${doc.errors[0]?.message}`);
30
- }
31
- if (doc.contents == null)
32
- doc.contents = doc.createNode({});
33
- const prev = doc.getIn(["providers", provider.id])?.toJSON?.();
72
+ const doc = parseModelsDocument(file, text);
73
+ const prev = doc.getIn(["providers", provider.id])?.toJS(doc);
34
74
  const anthropic = provider.protocol === "anthropic";
35
75
  const api = apiValue(provider.protocol, provider.openaiApi, entryApi(prev ?? {}));
36
76
  const baseUrl = sdkBaseUrl(provider.protocol, provider.baseUrl);
@@ -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,6 +1,7 @@
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
6
  import { apiValue, classifyApi, entryApi, mergeModels, sdkBaseUrl, stripConflictingOverrides } from "./wire.js";
6
7
  /** Per-model keys this adapter writes; one that stops being emitted is cleared, not inherited. */
@@ -14,6 +15,28 @@ const OWNED_MODEL_KEYS = [
14
15
  "maxTokens",
15
16
  "cost",
16
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
+ }
17
40
  /**
18
41
  * pi-family agents (pi, prime-agent) share the same config layout:
19
42
  * <dir>/models.json — { providers: { <id>: { baseUrl, apiKey, api, models[] } } }
@@ -50,14 +73,14 @@ export function piStyleTarget(opts) {
50
73
  const modelsFile = path.join(dir, "models.json");
51
74
  const settingsFile = path.join(dir, "settings.json");
52
75
  const notes = [];
53
- 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 };
54
80
  const providers = { ...modelsConfig.providers };
55
81
  // Keys agentsw does not model (headers, authHeader, oauth, ...) and per-model extras
56
82
  // are the user's; a re-sync overwrites only the fields it owns.
57
- const existing = providers[provider.id];
58
- const prev = (existing && typeof existing === "object" && !Array.isArray(existing)
59
- ? existing
60
- : {});
83
+ const prev = providers[provider.id] ?? {};
61
84
  const api = apiValue(provider.protocol, provider.openaiApi, entryApi(prev));
62
85
  const baseUrl = sdkBaseUrl(provider.protocol, provider.baseUrl);
63
86
  const models = mergeModels(prev.models, provider.models.map((m) => ({
@@ -93,60 +116,70 @@ export function piStyleTarget(opts) {
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")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentsw",
3
- "version": "0.6.1",
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",