@yhong91/cpac 0.1.0
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 +237 -0
- package/cpac.example.json +4 -0
- package/dist/cpac.js +691 -0
- package/package.json +44 -0
package/README.md
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
# CPA Companion (cpac)
|
|
2
|
+
|
|
3
|
+
CPAC 将 Codex 和 Claude Code 接入远端 CLIProxyAPI(CPA):
|
|
4
|
+
|
|
5
|
+
- **Codex**:注入模型目录和 provider,并可逐字节恢复原配置。
|
|
6
|
+
- **Claude Code**:通过临时环境变量启动,不修改 Claude Code 配置。
|
|
7
|
+
|
|
8
|
+
实现基于 TypeScript + Node.js,运行时零依赖。
|
|
9
|
+
|
|
10
|
+
## 要求
|
|
11
|
+
|
|
12
|
+
- [Node.js](https://nodejs.org/) 20 或更新版本(npm 随 Node 安装)
|
|
13
|
+
- 已安装需要使用的 agent:`codex` 或 `claude`
|
|
14
|
+
- 有权访问的 CPA API key
|
|
15
|
+
|
|
16
|
+
## 安装
|
|
17
|
+
|
|
18
|
+
从 npm 安装后:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
npm install -g @yhong91/cpac
|
|
22
|
+
cpac
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
开发目录中安装:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
npm install
|
|
29
|
+
npm run build
|
|
30
|
+
npm install -g .
|
|
31
|
+
cpac
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## 首次引导
|
|
35
|
+
|
|
36
|
+
无参数运行 `cpac` 会显示当前 CPA、密钥状态和可用命令:
|
|
37
|
+
|
|
38
|
+
```text
|
|
39
|
+
CPA Companion
|
|
40
|
+
|
|
41
|
+
CPA: https://cpa.vibetime.cc
|
|
42
|
+
CPA_API_KEY: configured / not configured
|
|
43
|
+
|
|
44
|
+
Commands:
|
|
45
|
+
cpac claude [args...] Launch Claude Code through CPA
|
|
46
|
+
cpac inject Inject CPA into Codex
|
|
47
|
+
cpac status Show Codex injection status
|
|
48
|
+
cpac restore Restore the original Codex config
|
|
49
|
+
cpac --help Show command usage
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
默认 CPA 地址内置为 `https://cpa.vibetime.cc`,不需要创建 `~/.config/cpac/`。
|
|
53
|
+
|
|
54
|
+
若 `CPA_API_KEY` 尚未设置,交互式引导会隐藏输入密钥,并将受 CPAC 标记管理的 export 区块写入当前 shell 的启动文件:
|
|
55
|
+
|
|
56
|
+
| Shell | 写入位置 |
|
|
57
|
+
| --- | --- |
|
|
58
|
+
| zsh | `~/.zshrc` |
|
|
59
|
+
| bash(macOS) | `~/.bash_profile` |
|
|
60
|
+
| bash(Linux 等) | `~/.bashrc` |
|
|
61
|
+
| sh / dash / ksh | `~/.profile` |
|
|
62
|
+
|
|
63
|
+
写入内容形如:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
# >>> CPAC CPA_API_KEY >>>
|
|
67
|
+
export CPA_API_KEY='...'
|
|
68
|
+
# <<< CPAC CPA_API_KEY <<<
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
CPAC 会保留启动文件中的其他设置;再次运行引导配置密钥时,受管区块会原位更新,不会重复追加。写入后按引导提示打开新终端,或在当前终端执行:
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
source ~/.zshrc # 使用引导实际显示的文件
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
程序无法直接修改已经运行的父 shell,所以 `source` 或新终端仍然是必要的。
|
|
78
|
+
|
|
79
|
+
也可以跳过引导,自行设置:
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
export CPA_API_KEY='...'
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## 命令
|
|
86
|
+
|
|
87
|
+
### 引导和帮助
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
cpac
|
|
91
|
+
cpac --help
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
### Claude Code
|
|
95
|
+
|
|
96
|
+
通过 CPA 启动 Claude Code,后续参数原样传递:
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
cpac claude
|
|
100
|
+
cpac claude --model claude-sonnet-4-5
|
|
101
|
+
cpac claude -- -p "检查当前项目"
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
`cpac claude` 会为 Claude Code 子进程设置:
|
|
105
|
+
|
|
106
|
+
```text
|
|
107
|
+
ANTHROPIC_BASE_URL=<CPA 地址>
|
|
108
|
+
ANTHROPIC_AUTH_TOKEN=<CPA_API_KEY>
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
同时清除可能覆盖网关选择的 `ANTHROPIC_API_KEY` 和 Claude Code 云 provider 环境变量。它不会修改 `~/.claude/`,并原样返回 Claude Code 的退出码。
|
|
112
|
+
|
|
113
|
+
> CPA 服务端必须支持 Claude Code 使用的 Anthropic Messages API(`/v1/messages`)。CPAC 不负责协议转换,也不启动本地代理。
|
|
114
|
+
|
|
115
|
+
### Codex
|
|
116
|
+
|
|
117
|
+
注入 CPA 模型目录:
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
cpac inject
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
查看状态:
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
cpac status
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
恢复注入前的原配置:
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
cpac restore
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
`inject` 从 `/v1/models?client_version=1` 获取 Codex rich catalog,将根级 `model_provider`、`model_catalog_json` 指向 CPA,并追加 `[model_providers.cpac_cpa]`。其他用户 TOML 内容会保留;若已有同名 provider 且没有 CPAC state,CPAC 会拒绝接管。
|
|
136
|
+
|
|
137
|
+
第一次注入时,原文件按原始 bytes 备份到 `state_dir/config.toml.backup`。重复注入不会覆盖首次备份,并会保留 CPAC 管理字段之外的用户编辑。`restore` 按原 mode 逐字节恢复首次备份;若原文件不存在,则删除注入创建的 `config.toml`。
|
|
138
|
+
|
|
139
|
+
默认路径:
|
|
140
|
+
|
|
141
|
+
```text
|
|
142
|
+
Codex 配置:$CODEX_HOME/config.toml,未设置 CODEX_HOME 时为 ~/.codex/config.toml
|
|
143
|
+
CPAC state:~/.cpac
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
为防止误删或覆盖,`state_dir` 不能是文件系统根目录、用户 home、系统临时目录,也不能包含 Codex 配置文件。
|
|
147
|
+
|
|
148
|
+
## 环境变量
|
|
149
|
+
|
|
150
|
+
| 变量 | 默认值 | 作用 |
|
|
151
|
+
| --- | --- | --- |
|
|
152
|
+
| `CPA_API_KEY` | 无 | CPA Bearer key;可由 `cpac` 首次引导写入 shell 启动文件 |
|
|
153
|
+
| `CPA_BASE_URL` | `https://cpa.vibetime.cc` | 覆盖内置 CPA 地址 |
|
|
154
|
+
| `CPAC_CONFIG` | `~/.config/cpac/config.json` | 指定可选 JSON 配置路径 |
|
|
155
|
+
| `CODEX_HOME` | `~/.codex` | Codex home 目录 |
|
|
156
|
+
|
|
157
|
+
CPAC CLI 可临时覆盖 CPA 地址:
|
|
158
|
+
|
|
159
|
+
```bash
|
|
160
|
+
CPA_BASE_URL='https://cpa.example.com' cpac claude
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
`pi-cpa.ts` 插件不读取此变量,固定连接远程 `https://cpa.vibetime.cc`,并且只从 Pi 进程环境读取 `CPA_API_KEY`;不读取用户 home 中的私有文件。
|
|
164
|
+
|
|
165
|
+
## 可选 JSON 配置
|
|
166
|
+
|
|
167
|
+
默认场景不需要配置文件,文件不存在时 CPAC 直接使用内置默认值,也不会创建目录。仅在需要覆盖 Codex 路径、state 目录或密钥变量名时创建配置,例如:
|
|
168
|
+
|
|
169
|
+
```json
|
|
170
|
+
{
|
|
171
|
+
"cpa_url": "https://cpa.example.com",
|
|
172
|
+
"api_key_env": "CPA_API_KEY",
|
|
173
|
+
"codex_config": "~/.codex/config.toml",
|
|
174
|
+
"state_dir": "~/.cpac"
|
|
175
|
+
}
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
支持字段:
|
|
179
|
+
|
|
180
|
+
- `cpa_url`:可选,绝对 `http(s)` URL,可带或不带 `/v1`
|
|
181
|
+
- `api_key_env`:可选,密钥环境变量名,默认 `CPA_API_KEY`
|
|
182
|
+
- `codex_config`:可选,Codex 配置路径
|
|
183
|
+
- `state_dir`:可选,CPAC state 路径
|
|
184
|
+
|
|
185
|
+
使用自定义配置:
|
|
186
|
+
|
|
187
|
+
```bash
|
|
188
|
+
cpac inject --config ./cpac.json
|
|
189
|
+
cpac claude --config ./cpac.json -- --model claude-sonnet-4-5
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
也可以设置:
|
|
193
|
+
|
|
194
|
+
```bash
|
|
195
|
+
export CPAC_CONFIG=/path/to/cpac.json
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
## 安全行为
|
|
199
|
+
|
|
200
|
+
- API key 不写入 CPAC JSON、Codex 配置、备份、state、catalog 或日志。
|
|
201
|
+
- 引导输入不回显,写入 shell 时会正确引用特殊字符。
|
|
202
|
+
- shell 启动文件和 Codex 配置均通过同目录临时文件原子替换。
|
|
203
|
+
- CPA 请求失败、catalog 无效或 key 缺失时,首次注入不会修改 Codex 配置。
|
|
204
|
+
- `restore` 只删除 CPAC 自有的 state、backup 和 catalog,不删除 `state_dir` 中的其他文件。
|
|
205
|
+
|
|
206
|
+
## 开发
|
|
207
|
+
|
|
208
|
+
```bash
|
|
209
|
+
npm install
|
|
210
|
+
npm test
|
|
211
|
+
npm run check
|
|
212
|
+
npm run build
|
|
213
|
+
npm run check:pi
|
|
214
|
+
npm run pack:check
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
发布包的 CLI 入口为 `dist/cpac.js`。`dist/` 是构建产物,不应手工编辑。
|
|
218
|
+
|
|
219
|
+
## npm 发布
|
|
220
|
+
|
|
221
|
+
`.github/workflows/publish.yml` 在推送 `v*` tag 或手动触发时执行测试、类型检查、构建并发布到 npm。tag 发布会校验 tag 必须等于 `v` 加 `package.json` 的版本,例如 `v0.1.0`。
|
|
222
|
+
|
|
223
|
+
首次发布前:
|
|
224
|
+
|
|
225
|
+
1. 在 npm 创建账号并启用 2FA。
|
|
226
|
+
2. 因为新包尚不存在,先创建允许 publish 且可绕过 2FA 的 npm granular access token,在仓库的 **Settings → Secrets and variables → Actions** 添加 `NPM_TOKEN`,然后手动运行 `Publish to npm` workflow 完成首次发布。
|
|
227
|
+
3. 首次发布后,在 npm 包 `@yhong91/cpac` 的设置中添加 Trusted Publisher:GitHub 用户 `yhong91`、仓库 `cpac`、workflow 文件 `publish.yml`,允许 `npm publish`。
|
|
228
|
+
4. Trusted Publisher 生效后删除 GitHub 的 `NPM_TOKEN`;后续 workflow 自动使用 OIDC。
|
|
229
|
+
|
|
230
|
+
正常发布新版本:
|
|
231
|
+
|
|
232
|
+
```bash
|
|
233
|
+
npm version patch # 或 minor / major
|
|
234
|
+
git push --follow-tags
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
仓库当前为 private,因此 npm Trusted Publishing 可以使用,但 npm 不会为发布包生成 provenance。
|
package/dist/cpac.js
ADDED
|
@@ -0,0 +1,691 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { chmodSync, closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, realpathSync, renameSync, rmdirSync, rmSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
5
|
+
import { homedir, tmpdir } from "node:os";
|
|
6
|
+
import { dirname, isAbsolute, join, parse, relative, resolve } from "node:path";
|
|
7
|
+
import { pathToFileURL } from "node:url";
|
|
8
|
+
import { createInterface } from "node:readline/promises";
|
|
9
|
+
import { Writable } from "node:stream";
|
|
10
|
+
export const PROVIDER = "cpac_cpa";
|
|
11
|
+
const DEFAULT_CPA_URL = "https://cpa.vibetime.cc";
|
|
12
|
+
const MAX_CATALOG_BYTES = 16 * 1024 * 1024;
|
|
13
|
+
const CONFIG_KEYS = new Set([
|
|
14
|
+
"cpa_url",
|
|
15
|
+
"api_key_env",
|
|
16
|
+
"codex_config",
|
|
17
|
+
"state_dir",
|
|
18
|
+
]);
|
|
19
|
+
const STATE_KEYS = new Set(["config_path", "config_existed", "config_mode"]);
|
|
20
|
+
const STATE_FILES = ["state.json", "config.toml.backup", "codex-models.json"];
|
|
21
|
+
const ROOT_KEY = /^\s*(?:model_provider|"model_provider"|'model_provider'|model_catalog_json|"model_catalog_json"|'model_catalog_json')\s*=/;
|
|
22
|
+
let atomicSequence = 0;
|
|
23
|
+
export class CPACError extends Error {
|
|
24
|
+
}
|
|
25
|
+
function objectValue(value) {
|
|
26
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
27
|
+
}
|
|
28
|
+
function expandUserPath(value) {
|
|
29
|
+
if (value === "~")
|
|
30
|
+
return homedir();
|
|
31
|
+
if (value.startsWith("~/") || value.startsWith("~\\"))
|
|
32
|
+
return join(homedir(), value.slice(2));
|
|
33
|
+
return value;
|
|
34
|
+
}
|
|
35
|
+
export function defaultConfigPath() {
|
|
36
|
+
return resolve(expandUserPath(process.env.CPAC_CONFIG || "~/.config/cpac/config.json"));
|
|
37
|
+
}
|
|
38
|
+
function normalizeCpaUrl(value) {
|
|
39
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
40
|
+
throw new CPACError("cpa_url must be a non-empty string");
|
|
41
|
+
}
|
|
42
|
+
let parsed;
|
|
43
|
+
try {
|
|
44
|
+
parsed = new URL(value);
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
throw new CPACError("cpa_url must be an absolute http(s) URL");
|
|
48
|
+
}
|
|
49
|
+
if (!["http:", "https:"].includes(parsed.protocol) ||
|
|
50
|
+
!parsed.hostname ||
|
|
51
|
+
parsed.username ||
|
|
52
|
+
parsed.password ||
|
|
53
|
+
parsed.search ||
|
|
54
|
+
parsed.hash) {
|
|
55
|
+
throw new CPACError("cpa_url must be an absolute http(s) URL without credentials, query, or fragment");
|
|
56
|
+
}
|
|
57
|
+
return value.trim().replace(/\/+$/, "");
|
|
58
|
+
}
|
|
59
|
+
export function loadConfig(path, useDefaultsIfMissing = false) {
|
|
60
|
+
let value;
|
|
61
|
+
try {
|
|
62
|
+
value = JSON.parse(readFileSync(path, "utf8"));
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
if (error.code === "ENOENT" &&
|
|
66
|
+
useDefaultsIfMissing) {
|
|
67
|
+
value = {};
|
|
68
|
+
}
|
|
69
|
+
else if (error.code === "ENOENT") {
|
|
70
|
+
throw new CPACError(`config not found: ${path}`);
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
throw new CPACError(`cannot read config: ${error instanceof Error ? error.message : String(error)}`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (!objectValue(value))
|
|
77
|
+
throw new CPACError("config must be a JSON object");
|
|
78
|
+
const unknown = Object.keys(value)
|
|
79
|
+
.filter((key) => !CONFIG_KEYS.has(key))
|
|
80
|
+
.sort();
|
|
81
|
+
if (unknown.length)
|
|
82
|
+
throw new CPACError(`unknown config keys: ${unknown.join(", ")}`);
|
|
83
|
+
const cpaUrl = normalizeCpaUrl(value.cpa_url ?? process.env.CPA_BASE_URL?.trim() ?? DEFAULT_CPA_URL);
|
|
84
|
+
const apiKeyEnv = value.api_key_env ?? "CPA_API_KEY";
|
|
85
|
+
if (typeof apiKeyEnv !== "string" ||
|
|
86
|
+
!/^[A-Za-z_][A-Za-z0-9_]*$/.test(apiKeyEnv)) {
|
|
87
|
+
throw new CPACError("api_key_env must be an environment variable name");
|
|
88
|
+
}
|
|
89
|
+
if (value.codex_config !== undefined &&
|
|
90
|
+
(typeof value.codex_config !== "string" || !value.codex_config.trim())) {
|
|
91
|
+
throw new CPACError("codex_config must be a non-empty string");
|
|
92
|
+
}
|
|
93
|
+
if (value.state_dir !== undefined &&
|
|
94
|
+
(typeof value.state_dir !== "string" || !value.state_dir.trim())) {
|
|
95
|
+
throw new CPACError("state_dir must be a non-empty string");
|
|
96
|
+
}
|
|
97
|
+
const codexHome = expandUserPath(process.env.CODEX_HOME?.trim() || join(homedir(), ".codex"));
|
|
98
|
+
const codexConfig = resolve(expandUserPath(value.codex_config ||
|
|
99
|
+
join(codexHome, "config.toml")));
|
|
100
|
+
const stateDir = resolve(expandUserPath(value.state_dir || join(homedir(), ".cpac")));
|
|
101
|
+
const stateToConfig = relative(stateDir, codexConfig);
|
|
102
|
+
if ([parse(stateDir).root, resolve(homedir()), resolve(tmpdir())].includes(stateDir) ||
|
|
103
|
+
stateToConfig === "" ||
|
|
104
|
+
(!stateToConfig.startsWith("..") && !isAbsolute(stateToConfig))) {
|
|
105
|
+
throw new CPACError("state_dir must not be a filesystem root, home/temp directory, or contain codex_config");
|
|
106
|
+
}
|
|
107
|
+
return {
|
|
108
|
+
cpa_url: cpaUrl,
|
|
109
|
+
api_key_env: apiKeyEnv,
|
|
110
|
+
codex_config: codexConfig,
|
|
111
|
+
state_dir: stateDir,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
export function apiBase(cpaUrl) {
|
|
115
|
+
try {
|
|
116
|
+
const url = new URL(cpaUrl);
|
|
117
|
+
return url.pathname.replace(/\/+$/, "").endsWith("/v1")
|
|
118
|
+
? cpaUrl
|
|
119
|
+
: `${cpaUrl}/v1`;
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
throw new CPACError("cpa_url must be an absolute http(s) URL");
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
export async function fetchCatalog(cpaUrl, apiKey) {
|
|
126
|
+
let response;
|
|
127
|
+
try {
|
|
128
|
+
response = await fetch(`${apiBase(cpaUrl)}/models?client_version=1`, {
|
|
129
|
+
headers: {
|
|
130
|
+
Authorization: `Bearer ${apiKey}`,
|
|
131
|
+
Accept: "application/json",
|
|
132
|
+
},
|
|
133
|
+
signal: AbortSignal.timeout(20_000),
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
throw new CPACError("CPA catalog request failed");
|
|
138
|
+
}
|
|
139
|
+
if (!response.ok)
|
|
140
|
+
throw new CPACError(`CPA catalog request failed: HTTP ${response.status}`);
|
|
141
|
+
const declaredLength = response.headers.get("content-length");
|
|
142
|
+
if (declaredLength &&
|
|
143
|
+
/^\d+$/.test(declaredLength) &&
|
|
144
|
+
Number(declaredLength) > MAX_CATALOG_BYTES) {
|
|
145
|
+
await response.body?.cancel();
|
|
146
|
+
throw new CPACError("CPA catalog exceeds 16 MiB");
|
|
147
|
+
}
|
|
148
|
+
const chunks = [];
|
|
149
|
+
let length = 0;
|
|
150
|
+
const reader = response.body?.getReader();
|
|
151
|
+
if (reader) {
|
|
152
|
+
while (true) {
|
|
153
|
+
let result;
|
|
154
|
+
try {
|
|
155
|
+
result = await reader.read();
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
throw new CPACError("CPA catalog request failed");
|
|
159
|
+
}
|
|
160
|
+
if (result.done)
|
|
161
|
+
break;
|
|
162
|
+
length += result.value.byteLength;
|
|
163
|
+
if (length > MAX_CATALOG_BYTES) {
|
|
164
|
+
await reader.cancel();
|
|
165
|
+
throw new CPACError("CPA catalog exceeds 16 MiB");
|
|
166
|
+
}
|
|
167
|
+
chunks.push(result.value);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
const body = Buffer.concat(chunks, length);
|
|
171
|
+
let document;
|
|
172
|
+
try {
|
|
173
|
+
document = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(body));
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
throw new CPACError("CPA catalog is not valid JSON");
|
|
177
|
+
}
|
|
178
|
+
const models = objectValue(document) ? document.models : undefined;
|
|
179
|
+
if (!Array.isArray(models) || models.length === 0) {
|
|
180
|
+
throw new CPACError("CPA rich catalog must contain a non-empty models array");
|
|
181
|
+
}
|
|
182
|
+
if (models.some((model) => !objectValue(model) ||
|
|
183
|
+
typeof model.slug !== "string" ||
|
|
184
|
+
!model.slug.trim())) {
|
|
185
|
+
throw new CPACError("CPA catalog contains a model without a slug");
|
|
186
|
+
}
|
|
187
|
+
return {
|
|
188
|
+
bytes: Buffer.from(`${JSON.stringify(document, null, 2)}\n`),
|
|
189
|
+
modelCount: models.length,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
export function tomlString(value) {
|
|
193
|
+
return JSON.stringify(value);
|
|
194
|
+
}
|
|
195
|
+
export function dominantEol(content) {
|
|
196
|
+
const crlf = (content.match(/\r\n/g) ?? []).length;
|
|
197
|
+
if (crlf === 0)
|
|
198
|
+
return "\n";
|
|
199
|
+
const bareLf = (content.match(/\n/g) ?? []).length - crlf;
|
|
200
|
+
return crlf >= bareLf ? "\r\n" : "\n";
|
|
201
|
+
}
|
|
202
|
+
function providerPatterns(provider = PROVIDER) {
|
|
203
|
+
const escaped = provider.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
204
|
+
const modelProviders = `(?:model_providers|"model_providers"|'model_providers')`;
|
|
205
|
+
const providerToken = `(?:${escaped}|"${escaped}"|'${escaped}')`;
|
|
206
|
+
return {
|
|
207
|
+
header: new RegExp(`^\\s*\\[\\s*${modelProviders}\\s*\\.\\s*${providerToken}\\s*\\]\\s*(?:#.*)?$`),
|
|
208
|
+
arrayHeader: new RegExp(`^\\s*\\[\\[\\s*${modelProviders}\\s*\\.\\s*${providerToken}\\s*\\]\\]\\s*(?:#.*)?$`),
|
|
209
|
+
parentHeader: new RegExp(`^\\s*\\[\\s*${modelProviders}\\s*\\]\\s*(?:#.*)?$`),
|
|
210
|
+
inlineKey: new RegExp(`^\\s*${providerToken}\\s*=`),
|
|
211
|
+
dottedKey: new RegExp(`^\\s*${modelProviders}\\s*\\.\\s*${providerToken}\\s*=`),
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
function providerHeader(provider = PROVIDER) {
|
|
215
|
+
return providerPatterns(provider).header;
|
|
216
|
+
}
|
|
217
|
+
export function hasProviderTable(content, provider = PROVIDER) {
|
|
218
|
+
const patterns = providerPatterns(provider);
|
|
219
|
+
let inRoot = true;
|
|
220
|
+
let inModelProviders = false;
|
|
221
|
+
for (const line of content.split(/\r?\n/)) {
|
|
222
|
+
if (patterns.header.test(line) || patterns.arrayHeader.test(line))
|
|
223
|
+
return true;
|
|
224
|
+
if (/^\s*\[/.test(line)) {
|
|
225
|
+
inRoot = false;
|
|
226
|
+
inModelProviders = patterns.parentHeader.test(line);
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
if ((inModelProviders && patterns.inlineKey.test(line)) ||
|
|
230
|
+
(inRoot && patterns.dottedKey.test(line)))
|
|
231
|
+
return true;
|
|
232
|
+
}
|
|
233
|
+
return false;
|
|
234
|
+
}
|
|
235
|
+
function stripManagedConfig(content) {
|
|
236
|
+
let text;
|
|
237
|
+
try {
|
|
238
|
+
text = new TextDecoder("utf-8", { fatal: true }).decode(content);
|
|
239
|
+
}
|
|
240
|
+
catch {
|
|
241
|
+
throw new CPACError("Codex config must be UTF-8");
|
|
242
|
+
}
|
|
243
|
+
const eol = dominantEol(text);
|
|
244
|
+
const header = providerHeader();
|
|
245
|
+
const lines = text.replace(/\r\n/g, "\n").split("\n");
|
|
246
|
+
let inRoot = true;
|
|
247
|
+
let inProvider = false;
|
|
248
|
+
const kept = [];
|
|
249
|
+
for (const line of lines) {
|
|
250
|
+
if (inProvider && /^\s*\[/.test(line))
|
|
251
|
+
inProvider = false;
|
|
252
|
+
if (!inProvider && header.test(line)) {
|
|
253
|
+
inProvider = true;
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
if (inProvider)
|
|
257
|
+
continue;
|
|
258
|
+
if (/^\s*\[/.test(line))
|
|
259
|
+
inRoot = false;
|
|
260
|
+
if (inRoot && (ROOT_KEY.test(line) || line.startsWith("# CPAC managed;")))
|
|
261
|
+
continue;
|
|
262
|
+
kept.push(line);
|
|
263
|
+
}
|
|
264
|
+
const output = kept.join("\n");
|
|
265
|
+
return Buffer.from(eol === "\n" ? output : output.replace(/\n/g, "\r\n"));
|
|
266
|
+
}
|
|
267
|
+
export function buildCodexConfig(original, cpaUrl, apiKeyEnv, catalogPath) {
|
|
268
|
+
let text;
|
|
269
|
+
try {
|
|
270
|
+
text = new TextDecoder("utf-8", { fatal: true }).decode(original);
|
|
271
|
+
}
|
|
272
|
+
catch {
|
|
273
|
+
throw new CPACError("Codex config must be UTF-8");
|
|
274
|
+
}
|
|
275
|
+
if (hasProviderTable(text)) {
|
|
276
|
+
throw new CPACError(`Codex config already contains [model_providers.${PROVIDER}]`);
|
|
277
|
+
}
|
|
278
|
+
const eol = dominantEol(text);
|
|
279
|
+
const lines = text.replace(/\r\n/g, "\n").split("\n");
|
|
280
|
+
let inRoot = true;
|
|
281
|
+
const kept = lines.filter((line) => {
|
|
282
|
+
if (/^\s*\[/.test(line))
|
|
283
|
+
inRoot = false;
|
|
284
|
+
return !(inRoot && ROOT_KEY.test(line));
|
|
285
|
+
});
|
|
286
|
+
let body = kept.join("\n");
|
|
287
|
+
if (body && !body.endsWith("\n"))
|
|
288
|
+
body += "\n";
|
|
289
|
+
const root = [
|
|
290
|
+
"# CPAC managed; run CPAC 'restore' to restore the original file.",
|
|
291
|
+
`model_provider = ${tomlString(PROVIDER)}`,
|
|
292
|
+
`model_catalog_json = ${tomlString(catalogPath)}`,
|
|
293
|
+
"",
|
|
294
|
+
].join("\n");
|
|
295
|
+
const provider = [
|
|
296
|
+
"",
|
|
297
|
+
`[model_providers.${PROVIDER}]`,
|
|
298
|
+
'name = "CPA"',
|
|
299
|
+
`base_url = ${tomlString(apiBase(cpaUrl))}`,
|
|
300
|
+
`env_key = ${tomlString(apiKeyEnv)}`,
|
|
301
|
+
'wire_api = "responses"',
|
|
302
|
+
"",
|
|
303
|
+
].join("\n");
|
|
304
|
+
const output = root + body + provider;
|
|
305
|
+
return Buffer.from(eol === "\n" ? output : output.replace(/\n/g, "\r\n"));
|
|
306
|
+
}
|
|
307
|
+
export function atomicWrite(path, data, mode = 0o600) {
|
|
308
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
309
|
+
const temporary = `${path}.cpac.${process.pid}.${Date.now()}.${++atomicSequence}.tmp`;
|
|
310
|
+
let descriptor;
|
|
311
|
+
try {
|
|
312
|
+
descriptor = openSync(temporary, "wx", 0o600);
|
|
313
|
+
writeFileSync(descriptor, data);
|
|
314
|
+
fsyncSync(descriptor);
|
|
315
|
+
closeSync(descriptor);
|
|
316
|
+
descriptor = undefined;
|
|
317
|
+
chmodSync(temporary, mode);
|
|
318
|
+
renameSync(temporary, path);
|
|
319
|
+
}
|
|
320
|
+
catch (error) {
|
|
321
|
+
if (descriptor !== undefined) {
|
|
322
|
+
try {
|
|
323
|
+
closeSync(descriptor);
|
|
324
|
+
}
|
|
325
|
+
catch (closeError) {
|
|
326
|
+
void closeError;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
try {
|
|
330
|
+
unlinkSync(temporary);
|
|
331
|
+
}
|
|
332
|
+
catch (unlinkError) {
|
|
333
|
+
void unlinkError;
|
|
334
|
+
}
|
|
335
|
+
throw error;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
function readState(stateDir) {
|
|
339
|
+
const path = join(stateDir, "state.json");
|
|
340
|
+
if (!existsSync(path))
|
|
341
|
+
return null;
|
|
342
|
+
let value;
|
|
343
|
+
try {
|
|
344
|
+
value = JSON.parse(readFileSync(path, "utf8"));
|
|
345
|
+
}
|
|
346
|
+
catch (error) {
|
|
347
|
+
throw new CPACError(`cannot read state: ${error instanceof Error ? error.message : String(error)}`);
|
|
348
|
+
}
|
|
349
|
+
if (!objectValue(value) ||
|
|
350
|
+
Object.keys(value).some((key) => !STATE_KEYS.has(key)) ||
|
|
351
|
+
Object.keys(value).length !== STATE_KEYS.size) {
|
|
352
|
+
throw new CPACError("invalid state file");
|
|
353
|
+
}
|
|
354
|
+
if (typeof value.config_path !== "string" || !isAbsolute(value.config_path)) {
|
|
355
|
+
throw new CPACError("invalid config path in state");
|
|
356
|
+
}
|
|
357
|
+
if (typeof value.config_existed !== "boolean")
|
|
358
|
+
throw new CPACError("invalid config_existed value in state");
|
|
359
|
+
if (!Number.isInteger(value.config_mode) ||
|
|
360
|
+
value.config_mode < 0 ||
|
|
361
|
+
value.config_mode > 0o7777) {
|
|
362
|
+
throw new CPACError("invalid config_mode value in state");
|
|
363
|
+
}
|
|
364
|
+
return value;
|
|
365
|
+
}
|
|
366
|
+
function originalBytes(stateDir, state, expectedPath) {
|
|
367
|
+
if (state.config_path !== expectedPath) {
|
|
368
|
+
throw new CPACError(`active injection belongs to ${state.config_path}; restore it first`);
|
|
369
|
+
}
|
|
370
|
+
if (!state.config_existed)
|
|
371
|
+
return new Uint8Array();
|
|
372
|
+
try {
|
|
373
|
+
return readFileSync(join(stateDir, "config.toml.backup"));
|
|
374
|
+
}
|
|
375
|
+
catch (error) {
|
|
376
|
+
throw new CPACError(`cannot read original config backup: ${error instanceof Error ? error.message : String(error)}`);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
function cleanupStateFiles(stateDir) {
|
|
380
|
+
for (const name of STATE_FILES)
|
|
381
|
+
rmSync(join(stateDir, name), { force: true });
|
|
382
|
+
try {
|
|
383
|
+
rmdirSync(stateDir);
|
|
384
|
+
}
|
|
385
|
+
catch (error) {
|
|
386
|
+
const code = error.code;
|
|
387
|
+
if (code !== "ENOENT" && code !== "ENOTEMPTY" && code !== "EEXIST")
|
|
388
|
+
throw error;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
export async function inject(config) {
|
|
392
|
+
const apiKey = process.env[config.api_key_env]?.trim();
|
|
393
|
+
if (!apiKey)
|
|
394
|
+
throw new CPACError(`environment variable ${config.api_key_env} is not set`);
|
|
395
|
+
const state = readState(config.state_dir);
|
|
396
|
+
let existed;
|
|
397
|
+
let original;
|
|
398
|
+
let originalMode;
|
|
399
|
+
if (state) {
|
|
400
|
+
const backup = originalBytes(config.state_dir, state, config.codex_config);
|
|
401
|
+
try {
|
|
402
|
+
original = existsSync(config.codex_config)
|
|
403
|
+
? stripManagedConfig(readFileSync(config.codex_config))
|
|
404
|
+
: backup;
|
|
405
|
+
}
|
|
406
|
+
catch (error) {
|
|
407
|
+
if (error instanceof CPACError)
|
|
408
|
+
throw error;
|
|
409
|
+
throw new CPACError(`cannot read Codex config: ${error instanceof Error ? error.message : String(error)}`);
|
|
410
|
+
}
|
|
411
|
+
existed = state.config_existed;
|
|
412
|
+
originalMode = state.config_mode;
|
|
413
|
+
}
|
|
414
|
+
else {
|
|
415
|
+
existed = existsSync(config.codex_config);
|
|
416
|
+
try {
|
|
417
|
+
original = existed ? readFileSync(config.codex_config) : new Uint8Array();
|
|
418
|
+
originalMode = existed
|
|
419
|
+
? statSync(config.codex_config).mode & 0o7777
|
|
420
|
+
: 0o600;
|
|
421
|
+
}
|
|
422
|
+
catch (error) {
|
|
423
|
+
throw new CPACError(`cannot read Codex config: ${error instanceof Error ? error.message : String(error)}`);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
const catalogPath = join(config.state_dir, "codex-models.json");
|
|
427
|
+
const injected = buildCodexConfig(original, config.cpa_url, config.api_key_env, catalogPath);
|
|
428
|
+
const catalog = await fetchCatalog(config.cpa_url, apiKey);
|
|
429
|
+
const stateDirExisted = existsSync(config.state_dir);
|
|
430
|
+
if (!state &&
|
|
431
|
+
STATE_FILES.some((name) => existsSync(join(config.state_dir, name)))) {
|
|
432
|
+
throw new CPACError("state_dir contains CPAC files without a valid state; refusing to overwrite them");
|
|
433
|
+
}
|
|
434
|
+
mkdirSync(config.state_dir, { recursive: true, mode: 0o700 });
|
|
435
|
+
if (!stateDirExisted)
|
|
436
|
+
chmodSync(config.state_dir, 0o700);
|
|
437
|
+
if (state) {
|
|
438
|
+
atomicWrite(catalogPath, catalog.bytes);
|
|
439
|
+
atomicWrite(config.codex_config, injected, originalMode);
|
|
440
|
+
}
|
|
441
|
+
else {
|
|
442
|
+
let configWritten = false;
|
|
443
|
+
try {
|
|
444
|
+
if (existed)
|
|
445
|
+
atomicWrite(join(config.state_dir, "config.toml.backup"), original);
|
|
446
|
+
atomicWrite(catalogPath, catalog.bytes);
|
|
447
|
+
atomicWrite(config.codex_config, injected, originalMode);
|
|
448
|
+
configWritten = true;
|
|
449
|
+
atomicWrite(join(config.state_dir, "state.json"), Buffer.from(`${JSON.stringify({
|
|
450
|
+
config_path: config.codex_config,
|
|
451
|
+
config_existed: existed,
|
|
452
|
+
config_mode: originalMode,
|
|
453
|
+
}, null, 2)}\n`));
|
|
454
|
+
}
|
|
455
|
+
catch (error) {
|
|
456
|
+
if (configWritten) {
|
|
457
|
+
try {
|
|
458
|
+
if (existed)
|
|
459
|
+
atomicWrite(config.codex_config, original, originalMode);
|
|
460
|
+
else
|
|
461
|
+
rmSync(config.codex_config, { force: true });
|
|
462
|
+
}
|
|
463
|
+
catch (rollbackError) {
|
|
464
|
+
throw new CPACError(`injection failed and config rollback failed; backup retained in ${config.state_dir}: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
try {
|
|
468
|
+
cleanupStateFiles(config.state_dir);
|
|
469
|
+
}
|
|
470
|
+
catch (cleanupError) {
|
|
471
|
+
throw new CPACError(`injection failed and state cleanup failed: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`);
|
|
472
|
+
}
|
|
473
|
+
throw error;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
console.log(`Injected ${catalog.modelCount} CPA models into ${config.codex_config}`);
|
|
477
|
+
}
|
|
478
|
+
export function restore(config) {
|
|
479
|
+
const state = readState(config.state_dir);
|
|
480
|
+
if (!state)
|
|
481
|
+
throw new CPACError("no active CPAC injection");
|
|
482
|
+
const original = originalBytes(config.state_dir, state, config.codex_config);
|
|
483
|
+
if (state.config_existed)
|
|
484
|
+
atomicWrite(config.codex_config, original, state.config_mode);
|
|
485
|
+
else
|
|
486
|
+
rmSync(config.codex_config, { force: true });
|
|
487
|
+
try {
|
|
488
|
+
cleanupStateFiles(config.state_dir);
|
|
489
|
+
}
|
|
490
|
+
catch (error) {
|
|
491
|
+
throw new CPACError(`config restored, but state cleanup failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
492
|
+
}
|
|
493
|
+
console.log(`Restored ${config.codex_config}`);
|
|
494
|
+
}
|
|
495
|
+
export function status(config) {
|
|
496
|
+
const state = readState(config.state_dir);
|
|
497
|
+
if (!state) {
|
|
498
|
+
console.log("native");
|
|
499
|
+
return 1;
|
|
500
|
+
}
|
|
501
|
+
originalBytes(config.state_dir, state, config.codex_config);
|
|
502
|
+
if (!existsSync(config.codex_config)) {
|
|
503
|
+
console.log(`injected (config missing: ${config.codex_config})`);
|
|
504
|
+
return 2;
|
|
505
|
+
}
|
|
506
|
+
const digest = createHash("sha256")
|
|
507
|
+
.update(readFileSync(config.codex_config))
|
|
508
|
+
.digest("hex")
|
|
509
|
+
.slice(0, 12);
|
|
510
|
+
console.log(`injected: ${config.codex_config} sha256=${digest}`);
|
|
511
|
+
return 0;
|
|
512
|
+
}
|
|
513
|
+
function claudeBaseUrl(cpaUrl) {
|
|
514
|
+
return cpaUrl.replace(/\/v1$/, "");
|
|
515
|
+
}
|
|
516
|
+
async function promptSecret(name) {
|
|
517
|
+
if (!process.stdin.isTTY || !process.stderr.isTTY) {
|
|
518
|
+
throw new CPACError(`environment variable ${name} is not set; run: export ${name}="..."`);
|
|
519
|
+
}
|
|
520
|
+
process.stderr.write(`Enter ${name}: `);
|
|
521
|
+
const silent = new Writable({
|
|
522
|
+
write(_chunk, _encoding, callback) {
|
|
523
|
+
callback();
|
|
524
|
+
},
|
|
525
|
+
});
|
|
526
|
+
const prompt = createInterface({
|
|
527
|
+
input: process.stdin,
|
|
528
|
+
output: silent,
|
|
529
|
+
terminal: true,
|
|
530
|
+
});
|
|
531
|
+
try {
|
|
532
|
+
const value = (await prompt.question("")).trim();
|
|
533
|
+
process.stderr.write("\n");
|
|
534
|
+
if (!value)
|
|
535
|
+
throw new CPACError(`${name} must not be empty`);
|
|
536
|
+
return value;
|
|
537
|
+
}
|
|
538
|
+
finally {
|
|
539
|
+
prompt.close();
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
async function resolveApiKey(name) {
|
|
543
|
+
return process.env[name]?.trim() || (await promptSecret(name));
|
|
544
|
+
}
|
|
545
|
+
function shellProfile() {
|
|
546
|
+
const shell = parse(process.env.SHELL || "").base;
|
|
547
|
+
if (shell === "zsh")
|
|
548
|
+
return join(homedir(), ".zshrc");
|
|
549
|
+
if (shell === "bash")
|
|
550
|
+
return join(homedir(), process.platform === "darwin" ? ".bash_profile" : ".bashrc");
|
|
551
|
+
if (["sh", "dash", "ksh"].includes(shell))
|
|
552
|
+
return join(homedir(), ".profile");
|
|
553
|
+
throw new CPACError(`unsupported shell ${shell || "unknown"}; run: export CPA_API_KEY="..."`);
|
|
554
|
+
}
|
|
555
|
+
function shellQuote(value) {
|
|
556
|
+
return `'${value.replaceAll("'", `'"'"'`)}'`;
|
|
557
|
+
}
|
|
558
|
+
export function saveApiKeyExport(profile, name, apiKey) {
|
|
559
|
+
const start = `# >>> CPAC ${name} >>>`;
|
|
560
|
+
const end = `# <<< CPAC ${name} <<<`;
|
|
561
|
+
const block = `${start}\nexport ${name}=${shellQuote(apiKey)}\n${end}`;
|
|
562
|
+
let content = existsSync(profile) ? readFileSync(profile, "utf8") : "";
|
|
563
|
+
const managed = new RegExp(`${start.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[\\s\\S]*?${end.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`);
|
|
564
|
+
content = managed.test(content)
|
|
565
|
+
? content.replace(managed, block)
|
|
566
|
+
: `${content}${content && !content.endsWith("\n") ? "\n" : ""}${block}\n`;
|
|
567
|
+
const mode = existsSync(profile) ? statSync(profile).mode & 0o7777 : 0o600;
|
|
568
|
+
atomicWrite(profile, Buffer.from(content), mode);
|
|
569
|
+
}
|
|
570
|
+
async function guide(config) {
|
|
571
|
+
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 claude [args...] Launch Claude Code through CPA\n cpac inject Inject CPA into Codex\n cpac status Show Codex injection status\n cpac restore Restore the original Codex config\n cpac --help Show command usage`);
|
|
572
|
+
if (process.env[config.api_key_env]?.trim())
|
|
573
|
+
return 0;
|
|
574
|
+
const apiKey = await promptSecret(config.api_key_env);
|
|
575
|
+
const profile = shellProfile();
|
|
576
|
+
saveApiKeyExport(profile, config.api_key_env, apiKey);
|
|
577
|
+
process.env[config.api_key_env] = apiKey;
|
|
578
|
+
console.log(`\nSaved ${config.api_key_env} to ${profile}. Open a new terminal or run:\n source ${profile}`);
|
|
579
|
+
return 0;
|
|
580
|
+
}
|
|
581
|
+
export async function runClaude(config, args, executable = "claude") {
|
|
582
|
+
const apiKey = await resolveApiKey(config.api_key_env);
|
|
583
|
+
const env = {
|
|
584
|
+
...process.env,
|
|
585
|
+
ANTHROPIC_BASE_URL: claudeBaseUrl(config.cpa_url),
|
|
586
|
+
ANTHROPIC_AUTH_TOKEN: apiKey,
|
|
587
|
+
};
|
|
588
|
+
delete env.ANTHROPIC_API_KEY;
|
|
589
|
+
delete env.CLAUDE_CODE_USE_ANTHROPIC_AWS;
|
|
590
|
+
delete env.CLAUDE_CODE_USE_BEDROCK;
|
|
591
|
+
delete env.CLAUDE_CODE_USE_FOUNDRY;
|
|
592
|
+
delete env.CLAUDE_CODE_USE_VERTEX;
|
|
593
|
+
return await new Promise((resolve, reject) => {
|
|
594
|
+
const child = spawn(executable, args, { env, stdio: "inherit" });
|
|
595
|
+
child.once("error", (error) => {
|
|
596
|
+
reject(new CPACError(error instanceof Error && "code" in error && error.code === "ENOENT"
|
|
597
|
+
? `${executable} not found`
|
|
598
|
+
: `cannot start ${executable}: ${error.message}`));
|
|
599
|
+
});
|
|
600
|
+
child.once("close", (code) => resolve(code ?? 1));
|
|
601
|
+
});
|
|
602
|
+
}
|
|
603
|
+
function usage() {
|
|
604
|
+
return [
|
|
605
|
+
"Usage: cpac",
|
|
606
|
+
" cpac <inject|status|restore> [--config PATH]",
|
|
607
|
+
" cpac claude [--config PATH] [--] [claude args...]",
|
|
608
|
+
].join("\n");
|
|
609
|
+
}
|
|
610
|
+
function parseArgs(args) {
|
|
611
|
+
if (args.length === 0) {
|
|
612
|
+
return { command: "guide", configPath: defaultConfigPath() };
|
|
613
|
+
}
|
|
614
|
+
if (args[0] === "claude") {
|
|
615
|
+
let configPath = defaultConfigPath();
|
|
616
|
+
let index = 1;
|
|
617
|
+
if (args[index] === "--config") {
|
|
618
|
+
const value = args[++index];
|
|
619
|
+
if (!value)
|
|
620
|
+
throw new CPACError("--config requires a path");
|
|
621
|
+
configPath = resolve(expandUserPath(value));
|
|
622
|
+
index += 1;
|
|
623
|
+
}
|
|
624
|
+
if (args[index] === "--")
|
|
625
|
+
index += 1;
|
|
626
|
+
return { command: "claude", configPath, args: args.slice(index) };
|
|
627
|
+
}
|
|
628
|
+
if (args.includes("-h") || args.includes("--help")) {
|
|
629
|
+
console.log(usage());
|
|
630
|
+
return null;
|
|
631
|
+
}
|
|
632
|
+
let configPath = defaultConfigPath();
|
|
633
|
+
const positional = [];
|
|
634
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
635
|
+
if (args[index] === "--config") {
|
|
636
|
+
const value = args[++index];
|
|
637
|
+
if (!value)
|
|
638
|
+
throw new CPACError("--config requires a path");
|
|
639
|
+
configPath = resolve(expandUserPath(value));
|
|
640
|
+
}
|
|
641
|
+
else if (args[index].startsWith("-")) {
|
|
642
|
+
throw new CPACError(`unknown option: ${args[index]}`);
|
|
643
|
+
}
|
|
644
|
+
else
|
|
645
|
+
positional.push(args[index]);
|
|
646
|
+
}
|
|
647
|
+
if (positional.length !== 1 ||
|
|
648
|
+
!["inject", "status", "restore"].includes(positional[0])) {
|
|
649
|
+
throw new CPACError(usage());
|
|
650
|
+
}
|
|
651
|
+
return {
|
|
652
|
+
command: positional[0],
|
|
653
|
+
configPath,
|
|
654
|
+
};
|
|
655
|
+
}
|
|
656
|
+
export async function main(args = process.argv.slice(2)) {
|
|
657
|
+
try {
|
|
658
|
+
const parsed = parseArgs(args);
|
|
659
|
+
if (!parsed)
|
|
660
|
+
return 0;
|
|
661
|
+
const config = loadConfig(parsed.configPath, true);
|
|
662
|
+
if (parsed.command === "guide")
|
|
663
|
+
return await guide(config);
|
|
664
|
+
if (parsed.command === "claude")
|
|
665
|
+
return await runClaude(config, parsed.args);
|
|
666
|
+
if (parsed.command === "inject")
|
|
667
|
+
await inject(config);
|
|
668
|
+
else if (parsed.command === "restore")
|
|
669
|
+
restore(config);
|
|
670
|
+
else
|
|
671
|
+
return status(config);
|
|
672
|
+
return 0;
|
|
673
|
+
}
|
|
674
|
+
catch (error) {
|
|
675
|
+
console.error(`cpac: ${error instanceof Error ? error.message : String(error)}`);
|
|
676
|
+
return 1;
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
function isMainModule() {
|
|
680
|
+
const entry = process.argv[1];
|
|
681
|
+
if (!entry)
|
|
682
|
+
return false;
|
|
683
|
+
try {
|
|
684
|
+
return import.meta.url === pathToFileURL(realpathSync(entry)).href;
|
|
685
|
+
}
|
|
686
|
+
catch {
|
|
687
|
+
return import.meta.url === pathToFileURL(entry).href;
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
if (isMainModule())
|
|
691
|
+
process.exitCode = await main();
|
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@yhong91/cpac",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Connect Codex and Claude Code to a remote CLIProxyAPI gateway",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"cpac": "dist/cpac.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist/cpac.js",
|
|
11
|
+
"cpac.example.json",
|
|
12
|
+
"README.md"
|
|
13
|
+
],
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=20"
|
|
16
|
+
},
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/yhong91/cpac.git"
|
|
20
|
+
},
|
|
21
|
+
"bugs": {
|
|
22
|
+
"url": "https://github.com/yhong91/cpac/issues"
|
|
23
|
+
},
|
|
24
|
+
"homepage": "https://github.com/yhong91/cpac#readme",
|
|
25
|
+
"publishConfig": {
|
|
26
|
+
"access": "public",
|
|
27
|
+
"registry": "https://registry.npmjs.org/"
|
|
28
|
+
},
|
|
29
|
+
"scripts": {
|
|
30
|
+
"clean": "node -e \"for (const d of ['dist','dist-test']) require('fs').rmSync(d,{recursive:true,force:true})\"",
|
|
31
|
+
"build": "npm run clean --if-present && tsc -p tsconfig.build.json && node -e \"const fs=require('fs');const p='dist/cpac.js';const s=fs.readFileSync(p,'utf8');if(!s.startsWith('#!'))fs.writeFileSync(p,'#!/usr/bin/env node\\n'+s);try{fs.chmodSync(p,0o755)}catch{}\"",
|
|
32
|
+
"check": "tsc -p tsconfig.json --noEmit",
|
|
33
|
+
"check:pi": "tsc -p tsconfig.pi.json --noEmit",
|
|
34
|
+
"test": "npm run clean && tsc -p tsconfig.test.json && node --test --test-reporter=spec dist-test/cpac.test.js",
|
|
35
|
+
"pack:check": "npm pack --dry-run"
|
|
36
|
+
},
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"@earendil-works/pi-ai": "^0.84.1",
|
|
39
|
+
"@earendil-works/pi-coding-agent": "^0.84.1",
|
|
40
|
+
"@types/node": "^22.13.10",
|
|
41
|
+
"typescript": "~5.8.2"
|
|
42
|
+
},
|
|
43
|
+
"license": "MIT"
|
|
44
|
+
}
|