From 2778262c12218137bdd7cdc85c6f329d4580986f Mon Sep 17 00:00:00 2001 From: Jovan <3071058281@qq.com> Date: Sat, 26 Sep 2026 13:42:05 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=81=A2=E5=A4=8D=E4=B8=BA=E7=8B=AC?= =?UTF-8?q?=E7=AB=8B=E4=BB=93=E5=BA=93=EF=BC=8C=E6=8F=92=E4=BB=B6=E5=86=85?= =?UTF-8?q?=E5=AE=B9=E6=94=BE=E5=9B=9E=E5=B9=B6=E5=90=88=E5=B9=B6=E4=B8=BB?= =?UTF-8?q?=E4=BB=93=E5=BA=93=201.3.2=20=E7=9A=84=E8=83=BD=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 撤销「转为指引」:README 换回讲插件本身,此前被删掉的插件内容全部放回,布局保持 原有的 `command-code-usage/` 子目录不变。 - cc-usage.mjs 以主仓库 core/cc-usage.mjs(2101 行)为新基底,把本仓库原有的 --serve / --port 增量逐处搬上去;node:http 改为在 serve() 内用已有的 createRequire 惰性取,不再顶层 import,其它模式的启动开销不受影响 - commands/ 与 skills/ 取主仓库的更新版;命令正文保留 @@CC_USAGE_SCRIPT@@ 占位符(用户级安装仍注入绝对路径),同时支持 $ZCODE_PLUGIN_ROOT 与已知目录兜底 - 清单与 URL 指向本仓库,版本统一到 1.3.2(四处清单 + 脚本 VERSION + CHANGELOG) - check.mjs 补入状态栏渲染、去向判定、阈值与钩子、输出模式、密钥与个人路径扫描, 并新增 --serve 端到端断言与安装器套件;原有清单一致性、版本号、命令/skill 解析规则检查保留 - CI 固定 action 到提交哈希,三平台 × Node 18/22,离线冒烟覆盖六种输出模式 - 新增 SECURITY.md 与 docs/FINDINGS.md --- .claude-plugin/marketplace.json | 23 + .gitattributes | 10 + .github/workflows/check.yml | 85 + .gitignore | 21 + CHANGELOG.md | 124 + README.md | 347 ++- README.zh-CN.md | 320 ++- SECURITY.md | 82 + assets/command-code-usage/icon.png | Bin 0 -> 5181 bytes command-code-usage/.claude-plugin/plugin.json | 18 + command-code-usage/.zcode-plugin/plugin.json | 18 + command-code-usage/README.md | 124 + command-code-usage/README_CN.md | 109 + command-code-usage/commands/quota.md | 20 + command-code-usage/commands/usage.md | 20 + command-code-usage/scripts/cc-usage.mjs | 2174 +++++++++++++++++ .../scripts/install-user-scope.mjs | 238 ++ .../scripts/verify-discoverable.cjs | 195 ++ .../skills/command-code-usage/SKILL.md | 105 + docs/FINDINGS.md | 352 +++ marketplace.json | 46 + scripts/check.mjs | 672 +++++ scripts/make-icon.mjs | 193 ++ 23 files changed, 5256 insertions(+), 40 deletions(-) create mode 100644 .claude-plugin/marketplace.json create mode 100644 .gitattributes create mode 100644 .github/workflows/check.yml create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 SECURITY.md create mode 100644 assets/command-code-usage/icon.png create mode 100644 command-code-usage/.claude-plugin/plugin.json create mode 100644 command-code-usage/.zcode-plugin/plugin.json create mode 100644 command-code-usage/README.md create mode 100644 command-code-usage/README_CN.md create mode 100644 command-code-usage/commands/quota.md create mode 100644 command-code-usage/commands/usage.md create mode 100644 command-code-usage/scripts/cc-usage.mjs create mode 100644 command-code-usage/scripts/install-user-scope.mjs create mode 100644 command-code-usage/scripts/verify-discoverable.cjs create mode 100644 command-code-usage/skills/command-code-usage/SKILL.md create mode 100644 docs/FINDINGS.md create mode 100644 marketplace.json create mode 100644 scripts/check.mjs create mode 100644 scripts/make-icon.mjs diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 0000000..852d7ba --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://anthropic.com/claude-code/marketplace.schema.json", + "name": "command-code-usage", + "description": "See your Command Code plan usage (5-hour and weekly rolling windows, monthly credits or balance) from inside your coding agent, with a remaining-requests estimate. Requires a Command Code plan.", + "owner": { + "name": "Jovan1666" + }, + "plugins": [ + { + "name": "command-code-usage", + "source": "./command-code-usage", + "version": "1.3.2", + "description": "See your Command Code plan usage — 5-hour and weekly rolling windows, monthly credits or balance — right inside the conversation, with a remaining-requests estimate and a burn-rate warning. Requires a Command Code plan.", + "displayName": "Command Code Usage", + "category": "utilities", + "homepage": "https://github.com/Jovan1666/zcode-command-code-usage", + "author": { + "name": "Jovan1666", + "url": "https://github.com/Jovan1666" + } + } + ] +} diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..f408298 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,10 @@ +# 仓库内统一 LF,避免 Windows/macOS/Linux 之间换行符抖动。 +# 本项目的 Markdown(含 frontmatter)、JSON 与 Node 脚本都按 LF 处理。 +* text=auto eol=lf + +*.png binary +*.jpg binary +*.ico binary +*.gif binary +*.woff binary +*.woff2 binary diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml new file mode 100644 index 0000000..536bf14 --- /dev/null +++ b/.github/workflows/check.yml @@ -0,0 +1,85 @@ +name: Check + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + check: + # 三平台矩阵 × 双 Node:插件声称跨平台、且只用内置模块,就必须真的验过。 + # 本地只在 Windows 上验证过,Linux 与 macOS 由这里覆盖。 + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + node: ['18', '22'] + runs-on: ${{ matrix.os }} + steps: + # action 固定到提交哈希,不用可变的 major 标签:标签会指向新的提交, + # 那等于让第三方随时改我 CI 里跑的东西。 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: ${{ matrix.node }} + + - name: 发布检查(清单一致性 / 版本 / 命名规则 / 密钥与个人路径 / serve) + run: node scripts/check.mjs + + - name: 离线功能冒烟测试(六种输出模式,--demo 不联网) + shell: bash + run: | + set -e + S=command-code-usage/scripts/cc-usage.mjs + node "$S" --help > /dev/null + node "$S" --demo > /dev/null + node "$S" --demo hot > /dev/null + node "$S" --demo --md > /dev/null + node "$S" --demo --compact > /dev/null + node "$S" --demo --json > /dev/null + echo "全部输出模式通过" + + - name: 安装到临时 HOME 并验证命令可被发现 + shell: bash + env: + # POSIX 上 Node 读 HOME,Windows 上读 USERPROFILE;两个都设,覆盖两平台。 + HOME: ${{ runner.temp }}/fakehome + USERPROFILE: ${{ runner.temp }}/fakehome + run: | + set -e + mkdir -p "$HOME" + node command-code-usage/scripts/install-user-scope.mjs + node command-code-usage/scripts/verify-discoverable.cjs . + + - name: 安装器冲突保护(不得覆盖用户自己的同名文件) + shell: bash + env: + HOME: ${{ runner.temp }}/fakehome2 + USERPROFILE: ${{ runner.temp }}/fakehome2 + run: | + set -e + mkdir -p "$HOME/.zcode/commands" + echo "我自己的命令" > "$HOME/.zcode/commands/quota.md" + if node command-code-usage/scripts/install-user-scope.mjs; then + echo "::error::安装器在存在同名外部文件时应当中止" + exit 1 + fi + grep -q "我自己的命令" "$HOME/.zcode/commands/quota.md" + echo "冲突保护生效:用户文件未被覆盖" + + - name: 安装器可卸载 + shell: bash + env: + HOME: ${{ runner.temp }}/fakehome + USERPROFILE: ${{ runner.temp }}/fakehome + run: | + set -e + node command-code-usage/scripts/install-user-scope.mjs --uninstall + test ! -e "$HOME/.zcode/commands/quota.md" + test ! -e "$HOME/.zcode/skills/command-code-usage" + echo "卸载干净" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4f6b822 --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +# 依赖与构建产物 +node_modules/ +package-lock.json +pnpm-lock.yaml +yarn.lock + +# 运行本插件生成的产物 +command-code-usage.html + +# 编辑器 / 系统 +.vscode/ +.idea/ +*.swp +.DS_Store +Thumbs.db +desktop.ini + +# 本地临时与调试 +*.log +tmp/ +.tmp/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..329cf8e --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,124 @@ +# Changelog + +All notable changes to this project are documented here. +This project follows [Semantic Versioning](https://semver.org/). + +## [1.3.2] — 2026-09-26 + +The plugin is maintained here again. This release restores the full plugin into this repository +and merges the changes that were made after it moved into the shared `commandcode-usage` repo. + +### Added + +- **`--serve`, kept deliberately.** The panel can still be served as a local page + (`node command-code-usage/scripts/cc-usage.mjs --serve`, default port 8787, `--port ` to + change it), refreshing every 30 s for ZCode's built-in browser pane — the zero-token way to + watch the numbers. + The shared implementation dropped its top-level `node:http` import to save startup time, + because in hosts that run the script once per message that cost is paid on every turn. This + plugin is not one of those: `/quota` and `/usage` run only when you ask for them, so startup + cost is not a reason to lose the feature. It is now resolved **lazily**, inside `serve()`, + with the `createRequire` the script already had — so every other mode keeps the faster start + and `--serve` still works. +- **`SECURITY.md`** — what the plugin reads, writes and sends, and how to report a problem + privately. +- **`docs/FINDINGS.md`** — the shared implementation's cross-host research notes, carried here + because this repository now owns its own copy of the implementation. + +### Changed + +- **`cc-usage.mjs` is now the current shared implementation** (2101 lines) with this + repository's `--serve` on top, instead of the older 1380-line revision. The visible + consequences: + - **Faster startup.** Internal modules load through `createRequire` rather than ESM static + imports, about 11 ms less per run. + - **A corrected plan table.** Pro is $80 (the `individual-pro-v1` alias is kept so older + accounts still resolve), `Max 10x` / `Max 20x` and their aliases are recognised, and + Provider is treated as pay-as-you-go. Window caps still come from the API; the table is only + a fallback for the monthly total. + - **Wider credential discovery.** After the environment, `~/.commandcode/auth.json` and + `~/.zcode/v2/provider_config.json`, the script also reads the provider configs other agent + tools leave behind — so a machine that has already used Command Code elsewhere needs no + second login. The key still only ever goes into an `Authorization: Bearer` header. + - **`--html --open` and `--serve --open` are safer.** The opener is invoked with an argv + array instead of a shell command string, so a path containing quotes or `&` can no longer + break the launch. + - The status-line and hook engine, and the local cache under `~/.commandcode-usage/` + (snapshot plus a 24 h model-catalog cache), came with the new base. This plugin's commands + never call those paths — ZCode exposes neither a status-line seat nor a hook output field — + so a `/quota` run still writes nothing. +- **The commands and the skill are the current revisions.** The command body now resolves the + script through `$ZCODE_PLUGIN_ROOT` first and falls back to searching the known agent + directories; the local install route still injects the absolute path, so a user-scope install + keeps working. +- **`scripts/check.mjs` grew the release gate's own suites** — status-line rendering (width + adaptation, never any ANSI), the route decision table, threshold and hook output, the output + formats, and the secret/personal-path scan — on top of the manifest, version, command-parse + and installer checks it already had. It now also asserts `--serve` end to end: a stub API on + loopback, a real server start, one request, and an assertion that the response is the HTML + panel. +- **Version 1.3.2 everywhere** — both plugin manifests, both marketplace catalogues, the + script's `VERSION` constant and this file. `scripts/check.mjs` fails the build if they drift. +- **CI** pins both actions to commit hashes, runs Node 18 and 22 across Ubuntu, Windows and + macOS, and keeps the offline smoke tests for every output mode. +- `verify-discoverable.cjs` no longer reports a missing script path when the command body is + still carrying the install-time placeholder. + +## [1.2.0] — 2026-09-21 + +### Changed +- **`/quota` costs about 43% fewer tokens.** The command body was cut from 731 to 311 characters, + and the agent is now told not to restate the panel — it is already visible from the tool call. + Measured cost per invocation dropped from roughly 680 to 390 tokens. +- Documented the cost, and the zero-token alternative (`--serve` in the built-in browser pane), in + both READMEs. + +## [1.1.0] — 2026-09-21 + +A second manifest family, so the plugin is not tied to one agent's layout. + +### Added +- **A `.claude-plugin/` manifest alongside the ZCode one.** The plugin now ships a + `.claude-plugin/plugin.json` next to the ZCode manifest, plus a strict-clean + `.claude-plugin/marketplace.json` that passes the other validator with zero warnings. +- **Cross-platform CI** (`.github/workflows/check.yml`) running on Ubuntu, Windows and macOS: the + release gate, offline smoke tests for every output mode, an install-and-discover check against a + throwaway home directory, and the installer's conflict guard. +- **`scripts/check.mjs`** — a release gate shared by CI and local runs. It compares the duplicated + fields between the ZCode and `.claude-plugin/` manifests, enforces version consistency across all + five places that carry a version, checks the command/skill files against ZCode's actual parsing + rules (name pattern, allowed frontmatter keys, reserved command names), and fails on any leaked + secret or machine-specific path. + +### Changed +- The command body's script lookup now searches the known agent directories — `~/.zcode`, + `~/.claude`, `~/.agents`, `~/.codex` — instead of only ZCode's, so the same command works + wherever the plugin was installed from. + +## [1.0.0] — 2026-09-21 + +First public release. + +### Added +- `/quota` (and the `/usage` alias): render Command Code usage in the ZCode conversation — + 5-hour rolling window, weekly rolling window, monthly allowance or balance. +- A **remaining-requests estimate** derived from the account's own average cost per request, so it + adapts to any plan and any model mix without hard-coded per-model rates. +- A **burn-rate warning** for windows projected to run out before they reset, gated on a minimum + sample (under 5% of the window elapsed draws no conclusion, to avoid false alarms). +- Account-shape handling: known plans, unknown/new/enterprise plans, pay-as-you-go balances, + organisation spend caps, and accounts with no requests yet. +- Output modes: terminal panel, `--md` Markdown table, `--compact` one-liner, `--json` normalised + fields plus raw responses, `--from-json` offline replay, `--demo` sample data. +- Optional HTML dashboard (`--html`, `--serve`) for people who want a big screen. +- `install-user-scope.mjs` — user-scope install, sync and uninstall, with a content-hash based + guard that refuses to overwrite files the user wrote or edited. +- `verify-discoverable.cjs` — diagnostic that re-implements ZCode's own command parser so + discovery problems can be reported with evidence. +- Bilingual documentation (English and Simplified Chinese). + +### Notes +- No credentials are stored, printed or committed. The key is resolved at runtime from the + environment, the Command Code CLI auth file, or the provider already configured in ZCode. +- ZCode snapshots its command catalogue at session start, and closing the window may only minimise + it to the tray. Restart the app fully after installing. diff --git a/README.md b/README.md index edba08d..c56801c 100644 --- a/README.md +++ b/README.md @@ -1,40 +1,343 @@ -# Moved → [commandcode-usage](https://github.com/Jovan1666/commandcode-usage) +# Command Code Usage -This plugin lives in the **commandcode-usage** monorepo now, under -[`plugins/zcode`](https://github.com/Jovan1666/commandcode-usage/tree/main/plugins/zcode), -next to adapters for six other agents — Claude Code, Codex, Grok Build, opencode, pi and -DeepSeek Harness. +[English](README.md) · [简体中文](README.zh-CN.md) · [![Check](https://github.com/Jovan1666/zcode-command-code-usage/actions/workflows/check.yml/badge.svg)](https://github.com/Jovan1666/zcode-command-code-usage/actions/workflows/check.yml) -Same `/quota` panel, same skill, same command names. +Check how much of your **Command Code** plan you have left, without leaving the conversation. + +Command Code plans (Go / GOAT / Pro / Max / Teams) pace your monthly credits with two **rolling +windows**: a 5-hour cap and a weekly cap. A window opens on your first request and resets a fixed +time later — it does not follow calendar days, and usage never carries over between windows. So +"can I still finish this task?" cannot be answered from the monthly balance alone: what matters is +how much of the *current* window is left and when it resets. + +This plugin reads all three numbers and renders them where you are already looking. + +``` +Command Code · GOAT 09-26 13:34 · 29d left in period +────────────────────────────────────────────────────────────────────────── +account demo-user + +5-hour window ████████░░░░░░░░░░░░░░░░ 32.0% $4.48 / $14.00 + resets 16:46 · in 3h 12m +weekly window ██████████░░░░░░░░░░░░░░ 41.0% $14.35 / $35.00 + resets 08:34 · in 6d 19h +monthly ████░░░░░░░░░░░░░░░░░░░░ 17.7% $12.40 / $70.00 + $57.60 remaining + +this period 312 requests · 100% success · 128.4M in / 1.2M out tokens +estimate at your $0.0387 average, room for ≈ 245 more requests in the 5-hour window + (based on your actual model mix this period; pricier models go much shorter) +``` + +When you are burning fast enough that a window will run out before it resets, it says so: + +``` +⚠ at the current $4.30/h, the 5-hour window runs out before it resets — exhausted in ~15m 20s +``` + +## Why it lives in the conversation + +ZCode has no plugin API for a persistent in-app widget, and that is not a guess — it is what the +app's own code shows: + +- a plugin manifest can contribute exactly five executable things — `commands`, `skills`, `hooks`, + `mcpServers`, `agents`. There is no status-bar, sidebar or dashboard slot. +- every `statusBar.*` string in `app.asar` (2271 of them) is a bundled editor theme colour token, + not a mountable component. +- `output-styles` exists as a packaged directory, but the `outputStyles` field appears **zero** times + in the code that runs — recognised on paper, never executed. +- hooks do render as a row in the transcript, but the hook entity carries only state, duration and + name — **it has no output field**, so a hook cannot display a live number either. + +That leaves the slash command, whose output is rendered in the conversation. So that is where this +plugin puts the panel. No browser tab, no website to poll. + +## Requirements + +- **A Command Code plan.** Without one the panel has nothing to show. If you are on pay-as-you-go + rather than a subscription, it still works but shows a balance instead of windows. +- **Node.js 18 or newer** on `PATH` — only to run the bundled scripts. They use nothing but Node + built-ins and the built-in `fetch`, so there is no `npm install`. ## Install -Add the monorepo as a marketplace, then install the plugin from it: +### From the marketplace (recommended) + +1. Open **Plugin Marketplace → Add → Add Plugin Marketplace**. +2. Paste this repository: + + ``` + Jovan1666/zcode-command-code-usage + ``` + +3. Go to **Personal → Command Code Usage → Install**. +4. **Fully quit and reopen ZCode**, then start a new task and run `/command-code-usage:quota`. + +### Option B — local install script (no marketplace needed) + +Clone the repo, then: + +```bash +node command-code-usage/scripts/install-user-scope.mjs +``` + +This copies the commands and the skill into your user-scope ZCode directories +(`~/.zcode/commands/`, `~/.zcode/skills/`), which ZCode scans with the **highest** priority, and it +substitutes the absolute path of `cc-usage.mjs` into the command body so the script is always read +live from the plugin directory — upgrading the script never needs a reinstall. + +Add `--workspace ` to also install into `/.zcode/commands`. Other switches: + +```bash +node command-code-usage/scripts/install-user-scope.mjs --dry-run # show the file plan, write nothing +node command-code-usage/scripts/install-user-scope.mjs --uninstall # remove the copies +node command-code-usage/scripts/verify-discoverable.cjs . # check ZCode will see the commands +``` + +The installer **never overwrites your own content**: it records a hash of everything it writes and +only updates a file that is both in its own install manifest *and* unchanged since it wrote it. A +same-named command you wrote yourself, or a file you edited afterwards, is refused with a reason +instead of being clobbered. + +> **Do not use both a local install and the marketplace route at once.** User-scope copies are +> discovered before plugin-provided ones, so a local copy shadows the marketplace version and the +> Update button stops affecting your commands. Run `--uninstall` before switching. + +### After installing: restart the app + +ZCode snapshots the command and skill catalogue when a session starts. A plugin directory created +while the app is running is not picked up by merely opening a new task — and since ZCode keeps a +tray icon, closing the window often just minimises it and leaves the process alive. **Quit the app +fully** (tray → Quit, or confirm no `ZCode` process remains in Task Manager) and reopen it. + +## Usage + +A marketplace install registers the commands under the plugin's own namespace — +`/command-code-usage:quota` and `/command-code-usage:usage`. A user-scope install registers the +bare `/quota` and `/usage`. Either form does the same thing; the table below writes the short one. + +| Command | What it does | +|---|---| +| `/quota` | The panel above | +| `/usage` | Same thing, alias | +| `/quota --md` | Markdown table, easier to copy | +| `/quota --compact` | One line: `CC GOAT · 5h 32%(≈245 次) · 周 41% · 月 17.7% · 剩 $57.60 · 周重置 6d 19h后` | +| `/quota --json` | Normalised fields, plus the raw API responses | +| `/quota --demo hot` | Sample data, previews the warning state without touching the network | + +You can also skip the command entirely and just ask: + +> How much Command Code quota do I have left? Is it enough to finish what we are doing? + +The bundled skill teaches the agent to fetch the panel and to answer "is it enough" from the +remaining-requests estimate rather than from the monthly balance. + +## Token cost, and the zero-token alternative + +A custom command is ultimately a prompt. `/quota` injects its body, the agent runs the script, and +the panel text passes through the model. Measured, one invocation costs roughly **390 tokens**: about +100 for the command body, 80 for the tool call, 180 for the panel text, 40 for the reply. The body is +deliberately short and the agent is told **not to restate the panel** — it is already visible from the +tool call. (The first version restated it and cost about 680.) +**To spend no tokens at all**, run the panel as a local page and open it in ZCode's built-in browser +pane: + +```bash +node command-code-usage/scripts/cc-usage.mjs --serve +# then open http://127.0.0.1:8787/ +``` + +It refreshes every 30 seconds, shows the same ring gauges, and never touches the model. `--serve` +takes `--port ` if 8787 is taken; it binds to loopback only and stops with Ctrl+C. This is the +only zero-token option available: ZCode exposes no plugin-contributed in-app widget, and a hook cannot +display content either — the hook record it renders carries status, duration and name, with no output +field (verified in `resources/glm/zcode.cjs`). + +ZCode *does* support inline shell expansion in command bodies (`` !`cmd` `` or a fenced `!` block), +which runs locally before the prompt is built. It is not used here: on Windows that shell is +`cmd.exe`, not bash, so it would only work with a hard-coded script path and would fail hard for +marketplace installs. Not worth the fragility for the ~80 tokens it would save. + +## How the two useful numbers are derived + +**"Room for ≈ N more requests"** = remaining allowance ÷ your average cost per request *this +period*. The average comes from your own usage, so the estimate adapts to any plan and any model +mix without hard-coding per-model rates. + +Because the baseline is your own average, **it stops holding the moment you switch models** — the +panel says so. Command Code's `/provider/v1/models` returns a model list with no allowance factors +or prices, so "how many requests of model X specifically" cannot be computed from the API. + +**The warning** extrapolates your current burn rate. That path has a trap worth knowing about: an +hour after a window opens, extrapolating one hour of activity across seven days will always scream +that the weekly cap is about to blow. Pure noise. So the script enforces a minimum sample — **under +5% of the window elapsed it draws no conclusion at all**. No warning therefore means "not enough +data yet", not "you are safe". + +## Account shapes it handles + +The script reads what your account actually is instead of forcing one template: + +| Situation | What is shown | +|---|---| +| Subscription, plan in the known table | monthly allowance bar plus both windows | +| Subscription, plan not in the table (new or enterprise) | "allowance" plus an explicit note that the total is inferred from spent + remaining | +| No active subscription (pay-as-you-go, enterprise pool) | balance only, no meaningless percentage | +| Organisation spend caps configured | extra limit rows (shapes it cannot recognise are skipped, never guessed) | +| No requests yet this period | no request-count estimate, and it says why | + +## Credentials + +Resolved in order, first hit wins. **Nothing is ever written to disk, printed, or committed.** + +1. Environment variable `COMMAND_CODE_API_KEY`, `CMD_API_KEY` or `COMMANDCODE_API_KEY` +2. `~/.commandcode/auth.json` — written by logging into the Command Code CLI +3. `~/.zcode/v2/provider_config.json` — a ZCode provider whose `api.baseUrl` points at + `commandcode.ai`. **If you already configured the provider in ZCode, you are done** — the plugin + reuses that key, and there is no second login. +4. Failing all three, the provider configs other agent tools leave behind — + `~/.claude/settings.json`, `~/.pi/agent/settings.json`, `~/.config/opencode/*`, + `~/.dsh/*.yaml`, `~/.codex/config.toml`, `~/.grok/config.toml` — so a machine that already used + Command Code elsewhere needs no second login either. + +Whichever source was used is shown by `--verbose` and at the bottom of the HTML panel. The key only +ever appears in an `Authorization: Bearer` header. + +## The API it reads + +Four read-only endpoints on `https://api.commandcode.ai`, all needing `Authorization: Bearer `: + +| Endpoint | Contents | +|---|---| +| `/alpha/whoami?limits=1` | user, org, organisation-level `orgLimits` | +| `/alpha/billing/credits` | `credits` (balance) and `windowLimits` (both rolling windows) | +| `/alpha/billing/subscriptions` | `planId`, `status`, billing period start and end | +| `/alpha/usage/summary?orgId=&since=` | request count, cost, tokens, success rate for the period | + +`/provider/v1/*` is the inference API (OpenAI- and Anthropic-compatible) and exposes **no** usage +data — allowance lives only under `/alpha/*`. + +Two field semantics that are easy to get backwards: `credits.credits.monthlyCredits` is the +**remaining** amount, not the used one; and a window's `used` / `cap` are **dollar values**, not +request counts. + +## Standalone use + +The scripts work without ZCode: + +```bash +node command-code-usage/scripts/cc-usage.mjs # panel +node command-code-usage/scripts/cc-usage.mjs --compact # one line +node command-code-usage/scripts/cc-usage.mjs --json > s.json # snapshot +node command-code-usage/scripts/cc-usage.mjs --from-json s.json # replay a snapshot offline +node command-code-usage/scripts/cc-usage.mjs --demo hot # sample data, no network ``` -/plugin marketplace add Jovan1666/commandcode-usage -/plugin install command-code-usage + +Optional, if you actually want a big screen — most people never need these: + +```bash +node command-code-usage/scripts/cc-usage.mjs --html --open # write an HTML dashboard +node command-code-usage/scripts/cc-usage.mjs --serve # serve it, refreshes every 30s ``` -## Already installed from this repository? +The script also carries `--statusline` and `--hook`, the compact per-turn surfaces a host with a +status-line or hook seat would call. ZCode exposes neither, so the plugin's own surface is the two +commands above and `/quota` never touches those code paths. `--help` lists the full option set. -Your installed copy keeps working; nothing here was deleted from your machine. Add the -monorepo as a marketplace as above and install from there when you want updates — the -plugin name (`command-code-usage`) is unchanged, so the two are interchangeable. +## Troubleshooting -## Reproducing an old setup +| Symptom | Cause and fix | +|---|---| +| `/quota` is missing from the `/` menu | The catalogue was snapshotted before installation. Fully quit ZCode and reopen (see above). | +| Typing `/quota` sends it as a normal message | The command was not discovered. Run `verify-discoverable.cjs .` — it re-implements ZCode's own parser and reports diagnostics. | +| "No Command Code credentials found" | Provide one of the four sources above. If you use the provider configured in ZCode, check that its `baseUrl` contains `commandcode.ai`. | +| HTTP 401 for every endpoint | The key is invalid or expired. Re-login, or re-enter it in the ZCode provider settings. | +| Numbers look stale | Window reset times move. Re-run the command; do not reuse a reading from minutes ago. | +| Requests are being rate-limited (429) | Check which window reports `exceeded`, then either wait for the reset, buy extra credits, or upgrade. | +| `--serve` says the port is in use | Something else holds 8787. Pass `--port 8788` (or any free port). | -The last version published from this repository is tagged -[`v1.2.0-final`](../../releases/tag/v1.2.0-final). Point a marketplace at that tag if you -need to reproduce a setup exactly as it was: +`/quota` and `/usage` are both confirmed **free** of ZCode's reserved command names. The full +reserved set, for reference: `clear, compact, compress, continue, dwf, effort, expert, fork, goal, +help, init, language, locale, login, logout, mcp, mode, model, new, plan, plugin, plugins, resume, +rewind, skill, target, variant`. + +## Security + +The plugin reads your Command Code credential, makes four read-only API calls, and writes no host +configuration. What it touches, what it sends, and how to report a problem privately: +[SECURITY.md](SECURITY.md). The shared implementation's cross-host research notes live in +[docs/FINDINGS.md](docs/FINDINGS.md). + +## Repository layout ``` -/plugin marketplace add Jovan1666/zcode-command-code-usage#v1.2.0-final +. +├── marketplace.json ZCode catalogue (repo root = marketplace root) +├── .claude-plugin/marketplace.json second catalogue: same shared fields, strict-clean +├── README.md / README.zh-CN.md +├── LICENSE / CHANGELOG.md / SECURITY.md +├── docs/FINDINGS.md cross-host research notes for the shared implementation +├── .github/workflows/check.yml CI: 3 platforms × Node 18/22, release checks, offline smoke +├── scripts/check.mjs release gate (the same script CI runs) +├── scripts/make-icon.mjs regenerates assets/command-code-usage/icon.png +└── command-code-usage/ the plugin + ├── .zcode-plugin/plugin.json manifest read by ZCode (checked first) + ├── .claude-plugin/plugin.json manifest read by the second ecosystem + ├── commands/ + │ ├── quota.md /quota + │ └── usage.md /usage + ├── skills/command-code-usage/SKILL.md + └── scripts/ + ├── cc-usage.mjs fetch + render (terminal / markdown / JSON / HTML / serve) + ├── install-user-scope.mjs user-scope install, sync and uninstall + └── verify-discoverable.cjs diagnostic: re-implements ZCode's command parser +``` + +### Why some files exist twice + +The plugin and the catalogue each carry a ZCode copy and a `.claude-plugin/` copy, because the two +manifest families are read from different places and accept different fields: + +- ZCode reads `.zcode-plugin/plugin.json` first, then falls back to `.claude-plugin/`. +- ZCode's catalogue accepts presentational fields the other validator reports as unknown and fails + under `--strict` — `displayName_i18n`, `description_i18n`, `examplePrompts`, `examplePrompts_i18n`. + +So the ZCode catalogue keeps the localized display names (its users see Chinese labels), and the +`.claude-plugin/` copy stays strict-clean. Everything the two share — name, version, description, +source, category, homepage, author — is identical, and `scripts/check.mjs` fails the build if that +ever drifts. Run it before committing: + +```bash +node scripts/check.mjs ``` -## This repository is archived +## Distribution status + +| Channel | State | +|---|---| +| **This repo added as a marketplace** | **Live.** Paste `Jovan1666/zcode-command-code-usage` in ZCode. | +| ZCode official marketplace (`zcode-plugins-official`) | **Live.** The plugin ships under the name `command-code-usage`; `marketplace.json`, the icon at `assets/command-code-usage/icon.png` and both README variants are the published artifacts. | + +## Status and scope + +Tested on Windows: the API integration, all output modes (terminal, `--md`, `--compact`, `--json`, +`--from-json`, HTML, serve), the account-shape branches, credential resolution, every error path, +and the installer's conflict handling (fresh install, re-install, edited file, foreign file). +A clean-machine install from the published repo was also exercised end to end (clone → install → +discovery → execution). + +CI runs the release gate, the offline smoke tests for every output mode, an install-and-discover +check, and the installer's conflict guard on **Ubuntu, Windows and macOS**, against Node 18 and 22. + +Not yet verified: a second person installing through the marketplace UI. If something misbehaves, +please open an issue with the output of `node scripts/check.mjs` (or +`command-code-usage/scripts/verify-discoverable.cjs .` for discovery problems) and the exact message +you saw. + +Not affiliated with Command Code. It reads your own account's usage through the same endpoints the +official CLI uses; it does not proxy, modify or transmit anything anywhere else. -Issues, pull requests and questions belong in the -[monorepo](https://github.com/Jovan1666/commandcode-usage/issues). +## License -Licensed MIT — see [LICENSE](LICENSE). +[MIT](LICENSE) diff --git a/README.zh-CN.md b/README.zh-CN.md index a54eb19..ca2c8ae 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -1,36 +1,320 @@ -# 已迁移 → [commandcode-usage](https://github.com/Jovan1666/commandcode-usage) +# Command Code Usage -本插件现在住在 **commandcode-usage** 这个 monorepo 里,位置是 -[`plugins/zcode`](https://github.com/Jovan1666/commandcode-usage/tree/main/plugins/zcode), -和另外六个 agent 的适配器放在一起——Claude Code、Codex、Grok Build、opencode、pi、DeepSeek Harness。 +[English](README.md) · [简体中文](README.zh-CN.md) · [![Check](https://github.com/Jovan1666/zcode-command-code-usage/actions/workflows/check.yml/badge.svg)](https://github.com/Jovan1666/zcode-command-code-usage/actions/workflows/check.yml) -`/quota` 面板、skill、命令名都没有变化。 +在对话里直接看 **Command Code** 套餐还剩多少用量,不用离开对话、不用开网页。 + +Command Code 的套餐(Go / GOAT / Pro / Max / Teams)除了月度额度,还压着两个**滚动窗口**: +5 小时上限和每周上限。窗口从你第一次请求开始计时,到点重置,**不跟自然日/周走**,用量也不跨 +窗口结转。所以「这个任务还能不能跑完」光看月度余额是答不出来的——要看**当前窗口**还剩多少、 +什么时候重置。 + +这个插件把这三个数读出来,渲染在你本来就在看的地方。 + +``` +Command Code · GOAT 09-26 13:34 · 周期剩 29 天 +────────────────────────────────────────────────────────────────────────── +账号 demo-user + +5 小时窗口 ████████░░░░░░░░░░░░░░░░ 32.0% $4.48 / $14.00 + 重置 16:46 · 3h 12m 后 +每周窗口 ██████████░░░░░░░░░░░░░░ 41.0% $14.35 / $35.00 + 重置 08:34 · 6d 19h 后 +月度额度 ████░░░░░░░░░░░░░░░░░░░░ 17.7% $12.40 / $70.00 + 剩 $57.60 + +本周期 312 次请求 · 成功率 100% · 入 128.4M / 出 1.2M tokens +预估 按本周期均单价 $0.0387 估算还能跑:5 小时窗口 ≈ 245 次 · 本周 ≈ 533 次 · 本月 ≈ 1,488 次 +(估算基于你本周期的实际模型组合;换更贵的模型,次数会明显变少) +``` + +当消耗速度足以在重置前撞上限时,它会直说: + +``` +⚠ 按当前速度($4.30/小时),5 小时窗口会在重置前用完,约 15m 20s 后耗尽 +``` + +## 为什么是「在对话里」而不是网页 + +ZCode 没有给插件留常驻显示位,这不是猜的,是它自己的代码写的: + +- 插件清单能执行的就五样——`commands`、`skills`、`hooks`、`mcpServers`、`agents`, + 没有状态栏、侧边栏或仪表盘槽位; +- `app.asar` 里 2271 处 `statusBar.*` 全是内置编辑器的主题色变量(如 `statusBar.background`), + 不是可挂载组件; +- 打包目录里确实有 `output-styles`,但真正运行的那段代码里 `outputStyles` 字段出现 **0 次**—— + 纸面上认,实际不执行; +- hook 会在对话里渲染成一行记录,但它的数据结构只有状态、耗时、名称,**没有输出字段**, + 所以 hook 也显示不了动态数字。 + +剩下的只有斜杠命令——它的输出会渲染在对话里。所以面板就放在那里。不开浏览器,也不用去轮询官网。 + +## 环境要求 + +- **一个 Command Code 套餐。** 没有套餐就没数可读。如果你是按量计费而非订阅,插件照样能用, + 只是显示余额而不是窗口。 +- **`PATH` 里有 Node.js 18 或更新版本** —— 只用来跑插件自带的脚本。脚本只用 Node 内置模块和 + 内置 `fetch`,**不需要 `npm install`**。 ## 安装 -把 monorepo 加为插件市场,再从里面装: +### 从插件市场装(推荐) + +1. ZCode 里打开 **插件市场 → 添加 → 添加插件市场**。 +2. 粘贴本仓库: + + ``` + Jovan1666/zcode-command-code-usage + ``` + +3. 到 **个人 → Command Code Usage → 安装**。 +4. **完全退出 ZCode 再打开**,然后新建任务,输入 `/command-code-usage:quota`。 + +### 方式 B:本地安装脚本(不走市场) + +先克隆仓库,然后: + +```bash +node command-code-usage/scripts/install-user-scope.mjs +``` + +它把命令和技能装进你的用户级 ZCode 目录(`~/.zcode/commands/`、`~/.zcode/skills/`)—— +ZCode 扫描这些目录时**优先级最高**;同时它会把 `cc-usage.mjs` 的绝对路径写进命令正文, +所以脚本始终从插件目录实时读取,升级脚本不用重装。 + +加 `--workspace <目录>` 可以同时装进 `<目录>/.zcode/commands`。其他开关: + +```bash +node command-code-usage/scripts/install-user-scope.mjs --dry-run # 只显示会写哪些文件 +node command-code-usage/scripts/install-user-scope.mjs --uninstall # 移除副本 +node command-code-usage/scripts/verify-discoverable.cjs . # 验证 ZCode 能否发现这些命令 +``` + +安装器**绝不覆盖你自己的内容**:它记录每次写入的哈希,只有「在自己安装清单里、且自写入后没被 +改动过」的文件才更新。你自己写的同名命令、或你事后改过的文件,都会被拒绝并说明原因,而不是被清掉。 + +> **本地安装与市场安装别同时用。** 用户级副本的发现优先级高于插件,同时存在时本地副本会遮蔽市场 +> 版本,市场的「更新」按钮对你的命令就不生效了。要切到市场方式,先跑一次 `--uninstall`。 + +### 装完之后:重启应用 + +ZCode 在**会话启动时**对命令与技能清单做快照。应用运行期间新建的插件目录,光靠「新建任务」是 +读不到的——而且 ZCode 有托盘图标,关窗口往往只是最小化、进程还活着。**要完全退出** +(托盘右键退出,或在任务管理器确认没有 `ZCode` 进程残留)再打开。 + +## 用法 + +从市场安装时,命令挂在插件自己的命名空间下——`/command-code-usage:quota`、`/command-code-usage:usage`; +用户级安装注册的是短的 `/quota`、`/usage`。两种写法效果相同,下表用短的写。 + +| 命令 | 作用 | +|---|---| +| `/quota` | 上面那个面板 | +| `/usage` | 同上,别名 | +| `/quota --md` | Markdown 表格,方便复制 | +| `/quota --compact` | 一行:`CC GOAT · 5h 32%(≈245 次) · 周 41% · 月 17.7% · 剩 $57.60 · 周重置 6d 19h后` | +| `/quota --json` | 归一化字段,外加原始接口响应 | +| `/quota --demo hot` | 样例数据,不联网也能预览告警长什么样 | + +也可以不打命令,直接问: + +> 我 Command Code 额度还剩多少?够不够把手上这个做完? + +插件里的技能会教 agent 取面板,并且**用剩余次数估算**而不是月度余额来回答「够不够」。 + +## Token 成本,以及零成本的替代方式 + +自定义命令本质上就是一段 prompt。`/quota` 会注入命令正文,agent 运行脚本,面板文本再经过模型。 +实测一次调用约 **390 tokens**:命令正文约 100、工具调用约 80、面板文本约 180、回复约 40。 +正文刻意写得很短,并且明确要求 agent **不要复述面板**——工具调用的输出本来就显示在界面上。 +(第一版会复述,约 680 tokens。) +**想完全不花 token**,就把面板当成本地页面、在 ZCode 内置浏览器面板里打开: + +```bash +node command-code-usage/scripts/cc-usage.mjs --serve +# 然后打开 http://127.0.0.1:8787/ +``` + +每 30 秒自动刷新,环形仪表盘和对话里的一样,完全不经过模型。8787 被占用时可以加 `--port `; +它只绑回环地址,Ctrl+C 停止。这是目前唯一零 token 的方式:ZCode 没有给插件留应用内组件位,hook +也显示不了内容——它渲染的那条记录只有状态、耗时和名称,**没有输出字段** +(我在 `resources/glm/zcode.cjs` 里核对过)。 + +ZCode 的命令正文**确实支持内联 shell 展开**(`` !`cmd` `` 或 ```` ```! ```` 围栏块),会在 +构建 prompt 之前本地执行。这里没用它,是因为 **Windows 上那个 shell 是 `cmd.exe` 而不是 bash**, +只有把脚本路径写死才可用,而走市场安装的路径是动态的、会直接硬失败。为了省那约 80 tokens +不值得引入这种脆弱性。 + +## 两个有用的数是怎么来的 + +**「还能跑约 N 次」** = 剩余额度 ÷ 本周期均单价。均单价取自**你自己**这个周期的实际用量, +所以它自动适配任何套餐、任何模型组合,不需要把每个模型的费率硬编码进来。 + +正因为基准是你自己的均值,**一换模型它就不再成立**——面板里写明了这一点。Command Code 的 +`/provider/v1/models` 只返回模型清单,不含额度系数或单价,所以「某个特定模型还能跑几次」从接口层 +就算不出来。 + +**告警**按当前消耗速度外推。这条路有个陷阱值得知道:窗口刚开一小时,拿这一小时的速度去推七天, +必然天天喊「周窗口要超限了」——纯噪音。所以脚本设了最小采样门槛:**不足窗口时长的 5% 就不出结论**。 +因此「没有告警」的意思是「样本还不够判断」,而不是「你安全」。 + +## 它怎么适配不同账号形态 + +脚本按账号实际形态决定显示什么,不硬套模板: + +| 情况 | 显示 | +|---|---| +| 有订阅、套餐在已知表里 | 月度额度进度条 + 两个窗口 | +| 有订阅、套餐不在表里(新套餐/企业套餐) | 显示「额度」,并明说总额是按「已花 + 剩余」推算 | +| 无有效订阅(按量计费 / 企业池) | 只显示余额,不套没有意义的百分比 | +| 组织配置了消费上限 | 追加限额行(认不出的字段形状直接跳过,不猜) | +| 本周期还没有请求 | 不给次数估算,并说明原因 | + +## 凭证 + +按顺序解析,命中即用。**不写入磁盘、不打印、不提交。** + +1. 环境变量 `COMMAND_CODE_API_KEY`、`CMD_API_KEY` 或 `COMMANDCODE_API_KEY` +2. `~/.commandcode/auth.json` —— 登录 Command Code CLI 后生成 +3. `~/.zcode/v2/provider_config.json` —— 其中 `api.baseUrl` 指向 `commandcode.ai` 的 provider。 + **如果你已经在 ZCode 里配好了这个 provider,就什么都不用做**,插件直接复用那把 key, + 不需要二次登录。 +4. 以上都没有时,再看其他 agent 工具留下的 provider 配置——`~/.claude/settings.json`、 + `~/.pi/agent/settings.json`、`~/.config/opencode/*`、`~/.dsh/*.yaml`、`~/.codex/config.toml`、 + `~/.grok/config.toml`——所以在别处已经配过 Command Code 的机器同样不用二次登录。 + +实际用了哪个来源,`--verbose` 和 HTML 面板底部都会显示。密钥只会出现在 `Authorization: Bearer` +请求头里。 + +## 它读的接口 + +`https://api.commandcode.ai` 上四个只读端点,都需要 `Authorization: Bearer `: + +| 端点 | 内容 | +|---|---| +| `/alpha/whoami?limits=1` | 用户、组织、组织级 `orgLimits` | +| `/alpha/billing/credits` | `credits`(余额)与 `windowLimits`(两个滚动窗口) | +| `/alpha/billing/subscriptions` | `planId`、`status`、计费周期起止 | +| `/alpha/usage/summary?orgId=&since=` | 本周期请求数、成本、token、成功率 | + +`/provider/v1/*` 是推理接口(OpenAI 与 Anthropic 兼容),**不提供**任何用量数据—— +额度只挂在 `/alpha/*` 下。 + +两个最容易搞反的字段:`credits.credits.monthlyCredits` 是**剩余**而不是已用; +窗口的 `used` / `cap` 是**美元价值**而不是请求条数。 + +## 脱离 ZCode 单独使用 + +脚本不依赖 ZCode: + +```bash +node command-code-usage/scripts/cc-usage.mjs # 面板 +node command-code-usage/scripts/cc-usage.mjs --compact # 一行 +node command-code-usage/scripts/cc-usage.mjs --json > s.json # 存快照 +node command-code-usage/scripts/cc-usage.mjs --from-json s.json # 离线重放快照 +node command-code-usage/scripts/cc-usage.mjs --demo hot # 样例数据,不联网 ``` -/plugin marketplace add Jovan1666/commandcode-usage -/plugin install command-code-usage + +可选——真想要个大屏时再用,多数人用不到: + +```bash +node command-code-usage/scripts/cc-usage.mjs --html --open # 生成 HTML 面板 +node command-code-usage/scripts/cc-usage.mjs --serve # 起本地服务,每 30s 刷新 ``` -## 已经从本仓库装过了? +脚本里还有 `--statusline` 与 `--hook` 这两条紧凑的「每轮输出」路径,供带状态栏位或钩子位的宿主 +调用。ZCode 两样都没有,所以本插件的界面就是上面那两条命令,`/quota` 不会走到那些代码。 +完整选项见 `--help`。 -已安装的那份照常工作,本仓库没有从你机器上删掉任何东西。想跟更新时,按上面的方式加上 -monorepo 市场再装一次即可——插件名(`command-code-usage`)没变,两者可以互换。 +## 排查 -## 想复现旧的环境 +| 现象 | 原因与处理 | +|---|---| +| `/` 菜单里找不到 `/quota` | 命令清单是安装前做的快照。完全退出 ZCode 再打开(见上文)。 | +| 输入 `/quota` 被当成普通消息发出去 | 命令没被发现。跑 `verify-discoverable.cjs .`——它复刻了 ZCode 自己的解析器,会报出诊断。 | +| 提示找不到凭证 | 按上面四种来源提供其一。若用 ZCode provider,检查它的 `baseUrl` 是否含 `commandcode.ai`。 | +| 每个端点都 HTTP 401 | key 无效或已过期。重新登录,或在 ZCode provider 设置里重新填。 | +| 数字看着不新 | 窗口重置时间一直在走。重新跑一次命令,别复用几分钟前的读数。 | +| 请求被限流(429) | 看哪个窗口报告 `exceeded`,然后等重置、买额外额度、或升级套餐。 | +| `--serve` 报端口被占用 | 8787 被别的进程占了。加 `--port 8788`(或任何空闲端口)。 | -本仓库发布的最后一个版本打了标签 -[`v1.2.0-final`](../../releases/tag/v1.2.0-final),需要完全按旧样子复现时把市场指向那个标签: +`/quota` 与 `/usage` 都已确认**不在** ZCode 的保留命令名里。完整保留名集合备查:`clear, compact, +compress, continue, dwf, effort, expert, fork, goal, help, init, language, locale, login, logout, +mcp, mode, model, new, plan, plugin, plugins, resume, rewind, skill, target, variant`。 + +## 安全 + +插件只读取你自己的 Command Code 凭证、只打四个只读接口,不改写任何宿主配置。具体读了什么、 +发了什么、以及怎么私下报漏洞,见 [SECURITY.md](SECURITY.md)。共享实现的跨宿主调研记录在 +[docs/FINDINGS.md](docs/FINDINGS.md)。 + +## 仓库结构 ``` -/plugin marketplace add Jovan1666/zcode-command-code-usage#v1.2.0-final +. +├── marketplace.json ZCode 侧市场清单(仓库根目录即市场根目录) +├── .claude-plugin/marketplace.json 第二份市场清单(共享字段一致,strict 零警告) +├── README.md / README.zh-CN.md +├── LICENSE / CHANGELOG.md / SECURITY.md +├── docs/FINDINGS.md 共享实现的跨宿主调研记录 +├── .github/workflows/check.yml CI:三平台 × Node 18/22 + 发布检查 + 离线冒烟 +├── scripts/check.mjs 发布门禁(CI 与本地共用同一个脚本) +├── scripts/make-icon.mjs 重新生成 assets/command-code-usage/icon.png +└── command-code-usage/ 插件本体 + ├── .zcode-plugin/plugin.json ZCode 读这个(优先) + ├── .claude-plugin/plugin.json 第二份清单 + ├── commands/ + │ ├── quota.md /quota + │ └── usage.md /usage + ├── skills/command-code-usage/SKILL.md + └── scripts/ + ├── cc-usage.mjs 取数 + 渲染(终端 / Markdown / JSON / HTML / serve) + ├── install-user-scope.mjs 用户级安装、同步与卸载 + └── verify-discoverable.cjs 诊断:复刻 ZCode 的命令解析器 +``` + +### 为什么有些文件有两份 + +插件清单和市场清单各有一个 ZCode 副本、一个 `.claude-plugin/` 副本——因为两套清单的读取位置 +不同、接受的字段也不同: + +- ZCode 先读 `.zcode-plugin/plugin.json`,读不到才回退到 `.claude-plugin/`。 +- ZCode 的市场条目支持 `displayName_i18n`、`description_i18n`、`examplePrompts`、 + `examplePrompts_i18n` 这些展示字段,而另一套校验会把它们当成未知字段告警、`--strict` 下直接失败。 + +所以 ZCode 那份保留了本地化显示名(中文用户看到中文标签),`.claude-plugin/` 那份保持 strict +零警告。两份共有的字段——name、version、description、source、category、homepage、author—— +完全一致,一旦漂移 `scripts/check.mjs` 会让构建失败。提交前跑: + +```bash +node scripts/check.mjs ``` -## 本仓库已归档 +## 分发状态 + +| 渠道 | 状态 | +|---|---| +| **把本仓库添加为市场** | **已可用。** 在 ZCode 里粘贴 `Jovan1666/zcode-command-code-usage`。 | +| ZCode 官方市场(`zcode-plugins-official`) | **已上线。** 插件以 `command-code-usage` 之名收录;`marketplace.json`、`assets/command-code-usage/icon.png` 与两个语种的 README 就是投稿所用的产物。 | + +## 状态与范围 + +已在 Windows 上验证:接口取数、全部输出模式(终端、`--md`、`--compact`、`--json`、 +`--from-json`、HTML、serve)、各账号形态分支、凭证解析、所有错误路径,以及安装器的冲突处理 +(全新安装、重复安装、被改过的文件、外来同名文件)。另外还做过一次从已发布仓库出发的 +干净环境端到端验证(克隆 → 安装 → 发现 → 执行)。 + +CI 在 **Ubuntu、Windows、macOS** 三个平台上,用 Node 18 与 22 跑发布门禁、全部输出模式的离线 +冒烟、安装并发现校验,以及安装器的冲突保护。 + +尚未验证:第二个人通过市场界面安装。如果遇到问题,请带上 `node scripts/check.mjs` 的输出 +(命令发现类问题用 `command-code-usage/scripts/verify-discoverable.cjs .`)和你看到的确切报错 +开 issue。 + +与 Command Code 官方无关。它通过官方 CLI 使用的同一批端点读取你自己账号的用量, +不做代理、不修改、也不向其他任何地方传输数据。 -问题、PR 和讨论请到 [monorepo](https://github.com/Jovan1666/commandcode-usage/issues)。 +## 许可证 -MIT 许可——见 [LICENSE](LICENSE)。 +[MIT](LICENSE) diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..eb662dc --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,82 @@ +# Security Policy + +## Supported versions + +The latest commit on `main` is supported. Fixes land there; there are no backport +branches and no maintained older releases. + +## Reporting a vulnerability + +Report privately through GitHub: open the **Security** tab of + and choose **Report a +vulnerability**. If that channel is not available to you, open a normal issue that +says only that you have a security report and how to reach you — put no details in +the issue itself. + +## What this plugin does on your machine + +| | | +|---|---| +| Reads | the credential files listed below, and the local transcript path only if the host passes one (the last 128 KB, only for the `message.model` field of recent entries — no message content is kept or sent) | +| Writes | nothing by default. The script can keep a local snapshot at `~/.commandcode-usage/last-report.json` and a 24 h cache of the public model catalog at `~/.commandcode-usage/models.json`; **those are written by the script's status-line/hook modes, which this platform's commands never invoke**. `--html` writes one file, only when you pass the flag, to the path you choose | +| Sends | only to `https://api.commandcode.ai`, over HTTPS, with your own key. No other host is contacted, and there is no telemetry | +| Collects | nothing. There is no analytics, no crash reporting, and no phone-home of any kind | +| Host config | **not written.** The plugin installs through ZCode's own plugin mechanism and never edits ZCode's settings, command directory or any other host configuration file | +| Install time | runs no scripts. Nothing executes at install time; the commands run the bundled script only when you invoke them | + +## API calls it makes + +Four authenticated, read-only `GET`s on `https://api.commandcode.ai`: + +| Endpoint | Contents | +|---|---| +| `/alpha/whoami?limits=1` | user, org, organisation-level `orgLimits` | +| `/alpha/billing/credits` | `credits` (balance) and `windowLimits` (both rolling windows) | +| `/alpha/billing/subscriptions` | `planId`, `status`, billing period start and end | +| `/alpha/usage/summary?orgId=&since=` | request count, cost, tokens, success rate for the period | + +Plus one unauthenticated call, `/provider/v1/models`, which returns the public model +catalog and carries no credential. These endpoints under `/alpha/` are not part of +Command Code's documented provider API; they are read because they carry the plan +windows. If one changes shape the panel reports the failure instead of guessing. +`--demo` contacts nothing at all. + +There is no MCP server, no hook, no background daemon and no listening socket, except +the loopback-only server you start yourself with `--serve`. + +## Credentials + +The key is discovered read-only, first hit wins: + +1. the environment variables `COMMAND_CODE_API_KEY`, `CMD_API_KEY` or `COMMANDCODE_API_KEY` +2. `~/.commandcode/auth.json`, written by logging into the Command Code CLI +3. `~/.zcode/v2/provider_config.json` — a provider whose `api.baseUrl` points at `commandcode.ai` +4. failing those, provider configs other agent tools leave behind + (`~/.claude/settings.json`, `~/.pi/agent/settings.json`, `~/.config/opencode/*`, + `~/.dsh/*.yaml`, `~/.codex/config.toml`, `~/.grok/config.toml`) + +**The key is never written to a log, a cache or a rendered result.** It is only ever +placed in an `Authorization: Bearer` header. The snapshot cache stores a short +non-cryptographic digest used to tell whether the cache belongs to the current +account — never the key itself. `--verbose` reports which *source* was used, never the +key. The repository contains no credentials. + +## Out of scope + +- Vulnerabilities in Command Code's own API or service, or in ZCode itself. +- Anything requiring an attacker to already run code as you on your machine, or to + have read access to your credential files — at that point the key is theirs to read + regardless of this plugin. +- Rate limiting, quota exhaustion or billing questions: those are Command Code's + account behaviour, not a security issue here. +- The accuracy of the numbers: they come from Command Code's own endpoints. + +## Scope + +In scope: credential leakage from this plugin (a key reaching a log, cache, rendered +output, subprocess argument list, or the network — other than `api.commandcode.ai`), +any request to a host other than `api.commandcode.ai`, command or path injection +through the plugin's own scripts, and unintended writes to host configuration. + +Not affiliated with Command Code. The plugin reads your own account's usage through +the same endpoints the official CLI uses. diff --git a/assets/command-code-usage/icon.png b/assets/command-code-usage/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..cab3f94aa95abdcc65d76a8ecff58e67f9fcab8e GIT binary patch literal 5181 zcmai2c|278+dgMz%nZg_ma$IOL`b&67}6qzWX)QZq(oG*4@w?e4528aB3rfyDa)hm zlWiDVOtKV_Fxkd;B8!^duLD2nPTFT*jx2 zEC2w6QV>8Rp@V~8z6Sv8MHm|ySl$^~c(M^-H9gSk<2UR7>3!{5i>=i07`cl{Xf>n} zT+p3gphzm#Dt?7}w6e;w{PZu!(5hPnF>M*DsJ}A^mPo9CK<|sttxG2JrR!0h_lns# zg=!|M%qo7wk^NG}g1c)sepKOi8vTE>p8EfOje3A;R>l0sr|W%M3bcuKF*cP-Y1vDg z%zIAR4RJJ~Z%E&xbD}Imh5!T05NGV0Y~#AP$gsmZliMWGT%3r6_NF9_gKkDM_Y*uF zhq^|+Gs;gi$BU~L4;eJah9Z5D+#~o)Eg}P#sR7jc)O6}gYBja>@kj7tTmE!V=(%it z+dLk*y@_3z%s`#heUGh~F<9d)b!m3c^Q38^Pr-wN(t_@Sp9MUHD*VY;?sNygXN830 zEcr0@{h4rlSX;b|DLpG`pJ_fcpwO()r|`k@XwOrhO$VR0pFF>5-DPEus%e#z_iyO> z%2+eYHuERuwIfe zvPF;&9oH}FI>p@o39%>_W*KT1dLc9uy~EN9Q{q*>&Y2k)+;wVFI}vWza)aT`xWTw3 z#nRtd2g7k8RzoqG8Oa|GiLq5=xz5ect;}tSA6*Y+xft&k3mhn$bfydUB%5d3OwfeC z#RRP95PCA)GRoyh!96)C_dJS?T@75HxxQnBMkp7Cpn$Sco`#ABWJrXvokL4X zW>Ib`WjOQz1E{yZl3$*4=vg`G)?au^*hubMT~DH98`lSXzis51^9f6b%zJP6}{dVelWv1K$+|`!f8`c!!$=s_>w2 zXFa>z-vtnKGJdp3cx{1w<2vN>^j>8{Z`T(^JUD;HyCP9o(V5=9?(Qg{%@W@juaO&? zALDeaGGy~`7aZ4o?D!Aoh?`DabGnCH?tt2t-(|G8jpVJp!7NQJfmwcvE=Hn#YTmYS zycy%1bnV0|*Ye(VmbzcpM{(2NjS%$|7DT8Ea!Lz;uTrbViSvw~+2l!{#tnf;h2i7* zCfvgVDEiIGlN#2eTjTmKGEI*-0JG1_M@ov?Z_xrfW3O`yH*VYlv#)l&v9AtSbvyff zj;B#?AVTD|NZmp*vy%tH$`MPzMi##2_2W%z*#nOLSjic4&|yH~j;(7o1;z`3`iMEO zmQy}D7$JFXkp+&~?;#%=OMAMb)*LH2sR}v-iu#^%tvQ(6kYcw4(l3~LVyc&yRpjNN zHJjoE9US&T3Dx#GWvAiejy}P93M?Ls-%`Fbs81YS0m{48)9kB$G*?cZ)HVWaXmGJB z1N_Np2;2&EB}%)sEX;mx=Li9)7D5b%g2vcJ|CDhwqL- zH-ZV@-4NusiyyiD?f`b@FgMLzkFta4o6k9TalwV4MjjskARuxi0KEU1G=7Hh9^9XL zhjyLheBr>x=ep4>{}VwWDr`I1eLwqF;0ZPRX>>4mkiKWIZ! zuD~{Ul~Xhn(}x`D{0bawo-3uWcf?Nny{B+&Y%B}-MV(C;t2tw*pmos7y8T0L(bSA= zm1as>ahh?QP3OTl+R)fr&sS&O*vVI0Db7y|S#=<5-+M)km6v_;==eM6Q3cs)(V?t2 z&yOWnTXe5(R%2-F@Y>nT;MscFHOjg1w5^9)nw@Y>`>Cd3`>C8^dvA%b&`gT->+$l9 z>EeauB3i2Wa=x1XOWn?*^^sCGyiVg*R8jAEu}=rNy;&{ z<3+!jiR`(akAesU?tDaem`yGH(|m3?1%q8?iPf%ty{C4q!9q4Y^o3>TwB$ijY-qu9zyMjsyC*!X?4_REu?OwnrZ z)XZy|?H^ucPR+dH@>@yMa_>vinogtm43E9t@P5R6Q(<>K*ko9$;m*~fF;-s1*+tJG zx$`39<+c9(w+k#a7nABV7gOsVZaVszZ7bJSU$3TwivDif*8JH4pPzV$qBY3#l#D9! zyn>=M*jd79t6sO_H$2ddf?Dp>knAW;9oU-c9gu8yt?CF1e0RG+w|veu)47ys6!x5| z-r;jqWV-#aT%g}a=ji}N)`9csJCvEV&YhD_FXS;DpE_*@JbJSl?mp57U+&_cKEKgC zY34qcwe7c7`OxAJ3$L&c`p99(wQRnIyoEcm6N$IzDQNp;nB)jcN>{ljFx^)@cST`B z%O*Okf)E|{4gh_V0o}TFNlmkEZ_=nlrgUg$>Fr$f&`n9VaDfjiI?_NvOY9Tr*$MRv z$ZM>))NnQD=nyqY(6=1Wt^St1?7+~U`hv}LTZ%DW6o`ja3M?9jKVzCadK?Z3+(wo9_PjZ3x3@bR-073eAMF^93V~r zJS?~E<@9y!V}rSFRH45Ktrr*SUUjd#^!Y4Lo`H0;6fdIVI!H;^Kjt7Ae9r|5e;#D?W?|UQVVI~aL1G3S_ z3lwu-&~g_`*!M494&ajl&;X1G-~cAg`f`a#oPsY zxX=KV~l@UH>faT~gH;;}~6U-{7$Fp5l(vCB*#r%DVS1_tf zB!0l980o4Z+-YFP-Qj1R7F8i3woAl>KZ5mtaq|CV=|7b}@)iO7Gffyx+Kj->3f@Zp z5cpX}rJ?6zvw8)HjMOGwlVrc${A^n7;%^vMZ<`J4w*~LJIwY(xhwpLxT3G>}h&-aS zq%&#WbJl)3ZThPFYJWJXUb-}W^67NR1bKdDIvc}ALBz9F*WMaFiVfTGc@aFp0V9>u z#g75D7{58&B?Zwp9W|GoQlG+4^2aD4+x!DW|n;1!$Itn7lj*A<0r?W?9-OT&N5cHD>vfpP-Y|zSk1R36=X!tUOt>yx7 z^uennaLZ#KaJg1&^)0s4xc&Gsc0&}E-Lsq*)^pkFYv`0huX67r&ka8VO+z(on=eq?`{SSo`K5FvEI000f`p%nN3 zC4WZZfPbbXCBO|nDJ<;UIR24e<%d+Skv#Gu1p2Hq2TnvL*y@=mmj<2dxP0beoV-~Y zX_>F@Ri(ab`JbC?MT9+$j!Yc4l9smVPHt)lb_jC9$3|o{n)k$$a?0th0i~p5jn~tp z(U+%g_`LZEb8YHR+#?tjKm6`YNBDL|>MAyR1s-S6w=omY;Z=5H)Vd={K09er?e;IQ zsHrcqKP@V8)Iu7*SZkx~b_7)>rp{p3moGula&bbAfn13^#V9k`f6-_f|*+YU_HufOvhZa1%I{tD~Zxi@%nxw z{(ynD9`@VaagfGA9NbuY6+Zq8rpkIH@?FmZ2L0myMv=BN@TQoA-y=7<>g#oua6`W? zju#|A|9S$j&IW{mybwVV{@{nV25AKBN5D8Dc!ARq5f&~8q6d!r2ROo>s1txN8iqj_ z0K)7@h?@5AKV}e-L;k}^s)AX&Pyzp?q((1Qh>67Z+H!XU`TomAkVxFUBE&Mg$@(F1 zDc}&~HzBF2h@g{OZmPk|{}r8rXd+gJd?@rcjLwk993E`@0=nG;)VK&q1KRL zrF;QvBoo{dzP}bEW@U}J48;uIfYd1t*cG@VUyTi6h9}(c_fhmP^)!t?qj*?(OE z#vZg($lLAuUk{lZBsjuXLtY~-MGy$bsdw!PYyu$U`tgCMlKepc^2#w@1Ypeu^x8c= zXcM8ctG}JW#Zz+}OZscTks~?2ZgTh0^hr^ouUlSA#Y>Gr5?Z&(09HfV^XfDZmWf(I zbz&65VxkbfUw6WpNiDubY&hC{90>*ZGIqkL{eqm)ufB`P5ji=De-GX?v!_U+tum~c zswfV6;nq&GNS(JJ0Qq`J&6VGN`9R4gbE2qx{z~2SROKpLNOKdcCer8e{EmI#cEE@a zQGaNnY%<%w3@B-1g_j(fzoODfVMH6As7z~S^F6lZQ)%rw*#A@z9W9@cynYU{J?$SJ zcoheYjNZ%>)&PM|4O?bjEN4<~)A?O*jN+o7O1v5$WZ@2V6mZvO7QT={v5nso&kxNR z=dwk%ctbBR9**+e+p4aqSD;x+ljIcbMRJQZ8u)}ukC4m18{gl6e<{K!ve-~O^qAls z6?Hki?-gSXKDfP!?@r&s5oU^5>Vg_jQ5|Y0T!K8b1VBJeuy#FkZwfoKM#hkTjzxh< z$%zf6>PWpu6%M5;)Mdp+7C>C}>tn61Z5lp3^SE2+o8dhiVN;Q%{#(~n)ls*R8zkOz z-*(Ne@)|x_s3b41WfR8>-G?j>8kcJm=Im8`r7JMYEr^Q`^XNR~RAiz7k?L#dsG9r(Kuf!{@0F96f@odL2Cs>_L;AI(nOHQCo5$v2wweJg9$RY$4|s*Mk%CzpcZWkwGl{JPY`*Lr8z zXZsA-imyea`Rt=AjEMV)s+&$Ll{PT@TDg}tc%}LA_O)T1#$~=X&BP*~q6aU%gGx=} z?sMF%GhXoq>(EULA08B{6q(gQO@?F62zE!m{7h4O>y#8F#G1@n@%rps-CVQ6=m5>Y z53%p_EeVWW|KA?tPLNA(X===&1>PSxIbbtzc{KERKKYX_AoN#Te3 How much of my 5-hour window is left, and when does it reset? + +Both commands render the panel straight into the conversation (a user-scope install registers the +bare `/quota` and `/usage` instead; either form does the same thing): + +| Command | What it does | +| --- | --- | +| `/command-code-usage:quota` | The panel: 5-hour window, weekly window, monthly credits or balance, reset times, a remaining-requests estimate, and a warning when a window is burning faster than it resets | +| `/command-code-usage:usage` | The same panel — an alias, so either name works | + +Both take the same optional arguments — `--compact`, `--md`, `--json`, `--html`, `--verbose`, +or `--demo hot` for offline sample data. `--help` lists the authoritative set. + +| Skill | Role | +| --- | --- | +| `command-code-usage` | Reads the quota and answers questions about the windows, reset times, credits and burn rate | + +No hooks, no MCP servers, no agents, no background processes. + +## Requirements + +| | | +| --- | --- | +| Host | ZCode | +| Runtime | Node.js 18 or newer — the script uses the built-in `fetch` and installs nothing | +| Plan | A Command Code plan whose key may call the usage endpoints. Without API access those endpoints answer `403`/`407` and the panel says so instead of printing a number. | + +## Data sources and authentication + +One host only: **`https://api.commandcode.ai`** (HTTPS). The endpoints it reads are +`/alpha/whoami`, `/alpha/billing/credits`, `/alpha/billing/subscriptions`, +`/alpha/usage/summary`, and `/provider/v1/models`. The last one sends no credential and is +used only to tell whether the current model is routed to Command Code. + +Endpoints under `/alpha/` are not part of Command Code's documented provider API. They are +read because they carry the plan windows; if one changes shape the panel reports the failure +rather than estimating from stale data. `--demo` calls nothing. + +The key is discovered read-only, first hit wins: + +1. `COMMANDCODE_API_KEY`, then `COMMAND_CODE_API_KEY`, then `CMD_API_KEY`, from the environment +2. `~/.commandcode/auth.json` +3. `~/.zcode/v2/provider_config.json` +4. failing those, provider configs other agent tools leave behind — `~/.claude/settings.json`, + `~/.pi/agent/settings.json`, `~/.config/opencode/*`, `~/.dsh/*.yaml`, `~/.codex/config.toml`, + `~/.grok/config.toml` + +The key is sent to `api.commandcode.ai` and nowhere else. It is never copied to another +location, never echoed into the output, and never logged. + +## What it does on your machine + +| | | +| --- | --- | +| Hooks | none — the plugin installs no hooks and does not intercept your tools | +| MCP servers | none — no `.mcp.json`, no server process | +| Network | one host, `api.commandcode.ai`, and only while rendering the panel; `--demo` makes no calls | +| Executes | `node /scripts/cc-usage.mjs` with the flags you passed. The command looks for that script at its installed path and, failing that, searches `~/.zcode`, `~/.claude`, `~/.codex`, `~/.grok` and `~/.dsh` for a copy under a `command-code*` or `commandcode*` path. Nothing else is executed. | +| Reads | the credential files listed above, and — when the host passes a transcript path — the last 128 KB of that transcript, only to pick up the `message.model` / `modelId` field of recent entries. No message content is stored or sent anywhere. | +| Writes files | nothing by default. `--html` writes one file, only when you pass the flag, to the path you choose. The script's status-line and hook modes keep local caches under `~/.commandcode-usage/` (a snapshot and a 24 h model-catalog cache); this plugin's commands never call those modes. | +| Host config | never modified — the plugin installs through ZCode's own plugin mechanism | +| Degrades gracefully | a missing key, a plan without API access, and an unparsable response each produce a message naming the cause. It does not invent a figure. | + +## Bundled scripts + +Besides the script the commands run, the plugin ships two standalone tools. Neither runs on +its own; they exist for the cases described here. + +- `scripts/cc-usage.mjs` — the panel itself. Runnable directly: + `node scripts/cc-usage.mjs --compact`. +- `scripts/install-user-scope.mjs` — installs the commands and the skill into your user-scope + ZCode directories (`~/.zcode/commands`, `~/.zcode/skills`) for people who would rather not go + through the marketplace. It writes those files, substitutes the absolute path of + `cc-usage.mjs` into the command body, and keeps a small manifest of what it wrote so it can + update or remove them later. **Do not run it on top of a marketplace installation**: + user-scope copies are discovered first and would shadow the installed plugin. `--uninstall` + removes them. +- `scripts/verify-discoverable.cjs` — a read-only diagnostic that re-implements ZCode's own + command parser, so a "my command does not show up" report can come with evidence. It reads + files and prints a report; it writes nothing. + +## Token cost, and the zero-token alternative + +A custom command is ultimately a prompt: the body is injected, the agent runs the script, and +the panel text passes through the model. Measured, one `/quota` costs roughly **390 tokens** — +about 100 for the command body, 80 for the tool call, 180 for the panel text, 40 for the reply. +The body is deliberately short and the agent is told **not to restate the panel**, because the +tool result is already visible. + +**To spend no tokens at all**, run the panel as a local page and open it in ZCode's built-in +browser pane: + +```bash +node scripts/cc-usage.mjs --serve # then open http://127.0.0.1:8787/ +node scripts/cc-usage.mjs --serve --port 8788 # if 8787 is taken +``` + +It refreshes every 30 seconds, shows the same ring gauges, and never touches the model. It +binds to loopback only and stops with Ctrl+C. ZCode exposes no plugin-contributed in-app +widget, and a hook cannot display content either — the hook record it renders carries status, +duration and name, with no output field. + +## Third-party code, assets and services + +No third-party code or assets are vendored; the commands, the skill and the script are this +project's own. The only external service is Command Code's own API, and its terms and +availability are Command Code's. Licensed MIT — see the `LICENSE` file in the repository. + +Security policy, and the full list of what this plugin touches: see `SECURITY.md` in the +repository. Installation routes, troubleshooting and the release gate: see the repository's +README. diff --git a/command-code-usage/README_CN.md b/command-code-usage/README_CN.md new file mode 100644 index 0000000..9eb7b54 --- /dev/null +++ b/command-code-usage/README_CN.md @@ -0,0 +1,109 @@ +# Command Code Usage (command-code-usage) + +[English](./README.md) + +在对话里直接看 Command Code 套餐用量——5 小时与每周滚动窗口、月度额度或余额, +以及各自什么时候重置——不用离开终端,也不用打开账单页。 + +## 快速开始 + +在 ZCode 的插件市场里安装 **Command Code Usage**,然后跑一条命令,或者直接把想问的说出来: + +> 我的 5 小时窗口还剩多少?什么时候重置? + +两条命令都把面板渲染在对话里(用户级安装注册的是短的 `/quota`、`/usage`,效果相同): + +| 命令 | 作用 | +| --- | --- | +| `/command-code-usage:quota` | 面板本体:5 小时窗口、每周窗口、月度额度或余额、重置时间、剩余次数估算,以及某个窗口消耗快于重置时的告警 | +| `/command-code-usage:usage` | 同一个面板——别名,两个名字都能用 | + +两者接受相同的可选参数——`--compact`、`--md`、`--json`、`--html`、`--verbose`,或 +`--demo hot` 用离线样例预览。完整选项以 `--help` 为准。 + +| 技能 | 作用 | +| --- | --- | +| `command-code-usage` | 读取额度,并回答关于窗口、重置时间、额度与消耗速度的问题 | + +没有 hook、没有 MCP server、没有 agent、没有后台常驻进程。 + +## 环境要求 + +| | | +| --- | --- | +| 宿主 | ZCode | +| 运行时 | Node.js 18 或更新版本——脚本用的是内置 `fetch`,不安装任何依赖 | +| 套餐 | 一把有权调用额度接口的 Command Code key。没有 API 权限时这些端点会返回 `403`/`407`,面板会照实说明,而不是编一个数字出来 | + +## 数据来源与鉴权 + +只有一个主机:**`https://api.commandcode.ai`**(HTTPS)。它读取的端点是 +`/alpha/whoami`、`/alpha/billing/credits`、`/alpha/billing/subscriptions`、 +`/alpha/usage/summary` 与 `/provider/v1/models`。最后那个不带凭证,只用来判断当前模型是否 +路由到了 Command Code。 + +`/alpha/` 下的端点不属于 Command Code 公开的 provider API。读它们是因为套餐窗口在这些端点里; +一旦某个端点改了形状,面板会报出失败,而不是拿旧数据估算。`--demo` 不调用任何接口。 + +key 只读发现,按顺序命中即用: + +1. 环境变量 `COMMANDCODE_API_KEY`、`COMMAND_CODE_API_KEY`、`CMD_API_KEY` +2. `~/.commandcode/auth.json` +3. `~/.zcode/v2/provider_config.json` +4. 以上都没有时,其他 agent 工具留下的 provider 配置——`~/.claude/settings.json`、 + `~/.pi/agent/settings.json`、`~/.config/opencode/*`、`~/.dsh/*.yaml`、 + `~/.codex/config.toml`、`~/.grok/config.toml` + +key 只会发往 `api.commandcode.ai`,不会被复制到别处、不会回显在输出里、也不会写进日志。 + +## 它在你机器上做什么 + +| | | +| --- | --- | +| 钩子 | 无——不安装任何 hook,也不拦截你的工具调用 | +| MCP server | 无——没有 `.mcp.json`,没有常驻服务进程 | +| 网络 | 只有 `api.commandcode.ai` 一个主机,且只在渲染面板时;`--demo` 不发起任何请求 | +| 执行 | `node <插件目录>/scripts/cc-usage.mjs`,参数就是你传的那些。命令先按安装路径找脚本,找不到就在 `~/.zcode`、`~/.claude`、`~/.codex`、`~/.grok`、`~/.dsh` 里找 `command-code*` / `commandcode*` 路径下的副本。除此之外不执行任何东西 | +| 读取 | 上面列出的凭证文件;当宿主传入 transcript 路径时,读该文件最后 128 KB,只为取出近期条目的 `message.model` / `modelId` 字段。消息内容不落盘、不外传 | +| 写入文件 | 默认不写任何文件。`--html` 只在你显式传参时写一个文件到你指定的路径。脚本的状态栏与钩子模式会在 `~/.commandcode-usage/` 下保留本地缓存(一份快照与一份 24 小时的模型目录缓存);本插件的命令不会走那两条路径 | +| 宿主配置 | 从不改写——插件走 ZCode 自己的插件机制安装 | +| 优雅降级 | 没有 key、套餐没有 API 权限、响应无法解析,都会给出说明原因的消息,不编数字 | + +## 附带脚本 + +除了命令执行的那个脚本,插件还带两个独立工具。它们不会自动运行,只在下列场景下由你手动使用。 + +- `scripts/cc-usage.mjs` —— 面板本体。可直接运行:`node scripts/cc-usage.mjs --compact`。 +- `scripts/install-user-scope.mjs` —— 把命令与技能装进你的用户级 ZCode 目录 + (`~/.zcode/commands`、`~/.zcode/skills`),供不想走市场的用户使用。它写入这些文件、把 + `cc-usage.mjs` 的绝对路径注入命令正文,并保留一份「我写过什么」的小清单以便日后更新或卸载。 + **不要在市场安装之上再跑它**:用户级副本的发现优先级更高,会遮蔽已安装的插件。 + `--uninstall` 可移除。 +- `scripts/verify-discoverable.cjs` —— 只读诊断工具,复刻了 ZCode 自己的命令解析器, + 这样「我的命令不出现」这类问题可以带着证据来报。它只读文件、打印报告,不写任何东西。 + +## Token 成本,以及零成本的替代方式 + +自定义命令本质上是一段 prompt:正文被注入,agent 运行脚本,面板文本再经过模型。实测一次 +`/quota` 约 **390 tokens**——命令正文约 100、工具调用约 80、面板文本约 180、回复约 40。 +正文刻意写得很短,并明确要求 agent **不要复述面板**,因为工具调用的结果本来就显示在界面上。 + +**想完全不花 token**,把面板当成本地页面、在 ZCode 的内置浏览器面板里打开: + +```bash +node scripts/cc-usage.mjs --serve # 然后打开 http://127.0.0.1:8787/ +node scripts/cc-usage.mjs --serve --port 8788 # 8787 被占用时 +``` + +每 30 秒自动刷新,环形仪表盘与对话里一致,完全不经过模型。只绑回环地址,Ctrl+C 停止。 +ZCode 没有给插件留应用内组件位,hook 也显示不了内容——它渲染的记录只有状态、耗时和名称, +没有输出字段。 + +## 第三方代码、素材与服务 + +没有引入任何第三方代码或素材;命令、技能与脚本都是本项目自己的。唯一的外部服务是 +Command Code 自己的 API,其条款与可用性由 Command Code 负责。MIT 许可——见仓库里的 +`LICENSE`。 + +安全策略,以及本插件到底碰了什么:见仓库里的 `SECURITY.md`。安装路线、排查与发布门禁: +见仓库的 README。 diff --git a/command-code-usage/commands/quota.md b/command-code-usage/commands/quota.md new file mode 100644 index 0000000..dd3f889 --- /dev/null +++ b/command-code-usage/commands/quota.md @@ -0,0 +1,20 @@ +--- +description: 查看 Command Code 额度用量(5 小时窗口 / 每周窗口 / 余额) +argument-hint: "[--md | --compact | --json | --demo hot]" +--- + +```bash +CC_SCRIPT="@@CC_USAGE_SCRIPT@@" +ROOT="${ZCODE_PLUGIN_ROOT:-$CLAUDE_PLUGIN_ROOT}" +[ -f "$CC_SCRIPT" ] || CC_SCRIPT="$ROOT/scripts/cc-usage.mjs" +if [ ! -f "$CC_SCRIPT" ]; then + # 兜底:只找 ZCode 自己的插件目录。本仓库自带这份脚本,若捡到为别的宿主装的副本, + # 执行的就是本仓库管不到的代码。 + CC_SCRIPT=$(find "$HOME/.zcode" -maxdepth 6 -type f -name cc-usage.mjs \( -path '*commandcode*' -o -path '*command-code*' \) -print -quit 2>/dev/null) +fi +[ -f "$CC_SCRIPT" ] || { echo "找不到 cc-usage.mjs,插件可能未正确安装。"; exit 2; } +node "$CC_SCRIPT" $ARGUMENTS +``` + +面板已经在上面了。**不要再重复输出一遍**——用 1 到 3 行给出结论即可: +最紧的是哪个窗口、还剩多少、什么时候重置。出现 `⚠` 告警时才多说一句风险。 diff --git a/command-code-usage/commands/usage.md b/command-code-usage/commands/usage.md new file mode 100644 index 0000000..dd3f889 --- /dev/null +++ b/command-code-usage/commands/usage.md @@ -0,0 +1,20 @@ +--- +description: 查看 Command Code 额度用量(5 小时窗口 / 每周窗口 / 余额) +argument-hint: "[--md | --compact | --json | --demo hot]" +--- + +```bash +CC_SCRIPT="@@CC_USAGE_SCRIPT@@" +ROOT="${ZCODE_PLUGIN_ROOT:-$CLAUDE_PLUGIN_ROOT}" +[ -f "$CC_SCRIPT" ] || CC_SCRIPT="$ROOT/scripts/cc-usage.mjs" +if [ ! -f "$CC_SCRIPT" ]; then + # 兜底:只找 ZCode 自己的插件目录。本仓库自带这份脚本,若捡到为别的宿主装的副本, + # 执行的就是本仓库管不到的代码。 + CC_SCRIPT=$(find "$HOME/.zcode" -maxdepth 6 -type f -name cc-usage.mjs \( -path '*commandcode*' -o -path '*command-code*' \) -print -quit 2>/dev/null) +fi +[ -f "$CC_SCRIPT" ] || { echo "找不到 cc-usage.mjs,插件可能未正确安装。"; exit 2; } +node "$CC_SCRIPT" $ARGUMENTS +``` + +面板已经在上面了。**不要再重复输出一遍**——用 1 到 3 行给出结论即可: +最紧的是哪个窗口、还剩多少、什么时候重置。出现 `⚠` 告警时才多说一句风险。 diff --git a/command-code-usage/scripts/cc-usage.mjs b/command-code-usage/scripts/cc-usage.mjs new file mode 100644 index 0000000..b47de09 --- /dev/null +++ b/command-code-usage/scripts/cc-usage.mjs @@ -0,0 +1,2174 @@ +#!/usr/bin/env node +/** + * Command Code 额度面板 + * + * 读取 Command Code(GOAT / Pro / Max ...)订阅的实时额度: + * 5 小时滚动窗口、每周滚动窗口、月度 credits、本周期用量统计。 + * + * 用法: + * node cc-usage.mjs 终端仪表盘 + * node cc-usage.mjs --compact 单行摘要(适合塞进提示) + * node cc-usage.mjs --json 归一化 JSON + * node cc-usage.mjs --html 生成本地 HTML 面板 + * node cc-usage.mjs --html --open 生成并自动打开浏览器 + * node cc-usage.mjs --serve 启动实时面板(默认 8787 端口) + * node cc-usage.mjs --demo 用内置样例数据预览外观(不联网) + * node cc-usage.mjs --verbose 附带凭证来源等诊断信息 + * + * 选项:--no-color --org --port --out + * + * 凭证来源(按顺序): + * 1. 环境变量 COMMAND_CODE_API_KEY / CMD_API_KEY / COMMANDCODE_API_KEY + * 2. ~/.commandcode/auth.json (Command Code CLI 登录后的凭证) + * 3. ~/.zcode/v2/provider_config.json(ZCode 里配置的 provider,自动匹配 baseUrl) + */ + +// 本仓库里这个脚本是**按需**跑的(ZCode 的 /quota、/usage),没有每轮消息都起的宿主钩子, +// 所以 --serve 这条常驻路径留在这里没有代价。启动开销仍然是体验本身,所以: +// ESM 静态 import 每个内建模块约 +2ms,改走 createRequire 实测省 11ms。 +// node:crypto 不用了;node:http 只有 --serve 需要,因此在 serve() 里惰性取—— +// 省掉的不是代码,是其它每一种模式都要付的启动时间。 +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { execFile, spawn } = require('node:child_process'); + +const VERSION = '1.3.2'; +export const DEFAULT_API_BASE = 'https://api.commandcode.ai'; +const PROVIDER_MATCH = /commandcode\.ai/i; + +/* ------------------------------------------------------------------ 套餐表 */ + +// 来源:Command Code 官方定价页 commandcode.ai/docs/resources/pricing-limits +// (核对于 2026-09-21)。这里只是**兜底**:接口报的 windowLimits.cap 优先, +// 只有在接口没给上限时才用这张表。金额那一列是套餐内含的额度,不是月费。 +// +// 几个容易搞错的点: +// - Go($1) 只有 $10 额度,且**没有 API 权限**,四个额度接口全 404; +// - Provider($15) 是按量计费,没有滚动窗口,也没有"月度额度"这个概念; +// - Enterprise 同样没有滚动窗口,额度是谈出来的; +// - Max 的额度在"标准模型/高级模型"之间分池,接口报的才是真的。 +const PLANS = { + 'individual-go': { name: 'Go', monthly: 10, fiveHour: 3, weekly: 6 }, + 'individual-goat': { name: 'GOAT', monthly: 70, fiveHour: 14, weekly: 35 }, + // Pro 的内含额度是 $80(早期版本曾报 $30,两版都留着以免老账号识别不出来) + 'individual-pro': { name: 'Pro', monthly: 80, fiveHour: 16, weekly: 40 }, + 'individual-provider': { name: 'Provider', monthly: null, fiveHour: null, weekly: null }, + 'individual-max-10x': { name: 'Max 10x', monthly: 150, fiveHour: 45, weekly: 90 }, + 'individual-max-20x': { name: 'Max 20x', monthly: 300, fiveHour: 90, weekly: 180 }, + 'individual-max': { name: 'Max 10x', monthly: 150, fiveHour: 45, weekly: 90 }, + 'individual-ultra': { name: 'Max 20x', monthly: 300, fiveHour: 90, weekly: 180 }, + 'teams-pro': { name: 'Team Pro', monthly: 40, fiveHour: 12, weekly: 24 }, + 'teams-enterprise': { name: 'Enterprise', monthly: null, fiveHour: null, weekly: null }, +}; + +function planInfo(planId) { + if (!planId || typeof planId !== 'string') return null; + const norm = planId.toLowerCase().replace(/_/g, '-'); + const hit = Object.keys(PLANS) + .sort((a, b) => b.length - a.length) + .find((k) => norm.startsWith(k)); + if (!hit) return null; + return { id: planId, ...PLANS[hit] }; +} + +/* -------------------------------------------------------------------- 参数 */ + +function parseArgs(argv) { + const out = { + mode: 'terminal', + color: true, + open: false, + org: undefined, + outFile: undefined, + verbose: false, + demo: false, + // 状态栏模式:给 Claude Code / Grok 这类「每轮自动跑一次脚本」的宿主用。 + // 默认走磁盘缓存,命中就直接出图,不会每条消息都去打四个接口。 + threshold: null, + // 超过这个年龄就先显示旧快照,同时后台补一次数。 + // 别再调回 60s:它正好等于宿主常见的刷新周期,于是每一跳都起一个后台进程打四个 + // 接口;而额度这种量级,3 分钟内的差异本来也看不出来。 + cacheTtl: 180_000, + // 超过这么久没有新的请求量增长,就当用户已经切走了,状态栏不再显示。 + // 0 = 永远显示(不推荐,见 README)。 + idleHideMs: 30 * 60_000, + always: false, + // 用户自己补的模型名别名——自动匹配不可能覆盖所有命名习惯。 + modelPatterns: [], + why: false, + hook: false, + noCache: false, + rows: 3, + // --serve 的监听端口;只绑回环地址。 + port: 8787, + }; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === '--json') out.mode = 'json'; + else if (a === '--md' || a === '--markdown') out.mode = 'md'; + else if (a === '--compact') out.mode = 'compact'; + else if (a === '--html') out.mode = 'html'; + else if (a === '--serve') out.mode = 'serve'; + else if (a === '--watch') out.mode = 'watch'; + else if (a === '--statusline' || a === '--line') out.mode = 'statusline'; + else if (a === '--threshold') out.threshold = Number(argv[++i]); + else if (a === '--cache-ttl') out.cacheTtl = Number(argv[++i]) || 0; + else if (a === '--no-cache') out.noCache = true; + else if (a === '--refresh-cache') out.refreshCache = true; + else if (a === '--rows') out.rows = Number(argv[++i]) === 1 ? 1 : 3; + else if (a === '--idle-hide') out.idleHideMs = Number(argv[++i]) * 60_000; + else if (a === '--always') { out.idleHideMs = 0; out.always = true; } + else if (a === '--model') out.modelPatterns.push(normalizeModel(argv[++i])); + else if (a === '--why') out.why = true; + // Codex 这类没有常驻位的宿主:用 UserPromptSubmit 钩子每轮弹一行。 + else if (a === '--hook') out.hook = true; + else if (a === '--open') out.open = true; + else if (a === '--no-color') out.color = false; + else if (a === '--color') out.color = true; + else if (a === '--verbose' || a === '-v') out.verbose = true; + else if (a === '--demo') { + out.demo = true; + const next = argv[i + 1]; + if (next && !next.startsWith('-')) { + out.demoScenario = next; + i++; + } + } + else if (a === '--org') out.org = argv[++i]; + else if (a === '--from-json') out.fromJson = argv[++i]; + else if (a === '--at') out.at = argv[++i]; + else if (a === '--port') out.port = Number(argv[++i]) || 8787; + else if (a === '--out') out.outFile = argv[++i]; + else if (a === '--help' || a === '-h') out.help = true; + } + if (process.env.NO_COLOR !== undefined) out.color = false; + // 状态栏的 stdout 必然不是 TTY——宿主是把脚本输出抓走再渲染的, + // 所以这里不能按 isTTY 推断,否则状态栏永远是灰的。 + else if (out.mode !== 'statusline' && !process.stdout.isTTY) out.color = false; + return out; +} + +const HELP = `Command Code 额度面板 v${VERSION} + + node cc-usage.mjs 终端面板(默认,聊天/终端里都能看) + node cc-usage.mjs --md 表格形式,适配聊天里的 Markdown 渲染 + node cc-usage.mjs --compact 单行摘要 + node cc-usage.mjs --json 归一化 JSON + node cc-usage.mjs --watch 终端里持续刷新(每 60s) + node cc-usage.mjs --statusline 状态栏(3 行彩色进度条,宿主每轮自动调用,不烧 token) + node cc-usage.mjs --statusline --threshold 70 + 只在某个窗口超过 70% 时才输出(给只能弹警告的宿主用) + node cc-usage.mjs --statusline --rows 1 单行(默认三行) + node cc-usage.mjs --statusline --model <子串> + 手工指定"哪些模型名算在用",可重复。 + 自动判定靠比对 Command Code 公开模型目录, + 命名对不上时用它补 + node cc-usage.mjs --statusline --always 不判断去向,永远显示 + node cc-usage.mjs --statusline --idle-hide <分钟> + 拿不到本轮模型证据时,账号闲置多久后隐藏(默认 30) + node cc-usage.mjs --statusline --why 解释"为什么现在没显示",输出到 stderr + node cc-usage.mjs --demo [场景] 内置样例,不联网预览外观 + 场景:normal(默认)/ hot(用量吃紧) + / provider(按量计费)/ max(Max 20x) + node cc-usage.mjs --from-json <文件> 渲染离线快照(配 --json 存下来的原始响应) + node cc-usage.mjs --verbose 附带诊断信息 + node cc-usage.mjs --org 指定组织 + +可选(想看大图时才用,平时用不到): + node cc-usage.mjs --html [--open] 生成本地 HTML 面板 + node cc-usage.mjs --serve [--port n] 实时面板(浏览器每 30s 刷新) +`; + +/* ------------------------------------------------------------------ 凭证 */ + +function readJsonSafe(file) { + try { + return JSON.parse(fs.readFileSync(file, 'utf8')); + } catch { + return null; + } +} + +/** 在任意 JSON 结构里找出 "access.apiKey + api.baseUrl" 形状且 baseUrl 指向 Command Code 的条目。 */ +function scanForProviderKey(node, depth = 0) { + if (!node || typeof node !== 'object' || depth > 8) return null; + if (Array.isArray(node)) { + for (const item of node) { + const hit = scanForProviderKey(item, depth + 1); + if (hit) return hit; + } + return null; + } + const baseUrl = node?.api?.baseUrl; + const apiKey = node?.access?.apiKey; + if (typeof apiKey === 'string' && apiKey.trim() && typeof baseUrl === 'string' && PROVIDER_MATCH.test(baseUrl)) { + return { apiKey: apiKey.trim(), baseUrl: baseUrl.trim() }; + } + for (const value of Object.values(node)) { + const hit = scanForProviderKey(value, depth + 1); + if (hit) return hit; + } + return null; +} + +function readTextFile(file) { + try { + return fs.readFileSync(file, 'utf8'); + } catch { + return null; + } +} + +// Command Code 的 key 长这样(官方 CLI 生成的形如 user_xxx,不是 sk-)。 +const KEY_SHAPE = /^(user_|cc_)[A-Za-z0-9_-]{8,}$/; + +// 别人机器上 key 藏在哪儿,取决于他用哪个宿主接的 Command Code。 +// 所以这里按"能自动找到就自动找"来排,找不到才要求用户显式设环境变量—— +// 一个要手动配 key 才能用的插件,绝大多数人第一步就放弃了。 +const JSON_CONFIGS = [ + '~/.zcode/v2/provider_config.json', + '~/.config/opencode/opencode.jsonc', + '~/.config/opencode/opencode.json', + '~/.claude/settings.json', + '~/.pi/agent/settings.json', +]; + +// 用 TOML/YAML 存 provider 的那几个宿主(dsh / Codex / Grok)。 +// 为这个引一个 YAML 解析器不值得,用"块内就近匹配"够用: +// 先找到提到 commandcode 的那一行,再在它同一块里找 apiKey / apiKeyEnv。 +const TEXT_CONFIGS = [ + '~/.dsh/settings.yaml', + '~/.dsh/.credentials.yaml', + '~/.codex/config.toml', + '~/.grok/config.toml', +]; + +/** 三种写法都认:apiKeyEnv 引用环境变量名、apiKey 直接写值、顶层 apiKey 键。 + * 直接写值的要过 KEY_SHAPE(user_ / cc_ 前缀加 8 位以上)。 */ +function keyFromLine(line) { + const envRef = line.match(/apiKeyEnv\s*[:=]\s*["']?([A-Za-z0-9_]+)["']?/i); + if (envRef) return { envName: envRef[1] }; + const direct = line.match(/apiKey\s*[:=]\s*["']?([A-Za-z0-9_-]{12,})["']?/i); + if (direct && KEY_SHAPE.test(direct[1])) return { apiKey: direct[1] }; + return null; +} + +/** 在文本配置里按块找 Command Code 的 key;块边界取 TOML 的 [section]。 */ +function scanTextConfig(absPath) { + const text = readTextFile(absPath); + if (!text || !PROVIDER_MATCH.test(text)) return null; + const lines = text.split(/\r?\n/); + for (let i = 0; i < lines.length; i++) { + if (!PROVIDER_MATCH.test(lines[i])) continue; + for (let j = i + 1; j < Math.min(lines.length, i + 40); j++) { + if (/^\s*\[[^\]]+\]\s*$/.test(lines[j])) break; + const hit = keyFromLine(lines[j]); + if (hit) return hit; + } + } + // 整个文件都没提到 commandcode 之外的线索时不做兜底——猜错 key 比找不到更糟。 + return null; +} + +/** apiKeyEnv 指向的名字,可能落在环境变量里,也可能落在 dsh 的 .credentials.yaml 里。 */ +function resolveEnvRef(name, home) { + const fromEnv = process.env[name]; + if (fromEnv && fromEnv.trim()) return fromEnv.trim(); + const cred = readTextFile(path.join(home, '.dsh', '.credentials.yaml')); + if (cred) { + const m = cred.match(new RegExp(`^\s*${name}\s*:\s*["']?([^\s"']+)`, 'm')); + if (m && KEY_SHAPE.test(m[1].trim())) return m[1].trim(); + } + return null; +} + +export function resolveCredentials() { + const home = os.homedir(); + const expand = (p) => path.join(home, p.replace(/^~[\/]/, '')); + + // 1. 显式环境变量:这是唯一"官方"的接入口,也是最容易被 CI 用上的。 + for (const name of ['COMMAND_CODE_API_KEY', 'COMMANDCODE_API_KEY', 'CMD_API_KEY']) { + const v = process.env[name]; + if (v && v.trim()) return { apiKey: v.trim(), source: `环境变量 ${name}` }; + } + + // 2. 名字里带 commandcode 的环境变量(dsh 那套命名习惯)。 + for (const [name, value] of Object.entries(process.env)) { + if (!/commandcode/i.test(name) || typeof value !== 'string') continue; + if (KEY_SHAPE.test(value.trim())) return { apiKey: value.trim(), source: `环境变量 ${name}` }; + } + + // 3. 官方 command-code CLI 的登录态。 + const cliDoc = readJsonSafe(expand('~/.commandcode/auth.json')); + if (cliDoc && typeof cliDoc.apiKey === 'string' && cliDoc.apiKey.trim()) { + const who = cliDoc.userName ? `(${cliDoc.userName})` : ''; + return { apiKey: cliDoc.apiKey.trim(), source: `~/.commandcode/auth.json${who}` }; + } + + // 4. 各宿主里配过的 Command Code provider(JSON 配置)。 + for (const rel of JSON_CONFIGS) { + const abs = expand(rel); + const doc = readJsonSafe(abs); + const hit = doc ? scanForProviderKey(doc) : null; + if (hit) { + return { + apiKey: hit.apiKey, + apiBase: hit.baseUrl.replace(/\/provider\/v\d+\/?$/, ''), + source: `${rel} 里的 provider 配置`, + }; + } + } + + // 5. TOML / YAML 配置里的 provider 段,或者它引用的环境变量名。 + for (const rel of TEXT_CONFIGS) { + const hit = scanTextConfig(expand(rel)); + if (!hit) continue; + if (hit.apiKey) return { apiKey: hit.apiKey, source: `${rel} 里的 provider 配置` }; + const resolved = resolveEnvRef(hit.envName, home); + if (resolved) return { apiKey: resolved, source: `${rel} 指向的 ${hit.envName}` }; + } + + return null; +} + +function maskKey(key) { + if (!key) return '(none)'; + return `${key.slice(0, 8)}…${key.slice(-4)}`; +} + +/* -------------------------------------------------------------------- 取数 */ + +async function apiGet(baseUrl, apiKey, route, query = {}, timeoutMs = 15000) { + const url = new URL(route, baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`); + for (const [k, v] of Object.entries(query)) { + if (v !== undefined && v !== null && v !== '') url.searchParams.set(k, String(v)); + } + const ac = new AbortController(); + const timer = setTimeout(() => ac.abort(), timeoutMs); + try { + const res = await fetch(url, { + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + 'User-Agent': `zcode-cc-usage/${VERSION}`, + }, + signal: ac.signal, + }); + const text = await res.text(); + let body = null; + try { + body = JSON.parse(text); + } catch { + body = null; + } + if (!res.ok) { + const detail = body?.error?.message || body?.message || text.slice(0, 200) || res.statusText; + const err = new Error(`${route} → HTTP ${res.status}: ${detail}`); + err.status = res.status; + throw err; + } + if (!body || typeof body !== 'object') throw new Error(`${route} 返回了非 JSON 内容`); + return body; + } catch (err) { + if (err.name === 'AbortError') throw new Error(`${route} 请求超时(${timeoutMs}ms)`); + if (err instanceof TypeError) throw new Error(`${route} 网络不可达(${err.message})`); + throw err; + } finally { + clearTimeout(timer); + } +} + +/** 单项取数失败不致命:记下错误,其余数据照常展示。 */ +async function soft(promise, fallback = null) { + try { + return { data: await promise, error: null }; + } catch (err) { + return { data: fallback, error: err instanceof Error ? err.message : String(err) }; + } +} + +/** 同一原因命中多个端点时合并成一行,避免把同一句报错重复四遍。 */ +function groupErrors(list) { + const groups = new Map(); + for (const raw of list) { + if (!raw) continue; + const m = /^(\S+)\s*→\s*([\s\S]+)$/.exec(raw); + const route = m ? m[1] : null; + const rest = m ? m[2] : raw; + if (!groups.has(rest)) groups.set(rest, []); + if (route) groups.get(rest).push(route); + } + return [...groups].map(([rest, routes]) => + routes.length > 1 ? `${rest}(${routes.join('、')})` : routes.length === 1 ? `${routes[0]} → ${rest}` : rest, + ); +} + +export async function collectUsage({ apiKey, apiBase, orgId }) { + const base = (apiBase || DEFAULT_API_BASE).replace(/\/+$/, ''); + const scoped = orgId ? { orgId } : {}; + + const whoamiRes = await soft(apiGet(base, apiKey, '/alpha/whoami', { limits: '1' })); + const effectiveOrg = orgId || whoamiRes.data?.org?.id || undefined; + + const [creditsRes, subRes] = await Promise.all([ + soft(apiGet(base, apiKey, '/alpha/billing/credits', { orgId: effectiveOrg })), + soft(apiGet(base, apiKey, '/alpha/billing/subscriptions', { orgId: effectiveOrg })), + ]); + + const since = subRes.data?.data?.currentPeriodStart; + const summaryRes = await soft(apiGet(base, apiKey, '/alpha/usage/summary', { ...(effectiveOrg ? { orgId: effectiveOrg } : {}), since })); + + const errors = groupErrors([whoamiRes.error, creditsRes.error, subRes.error, summaryRes.error]); + if (!whoamiRes.data && !creditsRes.data && !subRes.data) { + throw new Error(`无法读取 Command Code 额度:\n - ${errors.join('\n - ')}`); + } + + return { + whoami: whoamiRes.data, + credits: creditsRes.data, + subscription: subRes.data, + summary: summaryRes.data, + errors, + }; +} + +/* -------------------------------------------------------------- 归一化视图 */ + +export function normalize(raw, meta) { + const now = meta.now ?? Date.now(); + const sub = raw.subscription?.data ?? null; + const plan = planInfo(sub?.planId); + const c = raw.credits?.credits ?? {}; + const wl = raw.credits?.windowLimits ?? null; + const summary = raw.summary ?? {}; + + const monthlyRemaining = Math.max(0, Number(c.monthlyCredits) || 0); + const purchasedRemaining = Math.max(0, Number(c.purchasedCredits) || 0); + const freeRemaining = Math.max(0, Number(c.freeCredits) || 0); + const totalRemaining = monthlyRemaining + purchasedRemaining + freeRemaining; + const totalSpent = Math.max(0, Number(summary.totalCost) || 0); + + // 与 Command Code CLI 的 projectUsageView 保持一致: + // 订阅有效时用套餐面额作分母,否则退回「已花 + 剩余」。 + const active = sub?.status === 'active'; + const planMonthly = active && plan ? plan.monthly : null; + const totalPool = planMonthly !== null ? Math.max(planMonthly, monthlyRemaining) + purchasedRemaining + freeRemaining : totalSpent + totalRemaining; + const monthlyUsed = Math.max(0, totalPool - totalRemaining); + + const window = (spec, fallbackCap) => { + if (!spec) return null; + const used = Math.max(0, Number(spec.used) || 0); + const cap = Number(spec.cap) || fallbackCap || 0; + const resetAt = Number(spec.resetAt) || null; + const started = used > 0 || (resetAt !== null && resetAt > now); + return { + used, + cap, + percent: cap > 0 ? Math.min((used / cap) * 100, 100) : 0, + rawPercent: cap > 0 ? (used / cap) * 100 : 0, + remaining: Math.max(0, cap - used), + resetAt, + resetsInMs: resetAt !== null ? Math.max(0, resetAt - now) : null, + exceeded: Boolean(spec.exceeded), + started, + }; + }; + + const periodEnd = sub?.currentPeriodEnd ? Date.parse(sub.currentPeriodEnd) : null; + const fiveHour = window(wl?.fiveHour, plan?.fiveHour); + const weekly = window(wl?.weekly, plan?.weekly); + + // 用户实际要回答的是两个问题:还能跑多少、会不会在重置前用完。 + // 次数只能按「他自己这个周期的均单价」估——Command Code 的额度单位是美元价值, + // 换模型就换单价,所以这里必须说明估算基准,不能当成承诺。 + const avgCostPerRequest = Number(summary.averageCost) > 0 ? Number(summary.averageCost) : null; + const requestsLeft = (usd) => + avgCostPerRequest && usd > 0 ? Math.floor(usd / avgCostPerRequest) : null; + + // 速度外推只在样本占窗口足够比例时才成立:刚开窗口时的一波用量代表不了整周, + // 拿 1 小时的速度去推 7 天只会误报「要超限了」。所以不足 5% 就不给结论。 + const pace = (w, windowMs, startMs) => { + if (!w || !w.started || !w.resetAt || !(windowMs > 0)) return null; + const startedAt = startMs ?? w.resetAt - windowMs; + const elapsed = now - startedAt; + const minSample = Math.max(10 * 60_000, windowMs * 0.05); + if (!(elapsed >= minSample)) return null; + const ratePerMs = w.used / elapsed; + if (!(ratePerMs > 0)) return null; + const remainingMs = Math.max(0, w.resetAt - now); + const projected = w.used + ratePerMs * remainingMs; + return { + sampleMs: elapsed, + ratePerHour: ratePerMs * 3600_000, + projected, + willExceed: projected > w.cap, + exhaustInMs: Math.max(0, (w.cap - w.used) / ratePerMs), + }; + }; + + const periodStartMs = sub?.currentPeriodStart ? Date.parse(sub.currentPeriodStart) : null; + const periodMs = periodStartMs && periodEnd ? periodEnd - periodStartMs : 0; + + return { + fetchedAt: now, + apiBase: meta.apiBase, + credentialSource: meta.credentialSource, + account: { + userName: raw.whoami?.user?.userName ?? null, + name: raw.whoami?.user?.name ?? null, + email: raw.whoami?.user?.email ?? null, + userId: raw.whoami?.user?.id ?? null, + org: raw.whoami?.org ?? null, + }, + orgLimits: Array.isArray(raw.whoami?.orgLimits) ? raw.whoami.orgLimits : [], + plan: plan + ? { + id: plan.id, + name: plan.name, + monthlyTotal: plan.monthly, + status: sub?.status ?? null, + active, + currentPeriodStart: sub?.currentPeriodStart ?? null, + currentPeriodEnd: sub?.currentPeriodEnd ?? null, + cancelAtPeriodEnd: Boolean(sub?.cancelAtPeriodEnd), + daysLeft: periodEnd !== null ? Math.max(0, Math.ceil((periodEnd - now) / 86400000)) : null, + } + : sub + ? { + // 套餐表里没有这个 planId(Command Code 新套餐 / 企业套餐): + // 不编月度面额,窗口 cap 仍以接口返回为准。 + id: sub.planId ?? 'unknown', + name: sub.planId ?? '未知套餐', + monthlyTotal: null, + status: sub.status ?? null, + active, + currentPeriodStart: sub.currentPeriodStart ?? null, + currentPeriodEnd: sub.currentPeriodEnd ?? null, + cancelAtPeriodEnd: Boolean(sub.cancelAtPeriodEnd), + daysLeft: periodEnd !== null ? Math.max(0, Math.ceil((periodEnd - now) / 86400000)) : null, + } + : null, + monthly: { + used: monthlyUsed, + total: totalPool, + remaining: totalRemaining, + planRemaining: monthlyRemaining, + purchasedRemaining, + freeRemaining, + percent: totalPool > 0 ? Math.min((monthlyUsed / totalPool) * 100, 100) : 0, + rawPercent: totalPool > 0 ? (monthlyUsed / totalPool) * 100 : 0, + belowThreshold: Boolean(c.belowThreshold), + creditThreshold: Number(c.creditThreshold) || 0, + }, + windows: { + limited: wl ? Boolean(wl.limited) : null, + fiveHour, + weekly, + }, + estimate: { + avgCostPerRequest, + requestsLeft: { + fiveHour: fiveHour ? requestsLeft(fiveHour.remaining) : null, + weekly: weekly ? requestsLeft(weekly.remaining) : null, + monthly: requestsLeft(totalRemaining), + }, + pace: { + fiveHour: pace(fiveHour, 5 * 3600_000), + weekly: pace(weekly, 7 * 86400_000), + monthly: + periodStartMs && periodMs > 0 + ? pace( + { started: true, used: monthlyUsed, cap: totalPool, resetAt: periodEnd }, + periodMs, + periodStartMs, + ) + : null, + }, + }, + summary: { + requests: Number(summary.totalCount) || 0, + completed: Number(summary.completedCount) || 0, + failed: Number(summary.failedCount) || 0, + successRate: summary.successRate === undefined ? null : Number(summary.successRate), + totalCost: totalSpent, + averageCost: Number(summary.averageCost) || 0, + tokensIn: Number(summary.totalTokensIn) || 0, + tokensOut: Number(summary.totalTokensOut) || 0, + tokens: Number(summary.totalTokens) || 0, + periodBasis: summary.periodBasis ?? null, + }, + errors: raw.errors ?? [], + // 原始响应一并带上:--json 的输出因此是自洽快照,可用 --from-json 离线重放。 + raw: { + whoami: raw.whoami ?? null, + credits: raw.credits ?? null, + subscription: raw.subscription ?? null, + summary: raw.summary ?? null, + }, + }; +} + +/* -------------------------------------------------------------- 格式化工具 */ + +function isWide(cp) { + return ( + cp >= 0x1100 && + (cp <= 0x115f || + cp === 0x2329 || + cp === 0x232a || + (cp >= 0x2e80 && cp <= 0xa4cf && cp !== 0x303f) || + (cp >= 0xac00 && cp <= 0xd7a3) || + (cp >= 0xf900 && cp <= 0xfaff) || + (cp >= 0xfe30 && cp <= 0xfe6f) || + (cp >= 0xff00 && cp <= 0xff60) || + (cp >= 0xffe0 && cp <= 0xffe6) || + (cp >= 0x20000 && cp <= 0x3fffd)) + ); +} + +function displayWidth(str) { + let w = 0; + for (const ch of String(str)) { + const cp = ch.codePointAt(0); + if (cp === 0x200d || (cp >= 0xfe00 && cp <= 0xfe0f) || cp === 0x20e3) continue; + w += isWide(cp) ? 2 : 1; + } + return w; +} + +function padEndW(str, width) { + const s = String(str); + const diff = width - displayWidth(s); + return diff > 0 ? s + ' '.repeat(diff) : s; +} + +function truncateW(str, width) { + let out = ''; + let w = 0; + for (const ch of String(str)) { + const cw = isWide(ch.codePointAt(0)) ? 2 : 1; + if (w + cw > width) break; + out += ch; + w += cw; + } + return out; +} + +const money = (n) => `$${(Number(n) || 0).toFixed(2)}`; +const money4 = (n) => `$${(Number(n) || 0).toFixed(4)}`; + +function tokens(n) { + const v = Number(n) || 0; + if (v >= 1e9) return `${(v / 1e9).toFixed(2)}B`; + if (v >= 1e6) return `${(v / 1e6).toFixed(1)}M`; + if (v >= 1e3) return `${(v / 1e3).toFixed(1)}K`; + return String(Math.round(v)); +} + +function duration(ms) { + if (ms === null || ms === undefined) return '—'; + const total = Math.max(0, Math.floor(ms / 1000)); + const d = Math.floor(total / 86400); + const h = Math.floor((total % 86400) / 3600); + const m = Math.floor((total % 3600) / 60); + const s = total % 60; + if (d > 0) return `${d}d ${h}h`; + if (h > 0) return `${h}h ${m}m`; + if (m > 0) return `${m}m ${s}s`; + return `${s}s`; +} + +function clock(ms) { + if (!ms) return '—'; + const d = new Date(ms); + const p = (n) => String(n).padStart(2, '0'); + return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`; +} + +function timeOnly(ms) { + if (!ms) return '—'; + const d = new Date(ms); + const p = (n) => String(n).padStart(2, '0'); + return `${p(d.getHours())}:${p(d.getMinutes())}`; +} + +function bar(percent, width = 24) { + const filled = Math.max(0, Math.min(width, Math.round((percent / 100) * width))); + return '█'.repeat(filled) + '░'.repeat(width - filled); +} + +function severity(p, exceeded) { + if (exceeded || p >= 100) return 'critical'; + if (p >= 80) return 'high'; + if (p >= 50) return 'medium'; + return 'low'; +} + +function makeColors(enabled) { + const wrap = (code) => (s) => (enabled ? `\x1b[${code}m${s}\x1b[0m` : String(s)); + return { + dim: wrap(2), + bold: wrap(1), + red: wrap(31), + green: wrap(32), + yellow: wrap(33), + blue: wrap(34), + magenta: wrap(35), + cyan: wrap(36), + gray: wrap(90), + }; +} + +function sevColor(c, level, text) { + if (level === 'critical') return c.red(c.bold(text)); + if (level === 'high') return c.red(text); + if (level === 'medium') return c.yellow(text); + return c.green(text); +} + +/* ------------------------------------------------------------ 终端渲染 */ + +/** 组织级消费限额的字段名没有公开 schema,只认能识别的形状,认不出就不显示(不编造)。 */ +function describeOrgLimit(entry) { + if (!entry || typeof entry !== 'object') return null; + const num = (...keys) => { + for (const k of keys) { + const v = Number(entry[k]); + if (Number.isFinite(v)) return v; + } + return null; + }; + const spent = num('spent', 'used', 'consumed', 'usedUsd', 'spentUsd'); + const limit = num('limit', 'cap', 'max', 'amountUsd', 'limitUsd', 'capUsd'); + if (spent === null || limit === null || limit <= 0) return null; + const resetAt = num('resetAt', 'resetsAt', 'periodEnd'); + // 模型维度的限额常带 "vendor:model" 前缀,显示时只留模型名。 + const rawLabel = String(entry.scope ?? entry.model ?? entry.name ?? entry.type ?? '组织限额'); + const label = rawLabel.includes(':') ? rawLabel.slice(rawLabel.lastIndexOf(':') + 1) : rawLabel; + return { + label, + spent, + limit, + percent: Math.min((spent / limit) * 100, 100), + resetAt: resetAt && resetAt > 1e11 ? resetAt : null, + }; +} + +/** 账号形态不同,同一行数据的叫法也该不同:订阅额度 / 推算额度 / 纯余额。 */ +function balancePresentation(view) { + const p = view.plan; + if (p?.active && p.monthlyTotal !== null && p.monthlyTotal !== undefined) { + return { label: '月度额度', showBar: true, note: '月度额度已用' }; + } + if (p?.active) { + // 订阅有效但套餐面额未知(新套餐/企业套餐),分母是推算出来的,明说。 + return { label: '额度', showBar: true, note: '总额度按「已花 + 剩余」推算,仅供参考' }; + } + // 没有有效订阅:这是余额,不是额度,不该套百分比。 + return { label: '余额', showBar: false, note: null }; +} + +const count = (n) => (n === null || n === undefined ? null : n.toLocaleString('en-US')); + +/** 「还能跑多少次」这一句的措辞要与估算基准绑定,换模型就不再成立。 */ +function estimateLine(view, monthlyLabel = '本月') { + const e = view.estimate; + if (!e?.avgCostPerRequest) return null; + const parts = []; + const add = (label, n) => { + if (n !== null && n !== undefined) parts.push(`${label} ≈ ${count(n)} 次`); + }; + add('5 小时窗口', e.requestsLeft.fiveHour); + add('本周', e.requestsLeft.weekly); + add(monthlyLabel, e.requestsLeft.monthly); + if (!parts.length) return null; + return `按本周期均单价 ${money4(e.avgCostPerRequest)} 估算还能跑:${parts.join(' · ')}`; +} + +/** 按当前消耗速度,这个窗口会不会在重置之前就撞上限。 */ +function paceWarning(label, w, p) { + if (!w || !p || !p.willExceed) return null; + return `⚠ 按当前速度(${money(p.ratePerHour)}/小时),${label}会在重置前用完,约 ${duration(p.exhaustInMs)} 后耗尽`; +} + +function renderTerminal(view, opts = {}) { + const c = makeColors(opts.color !== false); + const W = 74; + const lines = []; + + const planName = view.plan?.name ?? '按量计费'; + const stamp = clock(view.fetchedAt); + const days = view.plan?.daysLeft; + const stampFull = days !== null && days !== undefined ? `${stamp} · 周期剩 ${days} 天` : stamp; + const title = `Command Code · ${planName}`; + lines.push(`${c.bold(c.magenta(title))}${' '.repeat(Math.max(1, W - displayWidth(title) - displayWidth(stampFull)))}${c.gray(stampFull)}`); + lines.push(c.gray('─'.repeat(W))); + + const acct = [view.account.userName, view.account.email && `<${view.account.email}>`].filter(Boolean).join(' '); + lines.push(`${c.gray('账号')} ${acct || '—'}`); + if (view.plan && !view.plan.active) { + lines.push(`${c.gray('状态')} ${c.yellow(`订阅${view.plan.status ? ` ${view.plan.status}` : ''},额度按接口返回的窗口计算`)}`); + } + + lines.push(''); + + // 按量计费 / 企业池:没有滚动窗口,只有余额,不要硬套两个窗口行。 + const noWindows = view.windows.limited === false; + if (noWindows) { + lines.push(c.gray('该账号没有滚动窗口限制(按量计费或企业池),只看余额。')); + } + + const row = (label, w) => { + if (!w) return; + const level = severity(w.percent, w.exceeded); + lines.push( + `${c.bold(padEndW(label, 12))}${sevColor(c, level, bar(w.percent, 24))}${sevColor(c, level, `${w.percent.toFixed(1)}%`.padStart(7))} ${`${money(w.used)} / ${money(w.cap)}`.padStart(16)}`, + ); + let foot; + if (!w.started) foot = c.gray('窗口未开启(发起第一次请求后开始计时)'); + else foot = c.gray(`重置 ${timeOnly(w.resetAt)} · ${duration(w.resetsInMs)} 后`); + if (w.exceeded) foot += ' ' + c.red(c.bold('已超限')); + lines.push(`${' '.repeat(12)}${foot}`); + }; + + row('5 小时窗口', noWindows ? null : view.windows.fiveHour); + row('每周窗口', noWindows ? null : view.windows.weekly); + + const m = view.monthly; + const bp = balancePresentation(view); + const mLevel = severity(m.percent, m.belowThreshold); + if (bp.showBar) { + lines.push( + `${c.bold(padEndW(bp.label, 12))}${sevColor(c, mLevel, bar(m.percent, 24))}${sevColor(c, mLevel, `${m.percent.toFixed(1)}%`.padStart(7))} ${`${money(m.used)} / ${money(m.total)}`.padStart(16)}`, + ); + const mExtra = [`剩 ${money(m.remaining)}`]; + if (m.purchasedRemaining > 0) mExtra.push(`含额外额度 ${money(m.purchasedRemaining)}`); + if (m.freeRemaining > 0) mExtra.push(`赠额 ${money(m.freeRemaining)}`); + if (m.belowThreshold) mExtra.push(c.red('已低于预警阈值')); + lines.push(`${' '.repeat(12)}${c.gray(mExtra.join(' · '))}`); + } else { + lines.push(`${c.bold(padEndW(bp.label, 12))}${c.green(money(m.remaining))}`); + const mExtra = []; + if (m.purchasedRemaining > 0) mExtra.push(`额外额度 ${money(m.purchasedRemaining)}`); + if (m.freeRemaining > 0) mExtra.push(`赠额 ${money(m.freeRemaining)}`); + if (m.creditThreshold > 0) mExtra.push(`预警线 ${money(m.creditThreshold)}`); + if (m.belowThreshold) mExtra.push(c.red('已低于预警阈值')); + if (mExtra.length) lines.push(`${' '.repeat(12)}${c.gray(mExtra.join(' · '))}`); + } + if (bp.note && bp.showBar && view.plan?.monthlyTotal === null) { + lines.push(`${' '.repeat(12)}${c.gray(bp.note)}`); + } + + // 组织级消费限额:字段名无公开 schema,认不出的条目直接跳过。 + for (const raw of view.orgLimits ?? []) { + const l = describeOrgLimit(raw); + if (!l) continue; + const level = severity(l.percent, false); + lines.push( + `${c.bold(padEndW(truncateW(l.label, 14), 14))}${sevColor(c, level, bar(l.percent, 22))}${sevColor(c, level, `${l.percent.toFixed(1)}%`.padStart(7))} ${`${money(l.spent)} / ${money(l.limit)}`.padStart(16)}`, + ); + if (l.resetAt) lines.push(`${' '.repeat(14)}${c.gray(`重置 ${clock(l.resetAt)}`)}`); + } + + lines.push(''); + const s = view.summary; + if (s.requests > 0) { + const parts = [ + `${s.requests} 次请求`, + s.successRate !== null ? `成功率 ${s.successRate}%` : null, + `入 ${tokens(s.tokensIn)} / 出 ${tokens(s.tokensOut)} tokens`, + ].filter(Boolean); + lines.push(`${c.gray('本周期')} ${parts.join(' · ')}`); + } else { + lines.push(`${c.gray('本周期')} 暂无请求记录`); + } + + const est = estimateLine(view, bp.showBar ? '本月' : '余额'); + if (est) { + lines.push(`${c.gray('预估')} ${est}`); + lines.push(c.gray('(估算基于你本周期的实际模型组合;换更贵的模型,次数会明显变少)')); + } else if (s.requests === 0) { + lines.push(c.gray('预估 本周期还没有请求记录,暂时无法估算次数')); + } + + const warns = [ + paceWarning('5 小时窗口', view.windows.fiveHour, view.estimate?.pace?.fiveHour), + paceWarning('每周窗口', view.windows.weekly, view.estimate?.pace?.weekly), + ].filter(Boolean); + for (const w of warns) lines.push(c.yellow(w)); + + if (opts.verbose) { + lines.push(''); + lines.push(c.gray(`接口 ${view.apiBase} · 凭证来源 ${view.credentialSource}`)); + } + if (view.errors?.length) { + lines.push(''); + for (const e of view.errors) lines.push(c.yellow(`⚠ ${e}`)); + } + + return lines.join('\n'); +} + +/* --------------------------------------------------------- 聊天渲染(Markdown) */ + +function renderMarkdown(view) { + const out = []; + const planName = view.plan?.name ?? '按量计费'; + out.push(`**Command Code · ${planName}** ${clock(view.fetchedAt)}`); + + const meta = []; + if (view.account.userName) meta.push(`账号 \`${view.account.userName}\``); + if (view.plan?.id) meta.push(`套餐 \`${view.plan.id}\``); + if (view.plan?.daysLeft !== null && view.plan?.daysLeft !== undefined) meta.push(`周期剩 ${view.plan.daysLeft} 天`); + if (meta.length) out.push(meta.join(' · ')); + + const rows = []; + const push = (label, used, cap, percent, note) => { + rows.push(`| ${label} | ${percent.toFixed(1)}% | $${used.toFixed(2)} / $${cap.toFixed(2)} | ${note} |`); + }; + const noWindows = view.windows.limited === false; + const noteOf = (w) => (w.exceeded ? '**已超限**' : !w.started ? '未开启' : `${duration(w.resetsInMs)} 后重置`); + + if (!noWindows) { + const w5 = view.windows.fiveHour; + const wk = view.windows.weekly; + if (w5) push('5 小时窗口', w5.used, w5.cap, w5.percent, noteOf(w5)); + if (wk) push('每周窗口', wk.used, wk.cap, wk.percent, noteOf(wk)); + } + const m = view.monthly; + const bp = balancePresentation(view); + if (bp.showBar) { + push(bp.label, m.used, m.total, m.percent, `剩 $${m.remaining.toFixed(2)}${m.belowThreshold ? ' · **低于阈值**' : ''}`); + } else { + rows.push(`| ${bp.label} | — | $${m.remaining.toFixed(2)} | ${m.belowThreshold ? '**低于预警线**' : '可用'}${ + m.creditThreshold > 0 ? ` · 预警线 $${m.creditThreshold.toFixed(2)}` : '' + } |`); + } + for (const raw of view.orgLimits ?? []) { + const l = describeOrgLimit(raw); + if (!l) continue; + rows.push(`| ${l.label} | ${l.percent.toFixed(1)}% | $${l.spent.toFixed(2)} / $${l.limit.toFixed(2)} | ${l.resetAt ? `${clock(l.resetAt)} 重置` : '组织限额'} |`); + } + + out.push(''); + out.push('| 额度 | 已用 | 用量 | 说明 |'); + out.push('| --- | ---: | ---: | --- |'); + out.push(...rows); + + if (noWindows) out.push(''); + if (noWindows) out.push('_该账号没有滚动窗口限制(按量计费或企业池),只看余额。_'); + + const est = estimateLine(view, bp.showBar ? '本月' : '余额'); + if (est) { + out.push(''); + out.push(est); + out.push('_估算基于你本周期的实际模型组合;换更贵的模型,次数会明显变少。_'); + } + + const warns = [ + paceWarning('5 小时窗口', view.windows.fiveHour, view.estimate?.pace?.fiveHour), + paceWarning('每周窗口', view.windows.weekly, view.estimate?.pace?.weekly), + ].filter(Boolean); + for (const w of warns) { + out.push(''); + out.push(w); + } + + const s = view.summary; + if (s.requests > 0) { + out.push(''); + out.push( + `本周期 ${s.requests} 次请求 · 成功率 ${s.successRate ?? '—'}% · 入 ${tokens(s.tokensIn)} / 出 ${tokens(s.tokensOut)} tokens`, + ); + } else { + out.push(''); + out.push('_本周期还没有请求记录,暂时无法估算次数。_'); + } + if (view.errors?.length) { + out.push(''); + for (const e of view.errors) out.push(`⚠ ${e}`); + } + return out.join('\n'); +} + +function renderCompact(view) { + const w5 = view.windows.fiveHour; + const wk = view.windows.weekly; + const m = view.monthly; + const e = view.estimate; + const noWindows = view.windows.limited === false; + const bp = balancePresentation(view); + const planName = view.plan?.name ?? '按量计费'; + + const parts = [`CC ${planName}`]; + if (noWindows) { + const n = e?.requestsLeft?.monthly; + parts.push(`余额 ${money(m.remaining)}${n !== null && n !== undefined ? `(≈${count(n)} 次)` : ''}`); + } else { + if (w5) { + const n = e?.requestsLeft?.fiveHour; + parts.push(w5.started ? `5h ${w5.percent.toFixed(0)}%${n !== null && n !== undefined ? `(≈${count(n)} 次)` : ''}` : '5h 未开启'); + } + if (wk) parts.push(wk.started ? `周 ${wk.percent.toFixed(0)}%` : '周未开启'); + if (bp.showBar) parts.push(`月 ${m.percent.toFixed(1)}%`); + parts.push(`剩 ${money(m.remaining)}`); + if (wk?.started && wk.resetAt) parts.push(`周重置 ${duration(wk.resetsInMs)}后`); + } + + const warns = [ + paceWarning('5 小时窗口', w5, e?.pace?.fiveHour), + paceWarning('每周窗口', wk, e?.pace?.weekly), + ].filter(Boolean); + return parts.join(' · ') + (warns.length ? `\n${warns.join('\n')}` : ''); +} + +/* ------------------------------------------------------------- HTML 面板 */ + +function renderHtml(view, opts = {}) { + const live = Boolean(opts.live); + const snapshot = JSON.stringify(view).replace(/ + + + + +Command Code 额度面板 + + + +
+
+

