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 +111 -0
- package/dist/targets/omp.js +52 -13
- package/dist/targets/pistyle.js +57 -24
- package/package.json +2 -1
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
|
+
}
|
package/dist/targets/omp.js
CHANGED
|
@@ -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 =
|
|
28
|
-
|
|
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,
|
|
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 =
|
|
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,
|
|
144
|
+
writeFileAtomic(file, output);
|
|
106
145
|
return { app: this.id, changed: [file], notes };
|
|
107
146
|
},
|
|
108
147
|
current() {
|
package/dist/targets/pistyle.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import { backupFile, home,
|
|
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
|
-
|
|
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
|
|
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,
|
|
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,
|
|
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
|
|
114
|
-
const
|
|
115
|
-
|
|
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,
|
|
125
|
-
|
|
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,
|
|
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 =
|
|
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 =
|
|
147
|
-
|
|
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.
|
|
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",
|