{"repo": "aihubmix/platfrom-cli", "files": {"CLAUDE.md": {"d83dbc139a82619333d5b0f557a8350b42b03583": {"t": 1785768987.4376402, "n": 4845, "h": "0085af8926fe551b", "c": "# CLAUDE.md\n\n面向在本仓库协作的 Claude 的工程笔记。**用中文**(项目注释、文档、提交均为中文)。\n\n## 项目是什么\n\n`aihubmix` CLI:用 Go + cobra 把 AIHubMix **CliEndpoints**(账户/余额查询、API Key 管理、可用模型查询)这组 REST 接口封装成命令行工具。接口文档在 `docs/`(已被 `.gitignore` 忽略,本地可读,不提交)。\n\n## 常用命令\n\n- 构建当前平台:`go build -o aihubmix.exe .`(Win)/ `go build -o aihubmix .`(\\*nix)\n- 交叉编译全平台:`bash scripts/build.sh`(产物 + `checksums.txt` 落到 `dist/`;可传 `linux/amd64` 等只编指定平台)\n- 单元测试:`go test ./...`\n- 直接运行:`go run . ` 或 `./aihubmix.exe `\n- 手测/冒烟:`bash scripts/smoke.sh `(一次输入 token 把所有命令跑一遍;`--read-only` 跳过写操作,`--json` 全 JSON 输出)\n- 发布:push `vX.Y.Z` tag 触发 `.github/workflows/release.yml`(注入 `VERSION` 交叉编译并建 GitHub Release)\n- Go 1.25。\n\n## 凭证与运行时\n\n- 凭证优先级:`--token/-t` > `AIHUBMIX_TOKEN` 环境变量 > `~/.aihubmix/config.json`(`cmd/root.go:setup`)。\n- Base URL:`--base-url/-b`,默认 `https://aihubmix.com`,备用 `https://api.aihubmix.com`。\n- 鉴权头是**裸 token**:`Authorization: `,**无 `Bearer` 前缀**(`internal/api/client.go`)。\n- 全局 flag:`-t/--token`、`-b/--base-url`、`-j/--json`、`--no-color`、`--config`。\n- 不登录、直连真实 API 调试:`./aihubmix.exe me --token --base-url --json`。\n\n## 架构(包职责)\n\n- `main.go` → `cmd.Execute()`;错误统一经 `cmd.PrintError` 映射退出码后 `os.Exit`。\n- `cmd/`:cobra 命令树。`root.go` 装配全局 flag + `setup()` 解析凭证;顶层 `login`/`logout`/`whoami`(status)/`me`(account);`keys/` 子命令组(list/search/get/token/create/update/delete);`models/`。\n- `internal/api`:HTTP 客户端。`client.go` 做统一 envelope 解析与错误映射(**先看 HTTP 状态码,2xx 再看 `success` 字段**);`endpoints.go` 是各接口方法;`errors.go` 定义退出码与错误 Kind。\n- `internal/auth`:保存解析后的运行时凭证,并据此构造 client。\n- `internal/config`:`~/.aihubmix/config.json` 加载/保存 + 平台相关权限收紧。\n- `internal/models`:数据结构与自定义反序列化。\n- `internal/output`:人类可读输出(表格/详情)、`--json` 输出、额度/时间格式化。\n\n## 关键约定 / 不变量\n\n- 统一响应:`{success, message, data}`。业务失败时 `success=false` + `message`。\n- **额度换算**:原始整数 `/ 500000 = 美元`(`api.QuotaPerDollar = 500000`);反向 `美元 × 500000`。`quota`=余额,`used_quota`=已用,`remain_quota`=Key 限额余量(`-1`=无限额)。\n- `--json`:**stdout 永远是干净 JSON**(`me`/`keys`/`models` 透传原始 envelope;`whoami` 输出会话状态对象);**错误一律走 stderr**,带稳定 `code` 字段(`jq`/agent 友好)。\n- **类型漂移兼容**:`expired_time`/`remain_quota` 在不同端点可能是 number 或 string,故用 `models.FlexInt64`;`get-user-token` 的 data 可能是裸字符串或对象,故 `UserToken.UnmarshalJSON` 两种都吃。**新增可能类型不一致的字段时沿用这套,别直接上 `int64`/`string`,否则反序列化会崩或被中断的 `me` 报错。**\n- Windows 配置权限:`config.Save` 收紧 DACL(等效 `0600`);非 NTFS 卷收紧失败会返回包 `ErrHardenDegraded` 的错误,文件仍可用,调用方降级为警告。\n\n## 排查笔记(领域知识,非显而易见)\n\n- **无限额 Key 的 `used_quota` 恒为 0,这不是 CLI 的 bug。** `keys list`/`keys get` 的数据源 `/api/token/` 对 `unlimited_quota: true` 的 Key,上游返回的 `used_quota` 字段**本身就是 `0`**——服务端不需要扣额度,就不回写这个计数。CLI 只是如实透传。\n - 真实用量看**账户维度**:`/api/user/self` 的 `used_quota`(`me` 命令的 `Used` 行)与 `request_count`。官网「用量」页也是从账户级 / 请求日志取数,所以会出现「官网有用量、`keys list` 的 USED 列全是 `$0.00`」。\n - 即:账户级用量(`me`)准确可信;per-key 用量(`keys list`)对无限额 Key 不可信。排查「用量为 0」类问题,先确认该 Key 是不是无限额。\n - 对比验证脚本:`scripts/usage-probe.sh`(同一个 key 同时打账户接口和 token 接口,并和 CLI 输出对照)。\n\n- **完整可用的 Key 只在 create-key 返回一次,字段是 `full_key`,不是 `key`。**(实测,非文档原述)create-key 的 `data` 里 `key` 是**掩码值**(`sk-xxxx****xxxx`,仅供识别、不可调用),`full_key` 才是**完整明文**;而 get-key **只返回掩码 `key`、没有 `full_key`**,事后无法还原。\n - 故 `models.ApiKey` 同时有 `Key`(掩码)与 `FullKey`(完整)。**`keys create` 人类可读输出默认把 `full_key` 写入系统剪贴板**(`atotto/clipboard`),终端只显示掩码 + 绿色「已复制、仅此一次」提示,避免明文残留在回滚缓冲/录屏里;剪贴板不可用时回退到终端明文展示 + 黄色告警(见 `output.PrintCreatedKey`)。`output.PrintKeyDetail`(get/update)只显示掩码 `Key`,不再明文展示 `FullKey`。\n - **`--json` 不变,仍原样透传含明文 `full_key` 的 envelope**(不写剪贴板),脚本照旧取完整 key:`keys create --json | jq -r '.data.full_key'`。别把剪贴板逻辑塞进 `--json` 分支,否则污染 stdout 破坏脚本。验证脚本:`scripts/create-key-probe.sh`。\n\n- **`PUT /api/token/` 是「整体覆盖」而非「局部更新」,且改状态必须带 `?status_only=true`。**(实测,与「只发改动字段,避免误置零」的常规约定正好相反,极易踩坑)上游 update-token 分两支:\n - **不带 `status_only`** → 用请求体**整体覆盖** name/expired_time/remain_quota/unlimited_quota/models/subnet:**请求体里缺的字段会被置零**(name→`\"\"`、expired_time→`0`、unlimited→`false`…),且这条分支**根本不读取 `status`**。所以「只发改动的那个字段」会把其余字段全部清空,而 `--status` 在这条分支下完全不生效。\n - **带 `?status_only=true`** → **只**改 `status`,其它字段原样保留。\n - 故 `keys update` 的正确姿势(见 `cmd/keys/update.go`):改非状态字段时先 `GetKey` 读当前完整状态、把所有字段回填、叠加用户改动后整体 PUT(read-modify-write);改状态走 `client.UpdateKeyStatus`(`status_only=true`);两者同时改则拆成两次请求(整体 PUT + status_only PUT)。\n - `api.UpdateKey` 负责整体覆盖分支(doc 里有⚠️),`api.UpdateKeyStatus` 负责 status_only 分支——**别再把 update 退化成「只发改动字段的单条 PUT」**,否则会重演「改名清空过期/额度、`--status` 改不动还把名字清空」的 bug。回归测试见 `cmd/keys/update_test.go`。\n - **改状态的取值:上游禁用是 `2` 不是 `0`。**(实测)`1`=启用、`2`=禁用,`0` 是无效默认值,直接下发会被拒为 `Invalid token status`。`docs/` 文档与 `SPEC` 里「0=禁用」是错的。CLI 对用户仍保留直觉写法 `--status 0|1`(0=禁用、1=启用),在 `cmd/keys/update.go` 内部映射 `0→2`、`1→1`(非 0/1 直接报错)后再下发;常量见 `api.TokenStatusEnabled`/`TokenStatusDisabled`。正因有这层映射,README/SPEC 的 `--status 0` 示例无需改。\n\n## 注意\n\n- `CLAUDE.md` 会被提交,且 `scripts/` 不在 `.gitignore`:**任何脚本/文档里都不要硬编码真实 token**,用 `` 占位或要求从环境变量传入。\n"}}}}