Command Code · —

+
—
+
+
+ +
+
+
5 小时滚动窗口
+
+
—
—
+
+
—
+
+
+
每周滚动窗口
+
+
—
—
+
+
—
+
+
+
月度额度
+
+
—
—
+
+
—
+
+
+ +
+
+
本周期请求
—
+
成功率
—
+
输入 tokens
—
+
输出 tokens
—
+
平均单价
—
+
本周期已花
—
+
+
+
+ +
+ — + ${live ? '' : '静态快照 · 重新运行命令即可更新'} +
+
+ + + + +`; +} + +/* -------------------------------------------------------------------- demo */ + +// 不同套餐的形态差别很大(滚动窗口有没有、额度多大、按量计费还是包月), +// 所以样例数据也要按套餐给——没账号的人靠它预览自己那档长什么样。 +function demoView(scenario = 'normal') { + const base = goatDemo(scenario === 'hot' ? 'hot' : 'normal'); + + if (scenario === 'provider') { + // 按量计费:没有滚动窗口,只有余额。 + return { + ...base, + plan: { id: 'individual-provider', name: 'Provider', monthlyTotal: null, status: 'active', active: true, + currentPeriodStart: null, currentPeriodEnd: null, cancelAtPeriodEnd: false, daysLeft: null }, + windows: { limited: false, fiveHour: null, weekly: null }, + monthly: { used: 32.34, total: 80, remaining: 47.66, planRemaining: 0, + purchasedRemaining: 47.66, freeRemaining: 0, percent: 0, rawPercent: 0, + belowThreshold: false, creditThreshold: 0 }, + }; + } + + if (scenario === 'max') { + const plan = PLANS['individual-max-20x']; + return { + ...base, + plan: { ...base.plan, id: 'individual-max-20x', name: plan.name, monthlyTotal: plan.monthly }, + windows: { + limited: true, + fiveHour: { ...base.windows.fiveHour, cap: plan.fiveHour }, + weekly: { ...base.windows.weekly, cap: plan.weekly }, + }, + monthly: { ...base.monthly, total: plan.monthly, remaining: plan.monthly - base.monthly.used }, + }; + } + + return base; +} + +function goatDemo(scenario = 'normal') { + const now = Date.now(); + const hot = scenario === 'hot'; + const fiveReset = now + (3 * 3600 + 12 * 60) * 1000; + const weekReset = now + (6 * 86400 + 19 * 3600) * 1000; + // hot 场景:窗口已开 3 小时、速度足以在重置前撞限,用来预览告警长什么样 + const fiveUsed = hot ? 12.9 : 4.48; + const weekUsed = hot ? 30.2 : 14.35; + const avg = hot ? 0.039 : 0.0387; + return { + fetchedAt: now, + apiBase: DEFAULT_API_BASE, + credentialSource: `示例数据(--demo${hot ? ' hot' : ''})`, + account: { userName: 'demo-user', name: 'demo-user', email: 'demo@example.com', userId: 'demo', org: null }, + orgLimits: [], + plan: { + id: 'individual-goat', name: 'GOAT', monthlyTotal: 70, status: 'active', active: true, + currentPeriodStart: new Date(now - 86400000).toISOString(), + currentPeriodEnd: new Date(now + 29 * 86400000).toISOString(), + cancelAtPeriodEnd: false, daysLeft: 29, + }, + monthly: { + used: hot ? 41.6 : 12.4, total: 70, remaining: hot ? 28.4 : 57.6, + planRemaining: hot ? 28.4 : 57.6, purchasedRemaining: 0, freeRemaining: 0, + percent: hot ? 59.4 : 17.7, rawPercent: hot ? 59.4 : 17.7, + belowThreshold: false, creditThreshold: 0, + }, + windows: { + limited: true, + fiveHour: { + used: fiveUsed, cap: 14, percent: (fiveUsed / 14) * 100, rawPercent: (fiveUsed / 14) * 100, + remaining: Math.max(0, 14 - fiveUsed), resetAt: fiveReset, resetsInMs: fiveReset - now, + exceeded: false, started: true, + }, + weekly: { + used: weekUsed, cap: 35, percent: (weekUsed / 35) * 100, rawPercent: (weekUsed / 35) * 100, + remaining: Math.max(0, 35 - weekUsed), resetAt: weekReset, resetsInMs: weekReset - now, + exceeded: false, started: true, + }, + }, + estimate: { + avgCostPerRequest: avg, + requestsLeft: { + fiveHour: Math.floor(Math.max(0, 14 - fiveUsed) / avg), + weekly: Math.floor(Math.max(0, 35 - weekUsed) / avg), + monthly: Math.floor((hot ? 28.4 : 57.6) / avg), + }, + // 5h 窗口已开 3h(>5% 门槛),速度外推成立;周窗口刚开 3h(<8.4h 门槛)故意留空, + // 展示「样本不足就不给结论」的行为。 + pace: hot + ? { + fiveHour: { sampleMs: 3 * 3600_000, ratePerHour: fiveUsed / 3, projected: (fiveUsed / 3) * 5, willExceed: (fiveUsed / 3) * 5 > 14, exhaustInMs: ((14 - fiveUsed) / (fiveUsed / 3)) * 3600_000 }, + weekly: null, + monthly: null, + } + : { fiveHour: null, weekly: null, monthly: null }, + }, + summary: { + requests: hot ? 1067 : 312, completed: hot ? 1067 : 312, failed: 0, successRate: 100, + totalCost: hot ? 41.6 : 12.4, averageCost: avg, + tokensIn: hot ? 412_000_000 : 128_400_000, tokensOut: hot ? 4_100_000 : 1_240_000, + tokens: hot ? 416_100_000 : 129_640_000, periodBasis: 'billing-period', + }, + errors: [], + }; +} + +function openInBrowser(target) { + // 参数走 argv 数组,不拼 shell 命令串:target 是 URL/路径,插进命令串再交给 + // shell 就要靠引号转义,而引号转义永远比"不经过 shell"更容易出错。 + const [opener, args] = + process.platform === 'win32' + ? ['cmd', ['/c', 'start', '', target]] + : process.platform === 'darwin' + ? ['open', [target]] + : ['xdg-open', [target]]; + execFile(opener, args, (err) => { + if (err) console.error(`(自动打开失败,请手动打开:${target})`); + }); +} + +/* ------------------------------------------------------------------ serve */ + +/** + * --serve:把面板挂在一个只绑回环地址的小 HTTP 服务上,浏览器每 30s 拉一次。 + * 页面里的数字和命令面板完全同源(同一个 collectUsage → normalize → renderHtml), + * 所以这里不重复任何取数或渲染逻辑。 + */ +async function serve(creds, opts) { + // 惰性取 node:http:只有这条路径用得到,顶层 import 会让 --compact / --statusline + // 这些每轮都跑的模式一起付加载开销(见文件开头的说明)。 + const http = require('node:http'); + const port = opts.port; + let cache = null; + let cacheAt = 0; + + async function current() { + if (cache && Date.now() - cacheAt < 15000) return cache; + const raw = await collectUsage({ apiKey: creds.apiKey, apiBase: creds.apiBase, orgId: opts.org }); + cache = normalize(raw, { now: Date.now(), apiBase: creds.apiBase, credentialSource: creds.source }); + cacheAt = Date.now(); + return cache; + } + + const server = http.createServer(async (req, res) => { + const url = new URL(req.url, 'http://localhost'); + if (url.pathname === '/api/usage') { + try { + const view = await current(); + res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' }); + res.end(JSON.stringify(view)); + } catch (err) { + res.writeHead(502, { 'Content-Type': 'application/json; charset=utf-8' }); + res.end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) })); + } + return; + } + if (url.pathname === '/' || url.pathname === '/index.html') { + try { + const view = await current(); + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' }); + res.end(renderHtml(view, { live: true })); + } catch (err) { + res.writeHead(502, { 'Content-Type': 'text/html; charset=utf-8' }); + res.end(`
读取失败:\n${String(err instanceof Error ? err.message : err)}
`); + } + return; + } + res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }); + res.end('Not found'); + }); + + server.listen(port, '127.0.0.1', () => { + const url = `http://127.0.0.1:${port}/`; + console.log(`Command Code 额度面板已启动:${url}`); + console.log('浏览器每 30 秒自动刷新一次;按 Ctrl+C 停止。'); + if (opts.open) openInBrowser(url); + }); + return server; +} + +/* ------------------------------------------------------------------- main */ + +/** + * 供别的适配器(dsh 的卡片、其它插件)直接调用的一步到位入口。 + * 不导出这个的话,每个适配器都要自己拼 resolveCredentials → collectUsage → normalize, + * 而凭证解析正是最不该各写一份的那部分。 + */ +export async function fetchView({ orgId } = {}) { + const creds = resolveCredentials(); + if (!creds) throw new Error('找不到 Command Code 凭证'); + const raw = await collectUsage({ apiKey: creds.apiKey, apiBase: creds.apiBase, orgId }); + const view = normalize(raw, { + now: Date.now(), + apiBase: creds.apiBase || DEFAULT_API_BASE, + credentialSource: creds.source, + }); + return { view, creds, digest: keyDigest(creds.apiKey) }; +} + +async function main() { + const opts = parseArgs(process.argv.slice(2)); + if (opts.help) { + console.log(HELP); + return; + } + + if (opts.demo) { + const view = demoView(opts.demoScenario); + return emit(view, opts); + } + + // 离线渲染:既接受 --json 存下来的自洽快照(含 raw),也接受原始四段响应。 + if (opts.fromJson) { + let doc; + try { + doc = JSON.parse(fs.readFileSync(path.resolve(opts.fromJson), 'utf8')); + } catch (err) { + console.error(`读取快照失败:${err instanceof Error ? err.message : String(err)}`); + process.exitCode = 2; + return; + } + const raw = doc && typeof doc.raw === 'object' && doc.raw !== null ? doc.raw : doc; + const view = normalize(raw, { + now: opts.at ? Date.parse(opts.at) : Date.now(), + apiBase: raw.__apiBase || DEFAULT_API_BASE, + credentialSource: `快照 ${opts.fromJson}`, + }); + return emit(view, opts); + } + + const creds = resolveCredentials(); + if (!creds) { + // 状态栏是把错误写在用户脸上最烦的一类插件,没有凭证就安静退场。 + if (opts.mode === 'statusline') return; + console.error( + [ + '找不到 Command Code 凭证。按下列任一方式提供:', + ' 1. 设置环境变量 COMMAND_CODE_API_KEY', + ' 2. 用 Command Code CLI 登录,生成 ~/.commandcode/auth.json', + ' 3. 在 ZCode 里配置 baseUrl 含 commandcode.ai 的 provider', + ].join('\n'), + ); + process.exitCode = 2; + return; + } + + if (opts.verbose) { + console.error(`凭证来源:${creds.source}(${maskKey(creds.apiKey)})`); + console.error(`接口地址:${creds.apiBase || DEFAULT_API_BASE}`); + } + + // 状态栏那侧起的后台进程,只负责刷新缓存,不产出任何输出。 + if (opts.refreshCache) { + try { + const fresh = await collectUsage({ apiKey: creds.apiKey, apiBase: creds.apiBase, orgId: opts.org }); + const view = normalize(fresh, { now: Date.now(), apiBase: creds.apiBase || DEFAULT_API_BASE, credentialSource: creds.source }); + writeCache(view, keyDigest(creds.apiKey)); + } catch { + // 后台补数失败就继续用旧快照;刷新失败不该让用户看到错误。 + } + return; + } + + if (opts.mode === 'statusline' || opts.hook) { + return statuslineMode(creds, opts); + } + + const raw = await collectUsage({ apiKey: creds.apiKey, apiBase: creds.apiBase, orgId: opts.org }); + const view = normalize(raw, { + now: Date.now(), + apiBase: creds.apiBase || DEFAULT_API_BASE, + credentialSource: creds.source, + }); + + if (opts.mode === 'serve') { + await serve(creds, opts); + return; + } + + if (opts.mode === 'watch') { + const tick = () => { + const r = collectUsage({ apiKey: creds.apiKey, apiBase: creds.apiBase, orgId: opts.org }); + r.then((fresh) => { + const v = normalize(fresh, { now: Date.now(), apiBase: creds.apiBase || DEFAULT_API_BASE, credentialSource: creds.source }); + if (opts.color) process.stdout.write('\x1b[2J\x1b[H'); + console.log(renderTerminal(v, opts)); + console.log(makeColors(opts.color).gray(`每 60s 刷新 · Ctrl+C 退出 · ${clock(Date.now())}`)); + }).catch((err) => console.error(String(err instanceof Error ? err.message : err))); + }; + tick(); + setInterval(tick, 60000); + return; + } + + return emit(view, opts); +} + +/* ------------------------------------- 这一轮到底走没走 Command Code */ + +// 背景:Claude Code 给 statusLine 的 JSON 里**没有** provider / base_url——唯一沾边的 +// model.id 还只是本地别名(走 cc-switch 这类本地路由时,实测 model.id 是 +// "claude-opus-5[1M]",而请求实际打到了 "deepseek/deepseek-v4.1-flash")。 +// +// 判据按可靠性排: +// 1. 环境变量里的路由映射(upstreamFromEnvAlias)。本地路由会把 +// ANTHROPIC_DEFAULT_OPUS_MODEL 和 ..._MODEL_NAME 成对设好,直接换算即可。 +// 这是路由自己的配置,不是推测。 +// 2. transcript 里每条 assistant 消息的 message.model——上游真实回报的模型名, +// transcript_path 由宿主通过 stdin 传来。 +// 3. 都没有就返回 unknown,退回"账号用量还动不动"的兜底判断。 +// +// 拿到真实模型名后,去对照 Command Code 公开的模型目录(/provider/v1/models,免鉴权)。 +// 实测精确命中率只有约 36%("K2.7 Code" 对不上目录里的 "moonshotai/Kimi-K2.7-Code"), +// 所以匹配不上时不猜——留给用户用 --model 补自己的别名。 +// +// 这套判据回答的是"这一轮在不在用它"(逐轮)。之前只看"账号用量有没有增长", +// 那是账号级的:你在 dsh 或另一台机器上用 Command Code,也会让数字增长, +// 于是这个会话明明没用它、状态栏却还挂着。 + +/* ------------------------------------------- 状态栏模式(宿主本地跑,零 token)*/ + +// 状态栏脚本会被宿主高频重跑(Claude Code 是每条助手消息一次),而一次取数要打 +// 四个接口。所以一律走磁盘缓存: +// 命中且新鲜 → 直接出图,几毫秒 +// 命中但过期 → 先出旧图,同时后台补一次,下次调用就是新的 +// 没有缓存 → 阻塞取一次(只在首次) +const CACHE_DIR = path.join(os.homedir(), '.commandcode-usage'); +const CACHE_FILE = path.join(CACHE_DIR, 'last-report.json'); + +/** 只存摘要,用来判断快照是不是当前这个账号的——不存 key 本身。 */ +export function keyDigest(key) { + const text = String(key); + // 非加密用途:只用来判断缓存是不是当前这个账号的。32 位 FNV-1a 足够 + // (不同 key 撞车概率约十亿分之一),换来的是不用加载 node:crypto。 + let h = 0x811c9dc5; + for (let i = 0; i < text.length; i++) { + h ^= text.charCodeAt(i); + h = Math.imul(h, 0x01000193) >>> 0; + } + return `${text.length.toString(36)}-${h.toString(36)}`; +} + +function readCache() { + const doc = readJsonSafe(CACHE_FILE); + if (!doc || typeof doc !== 'object' || !('savedAt' in doc)) return null; + // view 可以为 null —— 那是一条"上次取数失败"的退避记录,不是"没有缓存"。 + return { + view: doc.view ?? null, + digest: doc.digest, + lastActiveAt: typeof doc.lastActiveAt === 'number' ? doc.lastActiveAt : null, + age: Math.max(0, Date.now() - (Number(doc.savedAt) || 0)), + }; +} + +// 取数失败(Go 套餐没有 API 权限、断网、key 失效)时的退避时长。 +// 不退避的话状态栏每轮都会打四个接口然后失败,既慢又在刷日志。 +const FAIL_BACKOFF_MS = 300_000; + +// 「这个账号最近一次真的在跑」是什么时候。 +// 判断依据是接口报的请求数有没有变化——用户切走之后,这个数字就不再动了。 +// 注意这是**账号级**的兜底判据,不是主判据:你在别的机器/别的宿主上用它, +// 这里的数字照样在涨。主判据见上面的 routeDecision。 +function activityOf(view) { + const prev = readCache(); + const prevReq = prev?.view?.summary?.requests; + const nowReq = view?.summary?.requests; + const hasPrev = typeof prevReq === 'number'; + // 用"变了没"而不是"涨了没":跨计费周期时计数会归零,那同样说明账号在被使用。 + const changed = hasPrev && typeof nowReq === 'number' && nowReq !== prevReq; + // 第一次拿到数据时无从比较,当成"在用",否则刚装完会一直不显示。 + const lastActiveAt = !hasPrev || changed ? Date.now() : (prev?.lastActiveAt ?? Date.now()); + return { lastActiveAt, requests: typeof nowReq === 'number' ? nowReq : null }; +} + +function writeCache(view, digest) { + try { + fs.mkdirSync(CACHE_DIR, { recursive: true }); + const doc = view + ? { savedAt: Date.now(), digest, view, ...activityOf(view) } + : { savedAt: Date.now(), digest, view: null, lastActiveAt: readCache()?.lastActiveAt ?? null }; + fs.writeFileSync(CACHE_FILE, JSON.stringify(doc), 'utf8'); + } catch { + // 缓存写不进去不该让状态栏报错,静默降级为「每次都现取」。 + } +} + +/** 后台补一次数;不阻塞本次输出,失败也无所谓。 */ +function refreshInBackground(opts) { + const args = [process.argv[1], '--refresh-cache']; + if (opts.org) args.push('--org', opts.org); + try { + const child = spawn(process.execPath, args, { detached: true, stdio: 'ignore', windowsHide: true }); + child.unref(); + } catch { + // 起不来就下次再说。 + } +} + +const CATALOG_TTL_MS = 24 * 3600_000; +// 惰性求值:CACHE_DIR 在下面的状态栏小节里才定义,顶层直接算会踩暂时性死区。 +const catalogFile = () => path.join(CACHE_DIR, 'models.json'); + +/** 统一大小写、去掉 vendor 前缀和 [1M] 这类上下文后缀,便于比对。 */ +export function normalizeModel(name) { + return String(name || '') + .toLowerCase() + .replace(/\[[^\]]*\]\s*$/, '') + .replace(/^[a-z0-9._-]+\//, '') + .replace(/[\s_]+/g, '-') + .replace(/-+/g, '-') + .trim(); +} + +function readCatalog() { + const doc = readJsonSafe(catalogFile()); + if (!doc || !Array.isArray(doc.ids) || !doc.ids.length) return null; + if (Date.now() - (Number(doc.savedAt) || 0) > CATALOG_TTL_MS) return null; + return doc.ids; +} + +/** 模型表很少变,缓存一天;拉不到就沿用旧的。 */ +async function ensureCatalog(apiBase) { + if (readCatalog()) return; + try { + const res = await apiGet(apiBase, undefined, '/provider/v1/models', {}, 8000); + const list = res?.data ?? res?.models ?? (Array.isArray(res) ? res : null); + if (!Array.isArray(list)) return; + const ids = list.map((m) => normalizeModel(m?.id)).filter(Boolean); + if (!ids.length) return; + fs.mkdirSync(CACHE_DIR, { recursive: true }); + fs.writeFileSync(catalogFile(), JSON.stringify({ savedAt: Date.now(), ids }), 'utf8'); + } catch { + // 拉不到就算了——判定会退回"未知",再退回用量活跃度。 + } +} + +/** + * 读 stdin 上的 JSON,但**绝不阻塞**。 + * + * 不能用 readFileSync(0):宿主不喂 stdin 时(手动在终端跑、或某些宿主不传数据) + * 它会一直等到 EOF,而那个 EOF 永远不来——整个状态栏就卡死在那儿。 + * 所以限定一个很短的窗口,拿不到就当没有。 + */ +function readStdinJson(timeoutMs = 150) { + return new Promise((resolve) => { + if (process.stdin.isTTY) return resolve(null); + let settled = false; + let raw = ''; + const settle = (value) => { + if (settled) return; + settled = true; + clearTimeout(timer); + // 松手:剩下的数据不重要了,别让这个句柄吊着进程不退出。 + try { process.stdin.pause(); } catch { /* 已经关了 */ } + resolve(value); + }; + const timer = setTimeout(() => settle(null), timeoutMs); + try { + process.stdin.setEncoding('utf8'); + process.stdin.on('data', (chunk) => { raw += chunk; }); + process.stdin.on('end', () => { + try { settle(raw.trim() ? JSON.parse(raw) : null); } catch { settle(null); } + }); + process.stdin.on('error', () => settle(null)); + process.stdin.resume(); + } catch { + settle(null); + } + }); +} + +/** 从 transcript 尾部找这一轮真实用的模型名;只读尾部 128KB,不整文件扫。 */ +function lastUsedModel(transcriptPath) { + if (!transcriptPath) return null; + let fd; + try { + const size = fs.statSync(transcriptPath).size; + const start = Math.max(0, size - 128 * 1024); + fd = fs.openSync(transcriptPath, 'r'); + const buf = Buffer.alloc(size - start); + fs.readSync(fd, buf, 0, buf.length, start); + const lines = buf.toString('utf8').split('\n'); + // 从后往前:跳过子代理(isSidechain)和 这类占位。 + for (let i = lines.length - 1; i >= 0; i--) { + const line = lines[i]; + // 快速跳过不可能含模型名的行(避免每行都 JSON.parse)。 + // 注意要同时认 "message" 和 "modelId"——Grok 用的是后者。 + if (!line || !/"message"|"modelId"/.test(line)) continue; + let doc; + try { doc = JSON.parse(line); } catch { continue; } + if (doc?.isSidechain === true) continue; + // 各宿主的字段名不同: + // Claude Code —— 消息体里的 message.model + // Grok Build —— updates.jsonl 每条 update 顶层的 modelId + const m = doc?.message?.model ?? doc?.modelId ?? null; + if (typeof m === 'string' && m && !m.startsWith('<')) return m; + } + } catch { + // 读不到(路径不存在 / 权限 / 格式变了)就当未知。 + } finally { + if (fd !== undefined) { try { fs.closeSync(fd); } catch { /* 已经关了 */ } } + } + return null; +} + +/** + * cc-switch 这类"本地路由"会把模型名重映射。它们成对写进 settings.json 的 env: + * ANTHROPIC_DEFAULT_OPUS_MODEL = claude-opus-5[1M] ← Claude Code 看到的假名 + * ANTHROPIC_DEFAULT_OPUS_MODEL_NAME = deepseek/deepseek-v4.1-flash ← 真实上游 + * 这两个环境变量 statusLine 子进程能继承到,于是可以把假名直接换算成真名—— + * 不用去读 transcript,不用猜,也不用等网络。 + * + * 拿 stdin 里的 model.id 去反查:哪个 *_MODEL 的值等于它,就取配对的 *_NAME。 + * 对不上说明这个会话没走本地路由(或用的就是真名),返回 null 由后面的判据接手。 + */ +function upstreamFromEnvAlias(localId, env = process.env) { + if (!localId) return null; + const want = normalizeModel(localId); + for (const [name, value] of Object.entries(env)) { + if (!/^ANTHROPIC_DEFAULT_[A-Z0-9_]+_MODEL$/.test(name)) continue; + if (normalizeModel(value) !== want) continue; + const real = env[`${name}_NAME`]; + if (real && real.trim()) return real.trim(); + } + return null; +} + +/** + * 纯判定:给一个真实模型名和一份模型目录,说这一轮在不在用 Command Code。 + * + * 抽成纯函数是为了能脱离网络和凭证测试——routeDecision 的输入要靠取数才拿得到, + * 而 CI 上既没有凭证也不该联网。 + */ +export function decideRoute(used, catalog, { modelPatterns = [], trustedSource = false } = {}) { + if (!used) return 'unknown'; + const norm = normalizeModel(used); + + // 用户显式给了别名就用用户的——自动匹配不可能覆盖所有命名习惯。 + if (modelPatterns.length) { + return modelPatterns.some((p) => norm.includes(p)) ? 'yes' : 'no'; + } + + if (!Array.isArray(catalog) || !catalog.length) return 'unknown'; + if (!catalog.includes(norm)) return 'no'; + // 走本地路由时拿到的是路由配的真实上游,比 transcript 更可信, + // 所以不做下面那条规避——那不是名字,是路由配置。 + if (trustedSource) return 'yes'; + // claude-* 这类名字 Command Code 目录里有,但原生 Anthropic 也叫这个名, + // 光凭名字分不出路由到哪——不猜,退回下一级判据。 + if (/^claude-/.test(norm)) return 'unknown'; + return 'yes'; +} + +/** + * 这一轮在不在用 Command Code。 + * 'yes' 确定在用 / 'no' 确定没用 / 'unknown' 拿不到证据(退回用量活跃度判断)。 + */ +export function routeDecision(stdinDoc, opts = {}) { + // 目录可注入:CI 上没有缓存文件也没有网络,不注入就只能测到"未知"那条路。 + const catalog = opts.catalog ?? readCatalog(); + const ask = (used, trusted) => ({ + decision: decideRoute(used, catalog, { modelPatterns: opts.modelPatterns, trustedSource: trusted }), + used, + source: trusted ? '路由映射' : '模型名', + }); + + // 1) 本地路由(cc-switch 之类)把映射写在环境变量里,换算一下就知道真实上游是谁。 + // 这条最硬——它就是路由本身配的东西,不是推测。 + const aliasUpstream = upstreamFromEnvAlias(stdinDoc?.model?.id, opts.env ?? process.env); + if (aliasUpstream) return ask(aliasUpstream, true); + + // 2) 宿主直接给的模型名。 + // Codex 的钩子把 model 作为**字符串**放在 stdin 里(实测 "gpt-5.6-terra"), + // 而它的 transcript_path 是空的——所以这条对 Codex 是必需的,光靠 transcript 会永远判成未知。 + // Claude Code 那边 model 是对象,不会走到这里。 + const direct = typeof stdinDoc?.model === 'string' && stdinDoc.model.trim() ? stdinDoc.model.trim() : null; + if (direct) return ask(direct, false); + + // 3) 会话记录里这一轮真实用的模型。 + const fromTranscript = lastUsedModel(stdinDoc?.transcript_path); + if (!fromTranscript) return { decision: 'unknown', used: null, source: null }; + return ask(fromTranscript, false); +} + + + +// 进度条沿用用户自己 statusline.mjs 的视觉语言:填充上色、空白压暗、八分之一块做 +// 半格精度。空白如果也上亮色,低百分比时整条就是一片噪点——这是之前最丑的地方。 +const BAR_CELLS = 10; +const EIGHTHS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉']; + +function statusBar(pct, c, level, cells = BAR_CELLS) { + const exact = (Math.min(Math.max(pct, 0), 100) / 100) * cells; + let full = Math.floor(exact); + let rem = Math.round((exact - full) * 8); + if (rem >= 8) { full += 1; rem = 0; } + const filled = '█'.repeat(full) + (rem > 0 ? EIGHTHS[rem] : ''); + const empty = '░'.repeat(Math.max(0, cells - full - (rem > 0 ? 1 : 0))); + return `${sevColor(c, level, filled)}${c.dim(empty)}`; +} + +/** 「还有多久重置」比秒级精度重要。跨天的直接给日期——"29d21h" 远不如 "09-25" 好读。 */ +function resetText(w) { + if (!w?.resetAt) return null; + const ms = w.resetsInMs ?? Math.max(0, w.resetAt - Date.now()); + if (ms >= 86400_000) return `${clock(w.resetAt).slice(0, 5)}重置`; + const t = Math.max(0, Math.floor(ms / 1000)); + const h = Math.floor(t / 3600); + const m = Math.floor((t % 3600) / 60); + const body = h > 0 ? `${h}h${m > 0 ? `${m}m` : ''}` : m > 0 ? `${m}m` : `${t}s`; + return `${body}后重置`; +} + +/** + * 状态栏与钩子共用的输出。 + * + * 钩子要的是 stdout 上的 JSON,而且必须单行、无 ANSI——systemMessage 是纯文本, + * 带上转义码会原样显示成乱码。 + * + * 抽出来是因为 --demo 走的是 emit()、不走 statuslineMode():之前钩子分支只写在 + * statuslineMode() 里,于是 `--hook --demo` 会掉进终端面板那条路。 + */ +function emitStatus(view, opts, extra = {}) { + const text = renderStatusline(view, { + ...opts, + ...extra, + rows: opts.hook ? 1 : opts.rows, + color: opts.hook ? false : opts.color, + }); + if (!text) return; + + if (opts.hook) { + // Codex 的钩子协议:用 systemMessage 而不是 additionalContext——前者只显示给用户看, + // 不进模型上下文,所以每轮弹一次也不烧 token。 + process.stdout.write(JSON.stringify({ systemMessage: text }) + LF); + } else { + process.stdout.write(text + LF); + } +} + +function renderStatusline(view, opts = {}) { + const c = makeColors(opts.color !== false); + const w5 = view.windows?.fiveHour ?? null; + const wk = view.windows?.weekly ?? null; + const limited = view.windows?.limited !== false; + // 余额不受 limited 影响:Provider 这种按量计费套餐没有滚动窗口,但余额仍要看。 + const m = view.monthly ?? null; + + // 阈值模式:给 Codex 这类只能弹一行警告、弹了就要占屏幕的宿主用。 + // 没过线就一个字都不输出,平时完全安静。 + if (opts.threshold !== null && opts.threshold !== undefined) { + const peak = [w5, wk, m] + .filter(Boolean) + .reduce((acc, w) => Math.max(acc, w.rawPercent ?? w.percent ?? 0), 0); + if (!(peak >= opts.threshold)) return ''; + } + + const cols = Number(process.env.COLUMNS) || 80; + const barW = Math.max(6, Math.min(12, cols - 44)); + const planName = view.plan?.name ?? '按量计费'; + + const periodEnd = view.plan?.currentPeriodEnd ? Date.parse(view.plan.currentPeriodEnd) : null; + const monthAsWindow = m + ? { + started: true, + percent: m.percent, + rawPercent: m.rawPercent, + exceeded: m.rawPercent >= 100, + resetAt: periodEnd, + resetsInMs: periodEnd !== null ? Math.max(0, periodEnd - Date.now()) : null, + } + : null; + + // 状态栏是余光扫的东西,50% 就报黄会让人麻木——跟 dsh 插件一样按 60/85 分档。 + const levelFor = (p, exceeded) => (exceeded || p >= 100 ? 'critical' : p >= 85 ? 'high' : p >= 60 ? 'medium' : 'low'); + + // 单行模式:跟用户自己那个状态栏脚本同一套视觉语言——进度条 + `│` 分隔。 + // 每条窗口都带自己的重置时间;金额只给月度(5 小时和每周是"过/不过"的闸门,不是预算)。 + if (opts.rows === 1) { + // 按量计费 / Enterprise:没有滚动窗口,能看的就是余额本身。 + if (!limited) { + return m ? `${c.bold(`CC ${planName}`)} ${c.gray('│')} 余额 ${c.bold(money(m.remaining))}` : ''; + } + const win = [['5h', w5], ['周', wk], ['月', monthAsWindow]].filter(([, w]) => w?.started); + if (!win.length) return ''; + + const SEP = ' │ '; + const stripped = (x) => String(x).replace(/\[[0-9;]*m/g, ''); + const wOf = (x) => displayWidth(stripped(x)); + // 顺序固定(5h→周→月),不按松紧排:固定位置才不用每次重新找。 + const tightLabel = win.reduce((a, b) => ((b[1].rawPercent ?? 0) > (a[1].rawPercent ?? 0) ? b : a))[0]; + + const build = (barW, resetsFor, showMoney) => { + const segs = win.map(([label, w]) => { + const real = w.rawPercent ?? w.percent ?? 0; + const lvl = levelFor(real, w.exceeded); + const inner = []; + if (barW > 0) inner.push(statusBar(real, c, lvl, barW)); + inner.push(sevColor(c, lvl, `${Math.round(real)}%`)); + // 这条 bar 说的是「已用多少」,金额是「还剩多少」——两个方向,不加标签就会被读成一回事。 + if (showMoney && w === monthAsWindow && m) inner.push(c.gray(`剩${money(m.remaining)}`)); + if (resetsFor === 'all' || label === tightLabel) { + const r = resetText(w); + if (r) inner.push(c.gray(r)); + } + return `${c.gray(label)} ${inner.join(' ')}`; + }); + const head = `${c.bold(`CC ${planName}`)} ${c.gray('│')} `; + const text = `${head}${segs.join(c.gray(SEP))}`; + return { text, width: wOf(head) + segs.reduce((n, x) => n + wOf(x), 0) + SEP.length * (segs.length - 1) }; + }; + + // 宽度不够就按优先级往下砍:先缩条 → 去掉条 → 只留最紧那条的重置 → 去掉金额。 + // 宁可少显示几项,也不能折行——折行会让整个底部错位,比少一个数字难看得多。 + for (const [barW, resetsFor, showMoney] of [ + [10, 'all', true], + [8, 'all', true], + [6, 'all', true], + [0, 'all', true], + [0, 'tight', true], + [0, 'tight', false], + ]) { + const built = build(barW, resetsFor, showMoney); + if (built.width <= cols) return built.text; + } + return build(0, 'tight', false).text; + } + + // 三条窗口按「最紧的排最上面」——5 小时最先拦住你,所以它第一行。 + const rows = []; + const pushRow = (label, w, money, reset) => { + if (!w) return; + if (!w.started) { + rows.push([label, c.gray('未开启')]); + return; + } + const shown = Math.max(0, Math.min(100, w.percent)); + const real = w.rawPercent ?? shown; + const lvl = levelFor(real, w.exceeded); + const parts = [statusBar(shown, c, lvl, barW), sevColor(c, lvl, `${String(Math.round(real)).padStart(3)}%`)]; + if (money) parts.push(c.gray(money)); + if (reset) parts.push(c.gray(reset)); + rows.push([label, parts.join(' ')]); + }; + + if (!limited) { + return m ? `${c.bold(`CC ${planName}`)} ${c.gray('│')} 余额 ${c.bold(money(m.remaining))}` : ''; + } + pushRow('5h', w5, null, resetText(w5)); + pushRow('周', wk, null, resetText(wk)); + // 三行模式同理:bar 是「用了多少」,金额是「还剩多少」,标签放在决定数值的地方。 + pushRow('月', monthAsWindow, m ? `剩${money(m.remaining)}` : null, resetText(monthAsWindow)); + + if (!rows.length) return ''; + + // 第一行带套餐名,后两行留白对齐。 + const head = c.bold(`CC ${planName}`); + const pad = ' '.repeat(displayWidth(`CC ${planName}`) + 1); + const labelW = Math.max(...rows.map((r) => displayWidth(r[0]))); + + const lines = rows.map(([label, body], i) => { + const lead = i === 0 ? `${head} ` : pad; + return `${lead}${c.gray(padEndW(label, labelW))} ${body}`; + }); + + // 快照年龄只在「明显过期」时才标:几十秒的陈旧对额度这种量级没有意义, + // 每帧都挂个 ⟳12s 只会变成噪音。阈值取 max(cacheTtl, 3 分钟):TTL 调大时标记 + // 跟着一起走,TTL 调小时也不会退化成每帧都挂。 + const staleMarkMs = Math.max(Number(opts.cacheTtl) || 0, 180_000); + if (opts.stale && typeof opts.ageMs === 'number' && opts.ageMs > staleMarkMs) { + lines[lines.length - 1] += c.gray(` ⟳${Math.round(opts.ageMs / 1000)}s`); + } + return lines.join('\n'); +} + +// 换行符显式构造,避免多层引号里的转义歧义。 +const LF = String.fromCharCode(10); + +async function statuslineMode(creds, opts) { + if (!creds) return; // 没配 Command Code 就完全不占位置,这是状态栏该有的礼貌。 + + const stdinDoc = await readStdinJson(); + const digest = keyDigest(creds.apiKey); + const cached = opts.noCache ? null : readCache(); + // 快照属于另一个账号就作废,免得换了 key 还显示旧账号的数。 + const usable = cached && cached.digest === digest ? cached : null; + + let view = usable?.view ?? null; + let stale = false; + + if (!view) { + // 上次取数失败且还在退避窗口内:这轮什么都不显示,别再去撞一次。 + if (usable && usable.view === null && usable.age < FAIL_BACKOFF_MS) return; + try { + // 模型目录和用量一起取:判定"这一轮在不在用它"要用到目录。 + const [raw] = await Promise.all([ + collectUsage({ apiKey: creds.apiKey, apiBase: creds.apiBase, orgId: opts.org }), + ensureCatalog(creds.apiBase || DEFAULT_API_BASE), + ]); + view = normalize(raw, { now: Date.now(), apiBase: creds.apiBase || DEFAULT_API_BASE, credentialSource: creds.source }); + if (!opts.noCache) writeCache(view, digest); + } catch { + // 没有旧图可退,就彻底安静——状态栏不是报错的地方。 + if (!opts.noCache) writeCache(null, digest); + return; + } + } else if (usable.age > opts.cacheTtl) { + stale = true; + refreshInBackground(opts); + ensureCatalog(creds.apiBase || DEFAULT_API_BASE).catch(() => {}); + } + + if (!opts.always) { + // 一级判据:这一轮用的是哪个模型。切走了就不该继续挂在这儿。 + const { decision, used, source } = routeDecision(stdinDoc, opts); + if (decision === 'no') { + if (opts.why) { + process.stderr.write(`隐藏:这一轮用的是 ${used}(来自${source === '路由映射' ? '本地路由的环境变量映射' : 'transcript'}),不在 Command Code 的模型目录里 +`); + } + return; + } + // 二级判据(只在拿不到本轮证据时才用):账号用量还动不动。 + // 它能挡住"陈年旧数据一直占着",但挡不住"你在别的机器/别的宿主上用它"—— + // 那种情况下数字照样在涨,所以只能作为兜底。 + if (decision === 'unknown' && opts.idleHideMs > 0) { + const lastActiveAt = usable?.lastActiveAt ?? Date.now(); + if (Date.now() - lastActiveAt > opts.idleHideMs) { + if (opts.why) { + process.stderr.write(`隐藏:拿不到本轮模型证据(${used ? `只认出 ${used}` : 'stdin 里没有模型信息'}),且账号用量已闲置超过 ${opts.idleHideMs / 60000} 分钟` + LF); + } + return; + } + } + } + + emitStatus(view, opts, { stale, ageMs: stale ? usable?.age : undefined }); +} + +function emit(view, opts) { + // 状态栏和钩子都走这里——--demo 是经 emit() 出去的,漏掉钩子就预览不了。 + if (opts.mode === 'statusline' || opts.hook) { + emitStatus(view, opts); + return; + } + if (opts.mode === 'json') { + console.log(JSON.stringify(view, null, 2)); + return; + } + if (opts.mode === 'md') { + console.log(renderMarkdown(view)); + return; + } + if (opts.mode === 'compact') { + console.log(renderCompact(view)); + return; + } + if (opts.mode === 'html') { + const file = path.resolve(opts.outFile || path.join(process.cwd(), 'command-code-usage.html')); + try { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, renderHtml(view, { live: false }), 'utf8'); + } catch (err) { + console.error(`写入面板失败:${file}\n ${err instanceof Error ? err.message : String(err)}`); + process.exitCode = 1; + return; + } + console.log(`已生成面板:${file}`); + if (opts.open) openInBrowser(file); + return; + } + console.log(renderTerminal(view, opts)); +} + +// 只有直接执行时才跑 CLI。被适配器 import 时(dsh 的 quota.mjs 那样)只提供函数, +// 不能顺手把整个 CLI 跑一遍。 +const isDirectRun = (() => { + const entry = String(process.argv[1] || '').replace(/\\/g, '/').toLowerCase(); + if (!entry) return false; + const self = decodeURIComponent(new URL(import.meta.url).pathname) + .replace(/^\//, '') + .replace(/\\/g, '/') + .toLowerCase(); + return entry === self || entry.endsWith(self) || self.endsWith(entry); +})(); + +if (isDirectRun) main().catch((err) => { + console.error(err instanceof Error ? err.message : String(err)); + process.exitCode = 1; +}); diff --git a/command-code-usage/scripts/install-user-scope.mjs b/command-code-usage/scripts/install-user-scope.mjs new file mode 100644 index 0000000..5a98408 --- /dev/null +++ b/command-code-usage/scripts/install-user-scope.mjs @@ -0,0 +1,238 @@ +#!/usr/bin/env node +/** + * 用户级安装 / 同步 + * + * 把插件的命令和技能安装到 ZCode 的用户级目录: + * ~/.zcode/commands/.md 命令(发现顺序里优先级高于插件) + * ~/.zcode/skills//SKILL.md 技能 + * + * 为什么需要它:ZCode 没有命令行安装入口,市场安装只能在界面里点。这是等效的替代路径, + * 而且命令正文用路径探测定位脚本,所以脚本始终从插件目录实时读取——升级脚本不用重装。 + * 需要重新同步的只有命令和技能的说明文字,插件更新后重跑一次本脚本即可。 + * + * 用法: + * node install-user-scope.mjs 安装 / 同步(用户级) + * node install-user-scope.mjs --workspace <目录> 额外装进该工作区 .zcode/commands + * node install-user-scope.mjs --dry-run 只显示会写哪些文件 + * node install-user-scope.mjs --uninstall 移除副本(插件目录不动) + */ + +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createHash } from 'node:crypto'; + +const PLUGIN_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const HOME = os.homedir(); +const CMD_DIR = path.join(HOME, '.zcode', 'commands'); +const SKILL_DIR = path.join(HOME, '.zcode', 'skills'); + +// --workspace :额外把命令装进该工作区的 .zcode/commands。 +// 用户级目录万一没被读取,工作区级是第二条独立路径(发现优先级低于用户级)。 +const argv = process.argv.slice(2); +const dryRun = argv.includes('--dry-run'); +const uninstall = argv.includes('--uninstall'); +const wsIndex = argv.indexOf('--workspace'); +const WORKSPACE_DIR = wsIndex >= 0 && argv[wsIndex + 1] ? path.resolve(argv[wsIndex + 1]) : null; +const CMD_DIRS = WORKSPACE_DIR ? [CMD_DIR, path.join(WORKSPACE_DIR, '.zcode', 'commands')] : [CMD_DIR]; + +// 命令正文里的这个占位符在安装时被替换成脚本的绝对路径(bash 用的正斜杠形式)。 +// 这样命令正文不依赖 agent 先跑一次 find 再把路径填进下一条命令; +// 万一插件被搬走,正文里还留了 find 兜底。 +const SCRIPT_PLACEHOLDER = '@@CC_USAGE_SCRIPT@@'; +const SCRIPT_PATH = path.join(PLUGIN_ROOT, 'scripts', 'cc-usage.mjs'); + +/** 转成 Git Bash / POSIX 能用的路径写法 */ +function toPosix(p) { + return p.replace(/\\/g, '/'); +} + +function readJson(file) { + try { + return JSON.parse(fs.readFileSync(file, 'utf8')); + } catch { + return null; + } +} + +function listFiles(dir) { + try { + return fs.readdirSync(dir).filter((f) => f.endsWith('.md')); + } catch { + return []; + } +} + +/** 读源命令并把脚本路径占位符替换成真实绝对路径 */ +function renderCommand(from) { + return fs.readFileSync(from, 'utf8').split(SCRIPT_PLACEHOLDER).join(toPosix(SCRIPT_PATH)); +} + +// 安装清单:记录本安装器写过哪些文件、以及写入时的内容哈希。 +// 只有「清单里有、且自我写入后没被改动过」的文件才允许覆盖: +// - 不在清单里 → 可能是你自己写的同名文件,拒绝 +// - 在清单里但内容变了 → 你改过它,拒绝,不覆盖你的改动 +// 清单丢失时退回内容比对(内容与本次渲染一致即视为无冲突)。 +function statePath(pluginName) { + return path.join(SKILL_DIR, pluginName, '.installed.json'); +} +function sha256(text) { + return createHash('sha256').update(text, 'utf8').digest('hex'); +} +function readState(pluginName) { + try { + const s = JSON.parse(fs.readFileSync(statePath(pluginName), 'utf8')); + const map = new Map(); + // 兼容早期只存路径数组的格式 + for (const entry of s.commands ?? []) { + if (typeof entry === 'string') map.set(entry, null); + else if (entry?.path) map.set(entry.path, entry.sha256 ?? null); + } + return map; + } catch { + return new Map(); + } +} +function writeState(pluginName, manifest, plan, installedSkillDir) { + const state = { + plugin: pluginName, + version: manifest.version, + commands: plan + .filter((i) => i.kind === '命令' && fs.existsSync(i.to)) + .map((i) => ({ path: i.to, sha256: sha256(fs.readFileSync(i.to, 'utf8')) })), + skills: plan.filter((i) => i.kind === '技能').map((i) => i.to), + }; + try { + fs.mkdirSync(installedSkillDir, { recursive: true }); + fs.writeFileSync(path.join(installedSkillDir, '.installed.json'), JSON.stringify(state, null, 2) + '\n', 'utf8'); + } catch { + /* 清单写不了只影响下次更新能否自动识别,不影响本次使用 */ + } +} + +function main() { + const manifest = readJson(path.join(PLUGIN_ROOT, '.zcode-plugin', 'plugin.json')); + if (!manifest?.name) { + console.error(`读不到插件清单:${path.join(PLUGIN_ROOT, '.zcode-plugin', 'plugin.json')}`); + process.exitCode = 1; + return; + } + + const commandsDir = path.join(PLUGIN_ROOT, manifest.commands?.replace(/^\.\//, '') || 'commands'); + const skillsDir = path.join(PLUGIN_ROOT, manifest.skills?.replace(/^\.\//, '') || 'skills'); + + const commands = listFiles(commandsDir); + const skills = fs.existsSync(skillsDir) + ? fs.readdirSync(skillsDir).filter((d) => fs.existsSync(path.join(skillsDir, d, 'SKILL.md'))) + : []; + + if (uninstall) { + let removed = 0; + for (const dir of CMD_DIRS) { + for (const c of commands) { + const target = path.join(dir, c); + if (fs.existsSync(target)) { + if (!dryRun) fs.rmSync(target); + console.log(`移除命令 ${target}`); + removed++; + } + } + } + for (const s of skills) { + const target = path.join(SKILL_DIR, s); + if (fs.existsSync(target)) { + if (!dryRun) fs.rmSync(target, { recursive: true, force: true }); + console.log(`移除技能 ${target}`); + removed++; + } + } + console.log(removed ? `\n已移除 ${removed} 项(插件目录未改动)。` : '\n没有需要移除的副本。'); + return; + } + + const plan = []; + for (const dir of CMD_DIRS) { + for (const c of commands) { + plan.push({ kind: '命令', from: path.join(commandsDir, c), to: path.join(dir, c) }); + } + } + for (const s of skills) { + plan.push({ kind: '技能', from: path.join(skillsDir, s), to: path.join(SKILL_DIR, s) }); + } + + if (!plan.length) { + console.error('插件里没有可安装的命令或技能。'); + process.exitCode = 1; + return; + } + + const priorState = readState(manifest.name); + + const conflicts = []; + const reasons = []; + for (const item of plan) { + if (item.kind !== '命令' || !fs.existsSync(item.to)) continue; + const rendered = renderCommand(item.from); + const current = fs.readFileSync(item.to, 'utf8'); + if (current === rendered) continue; // 与本次要写的内容一致,无冲突 + const recorded = priorState.get(item.to); + if (recorded === undefined) { + conflicts.push(item.to); + reasons.push('不在本插件的安装清单里(像是你自己写的文件)'); + } else if (recorded !== null && recorded !== sha256(current)) { + conflicts.push(item.to); + reasons.push('内容自上次安装后被修改过'); + } + // recorded === null(旧格式清单)且内容不同 → 按更新处理,因为清单证明是我们写的 + } + if (conflicts.length) { + console.error('为避免覆盖你的内容,已中止。以下文件与本插件要安装的不同:'); + for (let i = 0; i < conflicts.length; i++) console.error(` ${conflicts[i]}\n ${reasons[i]}`); + console.error('\n确认可以覆盖就删掉这些文件再重跑;或先改名保留你自己的版本。'); + process.exitCode = 1; + return; + } + + for (const item of plan) { + if (dryRun) { + console.log(`将写入 [${item.kind}] ${item.to}`); + continue; + } + fs.mkdirSync(path.dirname(item.to), { recursive: true }); + if (item.kind === '技能') { + fs.rmSync(item.to, { recursive: true, force: true }); + fs.cpSync(item.from, item.to, { recursive: true }); + } else { + fs.writeFileSync(item.to, renderCommand(item.from), 'utf8'); + } + console.log(`已安装 [${item.kind}] ${item.to}`); + } + + if (dryRun) return; + + // 版本戳放在已安装的技能目录里,用于对比是否已与新版本同步 + const installedSkillDir = path.join(SKILL_DIR, manifest.name); + writeState(manifest.name, manifest, plan, installedSkillDir); + for (const s of skills) { + const stamp = path.join(SKILL_DIR, s, '.synced-version'); + try { + fs.writeFileSync(stamp, `${manifest.name} ${manifest.version}\n`, 'utf8'); + } catch { + /* 版本戳只是辅助信息,写不了不影响使用 */ + } + } + + console.log( + [ + '', + `插件 ${manifest.name} v${manifest.version} 已装到用户级目录。`, + '', + '接下来:**新建一个任务**(老任务的命令列表不会刷新),然后输入 /quota。', + '脚本始终从插件目录实时读取,所以以后升级脚本不需要重装;', + '只有命令/技能的说明文字变了才需要重跑本脚本。', + ].join('\n'), + ); +} + +main(); diff --git a/command-code-usage/scripts/verify-discoverable.cjs b/command-code-usage/scripts/verify-discoverable.cjs new file mode 100644 index 0000000..c79a5ab --- /dev/null +++ b/command-code-usage/scripts/verify-discoverable.cjs @@ -0,0 +1,195 @@ +/** + * 用 ZCode 自己的命令解析逻辑校验安装后的文件。 + * 以下函数逐行复刻自 resources/glm/zcode.cjs: + * Q4s extractFrontmatter / t6s parseFlatYaml / oOe parseScalar + * r6s extractDescription / X4s commandNameFromPath / tVr normalizeCommandName + * J4s 名称正则 / Y4s 允许的 frontmatter 键 + */ +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); + +const YWr = '.md'; +const J4s = /^[a-z0-9][a-z0-9_:-]{0,63}$/; +const XWr = 1024; +const Y4s = new Set(['allowed-tools', 'argument-hint', 'description', 'disable-noninteractive', 'model', 'skills']); + +function Q4s(e) { + let t = e.replace(/^\uFEFF/, ''); + if (!t.startsWith('---')) return null; + let n = t.split(/\r?\n/); + if (n[0]?.trim() !== '---') return null; + let o = n.findIndex((s, a) => a > 0 && s.trim() === '---'); + return o <= 0 ? null : n.slice(1, o).join('\n'); +} +function t6s(e, t, n) { + let o = {}, s = []; + for (let [a, l] of e.split(/\r?\n/).entries()) { + if (l.trim().length === 0 || l.trim().startsWith('#') || /^\s/.test(l)) continue; + let u = l.indexOf(':'); + if (u <= 0) { + n.push({ code: 'custom_command_invalid_frontmatter', message: `Invalid frontmatter line ${a + 1} in ${path.basename(t)}`, path: t, severity: 'warning' }); + continue; + } + let f = l.slice(0, u).trim(); + s.push(f); + o[f] = l.slice(u + 1).trim(); + } + return { keys: s, values: o }; +} +function e6s() { return { keys: [], values: {} }; } +function oOe(e) { + if (e === undefined) return; + let t = e.trim(); + if (t.length !== 0) return (t.startsWith('"') && t.endsWith('"')) || (t.startsWith("'") && t.endsWith("'")) ? t.slice(1, -1).trim() : t; +} +function nVr(e, t) { return e.length > t ? e.slice(0, t) : e; } +function r6s(e) { + let t = e.split(/\r?\n/).map((n) => n.replace(/^#+\s*/, '').replace(/^[-*]\s*/, '').trim()).find(Boolean); + return t ? nVr(t, XWr) : undefined; +} +function JWr(e) { + let t = e.replace(/^\uFEFF/, ''); + if (!t.startsWith('---')) return e; + let n = t.split(/\r?\n/); + if (n[0]?.trim() !== '---') return e; + let o = n.findIndex((s, a) => a > 0 && s.trim() === '---'); + return o <= 0 ? e : n.slice(o + 1).join('\n'); +} +function tVr(e) { return e.trim().replace(/^\/+/, '').toLowerCase(); } +function X4s(e, t) { return tVr(path.relative(t, e).slice(0, -YWr.length).split(/[\\/]+/).join(':')); } + +/** 复刻 parseCommand */ +function parseCommand(file, rootPath, diagnostics) { + let s; + try { s = fs.readFileSync(file, 'utf8'); } + catch (err) { diagnostics.push({ code: 'custom_command_read_failed', path: file, severity: 'warning' }); return null; } + const name = X4s(file, rootPath); + if (!J4s.test(name)) { diagnostics.push({ code: 'custom_command_invalid_name', message: `Invalid custom command name: ${name}`, path: file, severity: 'error' }); return null; } + const fm = Q4s(s); + const parsed = fm ? t6s(fm, file, diagnostics) : e6s(); + const body = JWr(s).trim(); + const description = oOe(parsed.values.description) ?? r6s(body); + if (!description) { diagnostics.push({ code: 'custom_command_invalid_frontmatter', message: `Custom command must include a description or non-empty body: ${file}`, path: file, severity: 'error' }); return null; } + for (const k of parsed.keys) { + if (!Y4s.has(k)) diagnostics.push({ code: 'custom_command_unknown_frontmatter', commandName: name, message: `Unknown custom command frontmatter key: ${k}`, path: file, severity: 'warning' }); + } + return { + name, + description: nVr(description, XWr), + argumentHint: oOe(parsed.values['argument-hint']), + frontmatterKeys: parsed.keys, + path: file, + scope: 'user', + rootPath, + }; +} + +/** 复刻 commandFilesUnderRoot + scanMarkdownFiles */ +function scan(rootPath, diagnostics, depth = 0) { + if (depth > 12) return []; + let entries; + try { entries = fs.readdirSync(rootPath, { withFileTypes: true }); } + catch (err) { diagnostics.push({ code: 'custom_command_scan_failed', message: String(err.message), path: rootPath, severity: 'warning' }); return []; } + const out = []; + for (const a of entries) { + const full = path.resolve(rootPath, a.name); + let isDir = a.isDirectory(); + let isFile = a.isFile(); + if (a.isSymbolicLink()) { + try { const st = fs.statSync(full); isDir = st.isDirectory(); isFile = st.isFile(); } catch { continue; } + } + if (isDir) out.push(...scan(full, diagnostics, depth + 1)); + else if (isFile && a.name.toLowerCase().endsWith(YWr)) out.push(full); + } + return out; +} + +// ---- 运行:模拟 HWr 的根目录解析(用户级 + 工作区级)---- +const home = process.env.HOME || process.env.USERPROFILE || os.homedir(); +const workspace = process.argv[2] ? path.resolve(process.argv[2]) : null; + +console.log('home =', home); +console.log('os.homedir =', os.homedir()); +console.log('workspace =', workspace ?? '(未指定,仅检查用户级)'); + +// HWr 顺序:用户级(优先级 10)→ 各工作区目录向上逐级 +const roots = [ + { path: path.resolve(path.join(home, '.zcode', 'commands')), scope: 'user', source: 'zcode', priority: 10 }, + { path: path.resolve(path.join(home, '.agents', 'commands')), scope: 'user', source: 'agents', priority: 10 }, +]; +if (workspace) { + // 与 ZCode 一致:向上找 git 仓库根(.git),找到就走到那里为止;没有仓库则只用 cwd 这一层。 + // 只走到盘根会重复扫到 ~/.zcode/commands,产生并不存在的同名告警。 + const findWorktreeRoot = (start) => { + let dir = start; + for (;;) { + if (fs.existsSync(path.join(dir, '.git'))) return dir; + const parent = path.dirname(dir); + if (parent === dir) return null; + dir = parent; + } + }; + const root = findWorktreeRoot(workspace); + const dirs = []; + if (root) { + let dir = workspace; + for (;;) { + dirs.push(dir); + if (dir === root) break; + dir = path.dirname(dir); + } + } else { + dirs.push(workspace); + } + let p = 20; + for (const dir of dirs) { + roots.push({ path: path.join(dir, '.zcode', 'commands'), scope: 'project', source: 'zcode', priority: p }); + roots.push({ path: path.join(dir, '.agents', 'commands'), scope: 'project', source: 'agents', priority: p }); + p += 10; + } + console.log(`git 仓库根 = ${root ?? '(不是 git 仓库,只用 cwd)'}`); +} + +const diagnostics = []; +const byName = new Map(); +let total = 0; +// 与 discoverCommands 一致:按 priority 升序,先到者胜 +for (const root of roots.sort((a, b) => a.priority - b.priority)) { + let exists = false; + try { exists = fs.statSync(root.path).isDirectory(); } catch { exists = false; } + if (!exists) continue; + const found = []; + for (const f of scan(root.path, diagnostics)) { + const c = parseCommand(f, root.path, diagnostics); + if (!c) continue; + total++; + if (byName.has(c.name)) { diagnostics.push({ code: 'custom_command_duplicate_name', commandName: c.name, message: `被更高优先级的同名命令忽略: ${c.name}`, path: f, severity: 'warning' }); continue; } + byName.set(c.name, c); + found.push(c.name); + } + console.log(`\n根目录 [${root.scope}/${root.source}] ${root.path}`); + console.log(` 发现 ${found.length} 个: ${found.map((n) => '/' + n).join(', ') || '(无)'}`); +} + +console.log(`\n最终可用命令 ${byName.size} 个(总扫描 ${total},去重后)`); +const reserved = ['clear','compact','compress','continue','dwf','effort','expert','fork','goal','help','init','language','locale','login','logout','mcp','mode','model','new','plan','plugin','plugins','resume','rewind','skill','target','variant']; +for (const c of [...byName.values()].sort((a, b) => a.name.localeCompare(b.name))) { + console.log(`\n /${c.name} [${c.scope}]`); + console.log(` description = ${c.description}`); + console.log(` argument-hint = ${c.argumentHint}`); + console.log(` frontmatter = [${c.frontmatterKeys.join(', ')}]`); + console.log(` 保留名冲突 = ${reserved.includes(c.name) ? '是(会被丢弃)' : '无'}`); + const body = fs.readFileSync(c.path, 'utf8'); + const m = body.match(/CC_SCRIPT="([^"]+)"/); + // 用户级安装会把占位符替换成绝对路径;走市场安装时它保持原样,改由 + // $ZCODE_PLUGIN_ROOT / 已知目录探测解析——两种情况都要如实报出来。 + if (m && !m[1].includes('@@')) { + console.log(` 脚本路径 = ${m[1]} ${fs.existsSync(m[1]) ? '✓ 存在' : '✗ 不存在'}`); + } else { + console.log(' 脚本路径 = 未注入(运行时按 $ZCODE_PLUGIN_ROOT → 已知目录探测)'); + } +} + +console.log(`\n诊断 ${diagnostics.length} 条:`); +for (const d of diagnostics) console.log(` [${d.severity}] ${d.code} ${d.path || ''} ${d.message || ''}`); diff --git a/command-code-usage/skills/command-code-usage/SKILL.md b/command-code-usage/skills/command-code-usage/SKILL.md new file mode 100644 index 0000000..811f022 --- /dev/null +++ b/command-code-usage/skills/command-code-usage/SKILL.md @@ -0,0 +1,105 @@ +--- +name: command-code-usage +description: Use when asked how much Command Code quota/credits/balance is left, how close the GOAT/Pro/Max/Teams plan is to its 5-hour or weekly cap, whether there is enough quota to finish a task, when a limit resets, or why a Command Code model request was rate-limited — covers reading the live panel via the bundled script and the underlying /alpha account API fields. +--- + +# Command Code 额度查询 + +用户问「用到什么程度了」「还剩多少」「够不够跑完」「什么时候重置」「怎么被限流了」,都归这里。 + +Command Code 的额度由**三个数**决定:月度额度(或余额)、5 小时滚动窗口、每周滚动窗口。 +两个窗口是关键——月度还剩很多不代表现在能连续跑,因为 5 小时窗口可能已经贴着上限。 + +## 做法:跑脚本,把面板原样给用户 + +```bash +find "$HOME/.zcode" -type f -name cc-usage.mjs -path '*command-code-usage*' -print -quit 2>/dev/null +node "<上一步的脚本路径>" --no-color +``` + +脚本内置凭证发现与字段换算,输出即成品面板。**放进代码块原样转述**,不要重算数字,也不要重排成表格。 + +| 参数 | 用途 | +|---|---| +| (无) | 默认面板:三条进度条 + 重置倒计时 + 预估 | +| `--md` | Markdown 表格,用户要复制或贴到别处时用 | +| `--compact` | 一行摘要 | +| `--json` | 归一化字段全量(含原始响应,可用 `--from-json` 离线重放) | +| `--demo hot` | 样例数据,预览用量吃紧时的告警 | +| `--verbose` | 凭证来源与接口地址,排查用 | +| `--html` / `--serve` | 可选:浏览器大图。用户没要求就别用 | + +## 怎么回答才真的有用 + +面板里已经有两个直接回答用户目的的数字: + +- **「还能跑约 N 次」** = 剩余额度 ÷ 本周期均单价。基准是**用户自己实际的模型组合**, + 所以必须一并说明:换更贵的模型,次数会明显变少。不要把这个数字说成承诺。 +- **`⚠` 告警行** = 按当前消耗速度,某窗口会在重置前耗尽。 + +用户问「够不够跑完 X」时:把「还能跑约 N 次」和任务规模对上再下判断。 +没有 `⚠` 且窗口用量低,可以说「够」;窗口已高且没有告警,只能说「本窗口够,但下一个窗口要等重置」。 + +**不要编造次数**:本周期没有请求记录时脚本不给估算(没有均单价可依据),这时只报绝对额度。 + +## 凭证从哪来 + +按顺序找,找到即用: + +1. 环境变量 `COMMAND_CODE_API_KEY` / `CMD_API_KEY` / `COMMANDCODE_API_KEY` +2. `~/.commandcode/auth.json`(Command Code CLI 登录后生成) +3. `~/.zcode/v2/provider_config.json` 里 `api.baseUrl` 含 `commandcode.ai` 的 provider + ——即 ZCode 已配好的那把 key,所以装了 ZCode provider 就无需额外登录 + +密钥只用于 `Authorization: Bearer`,脚本不打印、不落盘。**不要把 key 贴进回答、日志或提交。** + +## 底层接口(脚本失灵时手工查) + +Base `https://api.commandcode.ai`,全部 `GET`,头 `Authorization: Bearer `: + +| 端点 | 内容 | +|---|---| +| `/alpha/whoami?limits=1` | 用户、`org`、组织级 `orgLimits` | +| `/alpha/billing/credits` | `credits`(余额)与 `windowLimits`(两个滚动窗口) | +| `/alpha/billing/subscriptions` | `planId`、`status`、计费周期起止 | +| `/alpha/usage/summary?orgId=&since=` | 本周期请求数、成本、token、成功率 | + +`/provider/v1/*` 是模型推理接口(OpenAI/Anthropic 兼容),**不提供**额度查询——额度只在 `/alpha/*`。 +`/provider/v1/models` 只给模型清单,不含额度系数或单价,所以无法算出「某模型还能跑几次」。 +有 `orgId` 时带查询参数;`since` 用 `currentPeriodStart` 的 ISO 8601 值。 + +## 字段语义(最容易搞反的地方) + +- `credits.credits.monthlyCredits` 是**剩余**额度,不是已用。已用 = 面额 − 剩余。 +- 窗口的 `used` / `cap` 都是**美元价值**,不是请求条数。`cap` 一律取接口返回值。 +- `resetAt` 是毫秒时间戳,窗口**从该窗口内首次请求起算**,不随自然日/周边界,用量不跨窗口结转。 +- `limited: false` = 没有滚动窗口(按量计费或企业池),此时只有余额,不要去套百分比。 +- `belowThreshold` / `creditThreshold` 是余额预警线,与窗口无关。 +- `orgLimits` 是组织级消费上限,字段名没有公开 schema;认不出形状就跳过,别猜。 + +## 套餐面额参考(仅用于显示月度总额;窗口 cap 以接口为准) + +| planId | 名称 | 月度 | 5 小时 | 每周 | +|---|---|---|---|---| +| `individual-go` | Go | $10 | $3 | $6 | +| `individual-goat` | GOAT | $70 | $14 | $35 | +| `individual-pro` | Pro | $30 | $16 | $40 | +| `individual-pro-v1` | Pro | $80 | $16 | $40 | +| `individual-max` | Max 10× | $150 | $45 | $90 | +| `individual-ultra` | Max 20× | $300 | $90 | $180 | +| `teams-pro` | Teams Pro | $40 | $12 | $24 | + +表里没有的 `planId`(新套餐、企业套餐)不要硬套数字,直接用接口返回的窗口 `cap`, +并把总额度说明为「按已花 + 剩余推算」。 + +credits 记的是**用量价值**:全额度模型(如 GLM 系列)1 credit ≈ $1 用量,低额度模型按比例多扣。 +所以「还能跑多少请求」必然取决于模型组合,不要给出与模型无关的单一数字。 + +## 回答时注意 + +- 报百分比要同时给**绝对值和重置时间**:「5 小时窗口 5.7%($0.80 / $14),04:29 重置,还有 3h 51m」。 + 只给百分比用户没法判断能不能撑到任务做完。 +- 窗口重置时间随时在走,跨了一分钟以上的对话要重新取数,别复用旧读数。 +- 触发 429 时先看哪个窗口 `exceeded`,再给三条出路:等重置、买额外额度、升级套餐。 +- 脚本给出的速度外推有最小采样门槛(不足窗口的 5% 就不出结论)。**没有告警不代表安全**, + 只代表样本还不够判断——此时照实说,别替它下结论。 diff --git a/docs/FINDINGS.md b/docs/FINDINGS.md new file mode 100644 index 0000000..02a0245 --- /dev/null +++ b/docs/FINDINGS.md @@ -0,0 +1,352 @@ +# 调研记录:为什么 CommandCode 在每个宿主里都得手动接 + +> 这是共享实现的调研记录,拆自 `Jovan1666/commandcode-usage`。它记录的是跨宿主的 +> 外部事实,本仓库自己那份实现在 `command-code-usage/scripts/cc-usage.mjs`; +> 本文提到的代码路径均指本仓库内。 + +> 这份文档记录**外部事实**和**由此推出的设计约束**,不是教程。 +> 每条都标了来源和核实日期。官方一旦变更,需要更新的是这份文档和 §6 列出的对应代码。 +> +> 最后核实:2026-09-21 + +--- + +## 1. CommandCode 不是"原生可用"的 provider + +这是整件事的起点,也是一开始最容易判断错的地方。 + +**核实结果**:`models.dev`(opencode、pi 等宿主共用的模型目录,222 个 provider)里 +**没有 commandcode**。它收录的是模型厂商本身(`deepseek`、`zai`、`moonshotai`、 +`anthropic`、`openai`)和少数一方订阅产品(`opencode`、`opencode-go`), +但不收录 CommandCode 这类转售订阅。 + +``` +含 command 的 provider: [] ← 一个都没有 +含 opencode 的 provider: ['opencode', 'opencode-go'] +providers 总数: 222 +``` + +(核实方式:`curl -s https://models.dev/api.json`) + +**推论**:宿主不会"自带" CommandCode。用户必须自己在每个宿主里把它接上, +而**接入方式就是路由配置**——这正是本插件判断"这一轮在不在用它"的信息来源。 + +### 1.1 各宿主的接入方式,以及路由信息存在哪 + +| 宿主 | 接入方式 | 路由信息落在哪 | 能否被插件读到 | +|---|---|---|---| +| **Claude Code** | 改 `ANTHROPIC_BASE_URL` 指向代理,再用别名把模型名映射过去 | `settings.json` 的 `env`:`ANTHROPIC_DEFAULT_<档位>_MODEL`(本地假名)与 `..._MODEL_NAME`(真实上游)成对出现 | ✅ 这两个变量 statusLine 子进程能继承到 | +| **opencode** | provider 由用户配置(社区插件注册 `commandcode` / `commandcode-claude` 两个) | `~/.config/opencode/opencode.json(c)` 的 provider 段;key 也可能在 `~/.local/share/opencode/auth.json` | ✅ 本仓库的适配器不读 provider,key 走 core 的通用凭证发现 | +| **pi** | `pi-commandcode-provider` 之类的 provider 扩展 | pi 的 provider 配置(`~/.pi/agent/settings.json`)或环境变量 | ✅ key 走 core 的通用凭证发现;本仓库的扩展不读 `ctx.model` | +| **Codex** | `config.toml` 里配 `model_providers..base_url` | 配置文件 | ✅ 插件能读配置 | +| **Grok Build** | `config.toml` 的 `[model.]` 带 `base_url` | 配置文件 | ✅ 同上 | +| **DeepSeek Harness (dsh)** | `settings.yaml` 里配 provider 路由 | `apiKeyEnv` / `baseURL` | ✅ 同上 | + +**共同规律**:路由信息**总是**落在配置文件或环境变量里——因为接入动作本身就是写这些地方。 +所以"这一轮走没走 CommandCode"是**可判定**的,不需要猜。 + +--- + +## 2. Claude Code 的三个坑(本机实测) + +### 2.1 statusLine 的 `model.id` 是**本地别名**,不是真实上游 + +本机(走本地路由)实测捕获: + +```json +// statusLine 通过 stdin 收到的 +"model": { "id": "claude-opus-5[1M]", "display_name": "Opus 5" } + +// 但 transcript 里记的上游真实模型是 +{"role":"assistant","message":{"model":"deepseek/deepseek-v4.1-flash"}} +``` + +来源:`~/.claude/settings.json` 的 env 块 + +``` +ANTHROPIC_DEFAULT_OPUS_MODEL = claude-opus-5[1M] +ANTHROPIC_DEFAULT_OPUS_MODEL_NAME = deepseek/deepseek-v4.1-flash +``` + +**结论**:拿 `model.id` 去匹配模型目录**必然失败**,因为它是路由伪造的别名。 + +### 2.2 statusLine 的 JSON 里**没有** provider / base_url / endpoint 字段 + +官方字段表逐条核对过(https://code.claude.com/docs/en/statusline): +`model` / `workspace` / `cost` / `context_window` / `rate_limits` / `prompt_cache` / +`session_id` / `transcript_path` … 全是会话状态,**没有任何一个字段描述请求发去了哪**。 + +另外:`rate_limits` 只对 claude.ai 一方订阅出现,第三方套餐**永远不会有**—— +所以"顺手拿官方额度字段"这条路对 CommandCode 是死的。 + +### 2.3 `context_window` 是**账本**,不是自动压缩的预算(2026-09-21 验证) + +同一次会话里两处同时抓:`context_window.context_window_size` 报 **1,000,000** +(settings.json 里设了 `contextWindowTokens`),而自动压缩实际在 **约 18.8 万** token 处 fire。 + +| 上下文(transcript 里的输入 token) | 状态栏 `📊` 那格 | 自动压缩 | +|---|---|---| +| 61,510 | 6% | 未触发 | +| 158,194 | **16%** | 未触发 | +| **188,166** | ~19% | **fire**(`trigger=auto`,压完剩 34,267) | + +158,194 对应 16% ⇒ 那个百分比的分母是**账本窗口(100 万)**,不是压缩点: +**压缩发生在该数字约 19% 的时候**。拿它估"还剩多少余量"会严重高估。 + +`autoCompactWindow` 改不动它。同一天四次对照,压缩点分别是 187,946 / 188,166 / 188,262 +(不设)与 187,779(`autoCompactWindow = 100000`)——设定值差一半,压缩点纹丝不动 +(`CLAUDE_CODE_AUTO_COMPACT_WINDOW` 同样无效)。真正生效的窗口约 20 万, +与 `.claude.json` 里的 GrowthBook 缓存 `tengu_hawthorn_window = 200000` 吻合。 + +两点方法上的收获: + +- 判"压没压"要看 transcript 里的 `compact_boundary` 事件(`compactMetadata.trigger` 与 + `preTokens`),**别读界面措辞**——底栏那行百分比在 tmux 抓屏里经常根本不渲染, + 照它推断会得出完全相反的结论。 +- 这是"本机 + 本机这条代理路由"的实测。报出的模型名与上游真实模型不同时(见 §2.1), + 生效窗口跟哪个走尚无结论。 + +--- + +## 3. 模型目录:能拉到,但不能只靠它 + +**事实**:`GET https://api.commandcode.ai/provider/v1/models` **免鉴权可匿名访问**, +返回 71 个模型,`owned_by: "command-code"`。核实日期 2026-09-21。 + +``` +claude-sonnet-5 | claude-sonnet-4-6 | claude-fable-5-1 | claude-opus-5 | claude-opus-4-8 +gpt-5.6-sol | gpt-5.6-terra | gpt-5.6-luna | gpt-5.5 | gpt-5.4 | gpt-5.3-codex +deepseek-v4-pro | deepseek-v4-flash | deepseek-v4.1-flash +... +``` + +**为什么不能只靠它**:拿真实的 transcript 模型名去比对,**精确命中率只有约 36%**: + +| transcript 里的名字 | 命中 | +|---|---| +| `deepseek/deepseek-v4.1-flash` | ✅ | +| `claude-opus-4-8` | ✅ | +| `xiaomi/mimo-v2.5-pro` | ✅ | +| `glm-5.2` | ❌ 目录里是 `zai-org/GLM-5.2` | +| `K2.7 Code` | ❌ 目录里是 `moonshotai/Kimi-K2.7-Code` | +| `K3` | ❌ 目录里是 `moonshotai/Kimi-K3` | + +而且**同名不同源**:同一个模型名 `glm-5.2` 在同一份会话记录里被两种后端服务过 +(`message.id` 前缀分别是 `chatcmpl-*` 和 `cht000d…@dx…`)。 +→ **模型名 ≠ provider**,名字匹配只能当辅助。 + +--- + +## 4. 由此推定的判据(实现见 `scripts/cc-usage.mjs` 的 `routeDecision`) + +按可靠性从高到低,逐一尝试: + +1. **本地路由的环境变量映射**(最硬) + `model.id` 去反查 `ANTHROPIC_DEFAULT_*_MODEL`,取配对的 `*_MODEL_NAME` 得到真实上游。 + 这不是推测,是路由自己的配置。 +2. **宿主直接给的模型名**(`model` 是字符串时)。Codex 的钩子就是这样,而且它给的 + 直接是真实模型名——这条对 Codex 是必需的,因为它的 `transcript_path` 是空的(见 §5.1)。 +3. **transcript 里最近一条真实消息的 `message.model`** + (跳过 `isSidechain` 子代理和 `` 占位) +4. **账号用量活跃度**(兜底,账号级——在别的机器/宿主上用它也会让数字增长,所以只是兜底) +5. **`--model <子串>`** 用户手工补别名,覆盖以上全部 + +真实模型名拿到后对照 §3 的目录: + +- 目录里**没有** → 确定没用 → 隐藏 +- 目录里**有**且来自路由映射 → 确定在用 → 显示 +- 目录里**有**但来自 transcript,且名字是 `claude-*` → **不猜**(原生 Anthropic 也叫这个名) + → 退回第 4 条(账号用量活跃度) + +--- + +## 5. 各宿主的常驻位(决定每个平台能做到什么形态) + +| 宿主 | 有常驻位 | 机制 | 能塞自己的脚本 | +|---|---|---|---| +| **Claude Code** | ✅ | `settings.json` 的 `statusLine`,支持多行 + ANSI + `refreshInterval` | ✅ 外部命令 | +| **Grok Build** | ✅ | `[ui.status_line]` `type="command"`(**已抓屏确认**;Windows 上要多一层 `.cmd`,见 §5.2) | ✅ 外部命令 | +| **opencode** | ✅ | 11 个官方 TUI 插槽(`sidebar_content` / `session_prompt_right` / …),SolidJS 组件 | ✅ 进程内插件 | +| **pi** | ✅ | `setWidget` 的组件工厂重载 + `placement: "belowEditor"`(**已抓屏确认**) | ✅ 扩展 | +| **Codex** | ❌ | `tui.status_line` 是**封闭枚举**(31 个内置项,无外部脚本口子) | ❌ | +| | | 替代:`UserPromptSubmit` 钩子每轮弹一行(**已实测可触发**,见 §5.1) | ✅ 钩子 | +| **DeepSeek Harness** | ✅ | 侧边栏插槽(**已抓屏确认**,位置在「设置」上方) | ✅ 插件 | +| **ZCode** | ❌ | 无可插拔的常驻 UI 位 | ❌ | + +Codex 的替代路径:`UserPromptSubmit` hook 输出 `systemMessage`(每轮自动弹一行,零 token)。 +ZCode 的替代路径:只能按需调用命令(**会走模型、烧 token**)。 + +--- + +### 5.1 Codex 钩子的实测细节(2026-09-21 验证) + +`codex exec` 端到端跑通,钩子确实触发了。四条只靠读文档得不出来的结论: + +**① 必须用完整的 MatcherGroup 嵌套形状。** + +```toml +# ✅ 能触发 +hooks.UserPromptSubmit = [{ matcher = ".*", hooks = [{ type = "command", command = "node …" }] }] + +# ❌ 配置能加载、但不会触发 +hooks.UserPromptSubmit = [{ command = "node …" }] +``` + +扁平写法 serde 是接受的(`codex doctor` 也报配置正常),但不会被注册成真正的钩子组。 +**只看"配置能不能加载"会得出错误结论**——这一点值得单独记下来。 + +**② 钩子需要信任。** 二进制里有 `HookStateToml { enabled, trusted_hash }`, +并且存在 `--dangerously-bypass-hook-trust` 这个 flag——两者一起证实了信任是硬门槛。 +插件市场安装时 Codex 会提示授权;绕过只用于测试。 + +**③ 钩子 stdin 的字段与 Claude Code 不同。** + +| 字段 | Codex 实测 | 影响 | +|---|---|---| +| `model` | `"gpt-5.6-terra"`(**字符串**) | 直接就是真实模型名,比 Claude Code 的本地别名干净 | +| `transcript_path` | 存在但**为空** | 走不了"读会话记录"那条判据 | +| `session_id` / `cwd` / `turn_id` / `permission_mode` / `prompt` | 都有 | — | + +所以判据里必须专门认「`model` 是字符串」这种形状,否则 Codex 会永远判成"未知" +(本仓库的 `routeDecision` 就是这么修的)。 + +**④ `codex exec` 会读 stdin。** 非交互调用时 stdin 不关会一直挂住 +(输出停在 `Reading additional input from stdin...`),表现为超时而不是报错。 +自动化里记得 `< /dev/null`。 + +--- + +### 5.2 Grok 在 Windows 上起不动"带绝对路径参数"的命令(2026-09-21 验证) + +Grok 的状态栏命令在 POSIX 上是交给 `sh -c` 跑的,Windows 上没有 sh。但**失败原因不是没有 sh**: + +``` +[status line: could not start the script: 文件名、目录名或卷标语法不正确。 (os error 123)] +``` + +`os error 123` 是 `ERROR_INVALID_NAME`,从 CreateProcess 出来的。先用 `grok --cwd <真实 +Windows 路径>` 把"工作目录是 POSIX 路径"这个变量排掉,再逐个变量对测(grok 1.0.30): + +| command 写法 | 结果 | +|---|---| +| `C:/Windows/System32/hostname.exe` | ✅ 渲染出主机名 | +| `C:/Windows/System32/cmd.exe /c echo HIB` | ✅ 渲染出 `HIB` | +| `"C:/Windows/System32/hostname.exe"` | ❌ os error 123 | +| `D:/…/node.exe --version` | ✅ | +| `C:/Windows/System32/cmd.exe /c echo a b c d e` | ✅ | +| `C:/Windows/System32/cmd.exe /c echo a:b` | ✅ | +| `…/Temp/sp ace/cc-usage.cmd`(**路径含空格,不加引号**) | ✅ 渲染出额度行 | +| `"…/Temp/sp ace/cc-usage.cmd"`(同一条路径加上引号) | ❌ os error 123 | +| `C:/Windows/System32/cmd.exe /c echo C:/Windows/Temp` | ❌ os error 123 | +| `node C:/…/cc-usage.mjs --statusline --rows 1` | ❌ os error 123 | +| `D:/…/node.exe C:/…/cc-usage.mjs --statusline --rows 1` | ❌ os error 123 | + +四条结论,都是实测: + +1. **Grok 先拿整条 `command` 当一个路径试**,是存在的文件就直接执行。所以空格不是 + 问题:`…/sp ace/cc-usage.cmd` 这种裸写照样跑起来。 +2. 不是路径,才按空白切成"程序 + 参数"。程序名可以是裸的绝对路径;但**参数里出现盘符 + 绝对路径**(正反斜杠一样)就 123。相对参数没事,多个普通参数没事,单个冒号也没事。 +3. **加引号一定 123**——引号成了路径的一部分,整条既不是合法路径、切出来的程序名也非法。 + 官方文档那句"路径含空格就照 prompt 里那样加引号"在 Windows 上不成立,含空格的路径 + **不加引号反而是对的**。 +4. 所以 Windows 上 `command` 写**一条不加引号的裸路径**最稳,哪怕路径里有空格。 + +做法:`setup.mjs` 在 Windows 上生成一个 `cc-usage.cmd` 放在 `cc-usage.mjs` 旁边, +`config.toml` 里只写这个批处理的路径,node 调用写在批处理内部、用 `%~dp0` 定位脚本。 +这样 `--rows` 之类的参数照常生效,插件目录被搬走也不用重装。 + +写批处理时踩到的两个坑,都写进生成器里了: + +- **批处理必须纯 ASCII。** cmd.exe 用 OEM 代码页读它,一句 UTF-8 中文 `rem` 会变成它要去 + 执行的命令,整行状态栏消失(第一次实测就是这么挂的)。 +- **不要在 `( … )` 块里 `echo %PATH%`。** PATH 里的 `Program Files (x86)`、NVIDIA 目录 + 带括号,会把块提前闭合,报 `\NVIDIA was unexpected at this time.`。诊断代码自己把 + 包装搞崩过一次。 + +要区分开的是:**路径里有空格不是问题,引号才是。** 这条一开始判断反了,是最后补测才 +纠正过来的——Grok 既然先拿整条命令当路径试,一个带空格的裸路径本来就是合法路径。所以 +`setup.mjs` 只写一条不加引号的裸路径,不需要对安装位置提任何要求。 + +## 6. 待观察清单:官方改了什么,我们要跟着改什么 + +| 如果发生 | 要改的地方 | +|---|---| +| CommandCode 模型目录增删模型 | 无需改代码——目录是运行时拉的,缓存 24 小时(`~/.commandcode-usage/models.json`) | +| 模型目录接口路径或鉴权变了 | `ensureCatalog()` 里的 `/provider/v1/models` | +| 计费/额度接口(`/alpha/*`)字段改名 | `normalize()`;症状是数字变成 0 或空 | +| 官方开始提供**原生** provider(进了 models.dev) | §1 的接入方式变了,"路由信息在哪"随之变,`routeDecision` 要跟着调整 | +| Claude Code 的 statusLine JSON 增加了 provider 字段 | 可去掉 §4 的第 2、3 条兜底,直接读字段 | +| Claude Code 插件能自带 `statusLine` | 安装可以少一步(现在必须改用户 `settings.json`) | +| Codex 的 `status_line` 开放外部命令 | Codex 也能做常驻,不必用 hook 兜底(现在只能每轮弹一行) | +| 套餐档位/额度调整 | `PLANS` 表(`scripts/cc-usage.mjs` 顶部),来源是官方定价页 | +| 计费周期字段变化 | `activityOf()` 里的请求数对比(跨周期归零已按"变了就算活跃"处理) | +| Claude Code 让 `context_window` 反映压缩预算,或 `autoCompactWindow` 开始生效 | §2.3 的结论作废;那时"离压缩还有多少"可以直接读字段 | + +--- + +## 7. 这份记录里,哪些是实测、哪些是推断 + +**实测**(本机或具体接口上直接验证过) +- models.dev 不含 commandcode(`curl` 结果) +- CommandCode 模型目录 71 个、免鉴权(`curl` 结果) +- `model.id` 是本地别名、transcript 里是真实模型名(两份真实捕获对照) +- 环境变量映射成对出现(读 `~/.claude/settings.json`) +- 各宿主常驻位的有无与机制(读宿主的二进制 / 文档 / 源码) +- Windows 上 Node 启动耗时构成、Git Bash 29ms 地板(本机 25–30 次取中位数) + +**实测(续)** +- Codex 的 `UserPromptSubmit` 钩子确实会触发;必须用嵌套的 MatcherGroup 形状, + 扁平形状配置能加载但不生效(见 §5.1) +- Codex 钩子的 stdin 给 `model` 字符串、`transcript_path` 为空 +- **pi 的 widget 真的渲染出来了**(tmux 抓屏确认,位置在输入框与默认 footer 之间) +- pi 的扩展 API 与官方类型逐条对上:入口 `ExtensionFactory`、 + `setWidget` 的组件工厂重载、`WidgetPlacement`、`session_start`/`agent_settled`/`session_shutdown` +- **dsh 的卡片真的渲染出来了**(Playwright 抓屏确认,位置在侧栏「设置」上方), + 数字与本仓库 core 完全一致 +- Claude Code 的 `context_window`(账本,本机 100 万)与自动压缩的实际触发点 + (约 18.8 万)差五倍;`autoCompactWindow` 是空操作(见 §2.3) + +**实测踩到的坑:dsh 插件的版本门槛很硬,而且症状具有误导性** + +本机原来装的是 **dsh `0.1.1-rc.2`**,而插件要求 `^0.1.5-rc.1`。启动时直接崩: + +``` +Error: failed to apply loader entry commandcode-quota: +Cannot read properties of undefined (reading 'register') + at plugins/dsh/index.js:385 +``` + +`ctx.connection.fetch` 在那个版本里根本不存在,于是 `.register` 读到了 undefined。 + +两点值得记: + +1. **插件的 141 项离线校验全过,却掩盖了这个不兼容**——那些校验不需要 dsh 运行。 + "测试全绿"和"集成能用"是两件事。 +2. **版本要求写在 README 的徽章和要求段里,很容易被忽略**。装之前先跑 `dsh --version` + 对一下,比看报错快。升到 `0.1.5-rc.2` 后一切正常。 + +**实测踩到的坑:pi 禁止跨会话持有 ctx** + +用旧 `ctx` 调任何 UI 方法会抛 +`This extension ctx is stale after session replacement or reload`——**这一点在类型定义里看不出来, +只有真跑才会暴露**。正确写法是用 `setWidget` 的组件工厂形式拿 `tui` 句柄长期持有, +刷新时只调 `tui.requestRender()`,绝不碰 ctx。 + +**推断**(机制清楚,但没单独跑一轮验证) +- statusLine 子进程能否继承 `ANTHROPIC_DEFAULT_*_MODEL_NAME`。 + 机制上讲得通(statusLine 是宿主子进程,官方文档明确 `env` 设置对子进程生效), + 但没在真实的 statusLine 调用里 dump 过环境变量。 + **兜底方案**:拿不到就退到 transcript,功能不受影响。 + +**决定:dsh 继续不共用 core**(2026-09-21 复核,非疏漏) + +`plugins/dsh/quota.mjs` 是一个自成一体的数据层:自带凭据发现、端点表、套餐表、 +`CLI_VERSION`,以及被 `client.js` / `index.js` / `cli/` 依赖的那套导出契约。 +core 那边是两千行的状态栏/路由引擎,导出(`collectUsage` / `normalize` / +`routeDecision` / `statuslineMode` …)与 dsh 需要的东西对不上。硬换过去要重写 +宿主半边的契约,换来的只是"少一个维护点";而最容易漂移的那部分——两张套餐表—— +已经由 `plans` 套件逐档比对(`scripts/check.mjs`)。 + +会推翻这个决定的条件:core 抽出 dsh 也用得上的**数据层**(凭据 + 额度窗口), +而不是现在这个"状态栏引擎";或者 dsh 自己那套离线校验不再是它的契约。 diff --git a/marketplace.json b/marketplace.json new file mode 100644 index 0000000..647a6d4 --- /dev/null +++ b/marketplace.json @@ -0,0 +1,46 @@ +{ + "name": "command-code-usage", + "plugins": [ + { + "name": "command-code-usage", + "displayName": "Command Code Usage", + "displayName_i18n": { + "en": "Command Code Usage", + "zh-CN": "Command Code 额度面板" + }, + "source": "./command-code-usage", + "description": "See your Command Code plan usage — 5-hour and weekly rolling windows, monthly credits or balance — right inside the conversation, with a remaining-requests estimate and a burn-rate warning. Requires a Command Code plan.", + "description_i18n": { + "en": "See your Command Code plan usage — 5-hour and weekly rolling windows, monthly credits or balance — right inside the conversation, with a remaining-requests estimate and a burn-rate warning. Requires a Command Code plan.", + "zh-CN": "在对话里直接查看 Command Code 套餐用量——5 小时与每周滚动窗口、月度额度或余额,含剩余次数估算与超限预警。需要 Command Code 套餐。" + }, + "version": "1.3.2", + "author": { + "name": "Jovan1666", + "url": "https://github.com/Jovan1666" + }, + "homepage": "https://github.com/Jovan1666/zcode-command-code-usage", + "repository": "https://github.com/Jovan1666/zcode-command-code-usage", + "license": "MIT", + "keywords": [ + "quota", + "usage", + "command-code", + "billing", + "credits", + "rate-limit", + "limits", + "monitoring", + "tokens" + ], + "category": "utilities", + "icon": "https://cdn-zcode.z.ai/zcode/official-plugin/assets/command-code-usage/icon.png", + "requiresPaidPlan": true + } + ], + "description": "Command Code plan usage panel for ZCode: rolling windows, credits, estimates.", + "description_i18n": { + "en": "Command Code plan usage panel for ZCode: rolling windows, credits, estimates.", + "zh-CN": "ZCode 用的 Command Code 套餐额度面板:滚动窗口、额度与次数估算。" + } +} diff --git a/scripts/check.mjs b/scripts/check.mjs new file mode 100644 index 0000000..841575f --- /dev/null +++ b/scripts/check.mjs @@ -0,0 +1,672 @@ +#!/usr/bin/env node +/** + * 一条命令给出这个仓库的结论。 + * + * node scripts/check.mjs 全部套件 + * node scripts/check.mjs --quiet 每个套件只打一行 + * + * CI 直接调这个文件,所以本地和线上是同一套判定——不会出现「本地过了 CI 挂」。 + * 不联网、不需要真实凭证:--serve 那条也是对着本机回环上的桩接口跑的。 + * + * 套件: + * manifests 四个清单的重复字段、版本号、ZCode 解析规则 + * commands 命令与技能的 frontmatter / 命名 / 脚本定位 + * static 全仓 JSON 合法性与 node --check + * secrets 密钥、本机绝对路径、邮箱 + * statusline 状态栏渲染(宽度自适应、绝不带 ANSI) + * gating 「这一轮走没走 Command Code」的判定表 + * threshold+hook --threshold 静默与钩子的 JSON 形状 + * formats 各输出模式与 --help 的选项清单 + * serve --serve 端到端:起服务、请求一次、断言返回 HTML、关掉 + * installer 用户级安装器的冲突保护与安装/卸载 + * + * 只用 Node 内置模块。退出码 0 = 通过。 + */ +import { spawn, spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import http from 'node:http'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const PLUGIN = path.join(ROOT, 'command-code-usage'); +const CORE = path.join(PLUGIN, 'scripts', 'cc-usage.mjs'); +const QUIET = process.argv.includes('--quiet'); + +const suites = []; +const record = (name, fn) => suites.push({ name, fn }); + +/* ---------------------------------------------------------------- 工具 */ + +let failures = []; +let checks = 0; + +function ok(condition, label, detail) { + checks++; + if (condition) { + if (!QUIET) console.log(` \u2714 ${label}`); + } else { + failures.push(`${label}${detail ? ` — ${detail}` : ''}`); + console.log(` \u2718 ${label}${detail ? `\n ${detail}` : ''}`); + } +} + +function newSection(name) { + if (!QUIET) console.log(`\n${name}`); +} + +/** 跑一次脚本(不联网)并返回去掉 ANSI 的 stdout。 */ +function runCore(args, env = {}) { + const r = spawnSync(process.execPath, [CORE, ...args], { + encoding: 'utf8', + env: { ...process.env, ...env }, + timeout: 30_000, + }); + if (r.error) throw r.error; + return String(r.stdout || '').replace(/\x1b\[[0-9;]*m/g, ''); +} + +/** 显示宽度:CJK/全角算 2,其余算 1,与脚本内部口径一致。 */ +function displayWidth(text) { + let w = 0; + for (const ch of text) { + const cp = ch.codePointAt(0); + w += cp >= 0x1100 && ( + cp <= 0x115f || cp === 0x2329 || cp === 0x232a || + (cp >= 0x2e80 && cp <= 0xa4cf) || (cp >= 0xac00 && cp <= 0xd7a3) || + (cp >= 0xf900 && cp <= 0xfaff) || (cp >= 0xfe30 && cp <= 0xfe6f) || + (cp >= 0xff00 && cp <= 0xff60) || (cp >= 0xffe0 && cp <= 0xffe6) + ) ? 2 : 1; + } + return w; +} + +function walk(dir, out = []) { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + if (entry.name === '.git' || entry.name === 'node_modules' || entry.name === '.devdeps') continue; + const full = path.join(dir, entry.name); + if (entry.isDirectory()) walk(full, out); + else out.push(full); + } + return out; +} + +function readJson(rel) { + try { + return JSON.parse(fs.readFileSync(path.join(ROOT, rel), 'utf8')); + } catch (err) { + ok(false, `${rel}: 不是合法 JSON`, err.message); + return null; + } +} + +/* ------------------------------------------------------- 1. 清单与版本 */ + +record('manifests', () => { + newSection('清单与版本'); + const zPlugin = readJson('command-code-usage/.zcode-plugin/plugin.json'); + const cPlugin = readJson('command-code-usage/.claude-plugin/plugin.json'); + const zMarket = readJson('marketplace.json'); + const cMarket = readJson('.claude-plugin/marketplace.json'); + if (!zPlugin || !cPlugin || !zMarket || !cMarket) return '清单缺失或损坏,其余检查已跳过'; + + // 版本号:四处清单 + 脚本里的 VERSION,必须说同一件事。 + const srcVersion = /const VERSION = '([0-9.]+)'/.exec(fs.readFileSync(CORE, 'utf8'))?.[1]; + const versions = { + '.zcode-plugin/plugin.json': zPlugin.version, + '.claude-plugin/plugin.json': cPlugin.version, + 'marketplace.json': zMarket.plugins[0]?.version, + '.claude-plugin/marketplace.json': cMarket.plugins[0]?.version, + 'cc-usage.mjs VERSION': srcVersion, + }; + const distinct = [...new Set(Object.values(versions))]; + ok(distinct.length === 1, `五处版本号一致(${distinct.join(' / ')})`, JSON.stringify(versions)); + + // 双生态:重复字段必须完全一致,漂移了就要在这里拦住。 + const pluginFields = ['name', 'version', 'description', 'author', 'license', 'homepage', 'repository']; + for (const f of pluginFields) { + const same = JSON.stringify(zPlugin[f]) === JSON.stringify(cPlugin[f]); + ok(same, `插件清单 .${f} 一致`, same ? '' : `.zcode-plugin=${JSON.stringify(zPlugin[f])}\n .claude-plugin=${JSON.stringify(cPlugin[f])}`); + } + const entryFields = ['name', 'source', 'version', 'description', 'displayName', 'category', 'homepage', 'author']; + for (const f of entryFields) { + const a = zMarket.plugins[0][f]; + const b = cMarket.plugins[0][f]; + const same = JSON.stringify(a) === JSON.stringify(b); + ok(same, `市场条目 .${f} 一致`, same ? '' : `root=${JSON.stringify(a)}\n .claude-plugin=${JSON.stringify(b)}`); + } + ok(zMarket.name === cMarket.name, '两个市场文件的市场名一致'); + + // ZCode 自己的清单规则。 + const NAME_RE = /^[a-z0-9][a-z0-9._-]{0,127}$/; + ok(NAME_RE.test(zPlugin.name), `插件名符合 ZCode 规则 (${zPlugin.name})`); + ok(NAME_RE.test(zMarket.name), `市场名符合 ZCode 规则 (${zMarket.name})`); + ok(path.basename(PLUGIN) === zPlugin.name, '插件目录名 == 清单里的 name'); + ok(zMarket.plugins[0].name === zPlugin.name, '市场条目名 == 插件名'); + + const source = zMarket.plugins[0].source; + const resolved = path.resolve(ROOT, source); + ok(resolved.startsWith(ROOT + path.sep), `marketplace source 未逃出仓库根 (${source})`); + ok(fs.existsSync(path.join(resolved, '.zcode-plugin', 'plugin.json')), 'source 指向真实插件'); + + // 这几个字段 ZCode「只认不执行」,写进去会让使用者以为生效了。 + const inert = ['channels', 'lspServers', 'outputStyles', 'settings'].filter((k) => k in zPlugin); + ok(inert.length === 0, '插件清单不含 ZCode 只认不执行的字段', inert.join(', ')); + + // ZCode 实际支持的抽屉字段,写错会静默失效。 + const zcodeEntryAllowed = new Set([ + 'name', 'source', 'version', 'description', 'displayName', 'displayName_i18n', 'description_i18n', + 'icon', 'category', 'homepage', 'privacyPolicy', 'termsOfService', 'heroImage', 'author', + 'examplePrompts', 'examplePrompts_i18n', 'requiresPaidPlan', + // 官方 zai-org/zcode-plugins 也在用的字段 + 'keywords', 'license', 'repository', + ]); + const unknownZ = Object.keys(zMarket.plugins[0]).filter((k) => !zcodeEntryAllowed.has(k)); + ok(unknownZ.length === 0, 'ZCode 市场条目字段均受支持', unknownZ.join(', ')); + + // 官方市场的图标:清单里那个 CDN 地址对应的就是仓库里这张图。 + ok(fs.existsSync(path.join(ROOT, 'assets', 'command-code-usage', 'icon.png')), '市场图标文件存在'); + + return `版本 ${distinct[0] ?? '?'}`; +}); + +/* --------------------------------------------------- 2. 命令与技能 */ + +record('commands', () => { + newSection('命令与技能(按 ZCode 解析规则)'); + const CMD_NAME_RE = /^[a-z0-9][a-z0-9_:-]{0,63}$/; + const CMD_KEYS = new Set(['allowed-tools', 'argument-hint', 'description', 'disable-noninteractive', 'model', 'skills']); + // ZCode 保留名(内置命令 + 别名 + compress/plan);命中会被静默丢弃 + const RESERVED = new Set(['clear', 'compact', 'compress', 'continue', 'dwf', 'effort', 'expert', 'fork', 'goal', + 'help', 'init', 'language', 'locale', 'login', 'logout', 'mcp', 'mode', 'model', 'new', 'plan', 'plugin', + 'plugins', 'resume', 'rewind', 'skill', 'target', 'variant']); + + const cmdDir = path.join(PLUGIN, 'commands'); + const cmdFiles = fs.existsSync(cmdDir) ? fs.readdirSync(cmdDir).filter((f) => f.endsWith('.md')) : []; + ok(cmdFiles.length > 0, '至少有一个命令文件'); + for (const f of cmdFiles) { + const stem = f.replace(/\.md$/, ''); + const text = fs.readFileSync(path.join(cmdDir, f), 'utf8'); + ok(CMD_NAME_RE.test(stem), `${stem}: 命令名合法`); + ok(!RESERVED.has(stem.toLowerCase()), `${stem}: 不与 ZCode 保留名冲突`); + const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/.exec(text); + ok(!!m, `${stem}: frontmatter 结构完整`); + if (!m) continue; + const lines = m[1].split(/\r?\n/); + ok(lines.filter((l) => /^\s/.test(l)).length === 0, `${stem}: frontmatter 无缩进行`); + const keys = lines.filter((l) => l.includes(':')).map((l) => l.split(':')[0].trim()); + const bad = keys.filter((k) => !CMD_KEYS.has(k)); + ok(bad.length === 0, `${stem}: frontmatter 键均有效`, bad.join(', ')); + ok(m[2].trim().length > 0, `${stem}: 正文非空`); + ok(keys.includes('description'), `${stem}: 有 description`); + + // 两个工具各自依赖正文里的一处写法,删掉任何一个都会静默退化: + // install-user-scope.mjs 把 @@CC_USAGE_SCRIPT@@ 换成绝对路径 + // verify-discoverable.cjs 从 CC_SCRIPT="…" 里读回那个路径 + ok(text.includes('@@CC_USAGE_SCRIPT@@'), `${stem}: 保留安装器要替换的脚本路径占位符`); + ok(/CC_SCRIPT="[^"]*"/.test(text), `${stem}: 正文里有 CC_SCRIPT= 赋值可供诊断工具读取`); + ok(/ZCODE_PLUGIN_ROOT/.test(text), `${stem}: 走 $ZCODE_PLUGIN_ROOT 解析插件目录`); + ok(text.includes('$HOME/.zcode'), `${stem}: 保留已知目录兜底查找`); + } + + const skillDir = path.join(PLUGIN, 'skills'); + const skills = fs.existsSync(skillDir) + ? fs.readdirSync(skillDir).filter((d) => fs.existsSync(path.join(skillDir, d, 'SKILL.md'))) + : []; + ok(skills.length > 0, '至少有一个技能'); + for (const s of skills) { + const text = fs.readFileSync(path.join(skillDir, s, 'SKILL.md'), 'utf8'); + const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n/.exec(text); + ok(!!m, `${s}: SKILL.md frontmatter 完整`); + if (!m) continue; + const nm = /^name:\s*(\S+)/m.exec(m[1])?.[1]; + ok(nm === s, `${s}: skill name 与目录名一致`, nm); + ok(/^description:\s*\S/m.test(m[1]), `${s}: 有 description`); + ok(text.includes('--serve'), `${s}: 提到 --serve 这条零 token 路径`); + } + + return `${cmdFiles.length} 个命令 + ${skills.length} 个技能`; +}); + +/* ------------------------------------------------------- 3. 静态检查 */ + +record('static', () => { + newSection('静态检查'); + const files = walk(ROOT); + let json = 0; + let js = 0; + for (const f of files) { + if (!f.endsWith('.json')) continue; + try { + JSON.parse(fs.readFileSync(f, 'utf8')); + json += 1; + } catch (err) { + ok(false, `JSON 非法: ${path.relative(ROOT, f)}`, err.message); + } + } + for (const f of files) { + if (!/\.(mjs|cjs|js)$/.test(f)) continue; + const r = spawnSync(process.execPath, ['--check', f], { encoding: 'utf8', timeout: 20_000 }); + ok(r.status === 0, `语法可解析: ${path.relative(ROOT, f)}`, String(r.stderr || '').split('\n')[0]); + js += 1; + } + return `${json} 个 JSON + ${js} 个 JS`; +}); + +/* -------------------------------------------------------- 4. 密钥与隐私 */ + +record('secrets', () => { + // 别让 API key、本机绝对路径或邮箱被提交进去——这是要公开发布的仓库。 + newSection('密钥与隐私'); + const patterns = [ + [/user_[A-Za-z0-9_-]{16,}/, 'Command Code key'], + [/sk-[A-Za-z0-9]{20,}/, 'OpenAI 风格 key'], + [/ghp_[A-Za-z0-9]{20,}/, 'GitHub token'], + [/github_pat_[A-Za-z0-9_]{20,}/, 'GitHub PAT'], + [/C:[\\/]Users[\\/](?!admin[\\/]\.claude)[A-Za-z0-9._-]+/, '个人绝对路径'], + [/\/Users\/[A-Za-z0-9._-]+/, '个人绝对路径'], + [/[A-Za-z0-9._%+-]+@(?!example\.com|users\.noreply)[A-Za-z0-9.-]+\.[A-Za-z]{2,}/, '邮箱'], + ]; + let scanned = 0; + let hits = 0; + for (const f of walk(ROOT)) { + if (/\.(png|jpg|ico|woff2?|lock)$/.test(f)) continue; + // 本文件自己的规则里就写着这些形态,跳过它 + if (f === fileURLToPath(import.meta.url)) continue; + const text = fs.readFileSync(f, 'utf8'); + for (const [re, label] of patterns) { + const hit = text.match(re); + if (hit) { + hits += 1; + ok(false, `${label} 出现在 ${path.relative(ROOT, f)}`, `${hit[0].slice(0, 32)}…`); + } + } + scanned += 1; + } + ok(hits === 0, `${scanned} 个文件已扫描,无密钥、无本机路径、无邮箱`); + return `${scanned} 个文件`; +}); + +/* ------------------------------------------------------- 5. 状态栏渲染 */ + +record('statusline', () => { + newSection('状态栏渲染'); + let checked = 0; + + // 按量计费套餐的输出是确定的(金额固定、没有时间),可以逐字断言。 + const provider = runCore(['--statusline', '--rows', '1', '--demo', 'provider'], { COLUMNS: '140' }).trim(); + ok(provider === 'CC Provider │ 余额 $47.66', + `按量计费套餐应只显示余额,实际:${JSON.stringify(provider)}`); + checked += 1; + + for (const scenario of ['normal', 'hot', 'max']) { + const line = runCore(['--statusline', '--rows', '1', '--demo', scenario], { COLUMNS: '140' }).trim(); + const name = `--demo ${scenario}`; + ok(line.startsWith('CC '), `${name}: 应以 "CC " 开头,实际 ${JSON.stringify(line.slice(0, 20))}`); + ok(line.split('│').length === 4, `${name}: 单行模式应有 4 段(套餐名 + 三条窗口),实际 ${line.split('│').length}`); + ok(/\d+%/.test(line), `${name}: 应含百分比`); + ok(line.includes('重置'), `${name}: 三条窗口都该带重置时间`); + // 绝不带 ANSI:钩子的 systemMessage 是纯文本,带上会原样显示成乱码。 + ok(!/\x1b\[/.test(runCore(['--statusline', '--rows', '1', '--demo', scenario])), `${name}: 不应输出 ANSI`); + checked += 1; + + const three = runCore(['--statusline', '--demo', scenario], { COLUMNS: '140' }).trim(); + ok(three.split('\n').length === 3, `${name}: 三行模式应输出 3 行`); + checked += 1; + } + + // 宽度自适应:任何终端宽度下都不能折行(折行会让整个底部错位)。 + for (const cols of ['200', '140', '120', '110', '100', '95', '90', '80']) { + const line = runCore(['--statusline', '--rows', '1', '--demo'], { COLUMNS: cols }).trim(); + const w = displayWidth(line); + ok(w <= Number(cols), `COLUMNS=${cols}: 行宽 ${w} 超了`); + ok(!line.includes('\n'), `COLUMNS=${cols}: 不该折行`); + checked += 1; + } + + return `${checked} 项渲染断言`; +}); + +/* ---------------------------------------------------- 6. 去向判定表 */ + +record('gating', async () => { + // 直接测判定函数,不跑整条流水线:整条要凭证、要联网,CI 上两样都没有。 + newSection('去向判定(这一轮走没走 Command Code)'); + const { decideRoute, normalizeModel, routeDecision } = await import(pathToFileURL(CORE).href); + const catalog = ['deepseek-v4.1-flash', 'claude-opus-5', 'kimi-k2.7-code']; + + ok(normalizeModel('deepseek/deepseek-v4.1-flash') === 'deepseek-v4.1-flash', '归一化应去掉 vendor 前缀'); + ok(normalizeModel('claude-opus-5[1M]') === 'claude-opus-5', '归一化应去掉 [1M] 这类后缀'); + ok(normalizeModel('K2.7 Code') === 'k2.7-code', '归一化应把空白折成连字符'); + + ok(decideRoute('deepseek/deepseek-v4.1-flash', catalog) === 'yes', '目录里有的模型 -> 在用'); + ok(decideRoute('totally-made-up-xyz', catalog) === 'no', '目录里没有 -> 不在用'); + ok(decideRoute(null, catalog) === 'unknown', '拿不到模型名 -> 未知,交给下一级判据'); + ok(decideRoute('deepseek-v4.1-flash', null) === 'unknown', '没有目录 -> 未知,不猜'); + + // 裸 claude-* 名字原生也有,必须回避而不是当成命中 + ok(decideRoute('claude-opus-5', catalog) === 'unknown', 'claude-* 有歧义 -> 不猜'); + ok(decideRoute('claude-opus-5', catalog, { trustedSource: true }) === 'yes', + '来自本地路由映射的 claude-* 是确定的,应当显示'); + + // 用户自己补的别名优先于目录 + ok(decideRoute('kimi-k2.7-code', catalog, { modelPatterns: ['k2.7-code'] }) === 'yes', '用户别名应命中'); + ok(decideRoute('deepseek-v4.1-flash', catalog, { modelPatterns: ['k2.7-code'] }) === 'no', + '给了别名就按别名来,不再看目录'); + + // 各种宿主的 stdin 形状不同,routeDecision 必须都认。 + // 显式传空的 env:不然结果取决于跑测试那台机器有没有设本地路由的模型映射, + // 那正是上一个版本「本地过 CI 挂」的原因。 + const stringModel = routeDecision( + { model: 'gpt-5.6-terra', transcript_path: '' }, + { catalog: [...catalog, 'gpt-5.6-terra'], env: {} }); + ok(stringModel.decision === 'yes', '字符串形式的 model 应当被认出来'); + + const outside = routeDecision({ model: 'gpt-5.6-terra', transcript_path: '' }, { catalog, env: {} }); + ok(outside.decision === 'no', '给的模型不在目录里就该隐藏'); + + const noModel = routeDecision({ transcript_path: '' }, { catalog, env: {} }); + ok(noModel.decision === 'unknown', '没给 model 时是未知,不是「不在用」'); + + const objModel = routeDecision({ model: { id: 'claude-opus-5[1M]' }, transcript_path: '' }, { catalog, env: {} }); + ok(objModel.decision === 'unknown', '对象形状的 model 不该被当成模型名——真实模型在 transcript 里'); + + return '15 项判定断言'; +}); + +/* ------------------------------------------------------- 7. 阈值与钩子 */ + +record('threshold+hook', () => { + newSection('阈值与钩子'); + const under = runCore(['--statusline', '--threshold', '70', '--demo']).trim(); + ok(under === '', `未过阈值不该有输出,实际:${JSON.stringify(under.slice(0, 40))}`); + + const over = runCore(['--statusline', '--threshold', '30', '--demo']).trim(); + ok(over.startsWith('CC '), '过了阈值应输出面板'); + + // 钩子必须吐合法 JSON,且 systemMessage 是纯文本 + const hook = runCore(['--hook', '--always', '--demo']).trim(); + let parsed = null; + try { parsed = JSON.parse(hook); } catch { /* 下面断言会报 */ } + ok(parsed && typeof parsed.systemMessage === 'string', + `钩子应输出 {"systemMessage": …},实际:${hook.slice(0, 60)}`); + ok(parsed && !/\x1b\[/.test(parsed.systemMessage), 'systemMessage 不能含 ANSI(会原样显示成乱码)'); + ok(parsed && !parsed.hookSpecificOutput, + '钩子不该用 additionalContext——那会进模型上下文、每轮烧 token'); + + return '阈值静默 + 钩子 JSON 形状'; +}); + +/* --------------------------------------------------------- 8. 输出模式 */ + +record('formats', () => { + newSection('输出模式'); + let n = 0; + for (const [args, marker, name] of [ + [['--demo'], 'Command Code', '终端面板'], + [['--md', '--demo'], '|', 'Markdown'], + [['--compact', '--demo'], 'CC GOAT', '单行摘要'], + ]) { + const out = runCore(args); + ok(out.includes(marker), `${name} 应包含 ${JSON.stringify(marker)}`); + n += 1; + } + const json = runCore(['--json', '--demo']); + let doc = null; + try { doc = JSON.parse(json); } catch { /* 断言会报 */ } + ok(doc && doc.plan && doc.windows && doc.monthly, '--json 应是自洽快照'); + n += 1; + + // 本仓库最容易被「合并上游」时丢掉的两条路径:--serve 与 --watch。 + const help = runCore(['--help']); + ok(help.includes('--serve'), '--help 应列出 --serve'); + ok(help.includes('--watch'), '--help 应列出 --watch'); + ok(help.includes('--port'), '--help 应列出 --port'); + n += 1; + + return `${n} 种输出`; +}); + +/* ----------------------------------------------------------- 9. --serve */ + +record('serve', async () => { + // --serve 是本仓库独有的那条路径,而它偏偏只有真起服务才验得到。 + // 这里在回环上放一个桩接口冒充 Command Code 的接口,凭证指向它, + // 于是整个过程不联网也能把 起服务 → 请求 → 断言 HTML → 关掉 跑完。 + newSection('--serve 端到端'); + const now = Date.now(); + const payloads = { + '/alpha/whoami': { + user: { id: 'u1', userName: 'check', name: 'Check', email: 'check@example.com' }, + org: { id: 'org-1', name: 'Check Org' }, + orgLimits: [], + }, + '/alpha/billing/credits': { + credits: { monthlyCredits: 46.25, purchasedCredits: 0, freeCredits: 0 }, + windowLimits: { + limited: true, + fiveHour: { used: 3.5, cap: 14, resetAt: now + 2 * 3600_000 }, + weekly: { used: 4.5, cap: 35, resetAt: now + 3 * 86400_000 }, + }, + }, + '/alpha/billing/subscriptions': { + data: { + planId: 'individual-goat', + status: 'active', + currentPeriodStart: new Date(now - 10 * 86400_000).toISOString(), + currentPeriodEnd: new Date(now + 20 * 86400_000).toISOString(), + }, + }, + '/alpha/usage/summary': { + requests: 320, completed: 320, failed: 0, successRate: 100, + totalCost: 8, averageCost: 0.025, tokensIn: 1_000_000, tokensOut: 10_000, + }, + }; + + const API_KEY = 'user_check0000000000'; + const seenAuth = []; + const stub = http.createServer((req, res) => { + const route = new URL(req.url, 'http://stub').pathname; + seenAuth.push(req.headers.authorization ?? ''); + const body = payloads[route]; + if (!body) { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end('{}'); return; } + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(body)); + }); + await new Promise((r) => stub.listen(0, '127.0.0.1', r)); + const stubPort = stub.address().port; + + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'cc-serve-')); + fs.mkdirSync(path.join(home, '.zcode', 'v2'), { recursive: true }); + fs.writeFileSync( + path.join(home, '.zcode', 'v2', 'provider_config.json'), + JSON.stringify({ + providers: [{ + id: 'commandcode', + api: { baseUrl: `http://127.0.0.1:${stubPort}/commandcode.ai` }, + access: { apiKey: API_KEY }, + }], + }), + ); + + const freePort = () => new Promise((r) => { + const s = http.createServer(); + s.listen(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => r(p)); }); + }); + + let child = null; + try { + let base = null; + let stderr = ''; + // 挑端口和真正 listen 之间有窗口,被别的进程抢了就换一个再来。 + for (let attempt = 0; attempt < 3 && !base; attempt++) { + const port = await freePort(); + stderr = ''; + const proc = spawn(process.execPath, [CORE, '--serve', '--port', String(port)], { + env: { + ...process.env, + HOME: home, + USERPROFILE: home, + COMMAND_CODE_API_KEY: '', + COMMANDCODE_API_KEY: '', + CMD_API_KEY: '', + NO_COLOR: '1', + }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + proc.stdout.setEncoding('utf8'); + proc.stderr.setEncoding('utf8'); + proc.stdout.on('data', () => {}); + proc.stderr.on('data', (d) => { stderr += d; }); + + const url = `http://127.0.0.1:${port}/`; + for (let i = 0; i < 40 && !base; i++) { + await new Promise((r) => setTimeout(r, 250)); + if (proc.exitCode !== null) break; + try { + const probe = await fetch(url); + await probe.arrayBuffer(); + if (probe.ok) base = url; + } catch { /* 还没起来 */ } + } + if (base) child = proc; + else proc.kill(); + } + + ok(Boolean(base), '--serve 应在本机回环上起来(默认端口被占时用 --port 指定)', stderr.trim().slice(0, 200)); + + if (base) { + // 1) 面板本体:必须是真 HTML,而不是一段纯文本或报错页。 + const page = await fetch(base); + const html = await page.text(); + ok(page.status === 200, `GET / 应返回 200,实际 ${page.status}`); + ok(String(page.headers.get('content-type') || '').includes('text/html'), + `GET / 应是 text/html,实际 ${page.headers.get('content-type')}`); + ok(html.includes('') && html.includes('Command Code'), 'GET / 应返回面板 HTML'); + ok(html.includes('GOAT'), 'GET / 的 HTML 应已渲染桩接口的数据'); + + // 2) 同一份数据的 JSON 出口,数值要和桩接口对得上。 + const api = await fetch(`${base}api/usage`); + let view = null; + try { view = await api.json(); } catch { /* 断言会报 */ } + ok(api.status === 200 && view, `GET /api/usage 应返回 JSON,实际 ${api.status}`); + ok(view?.plan?.name === 'GOAT', `JSON 里的套餐应是 GOAT,实际 ${view?.plan?.name}`); + ok(Math.round(view?.windows?.fiveHour?.percent ?? 0) === 25, 'JSON 里 5 小时窗口应是 25%(3.5 / 14)'); + + // 3) 未知路径不该被当成面板。 + const missing = await fetch(`${base}nope`); + await missing.arrayBuffer(); + ok(missing.status === 404, `未知路径应 404,实际 ${missing.status}`); + + // 4) 取数只走桩接口,且每次都带用户的 key。 + ok(seenAuth.length > 0 && seenAuth.every((a) => a === `Bearer ${API_KEY}`), + '桩接口收到的每个请求都应带 Authorization: Bearer '); + } + } finally { + if (child) child.kill(); + stub.close(); + fs.rmSync(home, { recursive: true, force: true }); + } + + return '起服务 → 请求 → 断言 HTML → 关掉'; +}); + +/* ---------------------------------------------------------- 10. 安装器 */ + +record('installer', () => { + newSection('用户级安装器'); + const SETUP = path.join(PLUGIN, 'scripts', 'install-user-scope.mjs'); + const VERIFY = path.join(PLUGIN, 'scripts', 'verify-discoverable.cjs'); + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cc-install-')); + const foreign = fs.mkdtempSync(path.join(os.tmpdir(), 'cc-install-foreign-')); + const cmdRel = path.join('.zcode', 'commands', 'quota.md'); + const usageRel = path.join('.zcode', 'commands', 'usage.md'); + const skillRel = path.join('.zcode', 'skills', 'command-code-usage', 'SKILL.md'); + + const run = (script, args, home) => spawnSync(process.execPath, [script, ...args], { + encoding: 'utf8', + timeout: 30_000, + env: { ...process.env, HOME: home, USERPROFILE: home }, + }); + + try { + // 1) 冲突保护:用户自己写的同名文件必须原样留着,且安装器要非零退出。 + fs.mkdirSync(path.join(foreign, '.zcode', 'commands'), { recursive: true }); + fs.writeFileSync(path.join(foreign, cmdRel), '我自己的命令\n', 'utf8'); + const blocked = run(SETUP, [], foreign); + ok(blocked.status !== 0, '存在用户自己写的同名命令时应中止', String(blocked.stdout || '').slice(0, 120)); + ok(fs.readFileSync(path.join(foreign, cmdRel), 'utf8') === '我自己的命令\n', '用户自己的文件未被覆盖'); + ok(!fs.existsSync(path.join(foreign, skillRel)), '中止时不应写入技能'); + + // 2) --dry-run 只报计划,不落盘。 + const dry = run(SETUP, ['--dry-run'], root); + ok(dry.status === 0, '--dry-run 应以 0 退出', String(dry.stderr || '').slice(0, 120)); + ok(!fs.existsSync(path.join(root, cmdRel)), '--dry-run 不应写文件'); + + // 3) 干净安装:命令与技能都到位,且正文里的占位符被换成了真实存在的绝对路径。 + const installed = run(SETUP, [], root); + ok(installed.status === 0, '全新安装应以 0 退出', String(installed.stderr || '').slice(0, 120)); + ok(fs.existsSync(path.join(root, cmdRel)) && fs.existsSync(path.join(root, usageRel)), '两个命令都已安装'); + ok(fs.existsSync(path.join(root, skillRel)), '技能已安装'); + const body = fs.readFileSync(path.join(root, cmdRel), 'utf8'); + ok(!body.includes('@@CC_USAGE_SCRIPT@@'), '安装后占位符应已替换'); + const scriptPath = /CC_SCRIPT="([^"]+)"/.exec(body)?.[1]; + ok(scriptPath && fs.existsSync(scriptPath), `替换后的脚本路径应真实存在(${scriptPath})`); + ok(fs.existsSync(path.join(root, '.zcode', 'skills', 'command-code-usage', '.installed.json')), + '安装清单已写入(下次更新靠它判断哪些文件是本插件写的)'); + + // 4) 装完就能被 ZCode 发现:用复刻的解析器验一遍,error 级诊断一个都不该有。 + const discovery = run(VERIFY, ['.'], root); + const out = String(discovery.stdout || ''); + ok(discovery.status === 0, 'verify-discoverable 应以 0 退出'); + ok(out.includes('/quota') && out.includes('/usage'), 'ZCode 解析器应发现 /quota 与 /usage'); + ok(!/\[error\]/.test(out), '不应有 error 级诊断', out.split('\n').filter((l) => l.includes('[error]')).join(' | ')); + ok(out.includes('脚本路径') && out.includes('✓ 存在'), '诊断应报出安装注入的脚本路径存在'); + + // 5) 卸载:副本清掉,插件目录不动。 + const removed = run(SETUP, ['--uninstall'], root); + ok(removed.status === 0, '--uninstall 应以 0 退出'); + ok(!fs.existsSync(path.join(root, cmdRel)) && !fs.existsSync(path.join(root, skillRel)), '卸载后副本都不在'); + ok(fs.existsSync(CORE), '卸载不动插件目录本身'); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + fs.rmSync(foreign, { recursive: true, force: true }); + } + + return '冲突保护 + 安装/发现/卸载'; +}); + +/* ------------------------------------------------------------ 执行 */ + +console.log('Command Code Usage(ZCode 插件)— 发布前检查\n'); +let failed = 0; + +for (const { name, fn } of suites) { + failures = []; + const started = Date.now(); + let summary = ''; + let thrown = null; + try { + summary = (await fn()) ?? ''; + } catch (err) { + thrown = err instanceof Error ? err.message : String(err); + } + const ms = Date.now() - started; + + if (!thrown && failures.length === 0) { + console.log(`${QUIET ? '' : ' ok '}${name.padEnd(14)} ${summary} (${ms}ms)`); + } else { + failed += 1; + console.log(`${QUIET ? '' : ' FAIL '}${name.padEnd(14)} — (${ms}ms)`); + if (thrown) console.log(` 套件抛错:${thrown}`); + for (const f of failures) console.log(` ${f}`); + } +} + +console.log(''); +if (failed > 0) { + console.log(`${failed} 个套件失败。`); + process.exit(1); +} +console.log(`检查项 ${checks},${suites.length} 个套件全部通过。`); diff --git a/scripts/make-icon.mjs b/scripts/make-icon.mjs new file mode 100644 index 0000000..b77a308 --- /dev/null +++ b/scripts/make-icon.mjs @@ -0,0 +1,193 @@ +#!/usr/bin/env node +/** + * 生成插件图标 —— assets/command-code-usage/icon.png + * + * node scripts/make-icon.mjs + * + * 为什么不用图像生成模型:这就是一块纯几何图形(深色圆角底 + 三条渐变进度条), + * 代码画出来的结果确定、可复现、随仓库走,也不必依赖外部服务或 API key。 + * + * 画面内容对应该插件本身的形态:深色底 + 三条不同长度的进度条, + * 即面板里的 5 小时窗口 / 每周窗口 / 月度额度。 + * + * 实现:4 倍超采样 + 有符号距离场求覆盖率,再盒式降采样,得到抗锯齿边缘。 + * 只用 Node 内置模块(zlib 做 PNG 的 deflate)。 + */ + +import zlib from 'node:zlib'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const OUT = path.join(ROOT, 'assets', 'command-code-usage', 'icon.png'); + +const SIZE = 256; +const SS = 4; // 超采样倍数 +const W = SIZE * SS; + +/* ------------------------------------------------------------------ 绘图参数 */ + +const BG = [0x0b, 0x0d, 0x12]; // #0b0d12 +const BORDER = [0x2a, 0x30, 0x40]; // 细边,避免深色图标在深色界面上糊成一片 +const TRACK = [0x23, 0x28, 0x37]; // #232837 +const GRAD_FROM = [0x34, 0xd3, 0x99]; // #34d399 低用量 +const GRAD_TO = [0xa7, 0x8b, 0xfa]; // #a78bfa 高用量 +const LEVELS = [0.72, 0.44, 0.24]; // 与面板的三个进度条对应 + +const TILE = { x: 6, y: 6, w: 244, h: 244, r: 56 }; +const BAR = { x: 40, w: 176, h: 28, r: 14, gap: 18 }; + +/* ------------------------------------------------------------ 距离场与采样 */ + +// 圆角矩形有符号距离:<0 在内部,>0 在外部 +function sdRoundRect(px, py, cx, cy, hw, hh, r) { + const qx = Math.abs(px - cx) - (hw - r); + const qy = Math.abs(py - cy) - (hh - r); + const ax = Math.max(qx, 0); + const ay = Math.max(qy, 0); + return Math.hypot(ax, ay) + Math.min(Math.max(qx, qy), 0) - r; +} + +// 把距离换算成覆盖率(1px 过渡带),用于超采样下的平滑边缘 +function coverage(d) { + return Math.min(Math.max(0.5 - d, 0), 1); +} + +const lerp = (a, b, t) => a + (b - a) * t; +const mix = (c1, c2, t) => [lerp(c1[0], c2[0], t), lerp(c1[1], c2[1], t), lerp(c1[2], c2[2], t)]; + +/* -------------------------------------------------------------- 渲染(超采样) */ + +const buf = new Float32Array(W * W * 4); // RGBA,线性 0..1 + +function setPx(x, y, color, alpha) { + if (alpha <= 0) return; + const i = (y * W + x) * 4; + const a = Math.min(alpha, 1); + buf[i] = buf[i] * (1 - a) + (color[0] / 255) * a; + buf[i + 1] = buf[i + 1] * (1 - a) + (color[1] / 255) * a; + buf[i + 2] = buf[i + 2] * (1 - a) + (color[2] / 255) * a; + buf[i + 3] = buf[i + 3] * (1 - a) + a; +} + +// 三条进度条的几何位置(居中) +const bars = LEVELS.map((level, i) => { + const totalH = BAR.h * LEVELS.length + BAR.gap * (LEVELS.length - 1); + const top = (SIZE - totalH) / 2; + const y = top + i * (BAR.h + BAR.gap); + return { level, cy: y + BAR.h / 2 }; +}); + +for (let py = 0; py < W; py++) { + for (let px = 0; px < W; px++) { + const x = (px + 0.5) / SS; + const y = (py + 0.5) / SS; + + // 底:圆角方块 + const dTile = sdRoundRect(x, y, TILE.x + TILE.w / 2, TILE.y + TILE.h / 2, TILE.w / 2, TILE.h / 2, TILE.r); + setPx(px, py, BG, coverage(dTile)); + + // 描边:外缘附近一圈 + const borderBand = coverage(dTile) * (1 - coverage(dTile - 1.2)); + setPx(px, py, BORDER, borderBand * 0.85); + + // 三条进度条 + for (const bar of bars) { + const cx = BAR.x + BAR.w / 2; + const dTrack = sdRoundRect(x, y, cx, bar.cy, BAR.w / 2, BAR.h / 2, BAR.r); + const covTrack = coverage(dTrack); + if (covTrack <= 0) continue; + setPx(px, py, TRACK, covTrack); + + const fillW = Math.max(BAR.h, BAR.w * bar.level); + const dFill = sdRoundRect(x, y, BAR.x + fillW / 2, bar.cy, fillW / 2, BAR.h / 2, BAR.r); + const covFill = coverage(dFill); + if (covFill <= 0) continue; + + // 渐变按轨道整体位置取样:短条停在绿色端,长条延伸到紫色端 + const t = Math.min(Math.max((x - BAR.x) / BAR.w, 0), 1); + setPx(px, py, mix(GRAD_FROM, GRAD_TO, t), covFill); + } + } +} + +/* ------------------------------------------------------------- 降采样 + 编码 */ + +const out = Buffer.alloc(SIZE * SIZE * 4); +for (let y = 0; y < SIZE; y++) { + for (let x = 0; x < SIZE; x++) { + let r = 0, g = 0, b = 0, a = 0; + for (let dy = 0; dy < SS; dy++) { + for (let dx = 0; dx < SS; dx++) { + const i = ((y * SS + dy) * W + (x * SS + dx)) * 4; + const sa = buf[i + 3]; + // 按 alpha 加权,避免边缘出现暗边 + r += buf[i] * sa; + g += buf[i + 1] * sa; + b += buf[i + 2] * sa; + a += sa; + } + } + const n = SS * SS; + const o = (y * SIZE + x) * 4; + if (a > 0) { + out[o] = Math.round(Math.min(r / a, 1) * 255); + out[o + 1] = Math.round(Math.min(g / a, 1) * 255); + out[o + 2] = Math.round(Math.min(b / a, 1) * 255); + } + out[o + 3] = Math.round((a / n) * 255); + } +} + +function crc32(buf) { + let c; + const table = crc32.table ?? (crc32.table = (() => { + const t = new Int32Array(256); + for (let n = 0; n < 256; n++) { + c = n; + for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + t[n] = c; + } + return t; + })()); + let crc = -1; + for (let i = 0; i < buf.length; i++) crc = (crc >>> 8) ^ table[(crc ^ buf[i]) & 0xff]; + return (crc ^ -1) >>> 0; +} + +function chunk(type, data) { + const len = Buffer.alloc(4); + len.writeUInt32BE(data.length); + const body = Buffer.concat([Buffer.from(type, 'ascii'), data]); + const crc = Buffer.alloc(4); + crc.writeUInt32BE(crc32(body)); + return Buffer.concat([len, body, crc]); +} + +const ihdr = Buffer.alloc(13); +ihdr.writeUInt32BE(SIZE, 0); +ihdr.writeUInt32BE(SIZE, 4); +ihdr[8] = 8; // bit depth +ihdr[9] = 6; // colour type: RGBA +ihdr[10] = 0; // deflate +ihdr[11] = 0; // adaptive filtering +ihdr[12] = 0; // no interlace + +// 每行前加一个 filter 字节 0 +const raw = Buffer.alloc(SIZE * (SIZE * 4 + 1)); +for (let y = 0; y < SIZE; y++) { + raw[y * (SIZE * 4 + 1)] = 0; + out.copy(raw, y * (SIZE * 4 + 1) + 1, y * SIZE * 4, (y + 1) * SIZE * 4); +} + +const png = Buffer.concat([ + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + chunk('IHDR', ihdr), + chunk('IDAT', zlib.deflateSync(raw, { level: 9 })), + chunk('IEND', Buffer.alloc(0)), +]); + +fs.mkdirSync(path.dirname(OUT), { recursive: true }); +fs.writeFileSync(OUT, png); +console.log(`已写入 ${path.relative(ROOT, OUT)} ${SIZE}x${SIZE} ${(png.length / 1024).toFixed(1)} KB`);