agentsw 0.3.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/LICENSE +21 -0
- package/README.md +227 -0
- package/README.zh-CN.md +221 -0
- package/dist/apps.js +178 -0
- package/dist/commands.js +661 -0
- package/dist/discover.js +21 -0
- package/dist/filter.js +61 -0
- package/dist/fsutil.js +56 -0
- package/dist/i18n.js +214 -0
- package/dist/import.js +66 -0
- package/dist/index.js +113 -0
- package/dist/menu.js +157 -0
- package/dist/modelsdev.js +110 -0
- package/dist/slug.js +18 -0
- package/dist/sources/ccswitch.js +151 -0
- package/dist/store.js +23 -0
- package/dist/targets/claudecode.js +96 -0
- package/dist/targets/codex.js +136 -0
- package/dist/targets/dsh.js +296 -0
- package/dist/targets/hermes.js +173 -0
- package/dist/targets/index.js +26 -0
- package/dist/targets/omp.js +170 -0
- package/dist/targets/opencode.js +135 -0
- package/dist/targets/pistyle.js +208 -0
- package/dist/targets/types.js +1 -0
- package/dist/targets/wire.js +109 -0
- package/dist/targets/workbuddy.js +143 -0
- package/dist/types.js +1 -0
- package/package.json +55 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
|
|
4
|
+
import { backupFile, home, readJsonIfExists, readTextIfExists, writeFileAtomic } from "../fsutil.js";
|
|
5
|
+
const dir = path.join(home, ".codex");
|
|
6
|
+
const configFile = path.join(dir, "config.toml");
|
|
7
|
+
const authFile = path.join(dir, "auth.json");
|
|
8
|
+
/**
|
|
9
|
+
* Codex CLI: [model_providers.<id>] in ~/.codex/config.toml selects a custom
|
|
10
|
+
* OpenAI-protocol endpoint; the key ships via ~/.codex/auth.json OPENAI_API_KEY.
|
|
11
|
+
* TOML round-trip via smol-toml preserves data but not comments (backed up first).
|
|
12
|
+
*/
|
|
13
|
+
export const codex = {
|
|
14
|
+
id: "codex",
|
|
15
|
+
name: "Codex CLI",
|
|
16
|
+
protocols: ["openai"],
|
|
17
|
+
configPaths: [configFile, authFile],
|
|
18
|
+
detect: () => fs.existsSync(dir),
|
|
19
|
+
async apply(provider) {
|
|
20
|
+
const notes = [];
|
|
21
|
+
const text = readTextIfExists(configFile);
|
|
22
|
+
const config = (text ? parseToml(text) : {});
|
|
23
|
+
config.model_provider = provider.id;
|
|
24
|
+
config.model = provider.defaultModel;
|
|
25
|
+
const model = provider.models.find((m) => m.id === provider.defaultModel);
|
|
26
|
+
if (provider.reasoningEffort && model?.reasoning !== false) {
|
|
27
|
+
config.model_reasoning_effort = provider.reasoningEffort;
|
|
28
|
+
}
|
|
29
|
+
// Codex only speaks the OpenAI Responses API (wire_api = "chat" was removed Feb 2026).
|
|
30
|
+
// requires_openai_auth = true makes Codex take the key from auth.json instead of an env var.
|
|
31
|
+
const providers = (config.model_providers ?? {});
|
|
32
|
+
providers[provider.id] = {
|
|
33
|
+
name: provider.name,
|
|
34
|
+
base_url: provider.baseUrl.replace(/\/$/, ""),
|
|
35
|
+
wire_api: "responses",
|
|
36
|
+
requires_openai_auth: true,
|
|
37
|
+
};
|
|
38
|
+
notes.push("codex requires an OpenAI Responses-compatible endpoint (/v1/responses); chat-completions-only endpoints will not work");
|
|
39
|
+
config.model_providers = providers;
|
|
40
|
+
const configBackup = backupFile(configFile);
|
|
41
|
+
if (configBackup)
|
|
42
|
+
notes.push(`backup: ${configBackup}`);
|
|
43
|
+
if (text?.includes("#"))
|
|
44
|
+
notes.push("config.toml comments are not preserved by TOML round-trip");
|
|
45
|
+
writeFileAtomic(configFile, stringifyToml(config) + "\n");
|
|
46
|
+
const auth = readJsonIfExists(authFile) ?? {};
|
|
47
|
+
auth.auth_mode = "apikey";
|
|
48
|
+
auth.OPENAI_API_KEY = provider.apiKey;
|
|
49
|
+
const authBackup = backupFile(authFile);
|
|
50
|
+
if (authBackup)
|
|
51
|
+
notes.push(`backup: ${authBackup}`);
|
|
52
|
+
writeFileAtomic(authFile, JSON.stringify(auth, null, 2) + "\n", 0o600);
|
|
53
|
+
return { app: this.id, changed: [configFile, authFile], notes };
|
|
54
|
+
},
|
|
55
|
+
async prune(provider) {
|
|
56
|
+
const text = readTextIfExists(configFile);
|
|
57
|
+
if (!text)
|
|
58
|
+
return { app: this.id, changed: [], notes: [], skipped: "no config.toml" };
|
|
59
|
+
const config = parseToml(text);
|
|
60
|
+
const providers = config.model_providers;
|
|
61
|
+
if (!providers?.[provider.id]) {
|
|
62
|
+
return { app: this.id, changed: [], notes: [], skipped: `no model_providers.${provider.id} entry` };
|
|
63
|
+
}
|
|
64
|
+
delete providers[provider.id];
|
|
65
|
+
if (Object.keys(providers).length === 0)
|
|
66
|
+
delete config.model_providers;
|
|
67
|
+
const notes = [];
|
|
68
|
+
if (config.model_provider === provider.id) {
|
|
69
|
+
delete config.model_provider;
|
|
70
|
+
delete config.model;
|
|
71
|
+
delete config.model_reasoning_effort;
|
|
72
|
+
notes.push("codex was pointing at this provider; model selection reset to defaults");
|
|
73
|
+
}
|
|
74
|
+
const backup = backupFile(configFile);
|
|
75
|
+
if (backup)
|
|
76
|
+
notes.push(`backup: ${backup}`);
|
|
77
|
+
writeFileAtomic(configFile, stringifyToml(config) + "\n");
|
|
78
|
+
notes.push("auth.json OPENAI_API_KEY left in place (not attributable to one provider)");
|
|
79
|
+
return { app: this.id, changed: [configFile], notes };
|
|
80
|
+
},
|
|
81
|
+
current() {
|
|
82
|
+
const text = readTextIfExists(configFile);
|
|
83
|
+
if (!text)
|
|
84
|
+
return undefined;
|
|
85
|
+
try {
|
|
86
|
+
const config = parseToml(text);
|
|
87
|
+
if (!config.model_provider)
|
|
88
|
+
return undefined;
|
|
89
|
+
return `${String(config.model_provider)} · ${String(config.model ?? "?")}`;
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
candidates() {
|
|
96
|
+
const text = readTextIfExists(configFile);
|
|
97
|
+
if (!text)
|
|
98
|
+
return [];
|
|
99
|
+
let config;
|
|
100
|
+
try {
|
|
101
|
+
config = parseToml(text);
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
return [];
|
|
105
|
+
}
|
|
106
|
+
const providers = config.model_providers;
|
|
107
|
+
if (!providers)
|
|
108
|
+
return [];
|
|
109
|
+
const auth = readJsonIfExists(authFile);
|
|
110
|
+
const authKey = auth?.OPENAI_API_KEY || auth?.tokens?.access_token;
|
|
111
|
+
const active = typeof config.model_provider === "string" ? config.model_provider : undefined;
|
|
112
|
+
const self = this.id;
|
|
113
|
+
return Object.entries(providers).flatMap(([id, entry]) => {
|
|
114
|
+
if (!entry || typeof entry.base_url !== "string")
|
|
115
|
+
return [];
|
|
116
|
+
const envKey = typeof entry.env_key === "string" ? entry.env_key : undefined;
|
|
117
|
+
const apiKey = (envKey ? process.env[envKey] : undefined) ?? (active === id && entry.requires_openai_auth ? authKey : undefined);
|
|
118
|
+
const models = active === id && typeof config.model === "string" ? [config.model] : [];
|
|
119
|
+
return [
|
|
120
|
+
{
|
|
121
|
+
id,
|
|
122
|
+
name: typeof entry.name === "string" ? entry.name : id,
|
|
123
|
+
protocol: "openai",
|
|
124
|
+
// codex config declares its wire explicitly; legacy configs may still carry "chat"
|
|
125
|
+
openaiApi: entry.wire_api === "chat" ? "completions" : "responses",
|
|
126
|
+
baseUrl: entry.base_url,
|
|
127
|
+
apiKey: apiKey || undefined,
|
|
128
|
+
keyEnv: envKey,
|
|
129
|
+
models,
|
|
130
|
+
defaultModel: models[0],
|
|
131
|
+
source: self,
|
|
132
|
+
},
|
|
133
|
+
];
|
|
134
|
+
});
|
|
135
|
+
},
|
|
136
|
+
};
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import YAML from "yaml";
|
|
4
|
+
import { backupFile, expandHome, home, readTextIfExists, writeFileAtomic } from "../fsutil.js";
|
|
5
|
+
import { apiValue, classifyApi, mergeModels } from "./wire.js";
|
|
6
|
+
/** `$DSH_HOME`, or `~/.dsh` (packages/util/home-paths `resolveDshHome`). */
|
|
7
|
+
function dshHome() {
|
|
8
|
+
const env = process.env.DSH_HOME?.trim();
|
|
9
|
+
return env ? expandHome(env) : path.join(home, ".dsh");
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Settings document: `<harness home>/settings.yaml` by default; the extension
|
|
13
|
+
* picks the format, so an existing `.yml`/`.json` document is written in place.
|
|
14
|
+
*/
|
|
15
|
+
function settingsFile() {
|
|
16
|
+
const dir = dshHome();
|
|
17
|
+
for (const name of ["settings.yaml", "settings.yml", "settings.json"]) {
|
|
18
|
+
const file = path.join(dir, name);
|
|
19
|
+
if (fs.existsSync(file))
|
|
20
|
+
return file;
|
|
21
|
+
}
|
|
22
|
+
return path.join(dir, "settings.yaml");
|
|
23
|
+
}
|
|
24
|
+
const credentialsFile = () => path.join(dshHome(), ".credentials.yaml");
|
|
25
|
+
/** Layout version of `.credentials.yaml` this build reads and writes. */
|
|
26
|
+
const CREDENTIALS_VERSION = 1;
|
|
27
|
+
/** Credential reference: a POSIX identifier (`/^[A-Za-z_][A-Za-z0-9_]*$/`). */
|
|
28
|
+
const credentialRef = (id) => `AGENTSW_${id.toUpperCase().replace(/[^A-Z0-9]/g, "_")}_API_KEY`;
|
|
29
|
+
/** Thinking levels a `reasoningEfforts` entry may offer (llm-pi-ai THINKING_LEVEL_GATE). */
|
|
30
|
+
const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
|
|
31
|
+
/** Per-model keys this adapter writes; one that stops being emitted is cleared, not inherited. */
|
|
32
|
+
const OWNED_MODEL_KEYS = ["id", "name", "contextWindow", "maxTokens", "input", "reasoningEfforts"];
|
|
33
|
+
/** models.dev efforts -> `{ level: wire spelling }`; an unoffered level is omitted, not nulled. */
|
|
34
|
+
function reasoningEfforts(efforts) {
|
|
35
|
+
const map = {};
|
|
36
|
+
if (efforts.includes("none"))
|
|
37
|
+
map.off = "none";
|
|
38
|
+
for (const level of THINKING_LEVELS) {
|
|
39
|
+
if (level !== "off" && efforts.includes(level))
|
|
40
|
+
map[level] = level;
|
|
41
|
+
}
|
|
42
|
+
return map;
|
|
43
|
+
}
|
|
44
|
+
function modelEntry(m) {
|
|
45
|
+
return {
|
|
46
|
+
id: m.id,
|
|
47
|
+
...(m.name ? { name: m.name } : {}),
|
|
48
|
+
...(m.contextWindow ? { contextWindow: m.contextWindow } : {}),
|
|
49
|
+
...(m.maxOutput ? { maxTokens: m.maxOutput } : {}),
|
|
50
|
+
input: m.imageInput ? ["text", "image"] : ["text"],
|
|
51
|
+
...(m.reasoning === false
|
|
52
|
+
? { reasoningEfforts: false }
|
|
53
|
+
: m.reasoning && m.reasoningEfforts?.length
|
|
54
|
+
? { reasoningEfforts: reasoningEfforts(m.reasoningEfforts) }
|
|
55
|
+
: {}),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
function parseSettings(file) {
|
|
59
|
+
const text = readTextIfExists(file);
|
|
60
|
+
const doc = text ? YAML.parseDocument(text) : new YAML.Document({});
|
|
61
|
+
if (doc.errors.length) {
|
|
62
|
+
throw new Error(`${file} has settings errors; refusing to rewrite: ${doc.errors[0]?.message}`);
|
|
63
|
+
}
|
|
64
|
+
if (doc.contents == null)
|
|
65
|
+
doc.contents = doc.createNode({});
|
|
66
|
+
return doc;
|
|
67
|
+
}
|
|
68
|
+
/** JSON documents re-serialize without comments; YAML keeps them (dsh reads both). */
|
|
69
|
+
function renderSettings(file, doc) {
|
|
70
|
+
return file.endsWith(".json") ? JSON.stringify(doc.toJSON(), null, 2) + "\n" : doc.toString();
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* DeepSeek Harness (dsh): provider routes live in the `llm-pi-ai` section of
|
|
74
|
+
* `$DSH_HOME/settings.yaml`, the picked default in `agent-default-model`, and
|
|
75
|
+
* the key in `$DSH_HOME/.credentials.yaml` — settings carry only a reference.
|
|
76
|
+
*/
|
|
77
|
+
export const dsh = {
|
|
78
|
+
id: "dsh",
|
|
79
|
+
name: "DeepSeek Harness",
|
|
80
|
+
protocols: ["openai", "anthropic"],
|
|
81
|
+
configPaths: [settingsFile(), credentialsFile()],
|
|
82
|
+
detect: () => fs.existsSync(dshHome()),
|
|
83
|
+
async apply(provider) {
|
|
84
|
+
const notes = [];
|
|
85
|
+
const file = settingsFile();
|
|
86
|
+
const doc = parseSettings(file);
|
|
87
|
+
const at = ["llm-pi-ai", "providers", provider.id];
|
|
88
|
+
const prev = doc.getIn(at)?.toJSON?.();
|
|
89
|
+
const ref = credentialRef(provider.id);
|
|
90
|
+
const entry = {
|
|
91
|
+
displayName: provider.name,
|
|
92
|
+
apiKeyEnv: ref, // credential reference, never the secret
|
|
93
|
+
api: apiValue(provider.protocol, provider.openaiApi, prev?.api),
|
|
94
|
+
baseURL: provider.baseUrl,
|
|
95
|
+
models: mergeModels(prev?.models, provider.models.map(modelEntry), OWNED_MODEL_KEYS),
|
|
96
|
+
};
|
|
97
|
+
if (YAML.isMap(doc.getIn(at))) {
|
|
98
|
+
// Key-by-key: compat, headers, defaultInput, retryPolicy and their comments are the user's.
|
|
99
|
+
for (const [key, value] of Object.entries(entry))
|
|
100
|
+
doc.setIn([...at, key], doc.createNode(value));
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
doc.setIn(at, doc.createNode(entry));
|
|
104
|
+
}
|
|
105
|
+
if (doc.hasIn([...at, "modelOverrides"])) {
|
|
106
|
+
// llm-pi-ai refuses modelOverrides beside an explicit models list.
|
|
107
|
+
doc.deleteIn([...at, "modelOverrides"]);
|
|
108
|
+
notes.push("dropped modelOverrides (llm-pi-ai refuses it beside an explicit models list)");
|
|
109
|
+
}
|
|
110
|
+
// The picker writes this section whole; a partial write would keep a stale reasoningEffort.
|
|
111
|
+
doc.set("agent-default-model", doc.createNode({ provider: provider.id, model: provider.defaultModel }));
|
|
112
|
+
const backup = backupFile(file);
|
|
113
|
+
if (backup)
|
|
114
|
+
notes.push(`backup: ${backup}`);
|
|
115
|
+
writeFileAtomic(file, renderSettings(file, doc));
|
|
116
|
+
const changed = [file, writeCredential(ref, provider.apiKey, notes)];
|
|
117
|
+
notes.push(`select in dsh with the model picker, or run: dsh web`);
|
|
118
|
+
return { app: this.id, changed, notes };
|
|
119
|
+
},
|
|
120
|
+
async prune(provider) {
|
|
121
|
+
const notes = [];
|
|
122
|
+
// The stored key is removed even when the route is already gone, so a hand-deleted
|
|
123
|
+
// route never leaves its secret behind.
|
|
124
|
+
const droppedCredential = deleteCredential(credentialRef(provider.id), notes);
|
|
125
|
+
const file = settingsFile();
|
|
126
|
+
const doc = fs.existsSync(file) ? parseSettings(file) : undefined;
|
|
127
|
+
const at = ["llm-pi-ai", "providers", provider.id];
|
|
128
|
+
if (!doc?.hasIn(at)) {
|
|
129
|
+
const why = doc ? `no llm-pi-ai.providers.${provider.id} entry` : "no settings document";
|
|
130
|
+
if (!droppedCredential)
|
|
131
|
+
return { app: this.id, changed: [], notes, skipped: why };
|
|
132
|
+
notes.push(why);
|
|
133
|
+
return { app: this.id, changed: [droppedCredential], notes };
|
|
134
|
+
}
|
|
135
|
+
doc.deleteIn(at);
|
|
136
|
+
if (doc.getIn(["agent-default-model", "provider"]) === provider.id) {
|
|
137
|
+
doc.delete("agent-default-model");
|
|
138
|
+
notes.push("default model selection reset (was pointing at this provider)");
|
|
139
|
+
}
|
|
140
|
+
// leave no "providers: {}" / "llm-pi-ai: {}" behind
|
|
141
|
+
for (const empty of [["llm-pi-ai", "providers"], ["llm-pi-ai"]]) {
|
|
142
|
+
const node = doc.getIn(empty);
|
|
143
|
+
if (YAML.isMap(node) && node.items.length === 0)
|
|
144
|
+
doc.deleteIn(empty);
|
|
145
|
+
}
|
|
146
|
+
const backup = backupFile(file);
|
|
147
|
+
if (backup)
|
|
148
|
+
notes.push(`backup: ${backup}`);
|
|
149
|
+
writeFileAtomic(file, renderSettings(file, doc));
|
|
150
|
+
const changed = [file];
|
|
151
|
+
if (droppedCredential)
|
|
152
|
+
changed.push(droppedCredential);
|
|
153
|
+
return { app: this.id, changed, notes };
|
|
154
|
+
},
|
|
155
|
+
current() {
|
|
156
|
+
const text = readTextIfExists(settingsFile());
|
|
157
|
+
if (!text)
|
|
158
|
+
return undefined;
|
|
159
|
+
try {
|
|
160
|
+
const parsed = YAML.parse(text);
|
|
161
|
+
const selection = parsed?.["agent-default-model"];
|
|
162
|
+
if (!selection?.provider)
|
|
163
|
+
return undefined;
|
|
164
|
+
return `${selection.provider} · ${selection.model ?? "?"}`;
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
return undefined;
|
|
168
|
+
}
|
|
169
|
+
},
|
|
170
|
+
candidates() {
|
|
171
|
+
const text = readTextIfExists(settingsFile());
|
|
172
|
+
if (!text)
|
|
173
|
+
return [];
|
|
174
|
+
let parsed;
|
|
175
|
+
try {
|
|
176
|
+
parsed = YAML.parse(text);
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
return [];
|
|
180
|
+
}
|
|
181
|
+
const providers = parsed?.["llm-pi-ai"]?.providers;
|
|
182
|
+
if (!providers)
|
|
183
|
+
return [];
|
|
184
|
+
const refs = readCredentialRefs();
|
|
185
|
+
const selection = parsed?.["agent-default-model"];
|
|
186
|
+
const self = this.id;
|
|
187
|
+
return Object.entries(providers).flatMap(([id, entry]) => {
|
|
188
|
+
if (!entry || typeof entry.baseURL !== "string")
|
|
189
|
+
return [];
|
|
190
|
+
const wire = classifyApi(entry.api);
|
|
191
|
+
if (!wire)
|
|
192
|
+
return [];
|
|
193
|
+
const keyEnv = typeof entry.apiKeyEnv === "string" && entry.apiKeyEnv ? entry.apiKeyEnv : undefined;
|
|
194
|
+
// dsh credential precedence: inherited process environment first, then the managed store.
|
|
195
|
+
const apiKey = keyEnv ? (process.env[keyEnv] ?? refs[keyEnv]) : undefined;
|
|
196
|
+
const models = Array.isArray(entry.models)
|
|
197
|
+
? entry.models.map((m) => (typeof m?.id === "string" ? m.id : "")).filter(Boolean)
|
|
198
|
+
: [];
|
|
199
|
+
const defaultModel = selection?.provider === id ? selection.model : undefined;
|
|
200
|
+
if (defaultModel && !models.includes(defaultModel))
|
|
201
|
+
models.unshift(defaultModel);
|
|
202
|
+
return [
|
|
203
|
+
{
|
|
204
|
+
id,
|
|
205
|
+
name: typeof entry.displayName === "string" ? entry.displayName : id,
|
|
206
|
+
protocol: wire.protocol,
|
|
207
|
+
openaiApi: wire.openaiApi,
|
|
208
|
+
baseUrl: entry.baseURL,
|
|
209
|
+
apiKey,
|
|
210
|
+
keyEnv,
|
|
211
|
+
models,
|
|
212
|
+
defaultModel,
|
|
213
|
+
source: self,
|
|
214
|
+
},
|
|
215
|
+
];
|
|
216
|
+
});
|
|
217
|
+
},
|
|
218
|
+
};
|
|
219
|
+
/** Read the managed store's `refs` section; an unreadable document yields no keys. */
|
|
220
|
+
function readCredentialRefs() {
|
|
221
|
+
const text = readTextIfExists(credentialsFile());
|
|
222
|
+
if (!text)
|
|
223
|
+
return {};
|
|
224
|
+
try {
|
|
225
|
+
const parsed = YAML.parse(text);
|
|
226
|
+
const refs = parsed?.refs;
|
|
227
|
+
if (refs && typeof refs === "object")
|
|
228
|
+
return refs;
|
|
229
|
+
// pre-release flat layout: top-level reference-to-secret mapping
|
|
230
|
+
if (parsed && !("version" in parsed))
|
|
231
|
+
return parsed;
|
|
232
|
+
return {};
|
|
233
|
+
}
|
|
234
|
+
catch {
|
|
235
|
+
return {};
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
function parseCredentials(file) {
|
|
239
|
+
const text = readTextIfExists(file);
|
|
240
|
+
const doc = text ? YAML.parseDocument(text) : new YAML.Document({});
|
|
241
|
+
if (doc.errors.length) {
|
|
242
|
+
throw new Error(`${file} has YAML errors; refusing to rewrite: ${doc.errors[0]?.message}`);
|
|
243
|
+
}
|
|
244
|
+
if (doc.contents == null)
|
|
245
|
+
doc.contents = doc.createNode({});
|
|
246
|
+
return doc;
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* Store the key under `refs`, migrating a pre-release flat document first.
|
|
250
|
+
* The flat layout is recognized exactly the way dsh recognizes it — a mapping
|
|
251
|
+
* of reference names to non-empty string scalars — so a document that only
|
|
252
|
+
* looks unversioned (a hand-written `refs:` block, a future layout) is refused
|
|
253
|
+
* instead of being re-rooted one level deeper. Returns the file.
|
|
254
|
+
*/
|
|
255
|
+
function writeCredential(ref, apiKey, notes) {
|
|
256
|
+
const file = credentialsFile();
|
|
257
|
+
const doc = parseCredentials(file);
|
|
258
|
+
const root = (doc.toJSON() ?? {});
|
|
259
|
+
const version = root.version;
|
|
260
|
+
if (version !== undefined && version !== CREDENTIALS_VERSION) {
|
|
261
|
+
throw new Error(`${file} declares version ${JSON.stringify(version)}; agentsw writes version ${CREDENTIALS_VERSION}`);
|
|
262
|
+
}
|
|
263
|
+
if (version === undefined && Object.keys(root).length > 0) {
|
|
264
|
+
const flat = Object.entries(root).every(([key, value]) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(key) && typeof value === "string" && value.length > 0);
|
|
265
|
+
if (!flat) {
|
|
266
|
+
throw new Error(`${file} is neither the version ${CREDENTIALS_VERSION} layout nor a pre-release flat document; refusing to rewrite it`);
|
|
267
|
+
}
|
|
268
|
+
doc.contents = doc.createNode({ version: CREDENTIALS_VERSION, refs: root });
|
|
269
|
+
notes.push(`migrated ${file} to the version ${CREDENTIALS_VERSION} layout`);
|
|
270
|
+
}
|
|
271
|
+
doc.set("version", CREDENTIALS_VERSION);
|
|
272
|
+
doc.setIn(["refs", ref], apiKey);
|
|
273
|
+
const backup = backupFile(file);
|
|
274
|
+
if (backup)
|
|
275
|
+
notes.push(`backup: ${backup}`);
|
|
276
|
+
// dsh refuses a credentials document readable beyond its owner.
|
|
277
|
+
writeFileAtomic(file, doc.toString(), 0o600);
|
|
278
|
+
return file;
|
|
279
|
+
}
|
|
280
|
+
/** Drop this provider's reference. Returns the file when it was rewritten. */
|
|
281
|
+
function deleteCredential(ref, notes) {
|
|
282
|
+
const file = credentialsFile();
|
|
283
|
+
const text = readTextIfExists(file);
|
|
284
|
+
if (!text)
|
|
285
|
+
return undefined;
|
|
286
|
+
const doc = parseCredentials(file);
|
|
287
|
+
const at = doc.hasIn(["refs", ref]) ? ["refs", ref] : doc.has(ref) ? [ref] : undefined;
|
|
288
|
+
if (!at)
|
|
289
|
+
return undefined;
|
|
290
|
+
doc.deleteIn(at);
|
|
291
|
+
const backup = backupFile(file);
|
|
292
|
+
if (backup)
|
|
293
|
+
notes.push(`backup: ${backup}`);
|
|
294
|
+
writeFileAtomic(file, doc.toString(), 0o600);
|
|
295
|
+
return file;
|
|
296
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import YAML from "yaml";
|
|
4
|
+
import { backupFile, readTextIfExists, writeFileAtomic } from "../fsutil.js";
|
|
5
|
+
function hermesHome() {
|
|
6
|
+
const env = process.env.HERMES_HOME?.trim();
|
|
7
|
+
if (env)
|
|
8
|
+
return env;
|
|
9
|
+
if (process.platform === "win32" && process.env.LOCALAPPDATA) {
|
|
10
|
+
return path.join(process.env.LOCALAPPDATA, "hermes");
|
|
11
|
+
}
|
|
12
|
+
return path.join(process.env.HOME ?? "", ".hermes");
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* hermes (NousResearch): providers dict + model section in ~/.hermes/config.yaml,
|
|
16
|
+
* API key in ~/.hermes/.env referenced via key_env. hermes refuses to load an
|
|
17
|
+
* unparseable config.yaml, so we round-trip through YAML Document (comments kept).
|
|
18
|
+
*/
|
|
19
|
+
export const hermes = {
|
|
20
|
+
id: "hermes",
|
|
21
|
+
name: "Hermes",
|
|
22
|
+
protocols: ["openai", "anthropic"],
|
|
23
|
+
configPaths: [path.join(hermesHome(), "config.yaml"), path.join(hermesHome(), ".env")],
|
|
24
|
+
detect: () => fs.existsSync(hermesHome()),
|
|
25
|
+
async apply(provider) {
|
|
26
|
+
const home = hermesHome();
|
|
27
|
+
const configFile = path.join(home, "config.yaml");
|
|
28
|
+
const envFile = path.join(home, ".env");
|
|
29
|
+
const notes = [];
|
|
30
|
+
const text = readTextIfExists(configFile);
|
|
31
|
+
const doc = text ? YAML.parseDocument(text) : new YAML.Document({});
|
|
32
|
+
if (doc.errors.length) {
|
|
33
|
+
throw new Error(`${configFile} has YAML errors; refusing to rewrite: ${doc.errors[0]?.message}`);
|
|
34
|
+
}
|
|
35
|
+
if (doc.contents == null)
|
|
36
|
+
doc.contents = doc.createNode({});
|
|
37
|
+
const keyVar = `AGENTSW_${provider.id.toUpperCase().replace(/[^A-Z0-9]/g, "_")}_API_KEY`;
|
|
38
|
+
const at = ["providers", provider.id];
|
|
39
|
+
const prev = doc.getIn(at)?.toJSON?.();
|
|
40
|
+
const prevModels = (prev?.models ?? {});
|
|
41
|
+
const models = {};
|
|
42
|
+
for (const m of provider.models) {
|
|
43
|
+
const old = { ...prevModels[m.id] };
|
|
44
|
+
delete old.context_length; // owned: a model that loses its size must not keep the old one
|
|
45
|
+
models[m.id] = { ...old, ...(m.contextWindow ? { context_length: m.contextWindow } : {}) };
|
|
46
|
+
}
|
|
47
|
+
const entry = {
|
|
48
|
+
name: provider.name,
|
|
49
|
+
api: provider.baseUrl,
|
|
50
|
+
key_env: keyVar,
|
|
51
|
+
transport: provider.protocol === "anthropic" ? "anthropic_messages" : "chat_completions",
|
|
52
|
+
default_model: provider.defaultModel,
|
|
53
|
+
models,
|
|
54
|
+
};
|
|
55
|
+
// Key-by-key when the route exists: provider-level keys agentsw does not model
|
|
56
|
+
// (and their comments) belong to the user.
|
|
57
|
+
if (YAML.isMap(doc.getIn(at))) {
|
|
58
|
+
for (const [key, value] of Object.entries(entry))
|
|
59
|
+
doc.setIn([...at, key], doc.createNode(value));
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
doc.setIn(at, doc.createNode(entry));
|
|
63
|
+
}
|
|
64
|
+
doc.setIn(["model", "provider"], provider.id);
|
|
65
|
+
doc.setIn(["model", "default"], provider.defaultModel);
|
|
66
|
+
const configBackup = backupFile(configFile);
|
|
67
|
+
if (configBackup)
|
|
68
|
+
notes.push(`backup: ${configBackup}`);
|
|
69
|
+
writeFileAtomic(configFile, doc.toString());
|
|
70
|
+
// upsert the key into ~/.hermes/.env
|
|
71
|
+
const envText = readTextIfExists(envFile) ?? "";
|
|
72
|
+
const line = `${keyVar}=${provider.apiKey}`;
|
|
73
|
+
const pattern = new RegExp(`^${keyVar}=.*$`, "m");
|
|
74
|
+
const nextEnv = pattern.test(envText)
|
|
75
|
+
? envText.replace(pattern, line)
|
|
76
|
+
: envText + (envText.endsWith("\n") || envText === "" ? "" : "\n") + line + "\n";
|
|
77
|
+
const envBackup = backupFile(envFile);
|
|
78
|
+
if (envBackup)
|
|
79
|
+
notes.push(`backup: ${envBackup}`);
|
|
80
|
+
writeFileAtomic(envFile, nextEnv, 0o600);
|
|
81
|
+
return { app: this.id, changed: [configFile, envFile], notes };
|
|
82
|
+
},
|
|
83
|
+
async prune(provider) {
|
|
84
|
+
const home = hermesHome();
|
|
85
|
+
const configFile = path.join(home, "config.yaml");
|
|
86
|
+
const envFile = path.join(home, ".env");
|
|
87
|
+
const text = readTextIfExists(configFile);
|
|
88
|
+
if (!text)
|
|
89
|
+
return { app: this.id, changed: [], notes: [], skipped: "no config.yaml" };
|
|
90
|
+
const doc = YAML.parseDocument(text);
|
|
91
|
+
if (doc.errors.length) {
|
|
92
|
+
throw new Error(`${configFile} has YAML errors; refusing to rewrite: ${doc.errors[0]?.message}`);
|
|
93
|
+
}
|
|
94
|
+
if (!doc.hasIn(["providers", provider.id])) {
|
|
95
|
+
return { app: this.id, changed: [], notes: [], skipped: `no providers.${provider.id} entry` };
|
|
96
|
+
}
|
|
97
|
+
doc.deleteIn(["providers", provider.id]);
|
|
98
|
+
const notes = [];
|
|
99
|
+
if (doc.getIn(["model", "provider"]) === provider.id) {
|
|
100
|
+
doc.deleteIn(["model", "provider"]);
|
|
101
|
+
doc.deleteIn(["model", "default"]);
|
|
102
|
+
notes.push("model selection reset (was pointing at this provider)");
|
|
103
|
+
}
|
|
104
|
+
// drop now-empty sections instead of leaving "model: {}" / "providers: {}"
|
|
105
|
+
for (const key of ["model", "providers"]) {
|
|
106
|
+
const node = doc.get(key);
|
|
107
|
+
if (YAML.isMap(node) && node.items.length === 0)
|
|
108
|
+
doc.delete(key);
|
|
109
|
+
}
|
|
110
|
+
const changed = [configFile];
|
|
111
|
+
const configBackup = backupFile(configFile);
|
|
112
|
+
if (configBackup)
|
|
113
|
+
notes.push(`backup: ${configBackup}`);
|
|
114
|
+
writeFileAtomic(configFile, doc.toString());
|
|
115
|
+
const keyVar = `AGENTSW_${provider.id.toUpperCase().replace(/[^A-Z0-9]/g, "_")}_API_KEY`;
|
|
116
|
+
const envText = readTextIfExists(envFile);
|
|
117
|
+
if (envText && new RegExp(`^${keyVar}=`, "m").test(envText)) {
|
|
118
|
+
const envBackup = backupFile(envFile);
|
|
119
|
+
if (envBackup)
|
|
120
|
+
notes.push(`backup: ${envBackup}`);
|
|
121
|
+
writeFileAtomic(envFile, envText.replace(new RegExp(`^${keyVar}=.*\\n?`, "m"), ""), 0o600);
|
|
122
|
+
changed.push(envFile);
|
|
123
|
+
}
|
|
124
|
+
return { app: this.id, changed, notes };
|
|
125
|
+
},
|
|
126
|
+
current() {
|
|
127
|
+
const text = readTextIfExists(path.join(hermesHome(), "config.yaml"));
|
|
128
|
+
if (!text)
|
|
129
|
+
return undefined;
|
|
130
|
+
try {
|
|
131
|
+
const parsed = YAML.parse(text);
|
|
132
|
+
if (!parsed?.model?.provider)
|
|
133
|
+
return undefined;
|
|
134
|
+
return `${parsed.model.provider} · ${parsed.model.default ?? parsed.model.model ?? "?"}`;
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
return undefined;
|
|
138
|
+
}
|
|
139
|
+
},
|
|
140
|
+
candidates() {
|
|
141
|
+
const hh = hermesHome();
|
|
142
|
+
const text = readTextIfExists(path.join(hh, "config.yaml"));
|
|
143
|
+
if (!text)
|
|
144
|
+
return [];
|
|
145
|
+
let parsed;
|
|
146
|
+
try {
|
|
147
|
+
parsed = YAML.parse(text);
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
return [];
|
|
151
|
+
}
|
|
152
|
+
if (!parsed?.providers)
|
|
153
|
+
return [];
|
|
154
|
+
const envText = readTextIfExists(path.join(hh, ".env")) ?? "";
|
|
155
|
+
const readEnv = (key) => process.env[key] ?? (envText.match(new RegExp(`^${key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}=(.*)$`, "m"))?.[1]?.trim() || undefined);
|
|
156
|
+
const self = this.id;
|
|
157
|
+
return Object.entries(parsed.providers).flatMap(([id, entry]) => {
|
|
158
|
+
if (!entry || typeof entry.api !== "string" || !entry.api)
|
|
159
|
+
return [];
|
|
160
|
+
const protocol = entry.transport === "anthropic_messages" ? "anthropic" : entry.transport === "chat_completions" ? "openai" : undefined;
|
|
161
|
+
if (!protocol)
|
|
162
|
+
return [];
|
|
163
|
+
const keyEnv = typeof entry.key_env === "string" && entry.key_env ? entry.key_env : undefined;
|
|
164
|
+
const apiKey = keyEnv ? readEnv(keyEnv) : undefined;
|
|
165
|
+
const models = Object.keys(entry.models ?? {});
|
|
166
|
+
const active = parsed?.model?.provider === id ? parsed?.model?.default ?? parsed?.model?.model : undefined;
|
|
167
|
+
const defaultModel = entry.default_model ?? active;
|
|
168
|
+
if (defaultModel && !models.includes(defaultModel))
|
|
169
|
+
models.unshift(defaultModel);
|
|
170
|
+
return [{ id, name: entry.name ?? id, protocol, baseUrl: entry.api, apiKey, keyEnv, models, defaultModel, source: self }];
|
|
171
|
+
});
|
|
172
|
+
},
|
|
173
|
+
};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { claudecode } from "./claudecode.js";
|
|
2
|
+
import { dsh } from "./dsh.js";
|
|
3
|
+
import { codex } from "./codex.js";
|
|
4
|
+
import { omp } from "./omp.js";
|
|
5
|
+
import { opencode } from "./opencode.js";
|
|
6
|
+
import { hermes } from "./hermes.js";
|
|
7
|
+
import { pi, prime } from "./pistyle.js";
|
|
8
|
+
import { workbuddy } from "./workbuddy.js";
|
|
9
|
+
export const targets = [claudecode, codex, omp, pi, prime, opencode, hermes, workbuddy, dsh];
|
|
10
|
+
export function resolveTargets(filter) {
|
|
11
|
+
if (!filter || filter === "all")
|
|
12
|
+
return targets;
|
|
13
|
+
const wanted = filter.split(",").map((s) => s.trim().toLowerCase()).filter(Boolean);
|
|
14
|
+
const out = [];
|
|
15
|
+
for (const w of wanted) {
|
|
16
|
+
const hit = targets.find((t) => t.id === w || t.name.toLowerCase() === w);
|
|
17
|
+
if (!hit) {
|
|
18
|
+
throw new Error(`unknown app "${w}" (supported: ${targets.map((t) => t.id).join(", ")})`);
|
|
19
|
+
}
|
|
20
|
+
out.push(hit);
|
|
21
|
+
}
|
|
22
|
+
return out;
|
|
23
|
+
}
|
|
24
|
+
export function supportsProtocol(target, protocol) {
|
|
25
|
+
return target.protocols.includes(protocol);
|
|
26
|
+
}
|