@yhong91/cpac 0.2.3 → 0.2.4

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/README.md CHANGED
@@ -10,7 +10,7 @@ CPAC 将 Codex 和 Claude Code 接入远端 CLIProxyAPI(CPA):
10
10
  ## 要求
11
11
 
12
12
  - [Node.js](https://nodejs.org/) 20 或更新版本(npm 随 Node 安装)
13
- - 已安装需要使用的 agent:`codex`、`claude`、`pi`、`kimi`、`grok`、`zed`、`hermes` 或 `cline`
13
+ - 已安装需要使用的 agent:`codex`、`claude`、`pi`、`kimi`、`grok`、`zed`、`hermes` 或 `codebuddy`
14
14
  - 有权访问的 CPA API key
15
15
 
16
16
  ## 安装
@@ -37,7 +37,7 @@ echo 'export CPA_API_KEY="<你的key>"' >> ~/.zshrc && source ~/.zshrc
37
37
  cpac status
38
38
 
39
39
  # 4a. 持久注入(长期生效,写入 agent 配置,可完整恢复)
40
- cpac install --all # 注入所有检测到的目标(codex/pi/kimi/grok/zed/hermes/cline)
40
+ cpac install --all # 注入所有检测到的目标(codex/pi/kimi/grok/zed/hermes/codebuddy)
41
41
  cpac install codex # 只注入 Codex(默认开启 multi-agent v2)
42
42
  cpac install codex --v2_models # 先多选 spawn_agent 模型再注入
43
43
  cpac install codex --max_context # 注入时把模型上下文提升到实测上限(opt-in,仅限 codex)
@@ -61,7 +61,7 @@ cpac upgrade
61
61
 
62
62
  本节面向 AI agent:按顺序执行即可完成安装与配置,**全程无交互**。注意:`cpac codex --config` 和 `cpac claude --config` 的交互向导是 TTY-only,agent 必须走下方直接写配置 JSON 的路径。
63
63
 
64
- 1. **检查前置**:`node --version` ≥ 20;确认用户需要哪些 agent(`codex`/`claude`/`pi`/`kimi`/`grok`/`zed`/`hermes`/`cline` 在 PATH 上)。向用户索取 CPA API key。
64
+ 1. **检查前置**:`node --version` ≥ 20;确认用户需要哪些 agent(`codex`/`claude`/`pi`/`kimi`/`grok`/`zed`/`hermes`/`codebuddy` 在 PATH 上)。向用户索取 CPA API key。
65
65
 
66
66
  2. **安装与密钥**:
67
67
 
@@ -90,14 +90,14 @@ cpac status # 验证:各 target 显示 ready/injected
90
90
  ```
91
91
 
92
92
  1. **统一启动与配置**:
93
- - 启动:`cpac <codex|claude|kimi|grok|pi|zed|hermes|cline> [args...]`(所有原生参数如 `-c` 等原样透明传递)
93
+ - 启动:`cpac <codex|claude|kimi|grok|pi|zed|hermes|codebuddy> [args...]`(所有原生参数如 `-c` 等原样透明传递)
94
94
  - `setup` / `--setup`:配置 Codex/Claude,然后启动
95
95
  - `clear`:还原原生配置
96
96
  - 只写配置、不启动:`cpac setup [claude|codex]`;查看当前:`cpac setup`
97
97
 
98
98
  2. **已安装后升级**:npm 发布新版本后,等 publish 成功再执行 `cpac upgrade`。它会按需 `npm install -g`,再对已接入的 Codex / Pi / Kimi / Grok / Zed / Hermes 做 `sync`。不要拆成 `npm install -g @yhong91/cpac` 再手动 `cpac sync`。只刷新配置、不升 CLI 时才用 `cpac sync`。
99
99
 
100
- 失败处理:任何一步报错时先跑 `cpac status`;密钥未配置回到步骤 2;注入后异常用 `cpac <agent> clear` 或 `cpac restore <codex|pi|kimi|grok|zed|hermes|cline>` 恢复原配置。
100
+ 失败处理:任何一步报错时先跑 `cpac status`;密钥未配置回到步骤 2;注入后异常用 `cpac <agent> clear` 或 `cpac restore <codex|pi|kimi|grok|zed|hermes|codebuddy>` 恢复原配置。
101
101
 
102
102
  ## 首次引导
103
103
 
@@ -114,7 +114,7 @@ Commands:
114
114
  cpac setup Show current Codex/Claude setup
115
115
  cpac status Show agent support status
116
116
  cpac models List remote CPA catalog models
117
- cpac restore <codex|pi|kimi|grok|zed|hermes|cline> | --all Detach injected agents
117
+ cpac restore <codex|pi|kimi|grok|zed|hermes|codebuddy> | --all Detach injected agents
118
118
  cpac --help Show command usage
119
119
  ```
120
120
 
@@ -162,14 +162,14 @@ cpac --help
162
162
 
163
163
  ### 统一管理(detect / install / sync / restore / uninstall / upgrade)
164
164
 
165
- 参考 vibetime 的目标式命令,对 `codex`、`pi`、`kimi`、`grok`、`zed`、`hermes`、`cline` 七个持久注入目标统一管理(claude 是临时接管,用 `cpac claude` 启动,不属于 install 目标):
165
+ 参考 vibetime 的目标式命令,对 `codex`、`pi`、`kimi`、`grok`、`zed`、`hermes`、`codebuddy` 七个持久注入目标统一管理(claude 是临时接管,用 `cpac claude` 启动,不属于 install 目标):
166
166
 
167
167
  ```bash
168
168
  cpac detect [--json]
169
169
  cpac models [--json]
170
- cpac install [codex|pi|kimi|grok|zed|hermes|cline...] [--all] [--dry-run] [--force] [--v2_off] [--v2_models] [--max_context]
171
- cpac sync [codex|pi|kimi|grok|zed|hermes|cline...] [--all] [--dry-run] [--v2_off] [--v2_models] [--max_context]
172
- cpac restore <codex|pi|kimi|grok|zed|hermes|cline...> | --all [--dry-run]
170
+ cpac install [codex|pi|kimi|grok|zed|hermes|codebuddy...] [--all] [--dry-run] [--force] [--v2_off] [--v2_models] [--max_context]
171
+ cpac sync [codex|pi|kimi|grok|zed|hermes|codebuddy...] [--all] [--dry-run] [--v2_off] [--v2_models] [--max_context]
172
+ cpac restore <codex|pi|kimi|grok|zed|hermes|codebuddy...> | --all [--dry-run]
173
173
  cpac uninstall [--dry-run]
174
174
  cpac upgrade [--check]
175
175
  cpac -v | --version
@@ -177,13 +177,13 @@ cpac -v | --version
177
177
 
178
178
  - `models` 从远端 CPA 拉取 rich catalog,默认一行一个 slug;`--json` 输出 `cpa_url` 和模型字段(`slug` / `display_name` / `context_window`)。用来核对 catalog 里有没有某个模型(例如 `*-fast`),不写本地配置。
179
179
  - `detect` 列出每个目标是否被检测到、是否已接入 CPAC;`--json` 输出机器可读结果。
180
- - `install` 默认作用于检测到的目标;后面直接写 agent 名(`cpac install codex pi`),`--all` 全部。`codex` 写入 catalog 并拉起 loopback 代理;`pi`/`kimi`/`grok`/`zed`/`hermes`/`cline` 写入各自配置。`--dry-run` 只打印将要做的事,`--force` 允许重装已安装目标。
181
- - `sync` 按当前 `cpa_url` 和 CPA 目录强制刷新**已经接入**的目标(Codex 重新注入、Pi / Kimi / Grok / Zed / Hermes / Cline 配置重写),用来在 CPA 地址或模型目录变化后一次对齐本地配置。不带参数只动已安装目标;`--all` 会对检测到的目标做同样的强制刷新(尚未安装的会装上)。`--max_context` 仍只对 `cpac install codex` 有效。
180
+ - `install` 默认作用于检测到的目标;后面直接写 agent 名(`cpac install codex pi`),`--all` 全部。`codex` 写入 catalog 并拉起 loopback 代理;`pi`/`kimi`/`grok`/`zed`/`hermes`/`codebuddy` 写入各自配置。`--dry-run` 只打印将要做的事,`--force` 允许重装已安装目标。
181
+ - `sync` 把 CPA 模型列表和更新时间写到 `state_dir/models.json`(默认 `~/.cpac/models.json`)。这个时间戳就是模型版本:列表没变则时间戳不变,目标上次同步到的版本相同就跳过,不同才重写。不带参数只看已安装目标;`--all` 还会装上检测到但还没接入的目标。`install` 仍总是重写。`spawn_models`、`v2_off`、`max_context` 和代理端口不改这个版本,要马上生效用 `cpac install`。`--max_context` 仍只对 `cpac install codex` 有效。
182
182
  - Codex 注入默认开启 multi-agent v2(写入 `[features.multi_agent_v2] enabled = true`,catalog 行打 `multi_agent_version: "v2"`);`install codex --v2_off` 关掉 toml 并去掉 catalog 戳。v2 开着时会剥掉 `[agents] max_threads`(Codex 否则拒启动)。`cpac status` 对照 config 里的 `v2_off` / `max_context` 和 live toml / catalog。服务端还需在 CPA 配置中开启 `codex.optimize-multi-agent-v2`。
183
183
  - `--v2_models` 进入 arrow-key 复选框选择最多 5 个 spawn_agent 模型(勾选顺序即推荐顺序),选择结果存入配置文件的 `spawn_models`,注入时把这些模型排到 `codex-models.json` 最前——Codex 客户端只把 catalog 前 5 个可见模型广告为 spawn 覆盖项。
184
184
  - `restore` 必须带 agent 名或 `--all`,卸掉指定(或全部)已注入目标。`--all` 时未安装的目标静默跳过。
185
185
  - `uninstall` 先用和 `cpac setup codex` 相同的方向键选择器确认(默认 Cancel),再 `restore --all`,最后 `npm uninstall -g @yhong91/cpac`。不要跟 agent 名;卸载的是 cpac 包本身。非终端取消。`--dry-run` 跳过确认。
186
- - `upgrade` 是发布后更新本机的一次性入口:查 npm → 需要时 `npm install -g` → 对**已接入**的 Codex / Pi / Kimi / Grok / Zed / Hermes / Cline 执行 `sync`。已是最新也会 sync。不要先手动 `npm install -g` 再 `cpac sync`。`--check` 只报告版本,不安装也不 sync。没有已安装目标时跳过 sync,不报错。只刷新配置、不升 CLI 时用 `cpac sync`。
186
+ - `upgrade` 是发布后更新本机的一次性入口:查 npm → 需要时 `npm install -g` → 对**已接入**的 Codex / Pi / Kimi / Grok / Zed / Hermes / CodeBuddy 执行 `sync`。已是最新也会 sync。不要先手动 `npm install -g` 再 `cpac sync`。`--check` 只报告版本,不安装也不 sync。没有已安装目标时跳过 sync,不报错。只刷新配置、不升 CLI 时用 `cpac sync`。
187
187
 
188
188
  ### Claude Code
189
189
 
@@ -253,9 +253,9 @@ Codex App 的长驻 `app-server` 可能缓存旧目录。注入会删除 `models
253
253
 
254
254
  注入命令可选 `--max_context`(`cpac install codex --max_context`,默认不带):把 catalog 中 `max_context_window` 高于 `context_window` 的模型提升到上限,并把 `auto_compact_token_limit` 设为上限的 90%。Codex 把 `context_window` 当输入预算而非展示标签,上游默认保留保守运营值(如 GPT-5.6 家族 272k),带上该参数后可用到实测上限(约 921k,opencodex 实测);90% 压缩线确保在硬上限前触发 auto-compact。不带参数注入的仍是原本上下文的目录。该参数仅限 codex,与其他 target 组合使用会直接报错。
255
255
 
256
- `config.toml` 不存在时拒绝注入。当前是 native 时把原文件备份到 `state_dir/codex/config.toml`(覆盖更旧的 native 快照);已注入后再装不覆盖备份。kimi/grok/zed/hermes/pi/cline 同样:没有旧配置不写空备份,native 覆盖旧快照,已接入则保留。所有 target 的 restore 都按 Codex 规则:注入后记下文件 hash;相对注入未改则逐字节还原备份(没有备份则删除我们创建的文件);注入后改过则只剥 CPAC 字段,用户改动留下。没有其它需要代理的 agent 时才关闭代理。
256
+ `config.toml` 不存在时拒绝注入。当前是 native 时把原文件备份到 `state_dir/codex/config.toml`(覆盖更旧的 native 快照);已注入后再装不覆盖备份。kimi/grok/zed/hermes/pi/codebuddy 同样:没有旧配置不写空备份,native 覆盖旧快照,已接入则保留。所有 target 的 restore 都按 Codex 规则:注入后记下文件 hash;相对注入未改则逐字节还原备份(没有备份则删除我们创建的文件);注入后改过则只剥 CPAC 字段,用户改动留下。没有其它需要代理的 agent 时才关闭代理。
257
257
 
258
- loopback 代理是 detached 用户进程,不安装系统服务。机器重启或进程意外退出后,`cpac status` 会报告 `loopback proxy stopped`;重新执行 `cpac install codex` 或 `cpac pi` / `cpac kimi` / `cpac grok` / `cpac zed` / `cpac hermes` / `cpac cline` 即可恢复。
258
+ loopback 代理是 detached 用户进程,不安装系统服务。机器重启或进程意外退出后,`cpac status` 会报告 `loopback proxy stopped`;重新执行 `cpac install codex` 或 `cpac pi` / `cpac kimi` / `cpac grok` / `cpac zed` / `cpac hermes` / `cpac codebuddy` 即可恢复。
259
259
 
260
260
  默认路径:
261
261
 
@@ -268,8 +268,8 @@ CPAC state:~/.cpac/state.json(公用 proxy 字段 + 已接入 agent 记录
268
268
  Grok ~/.cpac/grok/config.toml
269
269
  Zed ~/.cpac/zed/settings.json
270
270
  Hermes ~/.cpac/hermes/config.yaml
271
- Cline ~/.cpac/cline/providers.json
272
271
  Pi ~/.cpac/pi/models.json
272
+ CodeBuddy ~/.cpac/codebuddy/models.json
273
273
  restore / `<agent> clear` 会删掉该 agent 的备份目录和 state.json 里的对应记录
274
274
  ```
275
275
 
@@ -336,32 +336,26 @@ cpac restore hermes
336
336
 
337
337
  配置目录尊重 `HERMES_HOME`(Windows 默认 `%LOCALAPPDATA%\hermes`)。首次写入前若 default `config.yaml` 已存在,会在 `state_dir/hermes/config.yaml` 保留原始快照。profile 文件不单独备份,restore 只剥 `providers.cpac`。
338
338
 
339
- ### Cline(CLI 与 Desktop)
339
+ ### CodeBuddy
340
340
 
341
- 写入 CPA 的 OpenAI-compatible provider 到 Cline:
341
+ 把 CPA 目录写成 CodeBuddy 的自定义模型:
342
342
 
343
343
  ```bash
344
- cpac install cline
345
- cpac cline # 未显式传 -P/--provider 时自动注入 -P openai-compatible
346
- cpac cline "帮我写一个测试" # 原生参数原样直通
347
- cpac cline -P cline "..." # 显式指定 provider 时不注入
344
+ cpac install codebuddy
345
+ cpac codebuddy # 启动;原生参数原样直通
346
+ cpac codebuddy -p "检查当前项目" # 非交互模式
348
347
  ```
349
348
 
350
- Cline CLI 与 Cline Desktop 共享同一份 `~/.cline/data/settings/providers.json`(并共用同一本地 hub)。CPAC 在 `providers["openai-compatible"]` 槽位写入 loopback `baseUrl`、占位 `apiKey`(`cpac`)和默认模型——这是 Cline 唯一接受自定义 OpenAI 兼容端点的 provider 槽位(Cline 拒绝未知 provider id),因此一次注入对 CLI 和 Desktop 同时生效。两者的请求都是 `POST {baseUrl}/chat/completions`,loopback 代理会把入站 Authorization 换成真实 `CPA_API_KEY`。
349
+ `~/.codebuddy/models.json` 是 CodeBuddy 官方放置第三方模型的地方,CPAC 只做**合并**:为每个 catalog slug 写一条 `models[]` 条目(`vendor: "CPAC"`、占位 `apiKey`(`cpac`)、`url` 指向 loopback `/v1/chat/completions`),你在同一文件里自己加的模型条目原样保留。loopback 代理把入站 Authorization 换成真实 `CPA_API_KEY`。
351
350
 
352
- - 模型:首次写入 catalog 第一个 slug;之后你在 Cline 里改过模型,`cpac sync cline` 会保留你的选择(只要该 slug 仍在目录里)。切换模型用 CLI 的 `-m <model>` 或 Desktop/IDE 的模型选择器。
353
- - provider 选择:`cpac cline` 默认注入 `-P openai-compatible`,避免走 Cline 官方托管 provider;Desktop 没有 CLI 参数,需要在 provider 列表里手动选 "OpenAI Compatible"。
354
- - 槽位冲突:如果该槽位已配置为其它端点,`cpac install cline` 会拒绝;用 `cpac install cline --force` 覆盖(原文件备份在 `state_dir/cline/providers.json`)。
355
- - 并发与恢复:Cline(尤其是常驻的 Desktop)会随时重写 `providers.json`。`restore` 按「注入后未改 → 逐字节还原备份;改过 → 只剥 CPAC 槽位、其余(包括你在 Cline 里改的 provider)留下」处理。Cline 侧改掉该槽位的 apiKey/baseUrl 后,注入即视为失效,重跑 `cpac install cline` / `cpac sync cline` 恢复。
356
- - Desktop 从 GUI 启动不继承 shell 环境变量,`CLINE_DATA_DIR` 覆盖只影响 CLI;两侧使用默认路径时注入对两边同时生效。
351
+ - 字段映射:`maxInputTokens` 取 catalog 的 `context_window`(不是 `max_context_window`),`maxOutputTokens` 取 `max_output_tokens`,catalog 没给就不写这两个键;`supportsImages` / `supportsReasoning` 按 `input_modalities` / `supported_reasoning_levels` 推导,`supportsToolCall` 恒为 true。
352
+ - 不写 `relatedModels`:CodeBuddy 的 lite / reasoning 场景因此回落到主模型,而不是借用内置默认值。
353
+ - `availableModels` 只在你已经设置过时才改写(缺失即「显示全部」);改写只并入 CPA slug,不会删掉你列出的 id。
354
+ - 归属识别:`apiKey` 为 `cpac` 且 `url` 是 loopback `…/chat/completions` 的条目才算 CPAC 的,`cpac sync codebuddy` 只替换这些条目。
355
+ - 恢复:注入后未改 → 逐字节还原原文件(原本没有该文件则删除它);改过 → 只剥 CPAC 条目,你的模型和 `availableModels` 留下。
356
+ - 模型选择仍由 CodeBuddy 决定:用 `/model` 选 CPA 模型或用 `CODEBUDDY_MODEL` 指定默认模型,CPAC 不改 `settings.json`。
357
357
 
358
- 卸载:
359
-
360
- ```bash
361
- cpac restore cline
362
- ```
363
-
364
- 配置目录尊重 `CLINE_DATA_DIR`(否则 `~/.cline/data`,CLI 与 Desktop 共享)。首次写入前若 `providers.json` 已存在,会在 `state_dir/cline/providers.json` 保留原始快照。
358
+ 配置目录尊重 `CODEBUDDY_CONFIG_DIR`(否则 `~/.codebuddy`)。首次写入前若 `models.json` 已存在,会在 `state_dir/codebuddy/models.json` 保留原始快照。
365
359
 
366
360
  ### 手动打开 5h 窗口
367
361
 
@@ -384,7 +378,7 @@ cpac ping agy # 目录里匹配 gemini-*-flash* 的一条,以及 claude-s
384
378
  | `KIMI_CODE_HOME` | `~/.kimi-code` | 覆盖 Kimi Code 目录(影响 `cpac install kimi` 写入位置) |
385
379
  | `ZED_CONFIG_DIR` | `~/.config/zed` | 覆盖 Zed 配置目录(影响 `cpac install zed` 写入位置) |
386
380
  | `HERMES_HOME` | `~/.hermes` | 覆盖 Hermes 目录(影响 `cpac install hermes` 写入位置) |
387
- | `CLINE_DATA_DIR` | `~/.cline/data` | 覆盖 Cline 数据目录(影响 `cpac install cline` 写入位置;仅 CLI 生效,Desktop 读默认路径) |
381
+ | `CODEBUDDY_CONFIG_DIR` | `~/.codebuddy` | 覆盖 CodeBuddy 配置目录(影响 `cpac install codebuddy` 写入的 `models.json` 位置) |
388
382
  | `CPAC_CONFIG` | `~/.config/cpac/config.json` | 指定可选 JSON 配置路径 |
389
383
  | `CODEX_HOME` | `~/.codex` | Codex home 目录 |
390
384
 
package/dist/agents.js CHANGED
@@ -3,15 +3,16 @@ import { existsSync, readFileSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
4
  import { basename, dirname, join } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
- import { MAX_SPAWN_MODELS, loadCatalogSlugs, pickSpawnModels, saveCodexSetup, saveSpawnModels, } from "./config.js";
6
+ import { MAX_SPAWN_MODELS, fetchCatalog, loadCatalogSlugs, pickSpawnModels, saveCodexSetup, saveSpawnModels, } from "./config.js";
7
+ import { markModelsApplied, refreshModelIndex } from "./models.js";
7
8
  import { MANAGED_MARKER, inject, restore } from "./targets/codex.js";
8
9
  import { grokConfigPath, grokHome, installGrokConfig, isGrokConfigInstalled, uninstallGrokConfig, } from "./targets/grok.js";
9
10
  import { installKimiConfig, isKimiConfigInstalled, kimiConfigPath, uninstallKimiConfig, } from "./targets/kimi.js";
10
11
  import { installPiConfig, isPiConfigInstalled, piAgentDir, piModelsPath, uninstallPiConfig, } from "./targets/pi.js";
11
12
  import { installZedConfig, isZedConfigInstalled, uninstallZedConfig, zedConfigPath, zedHome, } from "./targets/zed.js";
12
13
  import { hermesConfigPaths, hermesHome, installHermesConfig, isHermesConfigInstalled, uninstallHermesConfig, } from "./targets/hermes.js";
13
- import { CLINE_PROVIDER_ID, clineDataDir, clineProvidersPath, installClineConfig, isClineConfigInstalled, uninstallClineConfig, } from "./targets/cline.js";
14
- import { CPACError, atomicWrite, checkboxPicker, compareVersions, expandUserPath, objectValue, tabWizard, } from "./util.js";
14
+ import { codebuddyHome, codebuddyModelsPath, installCodebuddyConfig, isCodebuddyConfigInstalled, uninstallCodebuddyConfig, } from "./targets/codebuddy.js";
15
+ import { CPACError, atomicWrite, checkboxPicker, compareVersions, expandUserPath, objectValue, resolveApiKey, tabWizard, } from "./util.js";
15
16
  const CODEX_CTX_STD = "Standard (default input budget, e.g. 200K~272K)";
16
17
  const CODEX_CTX_MAX = "Max Context Window (lift to full upper bound, e.g. 921K~1M, 90% auto-compact limit)";
17
18
  const CODEX_V2_ON = "Enabled (Default)";
@@ -327,27 +328,24 @@ const TARGETS = [
327
328
  },
328
329
  },
329
330
  {
330
- id: "cline",
331
- home: () => clineDataDir(),
332
- detected: () => binaryOnPath("cline") ||
333
- existsSync(join(homedir(), ".cline")) ||
334
- (process.platform === "darwin" &&
335
- existsSync("/Applications/Cline.app")),
336
- installed: () => isClineConfigInstalled(),
337
- version: () => detectClientVersion("cline"),
338
- install: async (config, dryRun, _v2Off, _maxContext, force) => {
331
+ id: "codebuddy",
332
+ home: () => codebuddyHome(),
333
+ detected: () => binaryOnPath("codebuddy") || existsSync(codebuddyHome()),
334
+ installed: () => isCodebuddyConfigInstalled(),
335
+ version: () => detectClientVersion("codebuddy"),
336
+ install: async (config, dryRun) => {
339
337
  if (dryRun) {
340
- console.log(`would write Cline ${CLINE_PROVIDER_ID} provider: ${clineProvidersPath()}`);
338
+ console.log(`would write CPAC models into CodeBuddy: ${codebuddyModelsPath()}`);
341
339
  return;
342
340
  }
343
- await installClineConfig(config, force);
341
+ await installCodebuddyConfig(config);
344
342
  },
345
343
  uninstall: async (config, dryRun) => {
346
344
  if (dryRun) {
347
- console.log(`would remove Cline ${CLINE_PROVIDER_ID} provider: ${clineProvidersPath()}`);
345
+ console.log(`would remove CPAC models from CodeBuddy: ${codebuddyModelsPath()}`);
348
346
  return;
349
347
  }
350
- await uninstallClineConfig(config);
348
+ await uninstallCodebuddyConfig(config);
351
349
  },
352
350
  },
353
351
  ];
@@ -406,27 +404,51 @@ export async function runInstall(config, requested, options) {
406
404
  }
407
405
  return 0;
408
406
  }
409
- export async function runSync(config, requested, options) {
407
+ function syncTargetIds(config, requested, all) {
410
408
  const installed = TARGETS.filter((target) => target.installed(config)).map((target) => target.id);
411
- if (requested.length === 0 && !options.all) {
409
+ if (requested.length === 0 && !all) {
412
410
  if (installed.length === 0) {
413
411
  throw new CPACError("no installed CPAC integrations found; run cpac install first");
414
412
  }
415
- return runInstall(config, installed, {
416
- all: false,
417
- dryRun: options.dryRun,
418
- force: true,
419
- v2Off: options.v2Off,
420
- maxContext: options.maxContext,
421
- });
413
+ return installed;
422
414
  }
423
- return runInstall(config, requested, {
424
- all: options.all,
415
+ const eligible = TARGETS.filter((target) => target.detected()).map((target) => target.id);
416
+ const selected = selectTargets(requested, all, eligible);
417
+ if (selected.length === 0) {
418
+ throw new CPACError(`no supported targets detected; use: cpac install ${TARGETS.map((target) => target.id).join(" ")} or --all`);
419
+ }
420
+ return selected.map((target) => target.id);
421
+ }
422
+ export async function runSync(config, requested, options) {
423
+ const ids = syncTargetIds(config, requested, options.all);
424
+ if (options.maxContext && ids.some((id) => id !== "codex")) {
425
+ throw new CPACError("--max_context is only valid with codex");
426
+ }
427
+ const apiKey = await resolveApiKey(config.api_key_env);
428
+ const catalog = await fetchCatalog(config.cpa_url, apiKey);
429
+ const { index, changed } = refreshModelIndex(config.state_dir, catalog.bytes, new Date().toISOString(), !options.dryRun);
430
+ console.log(`models ${index.updated_at}${changed ? (options.dryRun ? " would update" : " updated") : " unchanged"}`);
431
+ const pending = ids.filter((id) => index.applied[id] !== index.updated_at);
432
+ for (const id of ids) {
433
+ if (index.applied[id] === index.updated_at) {
434
+ console.log(`${id}: model version ${index.updated_at} unchanged`);
435
+ }
436
+ }
437
+ if (pending.length === 0)
438
+ return 0;
439
+ // ponytail: version is the model-list timestamp only. spawn_models, v2_off,
440
+ // max_context, and the proxy port do not bump it; cpac install still rewrites.
441
+ const code = await runInstall(config, pending, {
442
+ all: false,
425
443
  dryRun: options.dryRun,
426
444
  force: true,
427
445
  v2Off: options.v2Off,
428
446
  maxContext: options.maxContext,
429
447
  });
448
+ if (!options.dryRun && code === 0) {
449
+ markModelsApplied(config.state_dir, pending, index.updated_at);
450
+ }
451
+ return code;
430
452
  }
431
453
  export async function runRestore(config, requested, options) {
432
454
  if (requested.length === 0 && !options.all) {
package/dist/config.js CHANGED
@@ -29,7 +29,7 @@ export const AGENT_IDS = [
29
29
  "grok",
30
30
  "zed",
31
31
  "hermes",
32
- "cline",
32
+ "codebuddy",
33
33
  ];
34
34
  const AGENT_ID_SET = new Set(AGENT_IDS);
35
35
  const STATE_KEYS = new Set([
@@ -472,9 +472,13 @@ export function readState(stateDir) {
472
472
  if (value.agents !== undefined) {
473
473
  if (!objectValue(value.agents))
474
474
  throw new CPACError("invalid agents in state");
475
+ // Records for targets that no longer exist (e.g. cline) are dropped on
476
+ // load instead of erroring, so a state file written by an older release
477
+ // keeps working after a target is removed.
478
+ const agents = {};
475
479
  for (const [id, rec] of Object.entries(value.agents)) {
476
480
  if (!AGENT_ID_SET.has(id))
477
- throw new CPACError(`unknown agent in state: ${id}`);
481
+ continue;
478
482
  if (!objectValue(rec) ||
479
483
  typeof rec.path !== "string" ||
480
484
  !rec.path ||
@@ -489,7 +493,12 @@ export function readState(stateDir) {
489
493
  Object.keys(rec).some((key) => !AGENT_RECORD_KEYS.has(key))) {
490
494
  throw new CPACError(`invalid agent record: ${id}`);
491
495
  }
496
+ agents[id] = rec;
492
497
  }
498
+ if (Object.keys(agents).length > 0)
499
+ value.agents = agents;
500
+ else
501
+ delete value.agents;
493
502
  }
494
503
  const proxyKeys = ["proxy_id", "proxy_pid", "proxy_port"];
495
504
  const proxyKeyCount = proxyKeys.filter((key) => key in value).length;
@@ -535,7 +544,13 @@ export function needsProxy(state) {
535
544
  if (state.config_path)
536
545
  return true;
537
546
  const agents = state.agents ?? {};
538
- return Boolean(agents.codex || agents.pi || agents.kimi || agents.grok || agents.zed || agents.hermes);
547
+ return Boolean(agents.codex ||
548
+ agents.pi ||
549
+ agents.kimi ||
550
+ agents.grok ||
551
+ agents.zed ||
552
+ agents.hermes ||
553
+ agents.codebuddy);
539
554
  }
540
555
  export function saveState(stateDir, state) {
541
556
  atomicWrite(join(stateDir, "state.json"), Buffer.from(`${JSON.stringify(state, null, 2)}\n`));
package/dist/cpac.js CHANGED
@@ -11,7 +11,7 @@ import { isKimiConfigInstalled } from "./targets/kimi.js";
11
11
  import { isPiConfigInstalled } from "./targets/pi.js";
12
12
  import { isZedConfigInstalled } from "./targets/zed.js";
13
13
  import { isHermesConfigInstalled } from "./targets/hermes.js";
14
- import { CLINE_PROVIDER_ID, isClineConfigInstalled } from "./targets/cline.js";
14
+ import { isCodebuddyConfigInstalled } from "./targets/codebuddy.js";
15
15
  import { defaultConfigPath, loadConfig, originalBytes, pickSpawnModels, readState, saveSpawnModels, stateProxy, catalogMaxContextLive, catalogModelId, catalogModelRows, catalogSlugs, fetchCatalog, } from "./config.js";
16
16
  import { runPing } from "./ping.js";
17
17
  import { proxyIsHealthy, runProxyChild } from "./proxy.js";
@@ -28,7 +28,7 @@ export { installPiConfig, isPiConfigInstalled, uninstallPiConfig, PI_PROVIDER_ID
28
28
  export { compactModelPrices, ensureModelPrices, lookupModelCost, resetModelPrices, } from "./pricing.js";
29
29
  export { installZedConfig, isZedConfigInstalled, uninstallZedConfig, ZED_PROVIDER_ID, } from "./targets/zed.js";
30
30
  export { installHermesConfig, isHermesConfigInstalled, uninstallHermesConfig, HERMES_PROVIDER_ID, } from "./targets/hermes.js";
31
- export { CLINE_PROVIDER_ID, installClineConfig, isClineConfigInstalled, uninstallClineConfig, } from "./targets/cline.js";
31
+ export { CODEBUDDY_API_KEY_PLACEHOLDER, codebuddyModelsPath, installCodebuddyConfig, isCodebuddyConfigInstalled, uninstallCodebuddyConfig, } from "./targets/codebuddy.js";
32
32
  export async function status(config) {
33
33
  const apiKey = process.env[config.api_key_env]?.trim();
34
34
  const keyConfigured = !!apiKey;
@@ -84,8 +84,8 @@ export async function status(config) {
84
84
  console.log(`zed: ${keyConfigured ? (zedInstalled ? "ready" : "config not installed; run: cpac install zed") : "not configured"}`);
85
85
  const hermesInstalled = isHermesConfigInstalled();
86
86
  console.log(`hermes: ${keyConfigured ? (hermesInstalled ? "ready" : "config not installed; run: cpac install hermes") : "not configured"}`);
87
- const clineInstalled = isClineConfigInstalled();
88
- console.log(`cline: ${keyConfigured ? (clineInstalled ? "ready" : "config not installed; run: cpac install cline") : "not configured"}`);
87
+ const codebuddyInstalled = isCodebuddyConfigInstalled();
88
+ console.log(`codebuddy: ${keyConfigured ? (codebuddyInstalled ? "ready" : "config not installed; run: cpac install codebuddy") : "not configured"}`);
89
89
  if (!state)
90
90
  return 1;
91
91
  const proxy = stateProxy(state);
@@ -130,10 +130,10 @@ async function listModels(config, json) {
130
130
  return 0;
131
131
  }
132
132
  async function guide(config) {
133
- console.log(`CPA Companion\n\nCPA: ${config.cpa_url}\nCPA_API_KEY: ${process.env[config.api_key_env]?.trim() ? "configured" : "not configured"}\n\nCommands:\n cpac <agent> [args...] Launch an agent (codex, kimi, grok, pi, zed, hermes, cline, claude) through CPA\n cpac setup Show current Codex/Claude setup; cpac setup <codex|claude> to change\n cpac status Show agent support status (Codex, Claude, Pi, Kimi, Grok, Zed, Hermes, Cline)\n cpac restore <codex|pi|kimi|grok|zed|hermes|cline> | --all Detach injected agents
133
+ console.log(`CPA Companion\n\nCPA: ${config.cpa_url}\nCPA_API_KEY: ${process.env[config.api_key_env]?.trim() ? "configured" : "not configured"}\n\nCommands:\n cpac <agent> [args...] Launch an agent (codex, kimi, grok, pi, zed, hermes, codebuddy, claude) through CPA\n cpac setup Show current Codex/Claude setup; cpac setup <codex|claude> to change\n cpac status Show agent support status (Codex, Claude, Pi, Kimi, Grok, Zed, Hermes, CodeBuddy)\n cpac restore <codex|pi|kimi|grok|zed|hermes|codebuddy> | --all Detach injected agents
134
134
  cpac detect Show supported targets and install status
135
- cpac install [codex|pi|kimi|grok|zed|hermes|cline...] Install CPA into detected targets, or named agents
136
- cpac sync Refresh installed Codex/Pi/Kimi/Grok/Zed/Hermes/Cline injections from CPA
135
+ cpac install [codex|pi|kimi|grok|zed|hermes|codebuddy...] Install CPA into detected targets, or named agents
136
+ cpac sync Refresh installed Codex/Pi/Kimi/Grok/Zed/Hermes/CodeBuddy injections from CPA
137
137
  cpac uninstall Detach all agents, then remove the cpac package (confirms first)\n cpac upgrade Update cpac from npm and sync installed agents\n cpac models List remote CPA catalog models\n cpac ping codex|agy Send one request to open that 5h window\n cpac --help Show command usage`);
138
138
  if (process.env[config.api_key_env]?.trim())
139
139
  return 0;
@@ -174,7 +174,7 @@ function usage() {
174
174
  cmd("cpac ping <codex|agy>", "send one request to open that 5h window"),
175
175
  "",
176
176
  "Positionals:",
177
- helpRow("agent", "codex, kimi, grok, pi, zed, hermes, cline, or claude (claude is launch-only)", "", 18),
177
+ helpRow("agent", "codex, kimi, grok, pi, zed, hermes, codebuddy, or claude (claude is launch-only)", "", 18),
178
178
  "",
179
179
  "Options:",
180
180
  opt("-h, --help", "show help", "[boolean]"),
@@ -338,7 +338,16 @@ function parseArgs(args) {
338
338
  }
339
339
  return parsed;
340
340
  }
341
- if (["codex", "kimi", "grok", "pi", "zed", "hermes", "cline", "claude"].includes(args[0])) {
341
+ if ([
342
+ "codex",
343
+ "kimi",
344
+ "grok",
345
+ "pi",
346
+ "zed",
347
+ "hermes",
348
+ "codebuddy",
349
+ "claude",
350
+ ].includes(args[0])) {
342
351
  const targetId = args[0];
343
352
  let configPath = defaultConfigPath();
344
353
  const raw = args.slice(1);
@@ -686,7 +695,7 @@ export async function main(args = process.argv.slice(2)) {
686
695
  });
687
696
  if (parsed.command === "uninstall") {
688
697
  if (parsed.targets.length || parsed.all) {
689
- throw new CPACError("uninstall removes the cpac package; detach agents with: cpac restore <codex|pi|kimi|grok|zed|hermes|cline> | --all");
698
+ throw new CPACError("uninstall removes the cpac package; detach agents with: cpac restore <codex|pi|kimi|grok|zed|hermes|codebuddy> | --all");
690
699
  }
691
700
  return await runUninstall(config, { dryRun: parsed.dryRun });
692
701
  }
@@ -737,19 +746,12 @@ export async function main(args = process.argv.slice(2)) {
737
746
  }
738
747
  return await runTargetLauncher(launcherConfig, "codex", parsed.args, "codex", { v2Off: parsed.v2Off, maxContext: parsed.maxContext });
739
748
  }
740
- if (parsed.command === "cline") {
741
- // Cline's default provider is its own hosted service; default the
742
- // launch to the injected CPAC slot unless the user picked one.
743
- const args = parsed.args.some((arg) => arg === "-P" || arg === "--provider")
744
- ? parsed.args
745
- : ["-P", CLINE_PROVIDER_ID, ...parsed.args];
746
- return await runTargetLauncher(config, "cline", args, "cline");
747
- }
748
749
  if (parsed.command === "kimi" ||
749
750
  parsed.command === "grok" ||
750
751
  parsed.command === "pi" ||
751
752
  parsed.command === "zed" ||
752
- parsed.command === "hermes") {
753
+ parsed.command === "hermes" ||
754
+ parsed.command === "codebuddy") {
753
755
  return await runTargetLauncher(config, parsed.command, parsed.args);
754
756
  }
755
757
  return await status(config);
package/dist/models.js ADDED
@@ -0,0 +1,145 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { AGENT_IDS, catalogModelRows } from "./config.js";
4
+ import { CPACError, atomicWrite, objectValue } from "./util.js";
5
+ const INDEX_KEYS = new Set(["updated_at", "models", "applied"]);
6
+ const AGENT_ID_SET = new Set(AGENT_IDS);
7
+ const VERSION = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/;
8
+ export function modelIndexPath(stateDir) {
9
+ return join(stateDir, "models.json");
10
+ }
11
+ function isJsonObject(value) {
12
+ return typeof value === "object" && value !== null && !Array.isArray(value);
13
+ }
14
+ function isModelRecord(value) {
15
+ return isJsonObject(value) && typeof value.slug === "string" && value.slug.trim() !== "";
16
+ }
17
+ function isAgentId(id) {
18
+ return AGENT_ID_SET.has(id);
19
+ }
20
+ function stableValue(value) {
21
+ if (value === null ||
22
+ typeof value === "boolean" ||
23
+ typeof value === "number" ||
24
+ typeof value === "string") {
25
+ return value;
26
+ }
27
+ if (Array.isArray(value))
28
+ return value.map(stableValue);
29
+ if (!objectValue(value))
30
+ throw new CPACError("invalid CPA catalog");
31
+ const stable = {};
32
+ for (const key of Object.keys(value).sort())
33
+ stable[key] = stableValue(value[key]);
34
+ return stable;
35
+ }
36
+ export function canonicalModels(bytes) {
37
+ let document;
38
+ try {
39
+ document = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
40
+ }
41
+ catch {
42
+ throw new CPACError("invalid CPA catalog");
43
+ }
44
+ const rows = catalogModelRows(document);
45
+ if (!rows || rows.length === 0)
46
+ throw new CPACError("invalid CPA catalog");
47
+ return rows.map((row) => {
48
+ const stable = stableValue(row);
49
+ if (!isModelRecord(stable))
50
+ throw new CPACError("invalid CPA catalog");
51
+ return stable;
52
+ });
53
+ }
54
+ function parseApplied(value) {
55
+ if (!objectValue(value))
56
+ return null;
57
+ const applied = {};
58
+ for (const [id, version] of Object.entries(value)) {
59
+ // Entries for targets that no longer exist (e.g. cline) are dropped
60
+ // instead of rejecting the whole index, matching how readState treats
61
+ // agent records left over from an older release.
62
+ if (!isAgentId(id))
63
+ continue;
64
+ if (typeof version !== "string" || !VERSION.test(version))
65
+ return null;
66
+ applied[id] = version;
67
+ }
68
+ return applied;
69
+ }
70
+ function parseModelIndex(value) {
71
+ if (!objectValue(value) ||
72
+ Object.keys(value).some((key) => !INDEX_KEYS.has(key)) ||
73
+ typeof value.updated_at !== "string" ||
74
+ !VERSION.test(value.updated_at) ||
75
+ !Array.isArray(value.models) ||
76
+ value.models.length === 0) {
77
+ return null;
78
+ }
79
+ const models = [];
80
+ for (const row of value.models) {
81
+ let stable;
82
+ try {
83
+ stable = stableValue(row);
84
+ }
85
+ catch {
86
+ return null;
87
+ }
88
+ if (!isModelRecord(stable))
89
+ return null;
90
+ models.push(stable);
91
+ }
92
+ const applied = parseApplied(value.applied);
93
+ if (!applied)
94
+ return null;
95
+ return { updated_at: value.updated_at, models, applied };
96
+ }
97
+ export function readModelIndex(stateDir) {
98
+ const path = modelIndexPath(stateDir);
99
+ if (!existsSync(path))
100
+ return null;
101
+ let value;
102
+ try {
103
+ value = JSON.parse(readFileSync(path, "utf8"));
104
+ }
105
+ catch (error) {
106
+ throw new CPACError(`cannot read model index: ${error instanceof Error ? error.message : String(error)}`);
107
+ }
108
+ const index = parseModelIndex(value);
109
+ if (!index)
110
+ throw new CPACError("invalid model index");
111
+ return index;
112
+ }
113
+ function writeModelIndex(stateDir, index) {
114
+ atomicWrite(modelIndexPath(stateDir), Buffer.from(`${JSON.stringify(index, null, 2)}\n`));
115
+ }
116
+ export function refreshModelIndex(stateDir, catalogBytes, now = new Date().toISOString(), write = true) {
117
+ const models = canonicalModels(catalogBytes);
118
+ const current = readModelIndex(stateDir);
119
+ if (current && JSON.stringify(current.models) === JSON.stringify(models)) {
120
+ return { index: current, changed: false };
121
+ }
122
+ const index = {
123
+ updated_at: now,
124
+ models,
125
+ applied: current?.applied ?? {},
126
+ };
127
+ if (write)
128
+ writeModelIndex(stateDir, index);
129
+ return { index, changed: true };
130
+ }
131
+ export function markModelsApplied(stateDir, ids, updatedAt) {
132
+ const current = readModelIndex(stateDir);
133
+ if (!current)
134
+ throw new CPACError("model index missing");
135
+ let changed = false;
136
+ const applied = { ...current.applied };
137
+ for (const id of ids) {
138
+ if (applied[id] === updatedAt)
139
+ continue;
140
+ applied[id] = updatedAt;
141
+ changed = true;
142
+ }
143
+ if (changed)
144
+ writeModelIndex(stateDir, { ...current, applied });
145
+ }
@@ -0,0 +1,204 @@
1
+ import { existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+ import { catalogModelId, catalogModelRows, dropAgent, fetchCatalog, readState, recordWrittenConfig, restoreOwnedConfig, } from "../config.js";
5
+ import { ensureLoopbackProxy, stopProxyProcess } from "../proxy.js";
6
+ import { CPACError, atomicWrite, ensureCpacBackup, expandUserPath, objectValue, resolveApiKey, } from "../util.js";
7
+ /**
8
+ * ~/.codebuddy/models.json is CodeBuddy's documented place for third-party
9
+ * models, so CPAC merges rather than owns the file: it writes one entry per
10
+ * catalog slug and leaves every model the user added there untouched. Entries
11
+ * CPAC wrote are recognizable by the placeholder key plus a loopback
12
+ * /chat/completions URL, and only those are replaced on re-sync and dropped on
13
+ * restore.
14
+ */
15
+ export const CODEBUDDY_API_KEY_PLACEHOLDER = "cpac";
16
+ export const CODEBUDDY_VENDOR = "CPAC";
17
+ export function codebuddyHome() {
18
+ return expandUserPath(process.env.CODEBUDDY_CONFIG_DIR?.trim() || join(homedir(), ".codebuddy"));
19
+ }
20
+ export function codebuddyModelsPath() {
21
+ return join(codebuddyHome(), "models.json");
22
+ }
23
+ function readModelsDoc(path) {
24
+ if (!existsSync(path))
25
+ return {};
26
+ let parsed;
27
+ try {
28
+ parsed = JSON.parse(readFileSync(path, "utf8"));
29
+ }
30
+ catch {
31
+ throw new CPACError(`invalid CodeBuddy models.json: ${path}`);
32
+ }
33
+ if (!objectValue(parsed))
34
+ throw new CPACError(`invalid CodeBuddy models.json: ${path}`);
35
+ return parsed;
36
+ }
37
+ function modelEntries(doc) {
38
+ return Array.isArray(doc.models) ? doc.models : [];
39
+ }
40
+ function loopbackChatCompletionsUrl(value) {
41
+ if (typeof value !== "string" || !value.trim())
42
+ return false;
43
+ try {
44
+ const url = new URL(value);
45
+ const host = url.hostname.toLowerCase();
46
+ return ((host === "127.0.0.1" || host === "localhost" || host === "::1") &&
47
+ url.pathname.endsWith("/chat/completions"));
48
+ }
49
+ catch {
50
+ return false;
51
+ }
52
+ }
53
+ /** True for entries CPAC wrote: placeholder key plus a loopback endpoint. */
54
+ function isCpacModel(entry) {
55
+ if (!objectValue(entry))
56
+ return false;
57
+ return (entry.apiKey === CODEBUDDY_API_KEY_PLACEHOLDER &&
58
+ loopbackChatCompletionsUrl(entry.url));
59
+ }
60
+ export function isCodebuddyConfigInstalled() {
61
+ try {
62
+ return modelEntries(readModelsDoc(codebuddyModelsPath())).some(isCpacModel);
63
+ }
64
+ catch {
65
+ return false;
66
+ }
67
+ }
68
+ function inputHasImage(row) {
69
+ const modalities = row.input_modalities;
70
+ return Array.isArray(modalities) && modalities.some((value) => value === "image");
71
+ }
72
+ function supportsReasoning(row) {
73
+ const levels = row.supported_reasoning_levels;
74
+ return Array.isArray(levels) && levels.length > 0;
75
+ }
76
+ function positiveInt(value) {
77
+ return typeof value === "number" && value > 0 ? Math.floor(value) : undefined;
78
+ }
79
+ function buildModel(row, slug, port) {
80
+ const model = {
81
+ id: slug,
82
+ name: typeof row.display_name === "string" && row.display_name.trim()
83
+ ? row.display_name.trim()
84
+ : slug,
85
+ vendor: CODEBUDDY_VENDOR,
86
+ apiKey: CODEBUDDY_API_KEY_PLACEHOLDER,
87
+ url: `http://127.0.0.1:${port}/v1/chat/completions`,
88
+ supportsToolCall: true,
89
+ supportsImages: inputHasImage(row),
90
+ supportsReasoning: supportsReasoning(row),
91
+ };
92
+ const maxInput = positiveInt(row.context_window);
93
+ if (maxInput)
94
+ model.maxInputTokens = maxInput;
95
+ const maxOutput = positiveInt(row.max_output_tokens);
96
+ if (maxOutput)
97
+ model.maxOutputTokens = maxOutput;
98
+ // No relatedModels: CodeBuddy then falls back to the main model for the
99
+ // lite / reasoning slots instead of borrowing product built-in defaults.
100
+ return model;
101
+ }
102
+ /** Keep the user's models and the position CPAC's block already occupied. */
103
+ function mergeModels(existing, cpaModels) {
104
+ const userModels = existing.filter((entry) => !isCpacModel(entry));
105
+ const firstCpac = existing.findIndex(isCpacModel);
106
+ const insertAt = firstCpac === -1
107
+ ? userModels.length
108
+ : existing.slice(0, firstCpac).filter((entry) => !isCpacModel(entry)).length;
109
+ return [
110
+ ...userModels.slice(0, insertAt),
111
+ ...cpaModels,
112
+ ...userModels.slice(insertAt),
113
+ ];
114
+ }
115
+ /**
116
+ * Only touch availableModels when the user already set it: an absent list means
117
+ * "show everything", but a present one would hide the CPA models. Slugs are
118
+ * added, never removed — the list cannot tell CPA ids from CodeBuddy built-ins.
119
+ */
120
+ function mergedAvailableModels(doc, slugs) {
121
+ if (!Array.isArray(doc.availableModels))
122
+ return undefined;
123
+ const available = doc.availableModels.filter((id) => typeof id === "string");
124
+ for (const slug of slugs) {
125
+ if (!available.includes(slug))
126
+ available.push(slug);
127
+ }
128
+ return available;
129
+ }
130
+ export async function installCodebuddyConfig(config) {
131
+ const apiKey = await resolveApiKey(config.api_key_env);
132
+ const catalog = await fetchCatalog(config.cpa_url, apiKey);
133
+ let document;
134
+ try {
135
+ document = JSON.parse(new TextDecoder("utf-8").decode(catalog.bytes));
136
+ }
137
+ catch {
138
+ throw new CPACError("invalid CPA catalog");
139
+ }
140
+ const rows = catalogModelRows(document) ?? [];
141
+ const catalogRows = rows.flatMap((row) => {
142
+ const slug = catalogModelId(row);
143
+ return slug ? [{ row, slug }] : [];
144
+ });
145
+ if (catalogRows.length === 0)
146
+ throw new CPACError("CPA catalog contains no models");
147
+ const { proxy, fingerprint, started } = await ensureLoopbackProxy(config, apiKey);
148
+ const models = catalogRows.map(({ row, slug }) => buildModel(row, slug, proxy.port));
149
+ const target = codebuddyModelsPath();
150
+ const hadFile = existsSync(target);
151
+ try {
152
+ const doc = readModelsDoc(target);
153
+ const available = mergedAvailableModels(doc, models.map((model) => String(model.id)));
154
+ ensureCpacBackup(config.state_dir, "codebuddy", target, !isCodebuddyConfigInstalled());
155
+ doc.models = mergeModels(modelEntries(doc), models);
156
+ if (available)
157
+ doc.availableModels = available;
158
+ mkdirSync(dirname(target), { recursive: true, mode: 0o700 });
159
+ const mode = existsSync(target) ? statSync(target).mode & 0o7777 : 0o600;
160
+ atomicWrite(target, Buffer.from(`${JSON.stringify(doc, null, 2)}\n`), mode);
161
+ recordWrittenConfig(config.state_dir, "codebuddy", target, {
162
+ id: proxy.id,
163
+ pid: proxy.pid,
164
+ port: proxy.port,
165
+ fingerprint,
166
+ }, hadFile);
167
+ }
168
+ catch (error) {
169
+ if (started)
170
+ await stopProxyProcess(proxy);
171
+ throw error;
172
+ }
173
+ console.log(`Installed ${models.length} CPAC models into CodeBuddy: ${target}`);
174
+ console.log(`Models keep the placeholder key "${CODEBUDDY_API_KEY_PLACEHOLDER}"; the loopback proxy injects ${config.api_key_env}.`);
175
+ }
176
+ function stripCodebuddyManaged(content) {
177
+ let parsed;
178
+ try {
179
+ parsed = JSON.parse(new TextDecoder("utf-8").decode(content));
180
+ }
181
+ catch {
182
+ return content;
183
+ }
184
+ if (!objectValue(parsed))
185
+ return content;
186
+ const doc = parsed;
187
+ const models = modelEntries(doc).filter((entry) => !isCpacModel(entry));
188
+ if (models.length > 0)
189
+ doc.models = models;
190
+ else
191
+ delete doc.models;
192
+ return Buffer.from(`${JSON.stringify(doc, null, 2)}\n`);
193
+ }
194
+ export async function uninstallCodebuddyConfig(config) {
195
+ const target = codebuddyModelsPath();
196
+ const recorded = readState(config.state_dir)?.agents?.codebuddy;
197
+ if (!recorded && !isCodebuddyConfigInstalled())
198
+ throw new CPACError("CodeBuddy CPAC models are not installed");
199
+ restoreOwnedConfig(config.state_dir, "codebuddy", target, stripCodebuddyManaged);
200
+ const { stopProxy } = dropAgent(config.state_dir, "codebuddy");
201
+ if (stopProxy)
202
+ await stopProxyProcess(stopProxy);
203
+ console.log(`Removed CPAC models from CodeBuddy: ${target}`);
204
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yhong91/cpac",
3
- "version": "0.2.3",
3
+ "version": "0.2.4",
4
4
  "description": "Connect Codex and Claude Code to a remote CLIProxyAPI gateway",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,197 +0,0 @@
1
- import { existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
2
- import { homedir } from "node:os";
3
- import { dirname, join } from "node:path";
4
- import { catalogModelId, catalogModelRows, dropAgent, fetchCatalog, readState, recordWrittenConfig, restoreOwnedConfig, } from "../config.js";
5
- import { ensureLoopbackProxy, stopProxyProcess } from "../proxy.js";
6
- import { CPACError, atomicWrite, ensureCpacBackup, expandUserPath, objectValue, resolveApiKey, } from "../util.js";
7
- /**
8
- * Cline only accepts its built-in provider ids; `openai-compatible` is the one
9
- * slot meant for a user-defined OpenAI-compatible endpoint. The CLI (`cline`)
10
- * and Cline Desktop share that slot through ~/.cline/data/settings/providers.json
11
- * (both talk to the same local hub daemon on 127.0.0.1:25463), so one injection
12
- * covers both apps.
13
- */
14
- export const CLINE_PROVIDER_ID = "openai-compatible";
15
- /** Cline sends this placeholder key; the loopback proxy swaps in the CPA key. */
16
- export const CLINE_API_KEY_PLACEHOLDER = "cpac";
17
- export function clineDataDir() {
18
- const override = process.env.CLINE_DATA_DIR?.trim();
19
- if (override)
20
- return expandUserPath(override);
21
- return join(homedir(), ".cline", "data");
22
- }
23
- export function clineProvidersPath() {
24
- return join(clineDataDir(), "settings", "providers.json");
25
- }
26
- function readProviders(path) {
27
- if (!existsSync(path))
28
- return {};
29
- let parsed;
30
- try {
31
- parsed = JSON.parse(readFileSync(path, "utf8"));
32
- }
33
- catch {
34
- throw new CPACError(`invalid Cline providers.json: ${path}`);
35
- }
36
- if (!objectValue(parsed))
37
- throw new CPACError(`invalid Cline providers.json: ${path}`);
38
- return parsed;
39
- }
40
- function providerEntry(doc) {
41
- if (!objectValue(doc.providers))
42
- return null;
43
- const entry = doc.providers[CLINE_PROVIDER_ID];
44
- return objectValue(entry) ? entry : null;
45
- }
46
- function loopbackBaseUrl(value) {
47
- if (typeof value !== "string" || !value.trim())
48
- return false;
49
- try {
50
- const host = new URL(value).hostname.toLowerCase();
51
- return host === "127.0.0.1" || host === "localhost" || host === "::1";
52
- }
53
- catch {
54
- return false;
55
- }
56
- }
57
- /** True while the slot still carries the CPAC placeholder key and loopback URL. */
58
- function isCpacEntry(entry) {
59
- if (!objectValue(entry) || !objectValue(entry.settings))
60
- return false;
61
- const settings = entry.settings;
62
- return (settings.apiKey === CLINE_API_KEY_PLACEHOLDER &&
63
- loopbackBaseUrl(settings.baseUrl));
64
- }
65
- export function isClineConfigInstalled() {
66
- try {
67
- return isCpacEntry(providerEntry(readProviders(clineProvidersPath())));
68
- }
69
- catch {
70
- return false;
71
- }
72
- }
73
- function clineModelSlugs(rows) {
74
- const slugs = [];
75
- for (const row of rows) {
76
- const slug = catalogModelId(row);
77
- if (slug)
78
- slugs.push(slug);
79
- }
80
- return slugs;
81
- }
82
- /**
83
- * Keep Cline's slot shape (settings/updatedAt/tokenSource) and merge over the
84
- * fields CPAC owns, so extra user tuning inside the slot survives re-injection.
85
- */
86
- function clineProviderEntry(port, model, previous) {
87
- const previousSettings = previous && objectValue(previous.settings) ? previous.settings : {};
88
- return {
89
- ...(previous ?? {}),
90
- settings: {
91
- ...previousSettings,
92
- provider: CLINE_PROVIDER_ID,
93
- apiKey: CLINE_API_KEY_PLACEHOLDER,
94
- model,
95
- baseUrl: `http://127.0.0.1:${port}/v1`,
96
- },
97
- updatedAt: new Date().toISOString(),
98
- tokenSource: "manual",
99
- };
100
- }
101
- function writeClineProvider(path, port, model) {
102
- const doc = readProviders(path);
103
- const previous = providerEntry(doc);
104
- const providers = objectValue(doc.providers) ? { ...doc.providers } : {};
105
- providers[CLINE_PROVIDER_ID] = clineProviderEntry(port, model, previous);
106
- doc.providers = providers;
107
- if (typeof doc.version !== "number")
108
- doc.version = 1;
109
- if (!objectValue(doc.modes))
110
- doc.modes = {};
111
- mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
112
- const mode = existsSync(path) ? statSync(path).mode & 0o7777 : 0o600;
113
- atomicWrite(path, Buffer.from(`${JSON.stringify(doc, null, 2)}\n`), mode);
114
- }
115
- export async function installClineConfig(config, force = false) {
116
- const apiKey = await resolveApiKey(config.api_key_env);
117
- const catalog = await fetchCatalog(config.cpa_url, apiKey);
118
- let document;
119
- try {
120
- document = JSON.parse(new TextDecoder("utf-8").decode(catalog.bytes));
121
- }
122
- catch {
123
- throw new CPACError("invalid CPA catalog");
124
- }
125
- const rows = catalogModelRows(document) ?? [];
126
- const slugs = clineModelSlugs(rows);
127
- if (slugs.length === 0)
128
- throw new CPACError("CPA catalog contains no models");
129
- const target = clineProvidersPath();
130
- const existing = providerEntry(readProviders(target));
131
- if (existing && !isCpacEntry(existing) && !force) {
132
- throw new CPACError(`Cline provider "${CLINE_PROVIDER_ID}" already holds a non-CPAC configuration (${target}); ` +
133
- `re-run with --force to replace it (the original file is backed up in the cpac state dir)`);
134
- }
135
- // Keep the model the user picked in Cline when it still exists in the catalog.
136
- const previousModel = existing && objectValue(existing.settings) ? existing.settings.model : undefined;
137
- const model = typeof previousModel === "string" && slugs.includes(previousModel)
138
- ? previousModel
139
- : slugs[0];
140
- const { proxy, fingerprint, started } = await ensureLoopbackProxy(config, apiKey);
141
- const hadFile = existsSync(target);
142
- try {
143
- ensureCpacBackup(config.state_dir, "cline", target, !isClineConfigInstalled());
144
- writeClineProvider(target, proxy.port, model);
145
- recordWrittenConfig(config.state_dir, "cline", target, {
146
- id: proxy.id,
147
- pid: proxy.pid,
148
- port: proxy.port,
149
- fingerprint,
150
- }, hadFile);
151
- }
152
- catch (error) {
153
- if (started)
154
- await stopProxyProcess(proxy);
155
- throw error;
156
- }
157
- console.log(`Installed Cline provider "${CLINE_PROVIDER_ID}" (model ${model}): ${target}`);
158
- console.log(`Cline CLI: cline -P ${CLINE_PROVIDER_ID} -m <model> (or cpac cline); Cline Desktop: pick "OpenAI Compatible" in the provider list.`);
159
- console.log(`The stored key stays "${CLINE_API_KEY_PLACEHOLDER}"; the loopback proxy injects ${config.api_key_env}.`);
160
- }
161
- /** Drop the CPAC slot; keep the file untouched when Cline now owns the slot. */
162
- function stripClineProvider(current) {
163
- let parsed;
164
- try {
165
- parsed = JSON.parse(new TextDecoder("utf-8").decode(current));
166
- }
167
- catch {
168
- return current;
169
- }
170
- if (!objectValue(parsed))
171
- return current;
172
- const doc = parsed;
173
- const providers = doc.providers;
174
- if (!objectValue(providers))
175
- return current;
176
- const entry = providers[CLINE_PROVIDER_ID];
177
- if (!isCpacEntry(entry))
178
- return current;
179
- const nextProviders = { ...providers };
180
- delete nextProviders[CLINE_PROVIDER_ID];
181
- if (Object.keys(nextProviders).length > 0)
182
- doc.providers = nextProviders;
183
- else
184
- delete doc.providers;
185
- return Buffer.from(`${JSON.stringify(doc, null, 2)}\n`);
186
- }
187
- export async function uninstallClineConfig(config) {
188
- const target = clineProvidersPath();
189
- const recorded = readState(config.state_dir)?.agents?.cline;
190
- if (!recorded && !isClineConfigInstalled())
191
- throw new CPACError("Cline CPAC provider is not installed");
192
- restoreOwnedConfig(config.state_dir, "cline", target, stripClineProvider);
193
- const { stopProxy } = dropAgent(config.state_dir, "cline");
194
- if (stopProxy)
195
- await stopProxyProcess(stopProxy);
196
- console.log(`Removed Cline provider "${CLINE_PROVIDER_ID}": ${target}`);
197
- }