From e34f8527e34235ee173d3c8a34bb6103d3f00228 Mon Sep 17 00:00:00 2001 From: Kieran Zhang Date: Wed, 29 Jul 2026 19:28:18 +0800 Subject: [PATCH] =?UTF-8?q?feat(adapters):=20Hermes=20=E9=80=82=E9=85=8D?= =?UTF-8?q?=E5=99=A8=EF=BC=88custom=5Fproviders=20=E5=91=BD=E5=90=8D?= =?UTF-8?q?=E6=9D=A1=E7=9B=AE=E3=80=81key=20=E5=85=A5=20~/.hermes/.env?= =?UTF-8?q?=E3=80=81YAML=20=E4=BF=9D=E6=B3=A8=E9=87=8A=E5=9B=9E=E5=86=99?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - config.yaml 写 custom_providers ApiFlux 条目(base_url /v1 + key_env=APIFLUX_API_KEY + 全量模型字符串列表,hermes 会自行从 /v1/models 刷新) - key 只进 ~/.hermes/.env(行级 upsert 照抄 hermes _write_env_vars,0600),完全避开 OPENAI_API_KEY 撞名 - yaml Document API 回写保用户注释(优于 hermes 官方 save_config 的行为);不可解析时不动文件、打印手动 snippet - --model 时置 model.provider=apiflux + model.default;路径尊重 HERMES_HOME - venv 隔离冒烟通过(hermes-agent 0.19.0);.scratch issue 07 闭环 --- .../issues/03-batch2-harnesses.md | 2 +- .../issues/07-hermes-adapter.md | 6 +- README.md | 3 +- bun.lock | 3 + package.json | 5 +- src/adapters/hermes.test.ts | 211 ++++++++++++++++++ src/adapters/hermes.ts | 169 ++++++++++++++ src/args.ts | 2 +- src/init.test.ts | 22 ++ src/init.ts | 2 + 10 files changed, 418 insertions(+), 7 deletions(-) create mode 100644 src/adapters/hermes.test.ts create mode 100644 src/adapters/hermes.ts diff --git a/.scratch/model-selection-and-harnesses/issues/03-batch2-harnesses.md b/.scratch/model-selection-and-harnesses/issues/03-batch2-harnesses.md index 3f3faed..e79eb95 100644 --- a/.scratch/model-selection-and-harnesses/issues/03-batch2-harnesses.md +++ b/.scratch/model-selection-and-harnesses/issues/03-batch2-harnesses.md @@ -13,6 +13,6 @@ Status: in-progress(2026-07-29 调研完成,已拆出 04–07) - [x] Pi Agent → `05-pi-agent-adapter.md`(适配器已实现,待冒烟发版) - [x] OpenCode → `04-opencode-adapter.md`(适配器已实现,待冒烟发版) - [ ] Openclaw → `06-openclaw-adapter.md` -- [ ] Hermes → `07-hermes-adapter.md` +- [x] Hermes → `07-hermes-adapter.md`(适配器已实现,venv 冒烟通过,待发版) 调研结论(2026-07-29):四个都支持自定义 OpenAI 兼容 base URL,无 wontfix 候选;难点分布——OpenCode(纯 JSON,最顺)< Pi(模型需全量元数据)< Hermes(YAML+.env 两文件)< Openclaw(JSON5 回写丢注释风险)。 diff --git a/.scratch/model-selection-and-harnesses/issues/07-hermes-adapter.md b/.scratch/model-selection-and-harnesses/issues/07-hermes-adapter.md index edcf615..6c983c7 100644 --- a/.scratch/model-selection-and-harnesses/issues/07-hermes-adapter.md +++ b/.scratch/model-selection-and-harnesses/issues/07-hermes-adapter.md @@ -1,6 +1,6 @@ # 07 Hermes 适配器 -Status: research-done(2026-07-29 源码级调研闭环,无真机冒烟——用户拍板不在本机安装) +Status: implemented(2026-07-29 适配器+测试完成,scratchpad venv 隔离冒烟通过,待发 minor) 从 03 占位拆出。Hermes = NousResearch/hermes-agent(Python),bin `hermes`。 @@ -29,4 +29,6 @@ Status: research-done(2026-07-29 源码级调研闭环,无真机冒烟—— - 写 `config.yaml`:`custom_providers` 数组 merge(按 name=ApiFlux 匹配更新/追加)+ 选中模型时置 `model.provider: apiflux` + `model.default: `;`yaml` Document API 保注释。 - 写 `.env`:`APIFLUX_API_KEY` 行级 upsert,0600。 - plan() 冲突:base_url 变更 / model.provider|default 变更 / .env 已有不同 APIFLUX_API_KEY(脱敏)。 -- 验证:单测 + 源码级契约(无真机冒烟);可选 scratchpad venv 冒烟待议。 +- 验证(2026-07-29 已过):单测 14 例 + **scratchpad venv 隔离冒烟**(hermes-agent 0.19.0 / PyPI + HERMES_HOME 指向 scratchpad):默认模型 one-shot(`hermes -z`)与 `--provider apiflux --model claude-haiku-4-5` 两发 SMOKE-OK;冒烟目录(含 key)已删。 +- ⚠️ 冒烟发现:hermes 裸 `--model `(无 --provider)可能撞别家 catalog 同名模型(实测撞到 opencode-zen)——需要显式指定时用 `--provider apiflux`;默认模型路径(config.yaml)不受影响。 +- 附注:hermes-agent 源码目录禁止非 editable 安装(uv pip install 目录会拒绝),从 PyPI 装即可。 diff --git a/README.md b/README.md index c5e806e..a4550e3 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ Paste your ApiFlux API key when prompted, pick the tools to configure, done. The | Codex CLI | `model_providers.apiflux` in `~/.codex/config.toml` (key stays in the `APIFLUX_API_KEY` env var, never in the file); with `--model`, also sets it as the default provider/model | | OpenCode | `provider.apiflux` in `~/.config/opencode/opencode.json` with every model your key can use; key goes to OpenCode's own credential store (`~/.local/share/opencode/auth.json`, `0600`), never into the config file; with `--model`, also sets it as the default model | | Pi | `providers.apiflux` in `~/.pi/agent/models.json` with every model your key can use; key goes to Pi's own credential store (`~/.pi/agent/auth.json`, `0600`), never into the config file; with `--model`, also persists it via `defaultProvider`/`defaultModel` in `settings.json` | +| Hermes | `custom_providers` entry in `~/.hermes/config.yaml` (comment-preserving YAML edit) with every model your key can use; key goes to `~/.hermes/.env` as `APIFLUX_API_KEY` (`0600`), never into config.yaml; with `--model`, also sets `model.provider`/`model.default` | | Anything OpenAI-compatible | Prints `export OPENAI_BASE_URL` / `OPENAI_API_KEY` (and `OPENAI_MODEL` if chosen) lines for your shell or `.env` | Any model your key can use works in any tool — the ApiFlux gateway converts between the Anthropic and OpenAI protocols. Picking a non-Claude model for Claude Code also pins its small/fast background model so every request stays on your chosen model. @@ -36,7 +37,7 @@ npx apiflux-cli init --key - # read the key from stdin | --- | --- | | `--key ` | API key; `-` reads from stdin; omit for a hidden prompt | | `--base-url ` | Override the API origin (self-hosted / testing) | -| `--tool ` | Configure only this tool (repeatable): `claude-code`, `codex`, `opencode`, `pi`, `export` | +| `--tool ` | Configure only this tool (repeatable): `claude-code`, `codex`, `opencode`, `pi`, `hermes`, `export` | | `--model ` | Default model for the configured tools; omit in a terminal to pick from a list, omit in scripts to keep each tool's default | | `--yes` | Skip confirmations, overwrite conflicting config | | `--skip-verify` | Don't send the verification request | diff --git a/bun.lock b/bun.lock index e14cc04..cabbe45 100644 --- a/bun.lock +++ b/bun.lock @@ -7,6 +7,7 @@ "dependencies": { "@clack/prompts": "^1.7.0", "smol-toml": "^1.7.0", + "yaml": "^2.9.0", }, }, }, @@ -24,5 +25,7 @@ "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], "smol-toml": ["smol-toml@1.7.0", "", {}, "sha512-aqVvWoyO21L23mb+drl4RmMXbf6N7FdHjAhTRA9ZBL7apWBgfWC16KjrASI+1p9GAroljyMHj6fK67i0UiTNvQ=="], + + "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], } } diff --git a/package.json b/package.json index 29dd9c1..8e393a3 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "apiflux-cli", "version": "0.5.0", - "description": "One-command setup of ApiFlux for local AI coding tools (Claude Code, Codex CLI, OpenCode, Pi)", + "description": "One-command setup of ApiFlux for local AI coding tools (Claude Code, Codex CLI, OpenCode, Pi, Hermes)", "type": "module", "bin": { "apiflux": "./dist/apiflux.js" @@ -40,6 +40,7 @@ "license": "MIT", "dependencies": { "@clack/prompts": "^1.7.0", - "smol-toml": "^1.7.0" + "smol-toml": "^1.7.0", + "yaml": "^2.9.0" } } diff --git a/src/adapters/hermes.test.ts b/src/adapters/hermes.test.ts new file mode 100644 index 0000000..1841434 --- /dev/null +++ b/src/adapters/hermes.test.ts @@ -0,0 +1,211 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { parse } from "yaml"; +import { hermesAdapter } from "./hermes"; + +const BASE_URL = "https://apiflux.ai"; +const KEY = "sk-test1234"; +const MODELS = ["deepseek-v4-pro", "kimi-k2.6", "claude-sonnet-5"]; + +function tempHome(): string { + return mkdtempSync(join(tmpdir(), "apiflux-cli-hermes-")); +} + +function hermesDir(home: string): string { + return join(home, ".hermes"); +} + +function readConfig(home: string): any { + return parse(readFileSync(join(hermesDir(home), "config.yaml"), "utf8")); +} + +// Hermes honors HERMES_HOME (hermes_constants.py); pin it to unset so paths +// derive from the temp home. +let savedHermesHome: string | undefined; +beforeEach(() => { + savedHermesHome = process.env.HERMES_HOME; + delete process.env.HERMES_HOME; +}); +afterEach(() => { + if (savedHermesHome === undefined) delete process.env.HERMES_HOME; + else process.env.HERMES_HOME = savedHermesHome; +}); + +describe("hermesAdapter.detect", () => { + test("false without ~/.hermes, true with it", () => { + const home = tempHome(); + expect(hermesAdapter.detect(home)).toBe(false); + mkdirSync(hermesDir(home)); + expect(hermesAdapter.detect(home)).toBe(true); + }); + + test("honors HERMES_HOME override", () => { + const home = tempHome(); + process.env.HERMES_HOME = join(home, "custom-hermes"); + expect(hermesAdapter.detect(home)).toBe(false); + mkdirSync(join(home, "custom-hermes"), { recursive: true }); + expect(hermesAdapter.detect(home)).toBe(true); + }); +}); + +describe("hermesAdapter.write", () => { + test("writes custom_providers entry, key_env indirection, and key into .env only", () => { + const home = tempHome(); + const notes = hermesAdapter.write(home, { baseUrl: BASE_URL, key: KEY, availableModels: MODELS }); + const config = readConfig(home); + const entry = config.custom_providers[0]; + expect(entry.name).toBe("ApiFlux"); + expect(entry.base_url).toBe(`${BASE_URL}/v1`); + expect(entry.key_env).toBe("APIFLUX_API_KEY"); + expect(entry.models).toEqual(MODELS); + // The key lives only in .env (0600), never in config.yaml. + expect(readFileSync(join(hermesDir(home), "config.yaml"), "utf8")).not.toContain(KEY); + const env = readFileSync(join(hermesDir(home), ".env"), "utf8"); + expect(env).toContain(`APIFLUX_API_KEY=${KEY}`); + expect(statSync(join(hermesDir(home), ".env")).mode & 0o777).toBe(0o600); + expect(notes.join("\n")).toContain("config.yaml"); + }); + + test("chosen model sets model.provider and model.default", () => { + const home = tempHome(); + hermesAdapter.write(home, { + baseUrl: BASE_URL, + key: KEY, + model: "deepseek-v4-pro", + availableModels: MODELS, + }); + const config = readConfig(home); + expect(config.model.provider).toBe("apiflux"); + expect(config.model.default).toBe("deepseek-v4-pro"); + }); + + test("no chosen model leaves model section untouched", () => { + const home = tempHome(); + mkdirSync(hermesDir(home), { recursive: true }); + writeFileSync( + join(hermesDir(home), "config.yaml"), + "model:\n provider: openrouter\n default: anthropic/claude-opus-4.6\n", + ); + hermesAdapter.write(home, { baseUrl: BASE_URL, key: KEY, availableModels: MODELS }); + const config = readConfig(home); + expect(config.model.provider).toBe("openrouter"); + expect(config.model.default).toBe("anthropic/claude-opus-4.6"); + }); + + test("preserves comments, other providers, and other .env lines", () => { + const home = tempHome(); + mkdirSync(hermesDir(home), { recursive: true }); + writeFileSync( + join(hermesDir(home), "config.yaml"), + [ + "# my hermes config", + "custom_providers:", + " - name: MyProxy", + " base_url: https://proxy.example.com/v1", + " key_env: MYPROXY_KEY", + "model:", + " provider: myproxy # keep this", + " default: some-model", + "", + ].join("\n"), + ); + writeFileSync(join(hermesDir(home), ".env"), "OPENROUTER_API_KEY=sk-or-1\n"); + hermesAdapter.write(home, { baseUrl: BASE_URL, key: KEY, availableModels: MODELS }); + const raw = readFileSync(join(hermesDir(home), "config.yaml"), "utf8"); + expect(raw).toContain("# my hermes config"); + expect(raw).toContain("# keep this"); + const config = readConfig(home); + expect(config.custom_providers.length).toBe(2); + expect(config.custom_providers[0].name).toBe("MyProxy"); + expect(config.custom_providers[1].name).toBe("ApiFlux"); + const env = readFileSync(join(hermesDir(home), ".env"), "utf8"); + expect(env).toContain("OPENROUTER_API_KEY=sk-or-1"); + expect(env).toContain(`APIFLUX_API_KEY=${KEY}`); + }); + + test("re-running updates the existing ApiFlux entry instead of duplicating", () => { + const home = tempHome(); + hermesAdapter.write(home, { baseUrl: BASE_URL, key: KEY, availableModels: MODELS }); + hermesAdapter.write(home, { baseUrl: BASE_URL, key: "sk-rotated", availableModels: ["only-one"] }); + const config = readConfig(home); + expect(config.custom_providers.length).toBe(1); + expect(config.custom_providers[0].models).toEqual(["only-one"]); + const env = readFileSync(join(hermesDir(home), ".env"), "utf8"); + expect(env).toContain("APIFLUX_API_KEY=sk-rotated"); + expect(env).not.toContain(KEY); + // Single line, updated in place. + expect(env.match(/APIFLUX_API_KEY/g)?.length).toBe(1); + }); + + test("backs up existing files once", () => { + const home = tempHome(); + mkdirSync(hermesDir(home), { recursive: true }); + writeFileSync(join(hermesDir(home), "config.yaml"), "model:\n provider: auto\n"); + hermesAdapter.write(home, { baseUrl: BASE_URL, key: KEY, availableModels: MODELS }); + hermesAdapter.write(home, { baseUrl: BASE_URL, key: KEY, availableModels: MODELS }); + const backups = readdirSync(hermesDir(home)).filter((f) => f.startsWith("config.yaml.bak.")); + expect(backups.length).toBe(1); + }); + + test("unparseable config.yaml is left untouched with manual instructions", () => { + const home = tempHome(); + mkdirSync(hermesDir(home), { recursive: true }); + const original = "model: [unclosed\n"; + writeFileSync(join(hermesDir(home), "config.yaml"), original); + const notes = hermesAdapter.write(home, { baseUrl: BASE_URL, key: KEY, availableModels: MODELS }); + expect(readFileSync(join(hermesDir(home), "config.yaml"), "utf8")).toBe(original); + expect(notes.join("\n")).toContain("custom_providers"); + // .env is still written so the manual merge is key-free. + expect(readFileSync(join(hermesDir(home), ".env"), "utf8")).toContain(`APIFLUX_API_KEY=${KEY}`); + }); +}); + +describe("hermesAdapter.plan", () => { + test("no config → no conflicts", () => { + expect(hermesAdapter.plan(tempHome(), { baseUrl: BASE_URL, key: KEY }).conflicts).toEqual([]); + }); + + test("existing ApiFlux entry with different base_url → conflict", () => { + const home = tempHome(); + mkdirSync(hermesDir(home), { recursive: true }); + writeFileSync( + join(hermesDir(home), "config.yaml"), + "custom_providers:\n - name: ApiFlux\n base_url: https://old.example.com/v1\n", + ); + const { conflicts } = hermesAdapter.plan(home, { baseUrl: BASE_URL, key: KEY }); + expect(conflicts.length).toBe(1); + expect(conflicts[0]).toContain("https://old.example.com/v1"); + }); + + test("existing different default model → conflict when a model is chosen", () => { + const home = tempHome(); + mkdirSync(hermesDir(home), { recursive: true }); + writeFileSync( + join(hermesDir(home), "config.yaml"), + "model:\n provider: openrouter\n default: anthropic/claude-opus-4.6\n", + ); + const { conflicts } = hermesAdapter.plan(home, { baseUrl: BASE_URL, key: KEY, model: "deepseek-v4-pro" }); + expect(conflicts.some((line) => line.includes("anthropic/claude-opus-4.6"))).toBe(true); + }); + + test("existing different APIFLUX_API_KEY in .env → conflict without echoing keys", () => { + const home = tempHome(); + mkdirSync(hermesDir(home), { recursive: true }); + writeFileSync(join(hermesDir(home), ".env"), "APIFLUX_API_KEY=sk-old-secret\n"); + const { conflicts } = hermesAdapter.plan(home, { baseUrl: BASE_URL, key: KEY }); + expect(conflicts.length).toBe(1); + expect(conflicts[0]).not.toContain("sk-old-secret"); + expect(conflicts[0]).toContain("sk-***"); + }); + + test("unparseable config.yaml → conflict announcing manual merge", () => { + const home = tempHome(); + mkdirSync(hermesDir(home), { recursive: true }); + writeFileSync(join(hermesDir(home), "config.yaml"), "model: [unclosed\n"); + const { conflicts } = hermesAdapter.plan(home, { baseUrl: BASE_URL, key: KEY }); + expect(conflicts.length).toBe(1); + expect(conflicts[0].toLowerCase()).toContain("manual"); + }); +}); diff --git a/src/adapters/hermes.ts b/src/adapters/hermes.ts new file mode 100644 index 0000000..e74ba43 --- /dev/null +++ b/src/adapters/hermes.ts @@ -0,0 +1,169 @@ +import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { Document, YAMLSeq, parseDocument } from "yaml"; +import { withV1 } from "../endpoint"; +import type { Adapter, AdapterInput } from "./types"; +import { backupOnce } from "./backup"; + +const KEY_ENV = "APIFLUX_API_KEY"; + +// Hermes resolves everything under HERMES_HOME, defaulting to ~/.hermes +// (hermes_constants.py); the adapter must honor the same override. +function hermesHome(home: string): string { + const override = process.env.HERMES_HOME; + if (override) return override.startsWith("~/") ? join(home, override.slice(2)) : override; + return join(home, ".hermes"); +} + +interface YamlFile { + doc: Document; + /** Set when the file exists but is not parseable YAML. */ + unparseable: boolean; +} + +function readYaml(path: string): YamlFile { + if (!existsSync(path)) return { doc: new Document({}), unparseable: false }; + const doc = parseDocument(readFileSync(path, "utf8")); + return doc.errors.length > 0 ? { doc: new Document({}), unparseable: true } : { doc }; +} + +/** Plain-JS view of the custom_providers list (for plan/inspection only). */ +function customProviders(doc: Document): Record[] { + const value = doc.toJS()?.custom_providers; + return Array.isArray(value) + ? value.filter((e): e is Record => typeof e === "object" && e !== null) + : []; +} + +function isApiflux(entry: Record): boolean { + return typeof entry.name === "string" && entry.name.trim().toLowerCase() === "apiflux"; +} + +function apifluxEntry(input: AdapterInput): Record { + return { + name: "ApiFlux", + base_url: withV1(input.baseUrl), + key_env: KEY_ENV, + // A plain string list: Hermes refreshes it from /v1/models on its own + // (model_switch.py) but preserves user-curated dict entries. + models: input.availableModels ?? (input.model === undefined ? [] : [input.model]), + }; +} + +/** Line-level .env upsert, mirroring Hermes's own _write_env_vars. */ +function upsertEnvLine(path: string, key: string, value: string): void { + const safeValue = value.replace(/\0/g, "").split(/\r?\n|\r/).join(""); + const lines = existsSync(path) + ? readFileSync(path, "utf8").split("\n").filter((line, i, all) => !(line === "" && i === all.length - 1)) + : []; + let updated = false; + const next = lines.map((line) => { + const name = line.includes("=") ? line.split("=", 1)[0].trim() : ""; + if (name !== key) return line; + updated = true; + return `${key}=${safeValue}`; + }); + if (!updated) next.push(`${key}=${safeValue}`); + writeFileSync(path, next.join("\n") + "\n"); + chmodSync(path, 0o600); +} + +function envValueOf(path: string, key: string): string | undefined { + if (!existsSync(path)) return undefined; + for (const line of readFileSync(path, "utf8").split("\n")) { + const eq = line.indexOf("="); + if (eq === -1) continue; + if (line.slice(0, eq).trim() === key) return line.slice(eq + 1).trim(); + } + return undefined; +} + +export const hermesAdapter: Adapter = { + id: "hermes", + label: "Hermes", + + detect(home) { + return existsSync(hermesHome(home)); + }, + + plan(home, input: AdapterInput) { + const dir = hermesHome(home); + const conflicts: string[] = []; + const config = readYaml(join(dir, "config.yaml")); + if (config.unparseable) { + conflicts.push( + "Hermes: config.yaml is not parseable YAML; it will be left untouched and a manual snippet printed.", + ); + } else { + const existing = customProviders(config.doc).find(isApiflux); + const targetUrl = withV1(input.baseUrl); + if (existing && typeof existing.base_url === "string" && existing.base_url !== targetUrl) { + conflicts.push(`Hermes: replace custom_providers ApiFlux base_url ${existing.base_url} → ${targetUrl}`); + } + if (input.model !== undefined) { + const model = config.doc.toJS()?.model; + const current = typeof model === "object" && model !== null ? model : {}; + if ( + typeof current.default === "string" && + (current.default !== input.model || current.provider !== "apiflux") + ) { + conflicts.push( + `Hermes: replace default model ${current.provider ?? "auto"}/${current.default} → apiflux/${input.model}`, + ); + } + } + } + const existingKey = envValueOf(join(dir, ".env"), KEY_ENV); + if (existingKey !== undefined && existingKey !== input.key) { + // Never echo key values, old or new. + conflicts.push(`Hermes: replace existing ${KEY_ENV} in .env (sk-***)`); + } + return { conflicts }; + }, + + write(home, input: AdapterInput) { + const dir = hermesHome(home); + mkdirSync(dir, { recursive: true }); + const notes: string[] = []; + + const configPath = join(dir, "config.yaml"); + const config = readYaml(configPath); + if (config.unparseable) { + notes.push( + `Hermes: ${configPath} is not parseable YAML; left untouched. Merge this yourself:`, + ` custom_providers entry: ${JSON.stringify(apifluxEntry(input))}`, + ); + } else { + backupOnce(configPath); + const doc = config.doc; + const providers = doc.get("custom_providers"); + const entryNode = doc.createNode(apifluxEntry(input)); + if (providers instanceof YAMLSeq) { + const index = customProviders(doc).findIndex(isApiflux); + if (index >= 0) providers.set(index, entryNode); + else providers.add(entryNode); + } else { + doc.set("custom_providers", doc.createNode([apifluxEntry(input)])); + } + if (input.model !== undefined) { + doc.setIn(["model", "provider"], "apiflux"); + doc.setIn(["model", "default"], input.model); + } + writeFileSync(configPath, doc.toString()); + notes.push(`Hermes: wrote custom_providers ApiFlux to ${configPath}`); + } + + // The key lives only in Hermes's .env (0600), referenced via key_env. + const envPath = join(dir, ".env"); + backupOnce(envPath); + upsertEnvLine(envPath, KEY_ENV, input.key); + notes.push(`Hermes: wrote ${KEY_ENV} to ${envPath}`); + + notes.push( + input.model !== undefined + ? `Hermes: default model set to apiflux/${input.model}` + : "Hermes: pick a model with: hermes model (provider ApiFlux)", + ); + return notes; + }, +}; diff --git a/src/args.ts b/src/args.ts index 38951fd..7ce7157 100644 --- a/src/args.ts +++ b/src/args.ts @@ -1,4 +1,4 @@ -export const KNOWN_TOOLS = ["claude-code", "codex", "opencode", "pi", "export"] as const; +export const KNOWN_TOOLS = ["claude-code", "codex", "opencode", "pi", "hermes", "export"] as const; export type ToolId = (typeof KNOWN_TOOLS)[number]; export interface ParsedArgs { diff --git a/src/init.test.ts b/src/init.test.ts index ffa6b16..230ed4a 100644 --- a/src/init.test.ts +++ b/src/init.test.ts @@ -160,6 +160,28 @@ describe("pi wiring", () => { }); }); +describe("hermes wiring", () => { + test("--tool hermes registers custom provider and stores the key in .env", async () => { + const savedHermesHome = process.env.HERMES_HOME; + delete process.env.HERMES_HOME; + try { + const { home, log } = setup(); + mkdirSync(join(home, ".hermes")); + const args = parseArgs(["init", "--key", KEY, "--base-url", baseUrl, "--tool", "hermes", "--yes"]); + const code = await runInit(args, { home, log }); + expect(code).toBe(0); + const raw = readFileSync(join(home, ".hermes", "config.yaml"), "utf8"); + expect(raw).toContain("ApiFlux"); + expect(raw).toContain("deepseek-v4-pro"); + expect(raw).not.toContain(KEY); + expect(readFileSync(join(home, ".hermes", ".env"), "utf8")).toContain(`APIFLUX_API_KEY=${KEY}`); + } finally { + if (savedHermesHome === undefined) delete process.env.HERMES_HOME; + else process.env.HERMES_HOME = savedHermesHome; + } + }); +}); + describe("model selection", () => { function claudeEnv(home: string): Record { return JSON.parse(readFileSync(join(home, ".claude", "settings.json"), "utf8")).env; diff --git a/src/init.ts b/src/init.ts index b69a6bb..946a9ae 100644 --- a/src/init.ts +++ b/src/init.ts @@ -5,6 +5,7 @@ import { codexAdapter } from "./adapters/codex"; import { genericExportAdapter } from "./adapters/generic-export"; import { opencodeAdapter } from "./adapters/opencode"; import { piAdapter } from "./adapters/pi"; +import { hermesAdapter } from "./adapters/hermes"; import { normalizeBaseUrl } from "./endpoint"; import { maskKeysIn } from "./mask"; import { groupModelsByMaker, type ModelGroup } from "./model-groups"; @@ -19,6 +20,7 @@ export const ADAPTERS: Record = { codex: codexAdapter, opencode: opencodeAdapter, pi: piAdapter, + hermes: hermesAdapter, export: genericExportAdapter, };