From 49cd313bcf93d146c729828f743e137371bdfef0 Mon Sep 17 00:00:00 2001 From: Kieran Zhang Date: Wed, 29 Jul 2026 18:45:08 +0800 Subject: [PATCH] =?UTF-8?q?feat(adapters):=20Pi=20=E9=80=82=E9=85=8D?= =?UTF-8?q?=E5=99=A8=EF=BC=88models.json=20=E5=85=A8=E9=87=8F=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E6=B3=A8=E5=86=8C=E3=80=81key=20=E5=85=A5=E5=8E=9F?= =?UTF-8?q?=E7=94=9F=20auth.json=E3=80=81settings.json=20=E9=92=89?= =?UTF-8?q?=E9=BB=98=E8=AE=A4=E6=A8=A1=E5=9E=8B=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - providers.apiflux 写入 ~/.pi/agent/models.json(openai-completions + /v1,模型仅需 id——元数据全可选已核实) - key 只进 pi 凭证库 auth.json(type=api_key,0600),stored credential 优先级已核实(provider-composer.ts) - --model 时 settings.json 写 defaultProvider/defaultModel 组合(resolver 要求两者同有) - pi 的 JSONC 配置安全降级:解析失败不动文件、打印手动 merge snippet - 路径尊重 PI_CODING_AGENT_DIR;.scratch issue 05 核对项闭环 --- .../issues/03-batch2-harnesses.md | 2 +- .../issues/05-pi-agent-adapter.md | 18 +- README.md | 3 +- package.json | 2 +- src/adapters/pi.test.ts | 196 ++++++++++++++++++ src/adapters/pi.ts | 138 ++++++++++++ src/args.ts | 2 +- src/init.test.ts | 25 +++ src/init.ts | 2 + 9 files changed, 377 insertions(+), 11 deletions(-) create mode 100644 src/adapters/pi.test.ts create mode 100644 src/adapters/pi.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 ada104f..3f3faed 100644 --- a/.scratch/model-selection-and-harnesses/issues/03-batch2-harnesses.md +++ b/.scratch/model-selection-and-harnesses/issues/03-batch2-harnesses.md @@ -10,7 +10,7 @@ Status: in-progress(2026-07-29 调研完成,已拆出 04–07) ## 清单 -- [ ] Pi Agent → `05-pi-agent-adapter.md` +- [x] Pi Agent → `05-pi-agent-adapter.md`(适配器已实现,待冒烟发版) - [x] OpenCode → `04-opencode-adapter.md`(适配器已实现,待冒烟发版) - [ ] Openclaw → `06-openclaw-adapter.md` - [ ] Hermes → `07-hermes-adapter.md` diff --git a/.scratch/model-selection-and-harnesses/issues/05-pi-agent-adapter.md b/.scratch/model-selection-and-harnesses/issues/05-pi-agent-adapter.md index c681228..a7a91bd 100644 --- a/.scratch/model-selection-and-harnesses/issues/05-pi-agent-adapter.md +++ b/.scratch/model-selection-and-harnesses/issues/05-pi-agent-adapter.md @@ -1,6 +1,6 @@ # 05 Pi Agent 适配器 -Status: ready-for-agent(调研已落,2026-07-29) +Status: implemented(2026-07-29 适配器+测试完成,待真机冒烟后发 minor) 从 03 占位拆出。Pi = badlogic/pi-mono(earendil-works/pi 镜像)coding agent,bin `pi`。 @@ -28,13 +28,17 @@ Status: ready-for-agent(调研已落,2026-07-29) - 默认模型:交互 `/model` 或 `--model` 旗标;**落盘持久化机制(settings.json?)文档未写清,实施时读源码确认**。 - Anthropic 兼容 compat 项(supportsStrictTools 等)仅在走 anthropic-messages 时需要,走 openai-completions 可忽略。 -## 待实施核对项 +## 核对结论(读 badlogic/pi-mono 源码,2026-07-29) -- [ ] 默认模型持久化位置(~/.pi/agent/settings.json?) -- [ ] models.json 与内置 provider 的 merge 语义(同名覆盖?) -- [ ] detect 依据:`~/.pi/` 目录 +- [x] **模型元数据全可选**:ModelDefinitionSchema 只有 `id` 必填(model-config.ts),"要求全量元数据"是过时文档误导 → models 数组只写 `{id}`。 +- [x] 默认模型持久化:`~/.pi/agent/settings.json` 的 `defaultProvider` + `defaultModel`(resolver 要求两者同有且 provider 已配置鉴权,model-resolver.ts)。 +- [x] key 走 pi 原生凭证库 `~/.pi/agent/auth.json`:`{"apiflux":{"type":"api_key","key":...}}`(0600/目录 0700);provider-composer.ts 里 stored credential 优先于 models.json `apiKey` 字段(后者另支持 `$ENV`/`!命令`)。 +- [x] merge:models.json 的 providers 与内置按 id 叠加,新 id `apiflux` 纯新增无冲突。 +- [x] detect:`~/.pi/`;路径尊重 `PI_CODING_AGENT_DIR` 覆盖(config.ts getAgentDir)。 +- [x] ⚠️ pi 读 JSON 会 strip 注释(JSONC):适配器安全读取,解析失败不动文件、打印手动 snippet(models.json/settings.json 各自独立降级,auth.json 照写)。 ## 方案 -- 写 `providers.apiflux`(openai-completions + withV1),key 字面量;仅写入用户选中的模型(不同步全目录),contextWindow/maxTokens 用保守默认(128k/32k),cost 全 0(网关侧计费,本地显示不作数——README 注明)。 -- 未选模型时的行为:Pi 要求 models 数组非空才有意义,倾向「未选模型则提示必须 --model / 交互选择」——实施时定。 +- 已实现 `src/adapters/pi.ts`:models.json 写 `providers.apiflux`(openai-completions + withV1 + 全量模型 `{id}` 列表);auth.json 写凭证(0600);选中模型时 settings.json 写 defaultProvider/defaultModel。 +- plan() 冲突项:baseUrl 变更 / auth key 变更(脱敏)/ 默认模型变更 / JSONC 无法解析(manual merge 提示)。 +- 冒烟步骤:`apiflux init --tool pi --model ` → 启动 pi 确认 /model 列出 ApiFlux 模型、默认模型生效、对话跑通。 diff --git a/README.md b/README.md index a4041ea..c5e806e 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ Paste your ApiFlux API key when prompted, pick the tools to configure, done. The | Claude Code | `ANTHROPIC_BASE_URL` / `ANTHROPIC_AUTH_TOKEN` in `~/.claude/settings.json` | | 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` | | 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. @@ -35,7 +36,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`, `export` | +| `--tool ` | Configure only this tool (repeatable): `claude-code`, `codex`, `opencode`, `pi`, `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/package.json b/package.json index 385edac..042a2a0 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "apiflux-cli", "version": "0.4.0", - "description": "One-command setup of ApiFlux for local AI coding tools (Claude Code, Codex CLI, OpenCode)", + "description": "One-command setup of ApiFlux for local AI coding tools (Claude Code, Codex CLI, OpenCode, Pi)", "type": "module", "bin": { "apiflux": "./dist/apiflux.js" diff --git a/src/adapters/pi.test.ts b/src/adapters/pi.test.ts new file mode 100644 index 0000000..45b6d5f --- /dev/null +++ b/src/adapters/pi.test.ts @@ -0,0 +1,196 @@ +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 { piAdapter } from "./pi"; + +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-pi-")); +} + +function agentDir(home: string): string { + return join(home, ".pi", "agent"); +} + +function readJson(path: string): any { + return JSON.parse(readFileSync(path, "utf8")); +} + +// Pi honors PI_CODING_AGENT_DIR (config.ts getAgentDir); pin it to unset so +// paths derive from the temp home. +let savedAgentDir: string | undefined; +beforeEach(() => { + savedAgentDir = process.env.PI_CODING_AGENT_DIR; + delete process.env.PI_CODING_AGENT_DIR; +}); +afterEach(() => { + if (savedAgentDir === undefined) delete process.env.PI_CODING_AGENT_DIR; + else process.env.PI_CODING_AGENT_DIR = savedAgentDir; +}); + +describe("piAdapter.detect", () => { + test("false without ~/.pi, true with it", () => { + const home = tempHome(); + expect(piAdapter.detect(home)).toBe(false); + mkdirSync(join(home, ".pi")); + expect(piAdapter.detect(home)).toBe(true); + }); + + test("honors PI_CODING_AGENT_DIR override", () => { + const home = tempHome(); + process.env.PI_CODING_AGENT_DIR = join(home, "custom-agent"); + expect(piAdapter.detect(home)).toBe(false); + mkdirSync(join(home, "custom-agent"), { recursive: true }); + expect(piAdapter.detect(home)).toBe(true); + }); +}); + +describe("piAdapter.write", () => { + test("writes provider to models.json without key material and key to auth.json", () => { + const home = tempHome(); + const notes = piAdapter.write(home, { baseUrl: BASE_URL, key: KEY, availableModels: MODELS }); + const models = readJson(join(agentDir(home), "models.json")); + const provider = models.providers.apiflux; + expect(provider.name).toBe("ApiFlux"); + expect(provider.baseUrl).toBe(`${BASE_URL}/v1`); + expect(provider.api).toBe("openai-completions"); + expect(provider.models.map((m: any) => m.id).sort()).toEqual([...MODELS].sort()); + expect(readFileSync(join(agentDir(home), "models.json"), "utf8")).not.toContain(KEY); + const auth = readJson(join(agentDir(home), "auth.json")); + expect(auth.apiflux).toEqual({ type: "api_key", key: KEY }); + expect(statSync(join(agentDir(home), "auth.json")).mode & 0o777).toBe(0o600); + expect(notes.join("\n")).toContain("models.json"); + }); + + test("chosen model persists defaultProvider/defaultModel in settings.json", () => { + const home = tempHome(); + piAdapter.write(home, { + baseUrl: BASE_URL, + key: KEY, + model: "deepseek-v4-pro", + availableModels: MODELS, + }); + const settings = readJson(join(agentDir(home), "settings.json")); + expect(settings.defaultProvider).toBe("apiflux"); + expect(settings.defaultModel).toBe("deepseek-v4-pro"); + }); + + test("no chosen model leaves settings.json untouched", () => { + const home = tempHome(); + mkdirSync(agentDir(home), { recursive: true }); + writeFileSync( + join(agentDir(home), "settings.json"), + JSON.stringify({ defaultProvider: "anthropic", defaultModel: "claude-sonnet-5", theme: "dark" }), + ); + piAdapter.write(home, { baseUrl: BASE_URL, key: KEY, availableModels: MODELS }); + const settings = readJson(join(agentDir(home), "settings.json")); + expect(settings.defaultProvider).toBe("anthropic"); + expect(settings.defaultModel).toBe("claude-sonnet-5"); + expect(settings.theme).toBe("dark"); + }); + + test("without availableModels falls back to the chosen model only", () => { + const home = tempHome(); + piAdapter.write(home, { baseUrl: BASE_URL, key: KEY, model: "kimi-k2.6" }); + const models = readJson(join(agentDir(home), "models.json")); + expect(models.providers.apiflux.models.map((m: any) => m.id)).toEqual(["kimi-k2.6"]); + }); + + test("preserves other providers, credentials, and settings keys", () => { + const home = tempHome(); + mkdirSync(agentDir(home), { recursive: true }); + writeFileSync( + join(agentDir(home), "models.json"), + JSON.stringify({ + providers: { ollama: { baseUrl: "http://localhost:11434/v1", api: "openai-completions" } }, + }), + ); + writeFileSync(join(agentDir(home), "auth.json"), JSON.stringify({ openrouter: { type: "api_key", key: "sk-or-1" } })); + piAdapter.write(home, { baseUrl: BASE_URL, key: KEY, availableModels: MODELS }); + const models = readJson(join(agentDir(home), "models.json")); + expect(models.providers.ollama.baseUrl).toBe("http://localhost:11434/v1"); + expect(models.providers.apiflux.baseUrl).toBe(`${BASE_URL}/v1`); + const auth = readJson(join(agentDir(home), "auth.json")); + expect(auth.openrouter).toEqual({ type: "api_key", key: "sk-or-1" }); + expect(auth.apiflux).toEqual({ type: "api_key", key: KEY }); + }); + + test("backs up existing models.json once and is idempotent", () => { + const home = tempHome(); + mkdirSync(agentDir(home), { recursive: true }); + writeFileSync(join(agentDir(home), "models.json"), JSON.stringify({ providers: {} })); + piAdapter.write(home, { baseUrl: BASE_URL, key: KEY, availableModels: MODELS }); + const first = readFileSync(join(agentDir(home), "models.json"), "utf8"); + piAdapter.write(home, { baseUrl: BASE_URL, key: KEY, availableModels: MODELS }); + expect(readFileSync(join(agentDir(home), "models.json"), "utf8")).toBe(first); + const backups = readdirSync(agentDir(home)).filter((f) => f.startsWith("models.json.bak.")); + expect(backups.length).toBe(1); + }); + + test("unparseable models.json (JSONC comments) is left untouched with manual instructions", () => { + const home = tempHome(); + mkdirSync(agentDir(home), { recursive: true }); + const original = `{\n // my hand-tuned providers\n "providers": {}\n}\n`; + writeFileSync(join(agentDir(home), "models.json"), original); + const notes = piAdapter.write(home, { baseUrl: BASE_URL, key: KEY, availableModels: MODELS }); + expect(readFileSync(join(agentDir(home), "models.json"), "utf8")).toBe(original); + // Manual snippet must still be offered, and auth.json still written. + expect(notes.join("\n")).toContain('"apiflux"'); + expect(readJson(join(agentDir(home), "auth.json")).apiflux.key).toBe(KEY); + }); +}); + +describe("piAdapter.plan", () => { + test("no config → no conflicts", () => { + expect(piAdapter.plan(tempHome(), { baseUrl: BASE_URL, key: KEY }).conflicts).toEqual([]); + }); + + test("existing apiflux provider with different baseUrl → conflict", () => { + const home = tempHome(); + mkdirSync(agentDir(home), { recursive: true }); + writeFileSync( + join(agentDir(home), "models.json"), + JSON.stringify({ providers: { apiflux: { baseUrl: "https://old.example.com/v1" } } }), + ); + const { conflicts } = piAdapter.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/provider → conflicts when a model is chosen", () => { + const home = tempHome(); + mkdirSync(agentDir(home), { recursive: true }); + writeFileSync( + join(agentDir(home), "settings.json"), + JSON.stringify({ defaultProvider: "anthropic", defaultModel: "claude-sonnet-5" }), + ); + const { conflicts } = piAdapter.plan(home, { baseUrl: BASE_URL, key: KEY, model: "deepseek-v4-pro" }); + expect(conflicts.some((line) => line.includes("claude-sonnet-5"))).toBe(true); + }); + + test("existing different auth key → conflict without echoing key material", () => { + const home = tempHome(); + mkdirSync(agentDir(home), { recursive: true }); + writeFileSync( + join(agentDir(home), "auth.json"), + JSON.stringify({ apiflux: { type: "api_key", key: "sk-old-secret" } }), + ); + const { conflicts } = piAdapter.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 models.json → conflict announcing manual merge", () => { + const home = tempHome(); + mkdirSync(agentDir(home), { recursive: true }); + writeFileSync(join(agentDir(home), "models.json"), `{ // comment\n "providers": {} }`); + const { conflicts } = piAdapter.plan(home, { baseUrl: BASE_URL, key: KEY }); + expect(conflicts.length).toBe(1); + expect(conflicts[0].toLowerCase()).toContain("manual"); + }); +}); diff --git a/src/adapters/pi.ts b/src/adapters/pi.ts new file mode 100644 index 0000000..0cff388 --- /dev/null +++ b/src/adapters/pi.ts @@ -0,0 +1,138 @@ +import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { withV1 } from "../endpoint"; +import type { Adapter, AdapterInput } from "./types"; +import { backupOnce } from "./backup"; + +// Pi resolves everything under one agent dir (config.ts getAgentDir), with a +// PI_CODING_AGENT_DIR override the adapter must honor. +function agentDir(home: string): string { + const override = process.env.PI_CODING_AGENT_DIR; + if (override) return override.startsWith("~/") ? join(home, override.slice(2)) : override; + return join(home, ".pi", "agent"); +} + +interface JsonFile { + data: Record; + /** Pi accepts JSONC; we don't. Set when the file exists but JSON.parse fails. */ + unparseable: boolean; +} + +function readJson(path: string): JsonFile { + if (!existsSync(path)) return { data: {}, unparseable: false }; + try { + return { data: JSON.parse(readFileSync(path, "utf8")), unparseable: false }; + } catch { + return { data: {}, unparseable: true }; + } +} + +function objectAt(parent: Record, key: string): Record { + const value = parent[key]; + return typeof value === "object" && value !== null ? (value as Record) : {}; +} + +function apifluxProvider(input: AdapterInput): Record { + const modelIds = input.availableModels ?? (input.model === undefined ? [] : [input.model]); + return { + name: "ApiFlux", + baseUrl: withV1(input.baseUrl), + api: "openai-completions", + // Only `id` is required; Pi treats all model metadata as optional. + models: modelIds.map((id) => ({ id })), + }; +} + +export const piAdapter: Adapter = { + id: "pi", + label: "Pi", + + detect(home) { + return process.env.PI_CODING_AGENT_DIR + ? existsSync(agentDir(home)) + : existsSync(join(home, ".pi")); + }, + + plan(home, input: AdapterInput) { + const dir = agentDir(home); + const conflicts: string[] = []; + const models = readJson(join(dir, "models.json")); + if (models.unparseable) { + conflicts.push( + "Pi: models.json is not plain JSON (comments?); it will be left untouched and a manual snippet printed.", + ); + } else { + const existing = objectAt(objectAt(models.data, "providers"), "apiflux"); + const targetUrl = withV1(input.baseUrl); + if (typeof existing.baseUrl === "string" && existing.baseUrl !== targetUrl) { + conflicts.push(`Pi: replace providers.apiflux baseUrl ${existing.baseUrl} → ${targetUrl}`); + } + } + const auth = readJson(join(dir, "auth.json")); + const credential = objectAt(auth.data, "apiflux"); + if (typeof credential.key === "string" && credential.key !== input.key) { + // Never echo key values, old or new. + conflicts.push("Pi: replace existing auth.json apiflux key (sk-***)"); + } + if (input.model !== undefined) { + const settings = readJson(join(dir, "settings.json")).data; + const { defaultProvider, defaultModel } = settings; + if ( + typeof defaultModel === "string" && + (defaultModel !== input.model || defaultProvider !== "apiflux") + ) { + conflicts.push(`Pi: replace default model ${defaultProvider}/${defaultModel} → apiflux/${input.model}`); + } + } + return { conflicts }; + }, + + write(home, input: AdapterInput) { + const dir = agentDir(home); + mkdirSync(dir, { recursive: true, mode: 0o700 }); + const notes: string[] = []; + + const modelsPath = join(dir, "models.json"); + const models = readJson(modelsPath); + if (models.unparseable) { + notes.push( + `Pi: ${modelsPath} is not plain JSON; left untouched. Merge this under "providers" yourself:`, + ` "apiflux": ${JSON.stringify(apifluxProvider(input))}`, + ); + } else { + backupOnce(modelsPath); + models.data.providers = { + ...objectAt(models.data, "providers"), + apiflux: apifluxProvider(input), + }; + writeFileSync(modelsPath, JSON.stringify(models.data, null, 2) + "\n"); + notes.push(`Pi: wrote providers.apiflux to ${modelsPath}`); + } + + // The key goes only into Pi's credential store, matching its own 0600 mode. + const authPath = join(dir, "auth.json"); + backupOnce(authPath); + const auth = readJson(authPath); + auth.data.apiflux = { type: "api_key", key: input.key }; + writeFileSync(authPath, JSON.stringify(auth.data, null, 2) + "\n"); + chmodSync(authPath, 0o600); + notes.push(`Pi: wrote key to ${authPath}`); + + if (input.model !== undefined) { + const settingsPath = join(dir, "settings.json"); + const settings = readJson(settingsPath); + if (settings.unparseable) { + notes.push(`Pi: ${settingsPath} is not plain JSON; pick the default model inside pi with /model.`); + } else { + backupOnce(settingsPath); + settings.data.defaultProvider = "apiflux"; + settings.data.defaultModel = input.model; + writeFileSync(settingsPath, JSON.stringify(settings.data, null, 2) + "\n"); + notes.push(`Pi: default model set to apiflux/${input.model}`); + } + } else { + notes.push("Pi: pick a model inside pi with /model (provider ApiFlux)"); + } + return notes; + }, +}; diff --git a/src/args.ts b/src/args.ts index 4a8e9dd..38951fd 100644 --- a/src/args.ts +++ b/src/args.ts @@ -1,4 +1,4 @@ -export const KNOWN_TOOLS = ["claude-code", "codex", "opencode", "export"] as const; +export const KNOWN_TOOLS = ["claude-code", "codex", "opencode", "pi", "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 aa4c61d..ffa6b16 100644 --- a/src/init.test.ts +++ b/src/init.test.ts @@ -135,6 +135,31 @@ describe("opencode wiring", () => { }); }); +describe("pi wiring", () => { + test("--tool pi registers all verified models and stores the key in auth.json", async () => { + const savedAgentDir = process.env.PI_CODING_AGENT_DIR; + delete process.env.PI_CODING_AGENT_DIR; + try { + const { home, log } = setup(); + mkdirSync(join(home, ".pi")); + const args = parseArgs(["init", "--key", KEY, "--base-url", baseUrl, "--tool", "pi", "--yes"]); + const code = await runInit(args, { home, log }); + expect(code).toBe(0); + const models = JSON.parse(readFileSync(join(home, ".pi", "agent", "models.json"), "utf8")); + expect(models.providers.apiflux.models.map((m: any) => m.id).sort()).toEqual([ + "claude-opus-5", + "deepseek-v4-pro", + "gpt-5.5", + ]); + const auth = JSON.parse(readFileSync(join(home, ".pi", "agent", "auth.json"), "utf8")); + expect(auth.apiflux).toEqual({ type: "api_key", key: KEY }); + } finally { + if (savedAgentDir === undefined) delete process.env.PI_CODING_AGENT_DIR; + else process.env.PI_CODING_AGENT_DIR = savedAgentDir; + } + }); +}); + 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 59fae2c..b69a6bb 100644 --- a/src/init.ts +++ b/src/init.ts @@ -4,6 +4,7 @@ import { claudeCodeAdapter } from "./adapters/claude-code"; import { codexAdapter } from "./adapters/codex"; import { genericExportAdapter } from "./adapters/generic-export"; import { opencodeAdapter } from "./adapters/opencode"; +import { piAdapter } from "./adapters/pi"; import { normalizeBaseUrl } from "./endpoint"; import { maskKeysIn } from "./mask"; import { groupModelsByMaker, type ModelGroup } from "./model-groups"; @@ -17,6 +18,7 @@ export const ADAPTERS: Record = { "claude-code": claudeCodeAdapter, codex: codexAdapter, opencode: opencodeAdapter, + pi: piAdapter, export: genericExportAdapter, };