From 0189cef2086322d8e359ce71868eda7f67102474 Mon Sep 17 00:00:00 2001 From: Kieran Zhang Date: Wed, 29 Jul 2026 20:51:19 +0800 Subject: [PATCH] =?UTF-8?q?feat(adapters):=20OpenClaw=20=E9=80=82=E9=85=8D?= =?UTF-8?q?=E5=99=A8=EF=BC=88models.providers=20=E5=85=A8=E9=87=8F?= =?UTF-8?q?=E6=B3=A8=E5=86=8C=E3=80=81JSON5=20=E5=AE=B9=E8=AF=BB=E3=80=81$?= =?UTF-8?q?include=20=E5=AE=89=E5=85=A8=E9=99=8D=E7=BA=A7=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - models.providers.apiflux 写入 openclaw.json(openai-completions + /v1 + 最小 {id,name} 模型条目,运行时补元数据——官方文档口径) - apiKey 字面量落盘(与 Control UI 存法一致;${VAR} 缺变量会拒载不采用) - --model 时置 agents.defaults.model.primary,保留已有 fallbacks;不写 models.mode(默认即 merge) - JSON5 容读;注释将被抹时提示(同官方 writer 行为,检测逻辑移植自 json5-comments.ts);$include 或不可解析时不动文件、打印手动 snippet - 路径尊重 OPENCLAW_STATE_DIR / OPENCLAW_CONFIG_PATH;Docker 隔离冒烟通过;issue 06 闭环 --- .../issues/03-batch2-harnesses.md | 2 +- .../issues/06-openclaw-adapter.md | 5 +- README.md | 3 +- bun.lock | 3 + package.json | 3 +- src/adapters/openclaw.test.ts | 225 ++++++++++++++++++ src/adapters/openclaw.ts | 166 +++++++++++++ src/args.ts | 2 +- src/init.test.ts | 28 +++ src/init.ts | 2 + 10 files changed, 433 insertions(+), 6 deletions(-) create mode 100644 src/adapters/openclaw.test.ts create mode 100644 src/adapters/openclaw.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 e79eb95..3e7104c 100644 --- a/.scratch/model-selection-and-harnesses/issues/03-batch2-harnesses.md +++ b/.scratch/model-selection-and-harnesses/issues/03-batch2-harnesses.md @@ -12,7 +12,7 @@ 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` +- [x] Openclaw → `06-openclaw-adapter.md`(适配器已实现,Docker 冒烟通过,待发版) - [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/06-openclaw-adapter.md b/.scratch/model-selection-and-harnesses/issues/06-openclaw-adapter.md index 4fca372..a85e733 100644 --- a/.scratch/model-selection-and-harnesses/issues/06-openclaw-adapter.md +++ b/.scratch/model-selection-and-harnesses/issues/06-openclaw-adapter.md @@ -1,6 +1,6 @@ # 06 Openclaw 适配器 -Status: research-done(2026-07-29 源码级调研闭环,无真机冒烟——用户拍板不在本机安装) +Status: implemented(2026-07-29 适配器+测试完成,Docker 隔离冒烟通过,待发 minor) 从 03 占位拆出。 @@ -35,4 +35,5 @@ Status: research-done(2026-07-29 源码级调研闭环,无真机冒烟—— - json5 依赖读(容注释)、`models.providers.apiflux` merge 回写(全量模型 `{id,name}`)、选中模型时置 `agents.defaults.model.primary`。 - plan() 冲突:baseUrl 变更 / apiKey 变更(脱敏)/ primary 变更 / 原文件含注释(提示将被抹掉,同官方行为)/ 含 `$include`(降级手动 snippet)。 -- 验证:单测 + 源码级契约(无真机冒烟);可选 docker 冒烟待议(仓库自带 Dockerfile)。 +- 验证(2026-07-29 已过):单测 16 例 + **Docker 隔离冒烟**(ghcr.io/openclaw/openclaw:latest,挂载隔离 state dir):`openclaw agent --local` 默认模型与 `--model apiflux/claude-haiku-4-5` 两发 SMOKE-OK,日志确认打到 apiflux.ai /v1/chat/completions 200;冒烟目录(含 key)已删。 +- ⚠️ Docker 冒烟坑:宿主目录必须在 Docker Desktop 文件共享列表内(/private/tmp 不在,bind 会静默落到 VM 空目录);容器内以 --user root -e HOME=/root 挂 /root/.openclaw 最省事。 diff --git a/README.md b/README.md index a4550e3..073b939 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ Paste your ApiFlux API key when prompted, pick the tools to configure, done. The | 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` | +| OpenClaw | `models.providers.apiflux` in `~/.openclaw/openclaw.json` with every model your key can use; with `--model`, also sets `agents.defaults.model.primary` (existing fallbacks kept). Note: like OpenClaw's own config writes, rewriting strips JSON5 comments; `$include` configs get a manual snippet instead | | 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. @@ -37,7 +38,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`, `hermes`, `export` | +| `--tool ` | Configure only this tool (repeatable): `claude-code`, `codex`, `opencode`, `pi`, `hermes`, `openclaw`, `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 cabbe45..f4138db 100644 --- a/bun.lock +++ b/bun.lock @@ -6,6 +6,7 @@ "name": "apiflux-cli", "dependencies": { "@clack/prompts": "^1.7.0", + "json5": "^2.2.3", "smol-toml": "^1.7.0", "yaml": "^2.9.0", }, @@ -22,6 +23,8 @@ "fast-wrap-ansi": ["fast-wrap-ansi@0.2.2", "", { "dependencies": { "fast-string-width": "^3.0.2" } }, "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q=="], + "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], "smol-toml": ["smol-toml@1.7.0", "", {}, "sha512-aqVvWoyO21L23mb+drl4RmMXbf6N7FdHjAhTRA9ZBL7apWBgfWC16KjrASI+1p9GAroljyMHj6fK67i0UiTNvQ=="], diff --git a/package.json b/package.json index b8f2b24..9b2275b 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "apiflux-cli", "version": "0.6.0", - "description": "One-command setup of ApiFlux for local AI coding tools (Claude Code, Codex CLI, OpenCode, Pi, Hermes)", + "description": "One-command setup of ApiFlux for local AI coding tools (Claude Code, Codex CLI, OpenCode, Pi, Hermes, OpenClaw)", "type": "module", "bin": { "apiflux": "./dist/apiflux.js" @@ -40,6 +40,7 @@ "license": "MIT", "dependencies": { "@clack/prompts": "^1.7.0", + "json5": "^2.2.3", "smol-toml": "^1.7.0", "yaml": "^2.9.0" } diff --git a/src/adapters/openclaw.test.ts b/src/adapters/openclaw.test.ts new file mode 100644 index 0000000..9d64876 --- /dev/null +++ b/src/adapters/openclaw.test.ts @@ -0,0 +1,225 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { openclawAdapter } from "./openclaw"; + +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-openclaw-")); +} + +function configPath(home: string): string { + return join(home, ".openclaw", "openclaw.json"); +} + +function readJson(home: string): any { + return JSON.parse(readFileSync(configPath(home), "utf8")); +} + +// OpenClaw honors OPENCLAW_STATE_DIR / OPENCLAW_CONFIG_PATH (src/config/paths.ts); +// pin them to unset so paths derive from the temp home. +let savedStateDir: string | undefined; +let savedConfigPath: string | undefined; +beforeEach(() => { + savedStateDir = process.env.OPENCLAW_STATE_DIR; + savedConfigPath = process.env.OPENCLAW_CONFIG_PATH; + delete process.env.OPENCLAW_STATE_DIR; + delete process.env.OPENCLAW_CONFIG_PATH; +}); +afterEach(() => { + if (savedStateDir === undefined) delete process.env.OPENCLAW_STATE_DIR; + else process.env.OPENCLAW_STATE_DIR = savedStateDir; + if (savedConfigPath === undefined) delete process.env.OPENCLAW_CONFIG_PATH; + else process.env.OPENCLAW_CONFIG_PATH = savedConfigPath; +}); + +describe("openclawAdapter.detect", () => { + test("false without ~/.openclaw, true with it", () => { + const home = tempHome(); + expect(openclawAdapter.detect(home)).toBe(false); + mkdirSync(join(home, ".openclaw")); + expect(openclawAdapter.detect(home)).toBe(true); + }); + + test("honors OPENCLAW_STATE_DIR override", () => { + const home = tempHome(); + process.env.OPENCLAW_STATE_DIR = join(home, "custom-state"); + expect(openclawAdapter.detect(home)).toBe(false); + mkdirSync(join(home, "custom-state"), { recursive: true }); + expect(openclawAdapter.detect(home)).toBe(true); + }); +}); + +describe("openclawAdapter.write", () => { + test("writes provider with minimal model entries and literal apiKey", () => { + const home = tempHome(); + const notes = openclawAdapter.write(home, { baseUrl: BASE_URL, key: KEY, availableModels: MODELS }); + const config = readJson(home); + const provider = config.models.providers.apiflux; + expect(provider.baseUrl).toBe(`${BASE_URL}/v1`); + expect(provider.apiKey).toBe(KEY); + expect(provider.api).toBe("openai-completions"); + // Official docs' minimal shape: {id, name}; runtime fills the rest. + expect(provider.models.map((m: any) => m.id).sort()).toEqual([...MODELS].sort()); + for (const model of provider.models) expect(model.name).toBe(model.id); + // models.mode must not be introduced (default is already merge). + expect(config.models.mode).toBeUndefined(); + expect(notes.join("\n")).toContain(configPath(home)); + }); + + test("chosen model sets agents.defaults.model.primary", () => { + const home = tempHome(); + openclawAdapter.write(home, { + baseUrl: BASE_URL, + key: KEY, + model: "deepseek-v4-pro", + availableModels: MODELS, + }); + expect(readJson(home).agents.defaults.model.primary).toBe("apiflux/deepseek-v4-pro"); + }); + + test("keeps existing fallbacks when updating primary", () => { + const home = tempHome(); + mkdirSync(join(home, ".openclaw"), { recursive: true }); + writeFileSync( + configPath(home), + JSON.stringify({ + agents: { defaults: { model: { primary: "anthropic/claude-sonnet-5", fallbacks: ["openai/gpt-5.5"] } } }, + }), + ); + openclawAdapter.write(home, { baseUrl: BASE_URL, key: KEY, model: "kimi-k2.6", availableModels: MODELS }); + const model = readJson(home).agents.defaults.model; + expect(model.primary).toBe("apiflux/kimi-k2.6"); + expect(model.fallbacks).toEqual(["openai/gpt-5.5"]); + }); + + test("no chosen model leaves agents.defaults untouched", () => { + const home = tempHome(); + mkdirSync(join(home, ".openclaw"), { recursive: true }); + writeFileSync(configPath(home), JSON.stringify({ agents: { defaults: { model: { primary: "a/b" } } } })); + openclawAdapter.write(home, { baseUrl: BASE_URL, key: KEY, availableModels: MODELS }); + expect(readJson(home).agents.defaults.model.primary).toBe("a/b"); + }); + + test("preserves other providers, models.mode, and unrelated keys; parses JSON5", () => { + const home = tempHome(); + mkdirSync(join(home, ".openclaw"), { recursive: true }); + writeFileSync( + configPath(home), + `{ + // my config + gateway: { port: 18789 }, + models: { + mode: "replace", + providers: { + moonshot: { baseUrl: "https://api.moonshot.ai/v1", apiKey: "sk-moon", api: "openai-completions", models: [] }, + }, + }, + }`, + ); + openclawAdapter.write(home, { baseUrl: BASE_URL, key: KEY, availableModels: MODELS }); + const config = readJson(home); + expect(config.gateway.port).toBe(18789); + expect(config.models.mode).toBe("replace"); + expect(config.models.providers.moonshot.apiKey).toBe("sk-moon"); + expect(config.models.providers.apiflux.baseUrl).toBe(`${BASE_URL}/v1`); + }); + + test("backs up existing config once and is idempotent", () => { + const home = tempHome(); + mkdirSync(join(home, ".openclaw"), { recursive: true }); + writeFileSync(configPath(home), JSON.stringify({ gateway: { port: 1 } })); + openclawAdapter.write(home, { baseUrl: BASE_URL, key: KEY, availableModels: MODELS }); + const first = readFileSync(configPath(home), "utf8"); + openclawAdapter.write(home, { baseUrl: BASE_URL, key: KEY, availableModels: MODELS }); + expect(readFileSync(configPath(home), "utf8")).toBe(first); + const backups = readdirSync(join(home, ".openclaw")).filter((f) => f.startsWith("openclaw.json.bak.")); + expect(backups.length).toBe(1); + }); + + test("config with $include is left untouched with manual instructions", () => { + const home = tempHome(); + mkdirSync(join(home, ".openclaw"), { recursive: true }); + const original = `{ "$include": "./base.json5", "gateway": { "port": 1 } }`; + writeFileSync(configPath(home), original); + const notes = openclawAdapter.write(home, { baseUrl: BASE_URL, key: KEY, availableModels: MODELS }); + expect(readFileSync(configPath(home), "utf8")).toBe(original); + expect(notes.join("\n")).toContain("apiflux"); + }); +}); + +describe("openclawAdapter.plan", () => { + test("no config → no conflicts", () => { + expect(openclawAdapter.plan(tempHome(), { baseUrl: BASE_URL, key: KEY }).conflicts).toEqual([]); + }); + + test("existing apiflux provider with different baseUrl → conflict", () => { + const home = tempHome(); + mkdirSync(join(home, ".openclaw"), { recursive: true }); + writeFileSync( + configPath(home), + JSON.stringify({ models: { providers: { apiflux: { baseUrl: "https://old.example.com/v1", models: [] } } } }), + ); + const { conflicts } = openclawAdapter.plan(home, { baseUrl: BASE_URL, key: KEY }); + expect(conflicts.length).toBe(1); + expect(conflicts[0]).toContain("https://old.example.com/v1"); + }); + + test("existing different apiKey → conflict without echoing key material", () => { + const home = tempHome(); + mkdirSync(join(home, ".openclaw"), { recursive: true }); + writeFileSync( + configPath(home), + JSON.stringify({ + models: { providers: { apiflux: { baseUrl: `${BASE_URL}/v1`, apiKey: "sk-old-secret", models: [] } } }, + }), + ); + const { conflicts } = openclawAdapter.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("existing different primary → conflict when a model is chosen", () => { + const home = tempHome(); + mkdirSync(join(home, ".openclaw"), { recursive: true }); + writeFileSync( + configPath(home), + JSON.stringify({ agents: { defaults: { model: { primary: "anthropic/claude-sonnet-5" } } } }), + ); + const { conflicts } = openclawAdapter.plan(home, { baseUrl: BASE_URL, key: KEY, model: "deepseek-v4-pro" }); + expect(conflicts.some((line) => line.includes("anthropic/claude-sonnet-5"))).toBe(true); + }); + + test("comments in config → warning that they will be stripped (matching official behavior)", () => { + const home = tempHome(); + mkdirSync(join(home, ".openclaw"), { recursive: true }); + writeFileSync(configPath(home), `{\n // tuned by hand\n gateway: { port: 1 },\n}`); + const { conflicts } = openclawAdapter.plan(home, { baseUrl: BASE_URL, key: KEY }); + expect(conflicts.length).toBe(1); + expect(conflicts[0].toLowerCase()).toContain("comment"); + }); + + test("comment-looking slashes inside strings are not flagged", () => { + const home = tempHome(); + mkdirSync(join(home, ".openclaw"), { recursive: true }); + writeFileSync( + configPath(home), + JSON.stringify({ models: { providers: { x: { baseUrl: "https://a//b/*c", models: [] } } } }), + ); + expect(openclawAdapter.plan(home, { baseUrl: BASE_URL, key: KEY }).conflicts).toEqual([]); + }); + + test("$include config → conflict announcing manual merge", () => { + const home = tempHome(); + mkdirSync(join(home, ".openclaw"), { recursive: true }); + writeFileSync(configPath(home), `{ "$include": "./base.json5" }`); + const { conflicts } = openclawAdapter.plan(home, { baseUrl: BASE_URL, key: KEY }); + expect(conflicts.length).toBe(1); + expect(conflicts[0].toLowerCase()).toContain("manual"); + }); +}); diff --git a/src/adapters/openclaw.ts b/src/adapters/openclaw.ts new file mode 100644 index 0000000..7720b60 --- /dev/null +++ b/src/adapters/openclaw.ts @@ -0,0 +1,166 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import JSON5 from "json5"; +import { withV1 } from "../endpoint"; +import type { Adapter, AdapterInput } from "./types"; +import { backupOnce } from "./backup"; + +// OpenClaw resolves its state dir and config path with env overrides +// (src/config/paths.ts); the adapter must honor the same ones. +function stateDir(home: string): string { + const override = process.env.OPENCLAW_STATE_DIR; + if (override) return override.startsWith("~/") ? join(home, override.slice(2)) : override; + return join(home, ".openclaw"); +} + +function configPath(home: string): string { + const override = process.env.OPENCLAW_CONFIG_PATH; + if (override) return override.startsWith("~/") ? join(home, override.slice(2)) : override; + return join(stateDir(home), "openclaw.json"); +} + +/** String-aware JSON5 comment scan, ported from OpenClaw's json5-comments.ts. */ +function hasJson5Comments(raw: string): boolean { + let quote: '"' | "'" | undefined; + for (let index = 0; index < raw.length; index += 1) { + const char = raw[index]; + if (quote) { + if (char === "\\") index += 1; + else if (char === quote) quote = undefined; + continue; + } + if (char === '"' || char === "'") { + quote = char; + continue; + } + if (char === "/" && (raw[index + 1] === "/" || raw[index + 1] === "*")) return true; + } + return false; +} + +interface ConfigFile { + data: Record; + raw: string | undefined; + /** File exists but cannot be safely rewritten ($include or broken JSON5). */ + unwritable: boolean; +} + +function readConfig(path: string): ConfigFile { + if (!existsSync(path)) return { data: {}, raw: undefined, unwritable: false }; + const raw = readFileSync(path, "utf8"); + try { + const data = JSON5.parse(raw); + // Rewriting an $include config would inline the includes; never do that. + const unwritable = typeof data === "object" && data !== null && "$include" in data; + return { data: unwritable ? {} : data, raw, unwritable }; + } catch { + return { data: {}, raw, unwritable: true }; + } +} + +function objectAt(parent: Record, key: string): Record { + const value = parent[key]; + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : {}; +} + +function apifluxProvider(input: AdapterInput): Record { + const modelIds = input.availableModels ?? (input.model === undefined ? [] : [input.model]); + return { + baseUrl: withV1(input.baseUrl), + // Literal key matches how OpenClaw's own Control UI stores provider keys; + // ${VAR} references abort config load when the var is missing. + apiKey: input.key, + api: "openai-completions", + // Official docs' minimal shape; the runtime fills remaining metadata. + models: modelIds.map((id) => ({ id, name: id })), + }; +} + +export const openclawAdapter: Adapter = { + id: "openclaw", + label: "OpenClaw", + + detect(home) { + return existsSync(stateDir(home)); + }, + + plan(home, input: AdapterInput) { + const conflicts: string[] = []; + const path = configPath(home); + const config = readConfig(path); + if (config.unwritable) { + conflicts.push( + "OpenClaw: openclaw.json uses $include or is not parseable; it will be left untouched and a manual snippet printed.", + ); + return { conflicts }; + } + const existing = objectAt(objectAt(objectAt(config.data, "models"), "providers"), "apiflux"); + const targetUrl = withV1(input.baseUrl); + if (typeof existing.baseUrl === "string" && existing.baseUrl !== targetUrl) { + conflicts.push(`OpenClaw: replace models.providers.apiflux baseUrl ${existing.baseUrl} → ${targetUrl}`); + } + if (typeof existing.apiKey === "string" && existing.apiKey !== input.key) { + // Never echo key values, old or new. + conflicts.push("OpenClaw: replace existing models.providers.apiflux apiKey (sk-***)"); + } + if (input.model !== undefined) { + const model = objectAt(objectAt(config.data, "agents"), "defaults").model; + const primary = + typeof model === "string" + ? model + : typeof model === "object" && model !== null + ? (model as Record).primary + : undefined; + const target = `apiflux/${input.model}`; + if (typeof primary === "string" && primary !== target) { + conflicts.push(`OpenClaw: replace agents.defaults.model.primary ${primary} → ${target}`); + } + } + if (config.raw !== undefined && hasJson5Comments(config.raw)) { + // Same caveat OpenClaw's own config writer prints. + conflicts.push("OpenClaw: rewriting openclaw.json will strip its JSON5 comments (matching OpenClaw's own writes)."); + } + return { conflicts }; + }, + + write(home, input: AdapterInput) { + const path = configPath(home); + const config = readConfig(path); + if (config.unwritable) { + return [ + `OpenClaw: ${path} uses $include or is not parseable; left untouched. Merge this under models.providers yourself:`, + ` "apiflux": ${JSON.stringify(apifluxProvider(input))}`, + ...(input.model === undefined + ? [] + : [` and set agents.defaults.model.primary to "apiflux/${input.model}"`]), + ]; + } + backupOnce(path); + const models = objectAt(config.data, "models"); + models.providers = { ...objectAt(models, "providers"), apiflux: apifluxProvider(input) }; + config.data.models = models; + if (input.model !== undefined) { + const agents = objectAt(config.data, "agents"); + const defaults = objectAt(agents, "defaults"); + const existingModel = defaults.model; + const carried = + typeof existingModel === "object" && existingModel !== null + ? (existingModel as Record) + : {}; + defaults.model = { ...carried, primary: `apiflux/${input.model}` }; + agents.defaults = defaults; + config.data.agents = agents; + } + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, JSON.stringify(config.data, null, 2) + "\n"); + const notes = [`OpenClaw: wrote models.providers.apiflux to ${path}`]; + notes.push( + input.model !== undefined + ? `OpenClaw: default model set to apiflux/${input.model}` + : "OpenClaw: pick a model with: openclaw models (provider apiflux)", + ); + return notes; + }, +}; diff --git a/src/args.ts b/src/args.ts index 7ce7157..2d96406 100644 --- a/src/args.ts +++ b/src/args.ts @@ -1,4 +1,4 @@ -export const KNOWN_TOOLS = ["claude-code", "codex", "opencode", "pi", "hermes", "export"] as const; +export const KNOWN_TOOLS = ["claude-code", "codex", "opencode", "pi", "hermes", "openclaw", "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 230ed4a..21854d7 100644 --- a/src/init.test.ts +++ b/src/init.test.ts @@ -182,6 +182,34 @@ describe("hermes wiring", () => { }); }); +describe("openclaw wiring", () => { + test("--tool openclaw registers all verified models with literal apiKey", async () => { + const savedStateDir = process.env.OPENCLAW_STATE_DIR; + const savedConfigPath = process.env.OPENCLAW_CONFIG_PATH; + delete process.env.OPENCLAW_STATE_DIR; + delete process.env.OPENCLAW_CONFIG_PATH; + try { + const { home, log } = setup(); + mkdirSync(join(home, ".openclaw")); + const args = parseArgs(["init", "--key", KEY, "--base-url", baseUrl, "--tool", "openclaw", "--yes"]); + const code = await runInit(args, { home, log }); + expect(code).toBe(0); + const config = JSON.parse(readFileSync(join(home, ".openclaw", "openclaw.json"), "utf8")); + expect(config.models.providers.apiflux.models.map((m: any) => m.id).sort()).toEqual([ + "claude-opus-5", + "deepseek-v4-pro", + "gpt-5.5", + ]); + expect(config.models.providers.apiflux.apiKey).toBe(KEY); + } finally { + if (savedStateDir === undefined) delete process.env.OPENCLAW_STATE_DIR; + else process.env.OPENCLAW_STATE_DIR = savedStateDir; + if (savedConfigPath === undefined) delete process.env.OPENCLAW_CONFIG_PATH; + else process.env.OPENCLAW_CONFIG_PATH = savedConfigPath; + } + }); +}); + 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 946a9ae..674b85f 100644 --- a/src/init.ts +++ b/src/init.ts @@ -6,6 +6,7 @@ import { genericExportAdapter } from "./adapters/generic-export"; import { opencodeAdapter } from "./adapters/opencode"; import { piAdapter } from "./adapters/pi"; import { hermesAdapter } from "./adapters/hermes"; +import { openclawAdapter } from "./adapters/openclaw"; import { normalizeBaseUrl } from "./endpoint"; import { maskKeysIn } from "./mask"; import { groupModelsByMaker, type ModelGroup } from "./model-groups"; @@ -21,6 +22,7 @@ export const ADAPTERS: Record = { opencode: opencodeAdapter, pi: piAdapter, hermes: hermesAdapter, + openclaw: openclawAdapter, export: genericExportAdapter, };