Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 回写丢注释风险)。
Original file line number Diff line number Diff line change
@@ -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`。

Expand Down Expand Up @@ -29,4 +29,6 @@ Status: research-done(2026-07-29 源码级调研闭环,无真机冒烟——
- 写 `config.yaml`:`custom_providers` 数组 merge(按 name=ApiFlux 匹配更新/追加)+ 选中模型时置 `model.provider: apiflux` + `model.default: <id>`;`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 <id>`(无 --provider)可能撞别家 catalog 同名模型(实测撞到 opencode-zen)——需要显式指定时用 `--provider apiflux`;默认模型路径(config.yaml)不受影响。
- 附注:hermes-agent 源码目录禁止非 editable 安装(uv pip install 目录会拒绝),从 PyPI 装即可。
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -36,7 +37,7 @@ npx apiflux-cli init --key - # read the key from stdin
| --- | --- |
| `--key <key\|->` | API key; `-` reads from stdin; omit for a hidden prompt |
| `--base-url <url>` | Override the API origin (self-hosted / testing) |
| `--tool <id>` | Configure only this tool (repeatable): `claude-code`, `codex`, `opencode`, `pi`, `export` |
| `--tool <id>` | Configure only this tool (repeatable): `claude-code`, `codex`, `opencode`, `pi`, `hermes`, `export` |
| `--model <id>` | 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 |
Expand Down
3 changes: 3 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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"
}
}
211 changes: 211 additions & 0 deletions src/adapters/hermes.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
Loading
Loading