11 Commits
Author SHA1 Message Date
yuez d7733552ee feat: commandcode usage query via /alpha endpoints with Provider API key (v0.5.0)
- New config commandcode_api_key (user_... provider key, long-lived);
  when set, usage queries hit /alpha/billing/credits + /alpha/usage/summary
  with Bearer auth — no more 7-day session cookie rotation
- session_token kept as optional fallback (legacy /internal + cookie path)
- alpha credits lack opensourceMonthlyCredits; formatter tolerates
- alpha non-200 passes through status without echoing upstream body
2026-09-23 17:13:01 +08:00
yuez c2b8d89ba6 fix(quota): declare monthlyTargetTime (undeclared since v0.4.2 refactor), ReferenceError when Command Code data absent; add undeclared-identifier scan to pagecheck (v0.4.5) 2026-09-23 10:09:23 +08:00
yuez 9d1884cf20 feat(quota): All tab Command Code card shows Plan + 5h/weekly/monthly windows with reset times (v0.4.4) 2026-09-20 15:25:29 +08:00
yuez 447ce65c9b feat(quota): All tab first in tab bar; remove header usage-alert badge (v0.4.3)
- Tab order: All | Command Code | OpenCode Go (All remains the default)
- Drop the global status badge in the header action row and its
  updateGlobalBadge aggregation; per-card chips and All-tab provider
  badges are unaffected
2026-09-20 14:52:24 +08:00
yuez 4fdc9ad6c8 feat(quota): All as default tab with per-key OpenCode cards grouped by provider, bump v0.4.2 2026-09-20 14:20:01 +08:00
yuez 2f1b2fb821 build: pagecheck as standalone node script (inline escaping was fragile) 2026-09-20 13:18:21 +08:00
yuez 3097d96a8e chore: bump v0.4.1 to force plugin re-sync with fixed page (v0.4.0 asset shipped with broken JS) 2026-09-20 13:00:35 +08:00
yuez 0a9213ec38 fix(quota): remove duplicate const lastUpdated declaration that broke JS parse
The v0.4.0 UI refactor left two top-level const lastUpdated declarations;
a SyntaxError at script parse time took down the whole quota page.
Also added a node --check based syntax verification to the release flow.
2026-09-20 12:59:46 +08:00
yuez 553226c96e feat: multi-key OpenCode Go usage + quota page redesign, bump v0.4.0
- support multiple OpenCode API keys for Go usage and quota queries
- redesigned quota page UI
- update README and tests
2026-09-20 10:50:06 +08:00
yuez 61c50280b1 feat: add OpenCode Go usage query with aggregated /all endpoint, bump v0.3.0
- New provider: OpenCode Go (GET https://opencode.ai/zen/go/v1/usage, Bearer auth)
- Extract shared transport doUpstreamRequest (host.http.do first, net/http fallback)
- New management routes: GET/POST /plugins/commandcode/opencode/usage, GET/POST /plugins/commandcode/all
- /all aggregates both providers sequentially with partial-failure semantics
  (>=1 success -> 200, all-local-missing -> 400, all-upstream-failure -> 502)
- QuotaCard UI: tabs (Command Code / OpenCode Go / All), OpenCode window cards,
  version badge v0.3.0, OpenCode API key test override in settings drawer
- Config: opencode_api_key / opencode_api_base (ConfigFields 2 -> 4)
- Tests: route-order regression, /all partial failure & misclassification guards,
  ParseOpenCodeUsage edge cases, host/http transport paths
2026-09-18 09:38:40 +08:00
zgs225 43c09885e5 chore: stop tracking release artifacts (uploaded to GitHub Releases) 2026-09-10 11:10:47 +08:00
14 changed files with 3557 additions and 109 deletions
+8
View File
@@ -15,3 +15,11 @@ coverage.txt
.idea/
.vscode/
*.swp
# Release artifacts (attached to GitHub Releases, not tracked in git)
*.zip
checksums.txt
dist/
# Internal planning docs (not for public repo)
docs/
+9 -2
View File
@@ -7,15 +7,22 @@ else
TARGET := commandcode.so
endif
.PHONY: all build test clean lint
.PHONY: all build test clean lint pagecheck
all: build
all: build pagecheck
build:
CGO_ENABLED=1 go build -buildmode=c-shared -o $(TARGET) main.go
# Extract embedded JS from quota_page.go and syntax-check it with node.
# Guards against parse-time SyntaxErrors (e.g. duplicate const) that break
# the whole resource page; Go substring tests cannot catch these.
pagecheck:
@node scripts/pagecheck.js
test:
go test -v -race ./...
$(MAKE) pagecheck
clean:
rm -f commandcode.dylib commandcode.so commandcode.dll commandcode.h
+94 -6
View File
@@ -4,7 +4,7 @@
[![CLIProxyAPI Plugin ABI](https://img.shields.io/badge/C%20ABI-v1-emerald.svg)](https://help.router-for.me/plugin/development.html)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
[CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI) 动态 C ABI 插件,用于提供 **Command Code** 上游配额与窗口限额查询、以及嵌入式配额监控仪表盘卡片(QuotaCard)。
[CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI) 动态 C ABI 插件,用于提供 **Command Code** 与 **OpenCode Go** 两个上游的配额与窗口限额查询、以及嵌入式配额监控仪表盘卡片(QuotaCard,Tab: Command Code / OpenCode Go / All)。
---
@@ -20,6 +20,8 @@
- [1. 浏览器资源页 (`QuotaCard`)](#1-浏览器资源页-quotacard)
- [2. 管理 API: 查询用量 (`GET`)](#2-管理-api-查询用量-get)
- [3. 管理 API: 测试用量 (`POST`)](#3-管理-api-测试用量-post)
- [4. 管理 API: OpenCode Go 用量 (`opencode/usage`)](#4-管理-api-opencode-go-用量-opencodeusage)
- [5. 管理 API: 聚合查询 (`all`)](#5-管理-api-聚合查询-all)
- [用量数据结构说明](#用量数据结构说明)
- [开发与测试](#开发与测试)
- [许可证](#许可证)
@@ -38,13 +40,18 @@
- 支持在 `config.yaml` 配置或在配额页面上直接输入。
- 支持纯 token 或完整 Cookie 字符串(自动提取 `__Secure-commandcode_prod_.session_token`)。
4. **精确用量与双滑动窗口限额解析**:
- 上游接口:`GET https://api.commandcode.ai/internal/billing/credits`。
- 上游接口(v0.5.0+):配置 `commandcode_api_key`(Provider API key,长期凭据)时走 `GET https://api.commandcode.ai/alpha/billing/credits` 与 `/alpha/usage/summary`(Bearer 认证);否则回退 session cookie 查 `/internal/billing/credits`。
- 请求优先走宿主提供的 `host.http.do` 回调(复用宿主代理、日志与鉴权管道),离线或未注入宿主时自动无缝降级至 Go 标准 `net/http`。
- 全面解析 `credits`(月度基础额度、开源奖励额度、总可用额度)与 `windowLimits`(5小时短期滑动窗口、周度窗口限额,计算已用量、上限、剩余量、使用百分比及重置时间)。
5. **嵌入式纯单文件 QuotaCard 资源页**:
- 页面挂载于 `/v0/resource/plugins/commandcode/quota`。
- 零外部 CDN 依赖,纯内置 HTML + CSS + JS,深色/浅色模式自适应。
- 具有进度条颜色变化、5小时/周限额卡片、秒级动态重置倒计时、同源 `localStorage` 鉴权与一键刷新。
- Tab 切换:Command Code / OpenCode Go / All(`#opencode` / `#all` hash 记忆状态)。
6. **OpenCode Go 用量查询 (v0.3.0+)**:
- 上游接口:`GET https://opencode.ai/zen/go/v1/usage`,`Authorization: Bearer` 认证(同样走 `host.http.do` 优先 + `net/http` 兜底)。
- 解析 rolling(5h)/ weekly / monthly 三个窗口的 `status`/`percent`/`resetsAt`,容忍未知 status 值。
- 聚合端点 `/plugins/commandcode/all` 一次返回两个 provider,部分失败不拖死另一 provider。
---
@@ -77,6 +84,7 @@
│ Upstream HTTPS
▼
https://api.commandcode.ai/internal/billing/credits
https://opencode.ai/zen/go/v1/usage (v0.3.0+)
```
---
@@ -124,8 +132,14 @@ plugins:
commandcode:
enabled: true
priority: 1
session_token: "YOUR_COMMANDCODE_SESSION_TOKEN" # 支持纯 token 或完整 Cookie 字符串
session_token: "YOUR_COMMANDCODE_SESSION_TOKEN" # v0.4.5 前唯一凭据;v0.5.0 起为可选回退
commandcode_api_key: "user_YOUR_COMMANDCODE_PROVIDER_KEY" # v0.5.0+ 推荐:非空则用量查询走 /alpha 端点(Bearer),无需 session cookie
api_base: "https://api.commandcode.ai" # 可选,默认为官方接口
opencode_api_key: "sk-YOUR_OPENCODE_GO_API_KEY" # 可选(单 key 兑底,v0.3.0+)
# v0.4.0+ 多 key:list 优先于单 key 字段,每 key 独立账号独立配额窗口
opencode_api_keys:
- "sk-KEY1..."
- "sk-KEY2..."
```
---
@@ -135,11 +149,11 @@ plugins:
### 1. 浏览器资源页 (`QuotaCard`)
- **访问路径**:`GET http://<cpa-host>:8317/v0/resource/plugins/commandcode/quota`
- **菜单名**:`Command Code 配额`
- **菜单名**:`用量配额`
- **说明**:
- 资源请求本身无需经过管理认证,可在浏览器中直接打开或嵌入仪表盘。
- 在同源模式下,页面 JavaScript 会自动读取 `localStorage` 中的管理密钥向 `/v0/management/plugins/commandcode/usage` 请求数据。
- 若在独立或跨域测试环境下打开,页面提供内置的诊断面板,可手动输入 Management Key 或测试 Session Token。
- 在同源模式下,页面 JavaScript 会自动读取 `localStorage` 中的管理密钥向 `/v0/management/plugins/commandcode/all` 请求数据(一次获取 Command Code + OpenCode Go)。
- 若在独立或跨域测试环境下打开,页面提供内置的诊断面板,可手动输入 Management Key、测试 Session Token 或 OpenCode API Key(仅当次请求生效,不持久化)。
### 2. 管理 API: 查询用量 (`GET`)
@@ -198,6 +212,75 @@ plugins:
}
```
### 4. 管理 API: OpenCode Go 用量 (`opencode/usage`)
- **端点**:`GET /v0/management/plugins/commandcode/opencode/usage`(认证同上,仅读插件配置;凭据覆盖走 POST)
- **端点**:`POST /v0/management/plugins/commandcode/opencode/usage`
- **POST 请求体**(多 key 数组优先;scalar 为 v0.3.0 兼容):
```json
{ "opencode_api_keys": ["sk-KEY1", "sk-KEY2"] }
```
- **响应(v0.4.0+,逐 key 结果数组)**:
```json
{
"ok": true,
"provider": "opencode_go",
"keys": [
{
"key_id": "sk-L…KqYB",
"ok": true,
"windows": {
"rolling": { "status": "ok", "percent": 4, "exceeded": false,
"reset_at": "2026-09-17T06:58:53Z", "reset_in_seconds": 2520 },
"weekly": { "status": "ok", "percent": 46, "exceeded": false,
"reset_at": "2026-09-21T00:00:00Z", "reset_in_seconds": 259200 },
"monthly": { "status": "ok", "percent": 23, "exceeded": false,
"reset_at": "2026-10-14T09:13:49Z", "reset_in_seconds": 1728000 }
},
"updated_at": "2026-09-16T12:00:00Z",
"status_code": 200
},
{
"key_id": "sk-U…PNHn",
"ok": false,
"updated_at": "2026-09-16T12:00:01Z",
"status_code": 401,
"error": "opencode upstream returned 401: check opencode_api_key"
}
],
"updated_at": "2026-09-16T12:00:01Z"
}
```
- `key_id` 为服务端脱敏标识(前4+…+后4),原始 key 永不出现在响应中;失败 key 无 `windows` 字段,单 key 失败不影响其他 key。
- **HTTP 状态**:≥1 key 成功 → 200;key 全配但全失败 → 502;未配置任何 key → 400。
### 5. 管理 API: 聚合查询 (`all`)
- **端点**:`GET /v0/management/plugins/commandcode/all`(仅读插件配置)
- **端点**:`POST /v0/management/plugins/commandcode/all`
- **POST 请求体**(可只带其一;多 key 覆盖为数组):
```json
{ "session_token": "...", "opencode_api_keys": ["sk-KEY1", "sk-KEY2"] }
```
- **部分失败语义**:HTTP 200 表示至少一个 provider(Command Code 或 ≥1 个 OpenCode key)成功;失败 provider 记入 `errors`,其响应字段整个省略;全失败且为本地凭据缺失 → 400,全失败且为上游错误 → 502。
```json
{
"ok": true,
"commandcode": { "ok": true, "plan": {...}, "credits": {...}, "window_limits": {...}, "updated_at": "..." },
"opencode": { "ok": true, "provider": "opencode_go", "keys": [ ...同上... ], "updated_at": "..." },
"updated_at": "2026-09-16T12:00:00Z"
}
```
> **v0.4.0 breaking note**:`opencode` 字段从单 key 对象变为 `{ok, provider, keys[], updated_at}` 多 key 结构(keys[].windows 为 v0.3.0 原窗口结构)。唯一消费方是同仓 QuotaCard 资源页,已同版本同步更新。
---
## 用量数据结构说明
@@ -215,6 +298,11 @@ plugins:
| `window_limits.five_hour.reset_at` | `string` | 5小时窗口重置时间的 RFC3339 字符串 |
| `window_limits.five_hour.reset_in_seconds`| `int64` | 距离 5 小时窗口重置的剩余秒数 |
| `window_limits.weekly.*` | - | 每周限额对应指标(结构同 5 小时窗口) |
| `windows.<rolling\|weekly\|monthly>.status` | `string` | OpenCode Go 窗口状态(`"ok"`/上游其他值,未知值不报错) |
| `keys[].key_id` | `string` | 服务端脱敏 key 标识(前4+…+后4),原始 key 不出响应 |
| `keys[].ok` | `bool` | 该 key 查询是否成功(单 key 401 隔离) |
| `keys[].windows.<...>` | `object` | 成功 key 的三窗口指标(结构同上;失败 key 无此字段) |
| `keys[].status_code` / `error` | `int` / `string` | 该 key 上游 HTTP 状态与失败原因 |
---
-1
View File
@@ -1 +0,0 @@
7830fab0eb763247602fd4815549a13eeb25841afbe2d3a047d25b9f4b9cf86d commandcode_0.2.2_linux_amd64.zip
Binary file not shown.
+400 -13
View File
@@ -3,6 +3,7 @@ package plugin
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
@@ -22,12 +23,32 @@ func RegisterManagement() (ManagementRegistrationResponse, error) {
Path: "/plugins/commandcode/usage",
Description: "Query Command Code credits and window limits usage with custom session_token",
},
{
Method: http.MethodGet,
Path: "/plugins/commandcode/opencode/usage",
Description: "Query OpenCode Go usage windows (rolling/weekly/monthly)",
},
{
Method: http.MethodPost,
Path: "/plugins/commandcode/opencode/usage",
Description: "Query OpenCode Go usage windows with custom opencode_api_key",
},
{
Method: http.MethodGet,
Path: "/plugins/commandcode/all",
Description: "Query both Command Code and OpenCode Go usage (aggregated, partial failures reported in errors map)",
},
{
Method: http.MethodPost,
Path: "/plugins/commandcode/all",
Description: "Query both providers with custom credentials in request body",
},
},
Resources: []ResourceRoute{
{
Path: "/quota",
Menu: "Command Code 配额",
Description: "Command Code 用量与限额卡片",
Menu: "用量配额",
Description: "Command Code + OpenCode Go 用量与限额卡片",
},
},
}, nil
@@ -49,7 +70,42 @@ func HandleManagement(ctx context.Context, req ManagementRequest, cfg *PluginCon
}, nil
}
// 2. Serve Usage API (GET / POST)
// 2. OpenCode Go usage API — MUST be matched before the generic /usage
// suffix match below, otherwise "/plugins/commandcode/opencode/usage"
// would be swallowed by the Command Code handler.
if strings.HasSuffix(path, "/plugins/commandcode/opencode/usage") {
switch method {
case http.MethodGet, http.MethodPost:
return handleOpenCodeUsage(ctx, req, cfg)
default:
return ManagementResponse{
StatusCode: http.StatusMethodNotAllowed,
Headers: map[string][]string{
"Content-Type": {"application/json"},
},
Body: []byte(`{"ok":false,"error":"method not allowed"}`),
}, nil
}
}
// 3. Aggregated usage API (both providers) — does not end with "/usage",
// but registered before the generic match for clarity and future safety.
if strings.HasSuffix(path, "/plugins/commandcode/all") {
switch method {
case http.MethodGet, http.MethodPost:
return handleAllUsage(ctx, req, cfg)
default:
return ManagementResponse{
StatusCode: http.StatusMethodNotAllowed,
Headers: map[string][]string{
"Content-Type": {"application/json"},
},
Body: []byte(`{"ok":false,"error":"method not allowed"}`),
}, nil
}
}
// 4. Command Code usage API (GET / POST) — generic suffix match kept as-is.
if strings.HasSuffix(path, "/plugins/commandcode/usage") || strings.HasSuffix(path, "/usage") {
switch method {
case http.MethodGet:
@@ -101,7 +157,15 @@ func handleGetUsage(ctx context.Context, req ManagementRequest, cfg *PluginConfi
apiBase = cfg.GetAPIBase()
}
return executeUsageQuery(ctx, apiBase, sessionToken, req.HostCallbackID)
// commandcode_api_key is NOT overridable via query parameters (same
// secrets-out-of-URLs policy as the OpenCode handler): the plugin
// config is the only credential source on GET.
apiKey := ""
if cfg != nil {
apiKey = cfg.GetCommandCodeAPIKey()
}
return executeUsageQuery(ctx, apiBase, apiKey, sessionToken, req.HostCallbackID)
}
func handlePostUsage(ctx context.Context, req ManagementRequest, cfg *PluginConfig) (ManagementResponse, error) {
@@ -109,6 +173,7 @@ func handlePostUsage(ctx context.Context, req ManagementRequest, cfg *PluginConf
SessionToken string `json:"session_token"`
Token string `json:"token"`
APIBase string `json:"api_base"`
CommandCodeAPIKey string `json:"commandcode_api_key"`
}
if len(req.Body) > 0 {
@@ -120,6 +185,7 @@ func handlePostUsage(ctx context.Context, req ManagementRequest, cfg *PluginConf
sessionToken = body.Token
}
apiBase := body.APIBase
apiKey := body.CommandCodeAPIKey
// Fallback to plugin config if body didn't specify
if sessionToken == "" && cfg != nil {
@@ -128,12 +194,22 @@ func handlePostUsage(ctx context.Context, req ManagementRequest, cfg *PluginConf
if apiBase == "" && cfg != nil {
apiBase = cfg.GetAPIBase()
}
if apiKey == "" && cfg != nil {
apiKey = cfg.GetCommandCodeAPIKey()
}
return executeUsageQuery(ctx, apiBase, sessionToken, req.HostCallbackID)
return executeUsageQuery(ctx, apiBase, apiKey, sessionToken, req.HostCallbackID)
}
func executeUsageQuery(ctx context.Context, apiBase, sessionToken, hostCallbackID string) (ManagementResponse, error) {
if strings.TrimSpace(sessionToken) == "" {
func executeUsageQuery(ctx context.Context, apiBase, apiKey, sessionToken, hostCallbackID string) (ManagementResponse, error) {
apiKey = strings.TrimSpace(apiKey)
// Credential priority: commandcode_api_key non-empty → /alpha + Bearer
// (Provider API key, no cookie); otherwise session_token → /internal
// + Cookie (legacy fallback). Neither present → 400 with the
// "session_token is required" prefix (isLocalCredentialError in the
// /all aggregate depends on this message).
if strings.TrimSpace(sessionToken) == "" && apiKey == "" {
resBytes, _ := json.Marshal(map[string]any{
"ok": false,
"error": "session_token is required. Configure session_token in plugin config, provide a credential file, or pass session_token in request",
@@ -147,7 +223,14 @@ func executeUsageQuery(ctx context.Context, apiBase, sessionToken, hostCallbackI
}, nil
}
raw, statusCode, errFetch := FetchCreditsRaw(ctx, apiBase, sessionToken, hostCallbackID)
var raw []byte
var statusCode int
var errFetch error
if apiKey != "" {
raw, statusCode, errFetch = FetchCommandCodeCreditsAlphaRaw(ctx, apiBase, apiKey, hostCallbackID)
} else {
raw, statusCode, errFetch = FetchCreditsRaw(ctx, apiBase, sessionToken, hostCallbackID)
}
if errFetch != nil {
resBytes, _ := json.Marshal(map[string]any{
"ok": false,
@@ -167,12 +250,19 @@ func executeUsageQuery(ctx context.Context, apiBase, sessionToken, hostCallbackI
}
if statusCode != http.StatusOK {
resBytes, _ := json.Marshal(map[string]any{
payload := map[string]any{
"ok": false,
"status_code": statusCode,
"error": "upstream returned non-200 status",
"body": string(raw),
})
}
if apiKey != "" {
// Alpha path: do NOT echo the upstream body; point at the
// configured Provider API key instead.
payload["error"] = fmt.Sprintf("commandcode upstream returned %d: check commandcode_api_key", statusCode)
} else {
payload["error"] = "upstream returned non-200 status"
payload["body"] = string(raw)
}
resBytes, _ := json.Marshal(payload)
return ManagementResponse{
StatusCode: statusCode,
Headers: map[string][]string{
@@ -184,7 +274,14 @@ func executeUsageQuery(ctx context.Context, apiBase, sessionToken, hostCallbackI
// Fetch billing-period (monthly) usage totals; non-fatal if unavailable.
var summary *UpstreamUsageSummaryResponse
if sumRaw, sumStatus, sumErr := FetchUsageSummaryRaw(ctx, apiBase, sessionToken, hostCallbackID); sumErr == nil && sumStatus == http.StatusOK {
if apiKey != "" {
if sumRaw, sumStatus, sumErr := FetchCommandCodeUsageSummaryAlphaRaw(ctx, apiBase, apiKey, hostCallbackID); sumErr == nil && sumStatus == http.StatusOK {
var parsed UpstreamUsageSummaryResponse
if errSum := json.Unmarshal(sumRaw, &parsed); errSum == nil && parsed.TotalMonthlyCredits > 0 {
summary = &parsed
}
}
} else if sumRaw, sumStatus, sumErr := FetchUsageSummaryRaw(ctx, apiBase, sessionToken, hostCallbackID); sumErr == nil && sumStatus == http.StatusOK {
var parsed UpstreamUsageSummaryResponse
if errSum := json.Unmarshal(sumRaw, &parsed); errSum == nil && parsed.TotalMonthlyCredits > 0 {
summary = &parsed
@@ -215,3 +312,293 @@ func executeUsageQuery(ctx context.Context, apiBase, sessionToken, hostCallbackI
Body: resBytes,
}, nil
}
// handleOpenCodeUsage serves GET/POST /plugins/commandcode/opencode/usage.
// Credentials can be overridden via POST body only (opencode_api_keys list /
// opencode_api_key scalar); GET queries are read-only against the plugin
// config — query parameter overrides are intentionally not supported to keep
// secrets out of URLs.
//
// The response is the multi-key OpenCodeMultiKeyResponse envelope (v0.4.0):
// >=1 key succeeded → 200; keys configured but all upstream-failed → 502; no
// keys configured at all → 400 with a top-level "no opencode api keys
// configured ..." error.
func handleOpenCodeUsage(ctx context.Context, req ManagementRequest, cfg *PluginConfig) (ManagementResponse, error) {
apiBase := ""
var keys []string
if req.Method == http.MethodPost && len(req.Body) > 0 {
var body struct {
OpenCodeAPIKeys []string `json:"opencode_api_keys"`
OpenCodeAPIKey string `json:"opencode_api_key"`
APIKey string `json:"api_key"`
OpenCodeAPIBase string `json:"opencode_api_base"`
}
_ = json.Unmarshal(req.Body, &body)
keys = normalizeOpenCodeKeys(body.OpenCodeAPIKeys)
if len(keys) == 0 {
single := strings.TrimSpace(body.OpenCodeAPIKey)
if single == "" {
single = strings.TrimSpace(body.APIKey)
}
if single != "" {
keys = []string{single}
}
}
apiBase = body.OpenCodeAPIBase
}
// Fallback to plugin config
if len(keys) == 0 && cfg != nil {
keys = cfg.GetOpenCodeAPIKeys()
}
if apiBase == "" && cfg != nil {
apiBase = cfg.GetOpenCodeAPIBase()
}
now := time.Now().UTC()
if len(keys) == 0 {
resBytes, _ := json.Marshal(OpenCodeMultiKeyResponse{
OK: false,
Provider: "opencode_go",
Keys: []OpenCodeKeyResult{},
UpdatedAt: now.Format(time.RFC3339),
Error: "no opencode api keys configured. Configure opencode_api_keys (YAML list) or opencode_api_key in the plugin config, or pass opencode_api_keys in the POST body",
})
return ManagementResponse{
StatusCode: http.StatusBadRequest,
Headers: map[string][]string{
"Content-Type": {"application/json"},
},
Body: resBytes,
}, nil
}
results := QueryOpenCodeKeys(ctx, apiBase, keys, req.HostCallbackID)
succeeded := 0
for _, r := range results {
if r.OK {
succeeded++
}
}
statusCode := http.StatusOK
if succeeded == 0 {
statusCode = http.StatusBadGateway
}
resBytes, _ := json.Marshal(OpenCodeMultiKeyResponse{
OK: succeeded > 0,
Provider: "opencode_go",
Keys: results,
UpdatedAt: now.Format(time.RFC3339),
})
return ManagementResponse{
StatusCode: statusCode,
Headers: map[string][]string{
"Content-Type": {"application/json"},
},
Body: resBytes,
}, nil
}
// handleAllUsage serves GET/POST /plugins/commandcode/all: it queries both
// providers sequentially (no goroutines — the host.http.do bridge's host-side
// concurrency safety cannot be verified and shared maps would race under -race).
// Partial failure: OK=true as long as at least one provider succeeds; failures
// land in the Errors map and successful fields are omitted when absent.
// HTTP status: any success → 200; all failed due to missing local credentials → 400;
// all failed due to upstream errors → 502.
func handleAllUsage(ctx context.Context, req ManagementRequest, cfg *PluginConfig) (ManagementResponse, error) {
sessionToken := ""
commandcodeAPIKey := ""
opencodeKeys := []string{}
if req.Method == http.MethodPost && len(req.Body) > 0 {
var body struct {
SessionToken string `json:"session_token"`
CommandCodeAPIKey string `json:"commandcode_api_key"`
OpencodeAPIKeys []string `json:"opencode_api_keys"`
OpencodeAPIKey string `json:"opencode_api_key"`
}
_ = json.Unmarshal(req.Body, &body)
sessionToken = body.SessionToken
commandcodeAPIKey = body.CommandCodeAPIKey
opencodeKeys = normalizeOpenCodeKeys(body.OpencodeAPIKeys)
if len(opencodeKeys) == 0 {
if single := strings.TrimSpace(body.OpencodeAPIKey); single != "" {
opencodeKeys = []string{single}
}
}
}
// Fallback to plugin config
if sessionToken == "" && cfg != nil {
sessionToken = cfg.GetSessionToken()
}
if commandcodeAPIKey == "" && cfg != nil {
commandcodeAPIKey = cfg.GetCommandCodeAPIKey()
}
if len(opencodeKeys) == 0 && cfg != nil {
opencodeKeys = cfg.GetOpenCodeAPIKeys()
}
apiBase := ""
if cfg != nil {
apiBase = cfg.GetAPIBase()
}
ocAPIBase := ""
if cfg != nil {
ocAPIBase = cfg.GetOpenCodeAPIBase()
}
now := time.Now().UTC()
resp := AllUsageResponse{OK: false, UpdatedAt: now.Format(time.RFC3339)}
errs := make(map[string]string)
localMissing := 0
upstreamFailed := 0
succeeded := 0
// Provider 1: Command Code (reuses executeUsageQuery).
ccResp, _ := executeUsageQuery(ctx, apiBase, commandcodeAPIKey, sessionToken, req.HostCallbackID)
if ccResp.StatusCode == http.StatusOK {
resp.CommandCode = ccResp.Body
succeeded++
} else {
ccErr := extractErrorResponseMessage(ccResp.Body)
errs["commandcode"] = ccErr
if isLocalCredentialError(ccErr) {
localMissing++
} else {
upstreamFailed++
}
}
// Provider 2: OpenCode Go, one sequential query per configured key
// (v0.4.0). >=1 key success counts the provider as successful and the
// multi-key payload is inlined; keys configured but all failed is an
// upstream failure (a configured-but-invalid key is NOT a local config
// problem); zero keys configured is a local missing-credential error.
if len(opencodeKeys) > 0 {
results := QueryOpenCodeKeys(ctx, ocAPIBase, opencodeKeys, req.HostCallbackID)
succeededKeys := 0
for _, r := range results {
if r.OK {
succeededKeys++
}
}
if succeededKeys > 0 {
ocBytes, _ := json.Marshal(OpenCodeMultiKeyResponse{
OK: true,
Provider: "opencode_go",
Keys: results,
UpdatedAt: now.Format(time.RFC3339),
})
resp.OpenCode = ocBytes
succeeded++
} else {
errs["opencode"] = fmt.Sprintf("all %d opencode keys failed", len(opencodeKeys))
upstreamFailed++
}
} else {
errs["opencode"] = "no opencode api keys configured. Configure opencode_api_keys (YAML list) or opencode_api_key in the plugin config, or pass opencode_api_keys in the POST body"
localMissing++
}
if len(errs) > 0 {
resp.Errors = errs
}
resp.OK = succeeded > 0
statusCode := http.StatusOK
if !resp.OK {
if upstreamFailed == 0 && localMissing == len(errs) {
statusCode = http.StatusBadRequest
} else {
statusCode = http.StatusBadGateway
}
}
resBytes, _ := json.Marshal(resp)
return ManagementResponse{
StatusCode: statusCode,
Headers: map[string][]string{
"Content-Type": {"application/json"},
},
Body: resBytes,
}, nil
}
// isLocalCredentialError reports whether an /all provider error is a local
// configuration problem (missing credential in plugin config), as opposed to
// an upstream failure. Local-credential errors carry fixed message prefixes;
// upstream 4xx/5xx never match them, so the /all 400-vs-502 classification
// does not rely on the HTTP status alone.
func isLocalCredentialError(msg string) bool {
for _, prefix := range []string{
"session_token is required",
"opencode_api_key is required",
"no opencode api keys configured",
} {
if strings.HasPrefix(msg, prefix) {
return true
}
}
return false
}
// queryOpenCodeKey runs the OpenCode Go usage query for a single API key and
// returns a typed per-key result, shared by handleOpenCodeUsage and
// handleAllUsage (via QueryOpenCodeKeys). The handler layer is responsible
// for marshaling the aggregate response and picking the HTTP status code.
func queryOpenCodeKey(ctx context.Context, apiBase, key, hostCallbackID string) (OpenCodeKeyResult, error) {
now := time.Now().UTC()
res := OpenCodeKeyResult{
KeyID: MaskAPIKey(key),
UpdatedAt: now.Format(time.RFC3339),
}
if strings.TrimSpace(key) == "" {
res.StatusCode = http.StatusBadRequest
res.Error = "opencode_api_key is required. Configure opencode_api_keys in plugin config or pass it in the request"
return res, nil
}
raw, statusCode, errFetch := FetchOpenCodeUsageRaw(ctx, apiBase, key, hostCallbackID)
if errFetch != nil {
if statusCode == 0 || statusCode == http.StatusOK {
statusCode = http.StatusBadGateway
}
res.StatusCode = statusCode
res.Error = fmt.Sprintf("opencode upstream request failed: %s", errFetch.Error())
return res, nil
}
if statusCode != http.StatusOK {
res.StatusCode = statusCode
res.Error = fmt.Sprintf("opencode upstream returned %d: check opencode_api_key", statusCode)
return res, nil
}
usage, errParse := ParseOpenCodeUsage(raw, now)
if errParse != nil {
res.StatusCode = http.StatusBadGateway
res.Error = "failed to parse opencode upstream usage: " + errParse.Error()
return res, nil
}
res.OK = true
res.StatusCode = http.StatusOK
res.Windows = &usage.Windows
res.UpdatedAt = usage.UpdatedAt
return res, nil
}
// extractErrorResponseMessage pulls the "error" field out of a JSON error body.
func extractErrorResponseMessage(body []byte) string {
var parsed struct {
Error string `json:"error"`
}
if err := json.Unmarshal(body, &parsed); err == nil && parsed.Error != "" {
return parsed.Error
}
return "unknown error"
}
+1019 -5
View File
File diff suppressed because it is too large Load Diff
+113 -1
View File
@@ -13,7 +13,7 @@ import (
const (
PluginID = "commandcode"
PluginName = "commandcode"
PluginVersion = "0.2.2"
PluginVersion = "0.5.0"
PluginAuthor = "zgs225"
PluginRepo = "https://github.com/zgs225/cliproxy-plugin-commandcode"
PluginLogo = "https://raw.githubusercontent.com/zgs225/cliproxy-plugin-commandcode/main/assets/logo.svg"
@@ -24,7 +24,11 @@ const (
type PluginConfig struct {
mu sync.RWMutex
SessionToken string `yaml:"session_token" json:"session_token"`
CommandCodeAPIKey string `yaml:"commandcode_api_key" json:"commandcode_api_key"`
APIBase string `yaml:"api_base" json:"api_base"`
OpenCodeAPIKey string `yaml:"opencode_api_key" json:"opencode_api_key"`
OpenCodeAPIKeys []string `yaml:"opencode_api_keys" json:"opencode_api_keys"`
OpenCodeAPIBase string `yaml:"opencode_api_base" json:"opencode_api_base"`
}
// UpdateFromYAML updates the configuration from raw YAML bytes.
@@ -34,7 +38,11 @@ func (c *PluginConfig) UpdateFromYAML(raw []byte) error {
}
var tmp struct {
SessionToken string `yaml:"session_token"`
CommandCodeAPIKey string `yaml:"commandcode_api_key"`
APIBase string `yaml:"api_base"`
OpenCodeAPIKey string `yaml:"opencode_api_key"`
OpenCodeAPIKeys []string `yaml:"opencode_api_keys"`
OpenCodeAPIBase string `yaml:"opencode_api_base"`
}
if err := yaml.Unmarshal(raw, &tmp); err != nil {
return fmt.Errorf("unmarshal config_yaml: %w", err)
@@ -46,9 +54,31 @@ func (c *PluginConfig) UpdateFromYAML(raw []byte) error {
if tmp.SessionToken != "" {
c.SessionToken = ExtractSessionToken(tmp.SessionToken)
}
if tmp.CommandCodeAPIKey != "" {
// Provider API key is a plain Bearer token; do not run it through
// ExtractSessionToken (that is Command Code cookie specific).
c.CommandCodeAPIKey = strings.TrimSpace(tmp.CommandCodeAPIKey)
}
if tmp.APIBase != "" {
c.APIBase = strings.TrimRight(tmp.APIBase, "/")
}
if tmp.OpenCodeAPIKey != "" {
// OpenCode API key is a plain Bearer token; do not run it through
// ExtractSessionToken (that is Command Code cookie specific).
c.OpenCodeAPIKey = strings.TrimSpace(tmp.OpenCodeAPIKey)
}
// Merge rule: opencode_api_keys (YAML list) wins when non-empty after
// trimming/dedup; otherwise opencode_api_key (scalar) degrades to a
// single-key list; both empty means no keys.
c.OpenCodeAPIKeys = normalizeOpenCodeKeys(tmp.OpenCodeAPIKeys)
if len(c.OpenCodeAPIKeys) == 0 {
if single := strings.TrimSpace(tmp.OpenCodeAPIKey); single != "" {
c.OpenCodeAPIKeys = []string{single}
}
}
if tmp.OpenCodeAPIBase != "" {
c.OpenCodeAPIBase = strings.TrimRight(tmp.OpenCodeAPIBase, "/")
}
if c.APIBase == "" {
c.APIBase = DefaultAPIBase
}
@@ -62,6 +92,15 @@ func (c *PluginConfig) GetSessionToken() string {
return c.SessionToken
}
// GetCommandCodeAPIKey safely returns the configured Command Code Provider
// API key. When non-empty, usage queries go through the /alpha endpoints
// with Bearer auth instead of the session-cookie /internal endpoints.
func (c *PluginConfig) GetCommandCodeAPIKey() string {
c.mu.RLock()
defer c.mu.RUnlock()
return c.CommandCodeAPIKey
}
// SetSessionToken safely sets the session token.
func (c *PluginConfig) SetSessionToken(token string) {
c.mu.Lock()
@@ -79,6 +118,59 @@ func (c *PluginConfig) GetAPIBase() string {
return c.APIBase
}
// GetOpenCodeAPIKey safely returns the single configured OpenCode Go API key
// (scalar opencode_api_key field; kept for backward compatibility).
func (c *PluginConfig) GetOpenCodeAPIKey() string {
c.mu.RLock()
defer c.mu.RUnlock()
return c.OpenCodeAPIKey
}
// GetOpenCodeAPIKeys safely returns the configured OpenCode Go API keys.
// The list field wins; when it is empty the scalar OpenCodeAPIKey degrades
// to a single-key list (same merge rule as UpdateFromYAML). The returned
// slice is a copy; callers may not mutate it.
func (c *PluginConfig) GetOpenCodeAPIKeys() []string {
c.mu.RLock()
defer c.mu.RUnlock()
if len(c.OpenCodeAPIKeys) > 0 {
out := make([]string, len(c.OpenCodeAPIKeys))
copy(out, c.OpenCodeAPIKeys)
return out
}
if c.OpenCodeAPIKey != "" {
return []string{c.OpenCodeAPIKey}
}
return nil
}
// normalizeOpenCodeKeys trims each key, drops empties and dedups while
// preserving the original order.
func normalizeOpenCodeKeys(keys []string) []string {
out := make([]string, 0, len(keys))
seen := make(map[string]bool, len(keys))
for _, k := range keys {
k = strings.TrimSpace(k)
if k == "" || seen[k] {
continue
}
seen[k] = true
out = append(out, k)
}
return out
}
// GetOpenCodeAPIBase safely returns the OpenCode Go API base URL,
// falling back to DefaultOpenCodeAPIBase when unset.
func (c *PluginConfig) GetOpenCodeAPIBase() string {
c.mu.RLock()
defer c.mu.RUnlock()
if c.OpenCodeAPIBase == "" {
return DefaultOpenCodeAPIBase
}
return c.OpenCodeAPIBase
}
// Plugin encapsulates the Command Code plugin instance.
type Plugin struct {
config *PluginConfig
@@ -143,11 +235,31 @@ func (p *Plugin) handleRegister(raw []byte) ([]byte, error) {
Type: "string",
Description: "Command Code session token (__Secure-commandcode_prod_.session_token cookie value)",
},
{
Name: "commandcode_api_key",
Type: "string",
Description: "Command Code Provider API key (user_…); when set, usage queries go through the /alpha endpoints with Bearer auth — no session cookie needed",
},
{
Name: "api_base",
Type: "string",
Description: "Command Code API base URL (default: https://api.commandcode.ai)",
},
{
Name: "opencode_api_key",
Type: "string",
Description: "OpenCode Go API key (single Bearer token; degraded path when opencode_api_keys is unset)",
},
{
Name: "opencode_api_keys",
Type: "string",
Description: "OpenCode Go API keys as a YAML list (e.g. opencode_api_keys: [\"sk-KEY1\", \"sk-KEY2\"]); takes precedence over opencode_api_key",
},
{
Name: "opencode_api_base",
Type: "string",
Description: "OpenCode Go API base URL (default: https://opencode.ai/zen/go/v1)",
},
},
},
Capabilities: RegistrationCapability{
+165 -6
View File
@@ -46,16 +46,16 @@ api_base: "https://custom-api.commandcode.ai"
t.Errorf("Capabilities.ManagementAPI = false, want true")
}
// Verify config fields
if len(reg.Metadata.ConfigFields) != 2 {
t.Fatalf("ConfigFields len = %d, want 2", len(reg.Metadata.ConfigFields))
// Verify config fields (v0.5.0: 5 → 6, adds commandcode_api_key)
if len(reg.Metadata.ConfigFields) != 6 {
t.Fatalf("ConfigFields len = %d, want 6", len(reg.Metadata.ConfigFields))
}
fieldNames := map[string]bool{}
for _, f := range reg.Metadata.ConfigFields {
fieldNames[f.Name] = true
}
if !fieldNames["session_token"] || !fieldNames["api_base"] {
t.Errorf("ConfigFields missing session_token or api_base: %+v", reg.Metadata.ConfigFields)
if !fieldNames["session_token"] || !fieldNames["commandcode_api_key"] || !fieldNames["api_base"] || !fieldNames["opencode_api_key"] || !fieldNames["opencode_api_keys"] || !fieldNames["opencode_api_base"] {
t.Errorf("ConfigFields missing expected fields: %+v", reg.Metadata.ConfigFields)
}
// Verify config parsed
@@ -65,7 +65,6 @@ api_base: "https://custom-api.commandcode.ai"
if p.config.GetAPIBase() != "https://custom-api.commandcode.ai" {
t.Errorf("APIBase = %q, want https://custom-api.commandcode.ai", p.config.GetAPIBase())
}
// Test plugin.reconfigure
reconfYAML := []byte(`
session_token: "new-token-abc"
@@ -84,6 +83,44 @@ session_token: "new-token-abc"
}
}
func TestPluginConfig_CommandCodeAPIKey(t *testing.T) {
p := NewPlugin()
// Trimmed, and NOT run through ExtractSessionToken (it is a plain
// Bearer token, not a Command Code cookie string).
configYAML := []byte("commandcode_api_key: \" user_abc123xyz \"\n")
lifecycleReq, _ := json.Marshal(LifecycleRequest{ConfigYAML: configYAML})
if _, err := p.HandleMethod("plugin.register", lifecycleReq); err != nil {
t.Fatalf("handleMethod(plugin.register) error: %v", err)
}
if got := p.config.GetCommandCodeAPIKey(); got != "user_abc123xyz" {
t.Errorf("CommandCodeAPIKey = %q, want user_abc123xyz (trimmed, raw)", got)
}
// Whitespace-only value clears the field.
p2 := NewPlugin()
spaceReq, _ := json.Marshal(LifecycleRequest{ConfigYAML: []byte("commandcode_api_key: \" \"\n")})
if _, err := p2.HandleMethod("plugin.register", spaceReq); err != nil {
t.Fatalf("handleMethod(plugin.register) error: %v", err)
}
if got := p2.config.GetCommandCodeAPIKey(); got != "" {
t.Errorf("CommandCodeAPIKey = %q, want empty (whitespace-only)", got)
}
// Omitting the key in a reconfigure must not clear a configured value.
reconfReq, _ := json.Marshal(LifecycleRequest{ConfigYAML: []byte("session_token: \"tok\"\n")})
if _, err := p.HandleMethod("plugin.reconfigure", reconfReq); err != nil {
t.Fatalf("handleMethod(plugin.reconfigure) error: %v", err)
}
if got := p.config.GetCommandCodeAPIKey(); got != "user_abc123xyz" {
t.Errorf("CommandCodeAPIKey after reconfigure = %q, want kept user_abc123xyz", got)
}
// Default is empty.
if got := NewPlugin().config.GetCommandCodeAPIKey(); got != "" {
t.Errorf("default CommandCodeAPIKey = %q, want empty", got)
}
}
func TestPluginAuthIdentifier_NotHandled(t *testing.T) {
p := NewPlugin()
raw, err := p.HandleMethod("auth.identifier", nil)
@@ -135,3 +172,125 @@ func TestEnvelopeError(t *testing.T) {
t.Errorf("env.Error = %+v", env.Error)
}
}
func TestPluginConfig_OpenCode(t *testing.T) {
p := NewPlugin()
configYAML := []byte("opencode_api_key: \" sk-opencode-123 \"\nopencode_api_base: \"https://custom.oc.example/v1/\"\n")
lifecycleReq, _ := json.Marshal(LifecycleRequest{ConfigYAML: configYAML})
if _, err := p.HandleMethod("plugin.register", lifecycleReq); err != nil {
t.Fatalf("handleMethod(plugin.register) error: %v", err)
}
if got := p.config.GetOpenCodeAPIKey(); got != "sk-opencode-123" {
t.Errorf("OpenCodeAPIKey = %q, want sk-opencode-123", got)
}
if got := p.config.GetOpenCodeAPIBase(); got != "https://custom.oc.example/v1" {
t.Errorf("OpenCodeAPIBase = %q, want https://custom.oc.example/v1 (trailing slash trimmed)", got)
}
// A Command Code cookie string must NOT be run through ExtractSessionToken.
cookieLike := []byte("opencode_api_key: \"sk-raw-bearer-value\"\n")
req2, _ := json.Marshal(LifecycleRequest{ConfigYAML: cookieLike})
if _, err := p.HandleMethod("plugin.reconfigure", req2); err != nil {
t.Fatalf("handleMethod(plugin.reconfigure) error: %v", err)
}
if got := p.config.GetOpenCodeAPIKey(); got != "sk-raw-bearer-value" {
t.Errorf("OpenCodeAPIKey = %q, want sk-raw-bearer-value (raw, no cookie extraction)", got)
}
// Empty config falls back to the default base.
empty := NewPlugin()
if got := empty.config.GetOpenCodeAPIBase(); got != DefaultOpenCodeAPIBase {
t.Errorf("default OpenCodeAPIBase = %q, want %q", got, DefaultOpenCodeAPIBase)
}
if got := empty.config.GetOpenCodeAPIKey(); got != "" {
t.Errorf("default OpenCodeAPIKey = %q, want empty", got)
}
if got := empty.config.GetOpenCodeAPIKeys(); len(got) != 0 {
t.Errorf("default GetOpenCodeAPIKeys = %v, want empty", got)
}
}
func TestPluginConfig_OpenCodeAPIKeys(t *testing.T) {
newCfg := func(t *testing.T, yaml string) *PluginConfig {
t.Helper()
cfg := &PluginConfig{}
if err := cfg.UpdateFromYAML([]byte(yaml)); err != nil {
t.Fatalf("UpdateFromYAML error: %v", err)
}
return cfg
}
t.Run("list takes precedence over scalar", func(t *testing.T) {
cfg := newCfg(t, `
opencode_api_key: "sk-scalar"
opencode_api_keys:
- " sk-key1 "
- "sk-key2"
`)
got := cfg.GetOpenCodeAPIKeys()
if len(got) != 2 || got[0] != "sk-key1" || got[1] != "sk-key2" {
t.Errorf("GetOpenCodeAPIKeys = %v, want [sk-key1 sk-key2] (list wins, trimmed)", got)
}
if cfg.GetOpenCodeAPIKey() != "sk-scalar" {
t.Errorf("GetOpenCodeAPIKey = %q, want sk-scalar (scalar field kept)", cfg.GetOpenCodeAPIKey())
}
})
t.Run("scalar degrades to single-key list", func(t *testing.T) {
cfg := newCfg(t, `
opencode_api_key: " sk-only "
`)
got := cfg.GetOpenCodeAPIKeys()
if len(got) != 1 || got[0] != "sk-only" {
t.Errorf("GetOpenCodeAPIKeys = %v, want [sk-only]", got)
}
})
t.Run("dedup preserve order and drop empties", func(t *testing.T) {
cfg := newCfg(t, `
opencode_api_keys:
- "sk-b"
- ""
- " "
- "sk-a"
- "sk-b"
- "sk-c"
- "sk-a"
`)
got := cfg.GetOpenCodeAPIKeys()
want := []string{"sk-b", "sk-a", "sk-c"}
if len(got) != len(want) {
t.Fatalf("GetOpenCodeAPIKeys = %v, want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Errorf("GetOpenCodeAPIKeys[%d] = %q, want %q (order preserved, deduped)", i, got[i], want[i])
}
}
})
t.Run("all empty yields no keys", func(t *testing.T) {
for _, yaml := range []string{
`opencode_api_key: ""`,
"opencode_api_keys: []\nopencode_api_key: \" \"",
"opencode_api_keys:\n - \"\"\n - \" \"",
} {
cfg := newCfg(t, yaml)
if got := cfg.GetOpenCodeAPIKeys(); len(got) != 0 {
t.Errorf("yaml %q: GetOpenCodeAPIKeys = %v, want empty", yaml, got)
}
}
})
t.Run("getter returns a copy", func(t *testing.T) {
cfg := newCfg(t, "opencode_api_keys:\n - sk-a\n - sk-b\n")
got := cfg.GetOpenCodeAPIKeys()
got[0] = "mutated"
again := cfg.GetOpenCodeAPIKeys()
if again[0] != "sk-a" {
t.Errorf("GetOpenCodeAPIKeys not a copy: after mutation got %q", again[0])
}
})
}
+736 -53
View File
@@ -10,7 +10,7 @@ const QuotaPageHTML = `<!DOCTYPE html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Command Code 配额与用量 - CLIProxyAPI</title>
<title>用量配额 - CLIProxyAPI</title>
<style>
:root {
--bg-page: #f8fafc;
@@ -217,11 +217,6 @@ const QuotaPageHTML = `<!DOCTYPE html>
}
}
.brand-subtitle {
font-size: 13px;
color: var(--text-muted);
}
.action-group {
display: flex;
align-items: center;
@@ -622,6 +617,266 @@ const QuotaPageHTML = `<!DOCTYPE html>
letter-spacing: 0.5px;
}
/* Tab Bar (Command Code | OpenCode Go | All) */
.tab-bar {
display: flex;
gap: 6px;
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
padding: 6px;
margin-bottom: 20px;
box-shadow: var(--shadow-sm);
}
.tab-btn {
flex: 1;
border: none;
background: transparent;
color: var(--text-muted);
font-size: 13px;
font-weight: 600;
padding: 8px 12px;
border-radius: var(--radius-md);
cursor: pointer;
transition: all 0.2s ease;
font-family: inherit;
}
.tab-btn:hover {
color: var(--text-main);
background: var(--bg-subtle);
}
.tab-btn.active {
background: var(--primary);
color: #fff;
}
.tab-section {
display: none;
}
.tab-section.active {
display: block;
}
/* Command Code tab head: title + plan badge (planBadge moved out of header) */
.cc-tab-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
gap: 10px;
}
.cc-tab-title {
font-size: 15px;
font-weight: 700;
color: var(--text-main);
}
/* OpenCode multi-key groups: one group per key, 3 compact window rows each */
.oc-key-group {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
padding: 20px;
box-shadow: var(--shadow-sm);
display: flex;
flex-direction: column;
gap: 12px;
margin-bottom: 16px;
}
.oc-key-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
}
.oc-key-id {
font-size: 13px;
font-family: monospace;
color: var(--text-muted);
}
.oc-key-body {
display: flex;
flex-direction: column;
gap: 10px;
}
.oc-win-row {
display: grid;
grid-template-columns: 92px 1fr 52px 84px;
align-items: center;
gap: 12px;
}
@media (max-width: 640px) {
.oc-win-row {
grid-template-columns: 86px 1fr 48px;
}
.oc-win-reset {
display: none;
}
}
.oc-win-name {
font-size: 12px;
font-weight: 600;
color: var(--text-muted);
white-space: nowrap;
}
/* .progress-track combo class: 8px mini bar, same as .all-grid */
.oc-win-bar {
height: 8px;
}
.oc-win-pct {
font-size: 13px;
font-weight: 700;
color: var(--text-main);
white-space: nowrap;
font-feature-settings: "tnum";
}
.oc-win-pct.warn { color: var(--warning); }
.oc-win-pct.bad { color: var(--danger); }
/* .countdown-timer combo class: smaller reset countdown */
.oc-win-reset {
font-size: 12px;
}
.oc-key-error {
font-size: 12px;
color: var(--danger);
word-break: break-all;
}
.all-key-id {
font-family: monospace;
font-size: 12px;
}
textarea.form-control {
resize: vertical;
}
/* All tab: two provider cards side by side */
.all-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
margin-bottom: 24px;
}
@media (max-width: 768px) {
.all-grid {
grid-template-columns: 1fr;
}
}
.all-group-title {
font-size: 13px;
font-weight: 700;
color: var(--text-muted);
margin: 8px 0 10px 2px;
}
.all-group-title:first-child {
margin-top: 0;
}
.all-provider-card {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
padding: 20px;
box-shadow: var(--shadow-sm);
display: flex;
flex-direction: column;
gap: 12px;
}
.all-provider-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
}
.all-provider-name {
font-size: 15px;
font-weight: 700;
color: var(--text-main);
}
.all-provider-body {
display: flex;
flex-direction: column;
gap: 8px;
}
.all-summary-row {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 10px;
font-size: 13px;
color: var(--text-muted);
}
.all-summary-value {
font-weight: 700;
color: var(--text-main);
font-feature-settings: "tnum";
}
.all-grid .progress-track {
height: 8px;
}
/* Per-tab provider failure error cards */
.error-card {
display: none;
background: var(--danger-subtle);
border: 1px solid rgba(239, 68, 68, 0.3);
border-radius: var(--radius-lg);
padding: 20px;
margin-bottom: 20px;
flex-direction: column;
gap: 8px;
}
.error-card.show {
display: flex;
}
.error-card-title {
color: var(--danger);
font-weight: 700;
font-size: 14px;
display: flex;
align-items: center;
gap: 8px;
}
.error-card-msg {
color: var(--danger);
font-size: 13px;
word-break: break-all;
}
.form-hint {
font-size: 11px;
color: var(--text-dim);
}
/* Footer */
.footer-bar {
display: flex;
@@ -662,20 +917,13 @@ const QuotaPageHTML = `<!DOCTYPE html>
</div>
<div>
<div class="brand-title">
Command Code 配额
<span class="version-tag">v0.2.2</span>
<span id="planBadge" class="plan-tag" style="display:none;">Plan: -</span>
用量配额
<span class="version-tag">v0.5.0</span>
</div>
<div class="brand-subtitle">CLIProxyAPI 实时限额与 Credits 用量监控</div>
</div>
</div>
<div class="action-group">
<div id="statusBadge" class="status-badge">
<span class="status-dot"></span>
<span id="statusText">正在检查...</span>
</div>
<button id="btnSettings" class="btn" title="配置选项">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3"></circle>
@@ -694,6 +942,13 @@ const QuotaPageHTML = `<!DOCTYPE html>
</div>
</div>
<!-- Tab Bar -->
<div id="tabBar" class="tab-bar">
<button type="button" class="tab-btn active" data-tab="all">All</button>
<button type="button" class="tab-btn" data-tab="commandcode">Command Code</button>
<button type="button" class="tab-btn" data-tab="opencode">OpenCode Go</button>
</div>
<!-- Alert Message -->
<div id="alertBox" class="alert alert-danger">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line></svg>
@@ -715,12 +970,33 @@ const QuotaPageHTML = `<!DOCTYPE html>
<label for="inputSessionToken">Command Code 会话 Token (Session Token 测试)</label>
<input type="password" id="inputSessionToken" class="form-control" placeholder="覆盖测试: __Secure-commandcode_prod_.session_token" />
</div>
<div class="form-group">
<label for="inputOpenCodeKeys">OpenCode Go API Keys (测试覆盖)</label>
<textarea id="inputOpenCodeKeys" class="form-control" rows="3" placeholder="每行一个 sk-..."></textarea>
<span class="form-hint">每行一个 key;仅当次请求生效,不持久化</span>
</div>
</div>
<div style="margin-top: 14px; display: flex; justify-content: flex-end; gap: 10px;">
<button id="btnSaveConfig" class="btn btn-primary">保存并重新获取用量</button>
</div>
</div>
<!-- Tab: Command Code -->
<div id="sectionCommandcode" class="tab-section">
<div id="ccErrorCard" class="error-card">
<div class="error-card-title">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line></svg>
<span>Command Code 查询失败</span>
</div>
<div id="ccErrorMsg" class="error-card-msg">-</div>
</div>
<div id="ccContent">
<div class="cc-tab-head">
<span class="cc-tab-title">Command Code</span>
<span id="planBadge" class="plan-tag" style="display:none;">Plan: -</span>
</div>
<!-- Overview Metrics -->
<div class="metrics-grid">
<div class="metric-card">
@@ -847,18 +1123,56 @@ const QuotaPageHTML = `<!DOCTYPE html>
</div>
</div>
</div>
</div>
<!-- /Tab: Command Code -->
<!-- Tab: OpenCode Go -->
<div id="sectionOpencode" class="tab-section">
<div id="ocErrorCard" class="error-card">
<div class="error-card-title">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line></svg>
<span>OpenCode Go 查询失败</span>
</div>
<div id="ocErrorMsg" class="error-card-msg">-</div>
</div>
<div id="ocContent"></div>
</div>
<!-- /Tab: OpenCode Go -->
<!-- Tab: All -->
<div id="sectionAll" class="tab-section active">
<!-- All tab: vertical groups, one group title per provider -->
<div class="all-group-title">Command Code</div>
<div class="all-provider-card">
<div class="all-provider-head">
<span id="allBadgeCommandcode" class="status-badge"><span class="status-dot"></span><span id="allBadgeTextCommandcode">-</span></span>
</div>
<div id="allBodyCommandcode" class="all-provider-body">尚未加载</div>
</div>
<div class="all-group-title">OpenCode Go</div>
<div class="all-provider-card">
<div class="all-provider-head">
<span id="allBadgeOpencode" class="status-badge"><span class="status-dot"></span><span id="allBadgeTextOpencode">-</span></span>
</div>
<div id="allBodyOpencode" class="all-provider-body">尚未加载</div>
</div>
</div>
<!-- /Tab: All -->
<!-- Footer Status -->
<div class="footer-bar">
<div>最后同步时间: <span id="lastUpdated">-</span></div>
<div class="footer-links">
<span id="authInfo">Provider: commandcode</span>
<span id="authInfo">Provider: commandcode + opencode_go</span>
</div>
</div>
</div>
<script>
(function () {
const USAGE_ENDPOINT = "/v0/management/plugins/commandcode/usage";
const ALL_ENDPOINT = "/v0/management/plugins/commandcode/all";
// Elements
const btnRefresh = document.getElementById("btnRefresh");
@@ -869,12 +1183,27 @@ const QuotaPageHTML = `<!DOCTYPE html>
const settingsDrawer = document.getElementById("settingsDrawer");
const inputMgmtKey = document.getElementById("inputMgmtKey");
const inputSessionToken = document.getElementById("inputSessionToken");
const inputOpenCodeKeys = document.getElementById("inputOpenCodeKeys");
const alertBox = document.getElementById("alertBox");
const alertMsg = document.getElementById("alertMsg");
const statusBadge = document.getElementById("statusBadge");
const statusText = document.getElementById("statusText");
const planBadge = document.getElementById("planBadge");
// Tab sections and per-provider error cards
const ccContent = document.getElementById("ccContent");
const ccErrorCard = document.getElementById("ccErrorCard");
const ccErrorMsg = document.getElementById("ccErrorMsg");
const ocContent = document.getElementById("ocContent");
const ocErrorCard = document.getElementById("ocErrorCard");
const ocErrorMsg = document.getElementById("ocErrorMsg");
// All tab elements
const allBodyCommandcode = document.getElementById("allBodyCommandcode");
const allBadgeCommandcode = document.getElementById("allBadgeCommandcode");
const allBadgeTextCommandcode = document.getElementById("allBadgeTextCommandcode");
const allBodyOpencode = document.getElementById("allBodyOpencode");
const allBadgeOpencode = document.getElementById("allBadgeOpencode");
const allBadgeTextOpencode = document.getElementById("allBadgeTextOpencode");
const valMonthlyCredits = document.getElementById("valMonthlyCredits");
const valOpensourceCredits = document.getElementById("valOpensourceCredits");
const valTotalCredits = document.getElementById("valTotalCredits");
@@ -904,11 +1233,19 @@ const QuotaPageHTML = `<!DOCTYPE html>
const timerWeekly = document.getElementById("timerWeekly");
const lastUpdated = document.getElementById("lastUpdated");
let monthlyTargetTime = null;
let fiveHourTargetTime = null;
let monthlyTargetTime = null;
let weeklyTargetTime = null;
let timerInterval = null;
// Per-provider render state (drives tab badge aggregation)
const providerState = {
commandcode: { level: "unknown", err: null, data: null },
opencode: { level: "unknown", err: null, data: null }
};
let activeTab = "all";
function getStoredManagementKey() {
if (inputMgmtKey.value.trim()) {
return inputMgmtKey.value.trim();
@@ -965,6 +1302,77 @@ const QuotaPageHTML = `<!DOCTYPE html>
return n < 10 ? "0" + n : n;
}
function esc(s) {
return String(s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
// Returns a clamped number in [0,100], or null when missing/invalid
function clampPercent(v) {
if (v === null || v === undefined || v === "") return null;
const n = Number(v);
if (!isFinite(n)) return null;
return Math.min(100, Math.max(0, n));
}
// Unknown status values never error: only explicit exceeded flag,
// status "exceeded", or percent >= 100 count as exceeded
function levelOf(pct, exceeded, status) {
if (exceeded || status === "exceeded" || (pct !== null && pct >= 100)) return "exceeded";
if (pct !== null && pct >= 80) return "warning";
return "online";
}
const LEVEL_RANK = { unknown: -1, online: 0, warning: 1, error: 2, exceeded: 3 };
const BADGE_TEXT = {
unknown: "正在检查...",
online: "正常运行 (Normal)",
warning: "配额紧张 (Warning)",
exceeded: "已达限额 (Exceeded)",
error: "查询错误"
};
function badgeVisualClass(level) {
// error reuses the danger/exceeded look
return level === "error" ? "exceeded" : level === "unknown" ? "" : level;
}
function setProviderStatus(provider, level) {
providerState[provider].level = level;
providerState[provider].err = null;
}
function summaryRow(label, value) {
return '<div class="all-summary-row"><span class="all-summary-label">' + label + '</span><span class="all-summary-value">' + value + '</span></div>';
}
function miniBar(pct, level) {
const cls = level === "exceeded" ? " danger" : level === "warning" ? " warning" : "";
const w = pct === null ? 0 : pct;
return '<div class="progress-track"><div class="progress-bar' + cls + '" style="width:' + w + '%"></div></div>';
}
function showProviderError(provider, msg) {
providerState[provider].level = "error";
providerState[provider].err = msg;
providerState[provider].data = null;
if (provider === "commandcode") {
ccContent.style.display = "none";
ccErrorMsg.textContent = msg;
ccErrorCard.classList.add("show");
} else {
ocContent.style.display = "none";
ocErrorMsg.textContent = msg;
ocErrorCard.classList.add("show");
}
}
// Multi-window countdown elements carry their reset info in data
// attributes (data-reset-at ISO string, or data-reset-secs seconds),
// so updateTimers() can walk every tab容器's .oc-win-reset uniformly
function updateTimers() {
if (monthlyTargetTime) {
timerMonthly.textContent = formatCountdown(monthlyTargetTime);
@@ -975,10 +1383,28 @@ const QuotaPageHTML = `<!DOCTYPE html>
if (weeklyTargetTime) {
timerWeekly.textContent = formatCountdown(weeklyTargetTime);
}
const allResetEls = document.querySelectorAll(".oc-win-reset");
for (let i = 0; i < allResetEls.length; i++) {
const el = allResetEls[i];
let target = null;
const at = el.getAttribute("data-reset-at");
if (at) {
const t = new Date(at);
if (!isNaN(t.getTime())) target = t;
}
if (!target) {
const secs = Number(el.getAttribute("data-reset-secs"));
if (secs > 0) target = new Date(Date.now() + secs * 1000);
}
el.textContent = target ? formatCountdown(target) : "-";
}
}
function renderUsage(data) {
hideAlert();
providerState.commandcode.err = null;
providerState.commandcode.data = data;
ccContent.style.display = "";
ccErrorCard.classList.remove("show");
const credits = data.credits || (data.data && data.data.credits) || {};
const limits = data.window_limits || (data.data && data.data.window_limits) || {};
@@ -1051,27 +1477,238 @@ const QuotaPageHTML = `<!DOCTYPE html>
weeklyTargetTime = null;
}
// Overall Status
// Per-provider status; consumed by All tab provider badges
let ccLevel;
if (fiveHour.exceeded || weekly.exceeded || monthly.exceeded) {
statusBadge.className = "status-badge exceeded";
statusText.textContent = "已达限额 (Exceeded)";
ccLevel = "exceeded";
} else if (pFive >= 80 || pWeek >= 80 || pMonth >= 80) {
statusBadge.className = "status-badge warning";
statusText.textContent = "配额紧张 (Warning)";
ccLevel = "warning";
} else {
statusBadge.className = "status-badge online";
statusText.textContent = "正常运行 (Normal)";
}
const updatedAtStr = data.updated_at || (data.data && data.data.updated_at);
if (updatedAtStr) {
lastUpdated.textContent = new Date(updatedAtStr).toLocaleString();
} else {
lastUpdated.textContent = new Date().toLocaleString();
ccLevel = "online";
}
setProviderStatus("commandcode", ccLevel);
updateTimers();
}
function renderOpencode(data) {
providerState.opencode.err = null;
providerState.opencode.data = data;
// 多 key 契约:keys[] 为空/缺失视为 provider 级失败,走全局错误卡片
const keys = data && Array.isArray(data.keys) ? data.keys : [];
if (keys.length === 0) {
showProviderError("opencode", (data && data.error) || "OpenCode 未返回任何 key 数据");
return;
}
ocContent.style.display = "";
ocErrorCard.classList.remove("show");
// All tab 的 OpenCode 组与本 tab 共用同一套逐 key 渲染逻辑
const rendered = renderOpenCodeKeyGroups(keys);
ocContent.innerHTML = rendered.html;
updateTimers();
setProviderStatus("opencode", rendered.worst);
}
// Shared per-key group renderer: the OpenCode tab and the All tab's
// OpenCode group both consume this to avoid logic drift. Returns
// { html, worst }. Countdown info is stamped into data-reset-at (ISO
// string) or data-reset-secs (seconds) attributes on .oc-win-reset
// elements, so updateTimers() uniformly walks every tab container.
function renderOpenCodeKeyGroups(keys) {
const WIN_DEFS = [
{ name: "Rolling 5h", key: "rolling" },
{ name: "Weekly", key: "weekly" },
{ name: "Monthly", key: "monthly" }
];
const rank = { online: 0, warning: 1, exceeded: 2 };
// provider 级别 = 全部 key 最差(失败 key 按 exceeded 视觉计入)
let worst = "online";
let html = "";
keys.forEach(function (k) {
const keyId = esc(k.key_id || "***");
if (k.ok && k.windows) {
const windows = k.windows;
let keyWorst = "online";
let rowsHtml = "";
WIN_DEFS.forEach(function (def) {
const w = windows[def.key] || {};
const pct = clampPercent(w.percent);
const level = levelOf(pct, w.exceeded, w.status);
if (rank[level] > rank[keyWorst]) keyWorst = level;
if (rank[level] > rank[worst]) worst = level;
// reset_at 优先;缺失/不可解析时回退 reset_in_seconds;皆无显 "-"
let resetAt = "";
let resetSecs = "";
if (w.reset_at) {
const t = new Date(w.reset_at);
if (!isNaN(t.getTime())) resetAt = t.toISOString();
}
if (!resetAt && w.reset_in_seconds > 0) {
resetSecs = String(w.reset_in_seconds);
}
const resetAttr = resetAt
? " data-reset-at=\"" + esc(resetAt) + "\""
: (resetSecs ? " data-reset-secs=\"" + esc(resetSecs) + "\"" : "");
const pctText = pct === null ? "-" : Math.round(pct) + "%";
const pctCls = level === "exceeded" ? " bad" : pct !== null && pct >= 80 ? " warn" : "";
const barCls = level === "exceeded" ? " danger" : level === "warning" ? " warning" : "";
rowsHtml += '<div class="oc-win-row">' +
'<span class="oc-win-name">' + def.name + '</span>' +
'<div class="progress-track oc-win-bar"><div class="progress-bar' + barCls + '" style="width:' + (pct === null ? 0 : pct) + '%"></div></div>' +
'<span class="oc-win-pct' + pctCls + '">' + pctText + '</span>' +
'<span class="oc-win-reset countdown-timer"' + resetAttr + '>-</span>' +
'</div>';
});
const chipText = keyWorst === "exceeded" ? "超限" : keyWorst === "warning" ? "紧张" : "正常";
html += '<div class="oc-key-group">' +
'<div class="oc-key-head"><span class="oc-key-id">' + keyId + '</span>' +
'<span class="oc-key-chip status-badge ' + keyWorst + '"><span class="status-dot"></span><span>' + chipText + '</span></span></div>' +
'<div class="oc-key-body">' + rowsHtml + '</div>' +
'</div>';
} else {
// 失败 key 无 windows 字段:只渲染 key 头 + 错误行
if (rank.exceeded > rank[worst]) worst = "exceeded";
const is401 = k.status_code === 401;
const label = is401 ? "凭据无效" : "查询错误";
const statusPart = k.status_code ? "上游 " + esc(String(k.status_code)) : "查询失败";
const errTail = k.error ? ":" + esc(k.error) : "";
const tip = is401 ? ",请检查该 key 或从配置中移除" : "";
html += '<div class="oc-key-group">' +
'<div class="oc-key-head"><span class="oc-key-id">' + keyId + '</span>' +
'<span class="oc-key-chip status-badge exceeded"><span class="status-dot"></span><span>' + label + '</span></span></div>' +
'<div class="oc-key-error">' + label + '(' + statusPart + ')' + errTail + tip + '</div>' +
'</div>';
}
});
return { html: html, worst: worst };
}
function renderAllTab() {
const cc = providerState.commandcode;
const oc = providerState.opencode;
// Command Code summary card
if (cc.err) {
allBadgeCommandcode.className = "status-badge exceeded";
allBadgeTextCommandcode.textContent = "查询错误";
allBodyCommandcode.innerHTML = '<div class="error-card-title">Command Code 查询失败</div><div class="error-card-msg">' + esc(cc.err) + '</div>';
} else if (cc.data) {
const data = cc.data;
const credits = data.credits || (data.data && data.data.credits) || {};
const limits = data.window_limits || (data.data && data.data.window_limits) || {};
const plan = data.plan || (data.data && data.data.plan);
const planName = plan ? (plan.name || (typeof plan === "string" ? plan : "Unknown")) : "-";
const windowsDef = [
{ name: "Monthly", o: limits.monthly || {} },
{ name: "5-Hour", o: limits.five_hour || {} },
{ name: "Weekly", o: limits.weekly || {} }
];
let worstName = "-";
let worstPct = null;
let worstLevel = "online";
windowsDef.forEach(function (d) {
const pct = clampPercent(d.o.percentage);
const level = levelOf(pct, d.o.exceeded, "");
if (worstPct === null || (pct !== null && pct > worstPct) || level === "exceeded") {
worstName = d.name;
worstPct = pct === null ? 0 : pct;
worstLevel = level;
}
});
allBadgeCommandcode.className = "status-badge " + badgeVisualClass(cc.level);
allBadgeTextCommandcode.textContent = BADGE_TEXT[cc.level] || "-";
// Plan + 三个限额窗口(百分比/进度条/重置时间),行结构与 OpenCode 卡片一致,
// 倒计时走 data-reset-at/data-reset-secs 属性,由 updateTimers() 统一刷新
const ccWins = [
{ name: "Rolling 5h", o: limits.five_hour || {} },
{ name: "Weekly", o: limits.weekly || {} },
{ name: "Monthly", o: limits.monthly || {} }
];
let ccRows = "";
ccWins.forEach(function (d) {
const w = d.o;
const pct = clampPercent(w.percentage);
const level = levelOf(pct, w.exceeded, "");
let resetAt = "";
let resetSecs = "";
if (w.reset_at) {
const t = new Date(w.reset_at);
if (!isNaN(t.getTime())) resetAt = t.toISOString();
}
if (!resetAt && w.reset_in_seconds > 0) {
resetSecs = String(w.reset_in_seconds);
}
const resetAttr = resetAt
? " data-reset-at=\"" + esc(resetAt) + "\""
: (resetSecs ? " data-reset-secs=\"" + esc(resetSecs) + "\"" : "");
const pctText = pct === null ? "-" : Math.round(pct) + "%";
const pctCls = level === "exceeded" ? " bad" : pct !== null && pct >= 80 ? " warn" : "";
const barCls = level === "exceeded" ? " danger" : level === "warning" ? " warning" : "";
ccRows += '<div class="oc-win-row">' +
'<span class="oc-win-name">' + d.name + '</span>' +
'<div class="progress-track oc-win-bar"><div class="progress-bar' + barCls + '" style="width:' + (pct === null ? 0 : pct) + '%"></div></div>' +
'<span class="oc-win-pct' + pctCls + '">' + pctText + '</span>' +
'<span class="oc-win-reset countdown-timer"' + resetAttr + '>-</span>' +
'</div>';
});
allBodyCommandcode.innerHTML =
summaryRow("Plan", esc(planName)) +
'<div class="oc-key-body">' + ccRows + '</div>';
} else {
allBadgeCommandcode.className = "status-badge";
allBadgeTextCommandcode.textContent = "尚未加载";
allBodyCommandcode.innerHTML = '<div class="all-summary-value">尚未加载</div>';
}
// OpenCode Go summary card
if (oc.err) {
allBadgeOpencode.className = "status-badge exceeded";
allBadgeTextOpencode.textContent = "查询错误";
allBodyOpencode.innerHTML = '<div class="error-card-title">OpenCode Go 查询失败</div><div class="error-card-msg">' + esc(oc.err) + '</div>';
} else if (oc.data) {
const data = oc.data;
// 多 key 契约:逐 key 完整卡片,与 OpenCode tab 共用同一渲染函数
const keys = Array.isArray(data.keys) ? data.keys : [];
const rendered = renderOpenCodeKeyGroups(keys);
const html = rendered.html || '<div class="all-summary-value">无 key 数据</div>';
allBadgeOpencode.className = "status-badge " + badgeVisualClass(oc.level);
allBadgeTextOpencode.textContent = BADGE_TEXT[oc.level] || "-";
allBodyOpencode.innerHTML = html;
} else {
allBadgeOpencode.className = "status-badge";
allBadgeTextOpencode.textContent = "尚未加载";
allBodyOpencode.innerHTML = '<div class="all-summary-value">尚未加载</div>';
}
}
function setActiveTab(tab, updateHash) {
activeTab = tab;
const tabBtns = document.querySelectorAll(".tab-btn");
for (let i = 0; i < tabBtns.length; i++) {
tabBtns[i].classList.toggle("active", tabBtns[i].getAttribute("data-tab") === tab);
}
document.getElementById("sectionCommandcode").classList.toggle("active", tab === "commandcode");
document.getElementById("sectionOpencode").classList.toggle("active", tab === "opencode");
document.getElementById("sectionAll").classList.toggle("active", tab === "all");
if (updateHash) {
if (tab === "all") {
// All 为默认 tab:激活 All 时清掉 hash(刷新回到默认)
history.replaceState(null, "", location.pathname + location.search);
} else {
location.hash = tab;
}
}
}
async function fetchUsage() {
refreshIcon.classList.add("spin");
@@ -1079,52 +1716,82 @@ const QuotaPageHTML = `<!DOCTYPE html>
const mgmtKey = getStoredManagementKey();
const overrideToken = inputSessionToken.value.trim();
const overrideOpenCodeKeys = inputOpenCodeKeys.value.split("\n").map(function (s) { return s.trim(); }).filter(Boolean);
const headers = {
"Accept": "application/json"
"Accept": "application/json",
"Content-Type": "application/json"
};
if (mgmtKey) {
headers["Authorization"] = "Bearer " + mgmtKey;
headers["X-Management-Key"] = mgmtKey;
}
let method = "GET";
let body = null;
// 整页只发一次 /all 请求,一次拿两个 provider;
// 覆盖凭据仅在填写时才进 body,不持久化
const bodyObj = {};
if (overrideToken) {
method = "POST";
headers["Content-Type"] = "application/json";
body = JSON.stringify({ session_token: overrideToken });
bodyObj.session_token = overrideToken;
}
// 非空才进 body:list 优先(后端仍接受旧 scalar 字段,前端不再发送)
if (overrideOpenCodeKeys.length > 0) {
bodyObj.opencode_api_keys = overrideOpenCodeKeys;
}
try {
const res = await fetch(USAGE_ENDPOINT, {
method: method,
const res = await fetch(ALL_ENDPOINT, {
method: "POST",
headers: headers,
body: body
body: JSON.stringify(bodyObj)
});
if (res.status === 401 || res.status === 403) {
settingsDrawer.classList.add("open");
showAlert("需要 CLIProxyAPI 管理密钥 (401/403)。请在上方输入框填入 Management Key 并保存。", true);
statusBadge.className = "status-badge warning";
statusText.textContent = "未授权";
return;
}
const json = await res.json();
if (!res.ok || json.ok === false) {
const msg = json.error || (json.message ? json.message : "获取配额失败 (HTTP " + res.status + ")");
if (!res.ok) {
const msg = (json && (json.error || json.message)) || "获取配额失败 (HTTP " + res.status + ")";
showProviderError("commandcode", msg);
showProviderError("opencode", msg);
showAlert(msg);
statusBadge.className = "status-badge exceeded";
statusText.textContent = "查询错误";
return;
}
renderUsage(json);
// 部分失败不阻塞:缺失/失败 provider 在各自 tab 内渲染错误卡片
let anyOk = false;
if (json.commandcode && json.commandcode.ok !== false) {
renderUsage(json.commandcode);
anyOk = true;
} else {
showProviderError("commandcode", (json.errors && json.errors.commandcode) || "Command Code 查询失败");
}
if (json.opencode && json.opencode.ok !== false) {
renderOpencode(json.opencode);
anyOk = true;
} else {
showProviderError("opencode", (json.errors && json.errors.opencode) || "OpenCode Go 查询失败");
}
if (anyOk) {
hideAlert();
} else {
showAlert("所有数据源查询失败,请检查配置或凭据。");
}
const updatedStr = json.updated_at ||
(json.commandcode && json.commandcode.updated_at) ||
(json.opencode && json.opencode.updated_at);
lastUpdated.textContent = updatedStr ? new Date(updatedStr).toLocaleString() : new Date().toLocaleString();
renderAllTab();
updateTimers();
} catch (err) {
showProviderError("commandcode", "网络或同源请求错误: " + err.message);
showProviderError("opencode", "网络或同源请求错误: " + err.message);
showAlert("网络或同源请求错误: " + err.message);
statusBadge.className = "status-badge exceeded";
statusText.textContent = "连接失败";
} finally {
refreshIcon.classList.remove("spin");
btnRefresh.disabled = false;
@@ -1150,6 +1817,13 @@ const QuotaPageHTML = `<!DOCTYPE html>
fetchUsage();
});
// Tab switching + hash persistence (#opencode / #all)
document.querySelectorAll(".tab-btn").forEach((btn) => {
btn.addEventListener("click", () => {
setActiveTab(btn.getAttribute("data-tab"), true);
});
});
// Init on load
const initKey = localStorage.getItem("management_key") || localStorage.getItem("cpa_management_key");
if (initKey) {
@@ -1225,6 +1899,15 @@ const QuotaPageHTML = `<!DOCTYPE html>
timerInterval = setInterval(updateTimers, 1000);
}
// Restore tab from location.hash, then initial fetch
// 无 hash 默认 All:All tab 才能通过刷新后的 #all hash 恢复
const initHash = location.hash.replace(/^#/, "");
if (initHash === "opencode" || initHash === "commandcode") {
setActiveTab(initHash, false);
} else {
setActiveTab("all", false);
}
// Initial fetch
fetchUsage();
})();
+82
View File
@@ -248,3 +248,85 @@ type FormattedUsageResponse struct {
UpdatedAt string `json:"updated_at"`
Error string `json:"error,omitempty"`
}
// OpenCodeUsageResponse reflects GET {opencode_api_base}/usage from OpenCode Go.
type OpenCodeUsageResponse struct {
Usage OpenCodeUsageWindows `json:"usage"`
}
// OpenCodeUsageWindows carries the three usage windows returned by OpenCode Go.
type OpenCodeUsageWindows struct {
Rolling OpenCodeUsageWindow `json:"rolling"`
Weekly OpenCodeUsageWindow `json:"weekly"`
Monthly OpenCodeUsageWindow `json:"monthly"`
}
// OpenCodeUsageWindow represents one quota window from OpenCode Go.
// Percent is int in the observed upstream payload but parsed as float64 for tolerance.
type OpenCodeUsageWindow struct {
Status string `json:"status"`
Percent float64 `json:"percent"`
ResetsAt string `json:"resetsAt"` // RFC3339 UTC
}
// OpenCodeFormattedWindows is the formatted OpenCode Go window section.
type OpenCodeFormattedWindows struct {
Rolling OpenCodeFormattedWindow `json:"rolling"`
Weekly OpenCodeFormattedWindow `json:"weekly"`
Monthly OpenCodeFormattedWindow `json:"monthly"`
}
// OpenCodeFormattedWindow is one formatted OpenCode Go window.
type OpenCodeFormattedWindow struct {
Status string `json:"status"`
Percent float64 `json:"percent"`
Exceeded bool `json:"exceeded"`
ResetAt string `json:"reset_at"`
ResetInSeconds int64 `json:"reset_in_seconds"`
}
// OpenCodeFormattedUsageResponse is the formatted OpenCode Go usage payload.
type OpenCodeFormattedUsageResponse struct {
OK bool `json:"ok"`
Provider string `json:"provider"` // "opencode_go"
Windows OpenCodeFormattedWindows `json:"windows"`
UpdatedAt string `json:"updated_at"`
Error string `json:"error,omitempty"`
}
// OpenCodeKeyResult is the per-key outcome of a multi-key OpenCode Go query
// (v0.4.0). Windows is a pointer so failed keys omit the field entirely
// instead of marshaling a zero-value struct with "status":"" noise.
type OpenCodeKeyResult struct {
KeyID string `json:"key_id"`
OK bool `json:"ok"`
Windows *OpenCodeFormattedWindows `json:"windows,omitempty"`
StatusCode int `json:"status_code"`
Error string `json:"error,omitempty"`
UpdatedAt string `json:"updated_at,omitempty"`
}
// OpenCodeMultiKeyResponse is the multi-key OpenCode Go usage payload returned
// by /plugins/commandcode/opencode/usage and the opencode field of /all.
// Top-level Error is non-empty only when no key is configured at all.
type OpenCodeMultiKeyResponse struct {
OK bool `json:"ok"`
Provider string `json:"provider"` // "opencode_go"
Keys []OpenCodeKeyResult `json:"keys"`
UpdatedAt string `json:"updated_at"`
Error string `json:"error,omitempty"`
}
// AllUsageResponse aggregates both providers for /plugins/commandcode/all.
// Partial failure semantics: each provider's payload is present only on success;
// failures are reported in Errors. CommandCode carries the raw JSON of
// FormattedUsageResponse; OpenCode carries the raw JSON of
// OpenCodeMultiKeyResponse (v0.4.0 breaking change: no longer the single-key
// OpenCodeFormattedUsageResponse).
type AllUsageResponse struct {
OK bool `json:"ok"` // at least one provider succeeded
CommandCode json.RawMessage `json:"commandcode,omitempty"`
OpenCode json.RawMessage `json:"opencode,omitempty"`
Errors map[string]string `json:"errors,omitempty"`
UpdatedAt string `json:"updated_at"`
}
+164 -11
View File
@@ -15,6 +15,7 @@ import (
const (
DefaultAPIBase = "https://api.commandcode.ai"
DefaultOpenCodeAPIBase = "https://opencode.ai/zen/go/v1"
)
// HTTPDoer abstracts HTTP requests for testing and fallback.
@@ -56,16 +57,24 @@ func fetchUpstream(ctx context.Context, apiBase, endpoint, sessionToken, hostCal
url := fmt.Sprintf("%s/%s", strings.TrimRight(apiBase, "/"), strings.TrimLeft(endpoint, "/"))
cookieValue := FormatSessionCookie(cleanToken)
// 1. Try host.http.do if hostCaller is configured
if hostCaller != nil {
reqPayload := HostHTTPRequest{
Method: http.MethodGet,
URL: url,
Headers: map[string][]string{
headers := map[string][]string{
"Cookie": {cookieValue},
"Accept": {"application/json"},
"User-Agent": {fmt.Sprintf("cliproxy-plugin-commandcode/%s", PluginVersion)},
},
}
return doUpstreamRequest(ctx, http.MethodGet, url, headers, hostCallbackID)
}
// doUpstreamRequest is the shared transport layer: it tries the host.http.do
// bridge first (when a host caller is registered) and falls back to net/http.
// Request semantics (method, URL, headers) are fully controlled by the caller.
func doUpstreamRequest(ctx context.Context, method, url string, headers map[string][]string, hostCallbackID string) ([]byte, int, error) {
// 1. Try host.http.do if hostCaller is configured
if hostCaller != nil {
reqPayload := HostHTTPRequest{
Method: method,
URL: url,
Headers: headers,
HostCallbackID: hostCallbackID,
}
rawReq, errMarshal := json.Marshal(reqPayload)
@@ -97,13 +106,15 @@ func fetchUpstream(ctx context.Context, apiBase, endpoint, sessionToken, hostCal
}
// 2. Fallback to Go net/http client
httpReq, errNew := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
httpReq, errNew := http.NewRequestWithContext(ctx, method, url, nil)
if errNew != nil {
return nil, http.StatusInternalServerError, fmt.Errorf("create HTTP request: %w", errNew)
}
httpReq.Header.Set("Cookie", cookieValue)
httpReq.Header.Set("Accept", "application/json")
httpReq.Header.Set("User-Agent", fmt.Sprintf("cliproxy-plugin-commandcode/%s", PluginVersion))
for key, values := range headers {
for _, value := range values {
httpReq.Header.Add(key, value)
}
}
res, errDo := defaultHTTPClient.Do(httpReq)
if errDo != nil {
@@ -131,6 +142,148 @@ func FetchUsageSummaryRaw(ctx context.Context, apiBase, sessionToken string, hos
return fetchUpstream(ctx, apiBase, "internal/usage/summary", sessionToken, hostCallbackID)
}
// fetchUpstreamAlpha performs a GET on a Command Code /alpha endpoint using a
// Provider API key (Bearer auth) instead of a session cookie, reusing the
// host.http.do bridge when available, else falling back to net/http.
func fetchUpstreamAlpha(ctx context.Context, apiBase, endpoint, apiKey, hostCallbackID string) ([]byte, int, error) {
apiKey = strings.TrimSpace(apiKey)
if apiKey == "" {
return nil, http.StatusBadRequest, errors.New("missing commandcode_api_key: please provide a valid Command Code Provider API key")
}
if apiBase == "" {
apiBase = DefaultAPIBase
}
url := fmt.Sprintf("%s/%s", strings.TrimRight(apiBase, "/"), strings.TrimLeft(endpoint, "/"))
headers := map[string][]string{
"Authorization": {"Bearer " + apiKey},
"Accept": {"application/json"},
"User-Agent": {fmt.Sprintf("cliproxy-plugin-commandcode/%s", PluginVersion)},
}
return doUpstreamRequest(ctx, http.MethodGet, url, headers, hostCallbackID)
}
// FetchCommandCodeCreditsAlphaRaw fetches raw credit data from
// {apiBase}/alpha/billing/credits with Bearer auth (Provider API key),
// via host.http.do or net/http fallback.
func FetchCommandCodeCreditsAlphaRaw(ctx context.Context, apiBase, apiKey, hostCallbackID string) ([]byte, int, error) {
return fetchUpstreamAlpha(ctx, apiBase, "alpha/billing/credits", apiKey, hostCallbackID)
}
// FetchCommandCodeUsageSummaryAlphaRaw fetches the billing-period (monthly)
// usage totals from {apiBase}/alpha/usage/summary with Bearer auth.
func FetchCommandCodeUsageSummaryAlphaRaw(ctx context.Context, apiBase, apiKey, hostCallbackID string) ([]byte, int, error) {
return fetchUpstreamAlpha(ctx, apiBase, "alpha/usage/summary", apiKey, hostCallbackID)
}
// FetchOpenCodeUsageRaw fetches raw OpenCode Go usage data from
// {apiBase}/usage with Bearer auth, via host.http.do or net/http fallback.
func FetchOpenCodeUsageRaw(ctx context.Context, apiBase, apiKey, hostCallbackID string) ([]byte, int, error) {
apiKey = strings.TrimSpace(apiKey)
if apiKey == "" {
return nil, http.StatusBadRequest, errors.New("missing opencode_api_key: configure opencode_api_key in plugin config or pass it in the request")
}
if apiBase == "" {
apiBase = DefaultOpenCodeAPIBase
}
url := strings.TrimRight(apiBase, "/") + "/usage"
headers := map[string][]string{
"Authorization": {"Bearer " + apiKey},
"Accept": {"application/json"},
"User-Agent": {fmt.Sprintf("cliproxy-plugin-commandcode/%s", PluginVersion)},
}
return doUpstreamRequest(ctx, http.MethodGet, url, headers, hostCallbackID)
}
// ParseOpenCodeUsage parses OpenCode Go usage JSON into the formatted response.
// Unknown status values are tolerated; a resetsAt that fails to parse is not
// fatal (ResetAt stays empty and ResetInSeconds stays 0).
func ParseOpenCodeUsage(raw []byte, now time.Time) (*OpenCodeFormattedUsageResponse, error) {
if len(raw) == 0 {
return nil, errors.New("empty response body from upstream")
}
var upstream OpenCodeUsageResponse
if err := json.Unmarshal(raw, &upstream); err != nil {
return nil, fmt.Errorf("unmarshal opencode usage response: %w", err)
}
if now.IsZero() {
now = time.Now().UTC()
}
return &OpenCodeFormattedUsageResponse{
OK: true,
Provider: "opencode_go",
Windows: OpenCodeFormattedWindows{
Rolling: formatOpenCodeWindow(upstream.Usage.Rolling, now),
Weekly: formatOpenCodeWindow(upstream.Usage.Weekly, now),
Monthly: formatOpenCodeWindow(upstream.Usage.Monthly, now),
},
UpdatedAt: now.Format(time.RFC3339),
}, nil
}
// formatOpenCodeWindow formats a single OpenCode Go usage window.
func formatOpenCodeWindow(w OpenCodeUsageWindow, now time.Time) OpenCodeFormattedWindow {
percent := clampOpenCodePercent(w.Percent)
out := OpenCodeFormattedWindow{
Status: w.Status,
Percent: percent,
Exceeded: percent >= 100 || w.Status == "exceeded",
}
if w.ResetsAt != "" {
if t, err := time.Parse(time.RFC3339, w.ResetsAt); err == nil {
out.ResetAt = t.UTC().Format(time.RFC3339)
if diff := t.UTC().Sub(now); diff > 0 {
out.ResetInSeconds = int64(diff.Seconds())
}
}
// Parse failure is not fatal: ResetAt stays empty, ResetInSeconds stays 0.
}
return out
}
// clampOpenCodePercent clamps a percentage to [0, 100] with 2-decimal rounding.
func clampOpenCodePercent(p float64) float64 {
if p < 0 {
p = 0
}
if p > 100 {
p = 100
}
return math.Round(p*100) / 100
}
// MaskAPIKey masks an OpenCode Go API key for display: first 4 + "…" + last 4
// characters (e.g. "sk-L…KqYB"). Keys shorter than 8 characters are fully
// masked as "***"; an empty key masks to "".
func MaskAPIKey(key string) string {
if key == "" {
return ""
}
if len(key) < 8 {
return "***"
}
return key[:4] + "…" + key[len(key)-4:]
}
// QueryOpenCodeKeys queries OpenCode Go usage for each key sequentially and
// returns one typed result per key, in input order. A single key's failure is
// recorded only in that key's result and never aborts the loop.
func QueryOpenCodeKeys(ctx context.Context, apiBase string, keys []string, hostCallbackID string) []OpenCodeKeyResult {
results := make([]OpenCodeKeyResult, 0, len(keys))
for _, key := range keys {
res, _ := queryOpenCodeKey(ctx, apiBase, key, hostCallbackID)
results = append(results, res)
}
return results
}
// ParseAndFormatUsage parses upstream credits JSON into structured usage metrics.
// summary (optional) carries the billing-period usage totals used to derive the monthly window.
func ParseAndFormatUsage(raw []byte, summary *UpstreamUsageSummaryResponse, now time.Time) (*FormattedUsageResponse, error) {
+539
View File
@@ -6,6 +6,7 @@ import (
"math"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
@@ -376,3 +377,541 @@ func TestPlanFromWindowLimits(t *testing.T) {
})
}
}
func TestParseOpenCodeUsage(t *testing.T) {
now := time.Date(2026, 9, 16, 12, 0, 0, 0, time.UTC)
t.Run("normal payload from real upstream shape", func(t *testing.T) {
raw := []byte(`{"usage":{
"rolling": {"status":"ok","percent":4, "resetsAt":"2026-09-17T06:58:53.171Z"},
"weekly": {"status":"ok","percent":46,"resetsAt":"2026-09-21T00:00:00.000Z"},
"monthly": {"status":"ok","percent":23,"resetsAt":"2026-10-14T09:13:49.000Z"}
}}`)
usage, err := ParseOpenCodeUsage(raw, now)
if err != nil {
t.Fatalf("ParseOpenCodeUsage error: %v", err)
}
if !usage.OK || usage.Provider != "opencode_go" {
t.Fatalf("unexpected header: ok=%v provider=%q", usage.OK, usage.Provider)
}
if usage.UpdatedAt != "2026-09-16T12:00:00Z" {
t.Errorf("UpdatedAt = %q", usage.UpdatedAt)
}
rolling := usage.Windows.Rolling
if rolling.Percent != 4 || rolling.Status != "ok" || rolling.Exceeded {
t.Errorf("rolling = %+v", rolling)
}
if rolling.ResetAt != "2026-09-17T06:58:53Z" {
t.Errorf("rolling reset_at = %q", rolling.ResetAt)
}
if rolling.ResetInSeconds != 68333 {
t.Errorf("rolling reset_in_seconds = %d, want 68333", rolling.ResetInSeconds)
}
weekly := usage.Windows.Weekly
if weekly.Percent != 46 {
t.Errorf("weekly percent = %v, want 46", weekly.Percent)
}
if weekly.ResetAt != "2026-09-21T00:00:00Z" {
t.Errorf("weekly reset_at = %q, want 2026-09-21T00:00:00Z (.000Z tolerated)", weekly.ResetAt)
}
monthly := usage.Windows.Monthly
if monthly.Percent != 23 {
t.Errorf("monthly percent = %v, want 23", monthly.Percent)
}
})
t.Run("float percent", func(t *testing.T) {
raw := []byte(`{"usage":{"rolling":{"status":"ok","percent":12.345,"resetsAt":"2026-09-17T06:58:53Z"}}}`)
usage, err := ParseOpenCodeUsage(raw, now)
if err != nil {
t.Fatalf("ParseOpenCodeUsage error: %v", err)
}
if got := usage.Windows.Rolling.Percent; got != 12.35 { // Round(x*100)/100
t.Errorf("percent = %v, want 12.35", got)
}
})
t.Run("unknown status tolerated", func(t *testing.T) {
raw := []byte(`{"usage":{"rolling":{"status":"weird-status","percent":50,"resetsAt":"2026-09-17T06:58:53Z"}}}`)
usage, err := ParseOpenCodeUsage(raw, now)
if err != nil {
t.Fatalf("ParseOpenCodeUsage error: %v", err)
}
if got := usage.Windows.Rolling; got.Status != "weird-status" || got.Exceeded {
t.Errorf("rolling = %+v, want status kept and not exceeded", got)
}
})
t.Run("exceeded status", func(t *testing.T) {
raw := []byte(`{"usage":{"rolling":{"status":"exceeded","percent":99,"resetsAt":"2026-09-17T06:58:53Z"}}}`)
usage, err := ParseOpenCodeUsage(raw, now)
if err != nil {
t.Fatalf("ParseOpenCodeUsage error: %v", err)
}
if !usage.Windows.Rolling.Exceeded {
t.Error("expected Exceeded=true for status=exceeded")
}
})
t.Run("percent 100 exceeded", func(t *testing.T) {
raw := []byte(`{"usage":{"rolling":{"status":"ok","percent":100,"resetsAt":""}}}`)
usage, err := ParseOpenCodeUsage(raw, now)
if err != nil {
t.Fatalf("ParseOpenCodeUsage error: %v", err)
}
if !usage.Windows.Rolling.Exceeded {
t.Error("expected Exceeded=true for percent=100")
}
if usage.Windows.Rolling.ResetAt != "" || usage.Windows.Rolling.ResetInSeconds != 0 {
t.Errorf("expected empty reset fields, got %+v", usage.Windows.Rolling)
}
})
t.Run("percent above 100 clamped", func(t *testing.T) {
raw := []byte(`{"usage":{"rolling":{"status":"ok","percent":150.5,"resetsAt":""}}}`)
usage, err := ParseOpenCodeUsage(raw, now)
if err != nil {
t.Fatalf("ParseOpenCodeUsage error: %v", err)
}
if got := usage.Windows.Rolling.Percent; got != 100 {
t.Errorf("percent = %v, want 100 (clamped)", got)
}
if !usage.Windows.Rolling.Exceeded {
t.Error("expected Exceeded=true when clamped to 100")
}
})
t.Run("malformed resetsAt not fatal", func(t *testing.T) {
raw := []byte(`{"usage":{"rolling":{"status":"ok","percent":5,"resetsAt":"not-a-timestamp"}}}`)
usage, err := ParseOpenCodeUsage(raw, now)
if err != nil {
t.Fatalf("ParseOpenCodeUsage must not fail on bad resetsAt: %v", err)
}
if got := usage.Windows.Rolling; got.ResetAt != "" || got.ResetInSeconds != 0 {
t.Errorf("expected zero reset fields on parse failure, got %+v", got)
}
})
t.Run("missing windows tolerated as zero values", func(t *testing.T) {
raw := []byte(`{"usage":{}}`)
usage, err := ParseOpenCodeUsage(raw, now)
if err != nil {
t.Fatalf("ParseOpenCodeUsage error: %v", err)
}
if usage.Windows.Rolling.Percent != 0 {
t.Errorf("rolling percent = %v, want 0", usage.Windows.Rolling.Percent)
}
})
t.Run("empty body", func(t *testing.T) {
if _, err := ParseOpenCodeUsage(nil, now); err == nil {
t.Fatal("expected error for empty body")
}
})
t.Run("invalid JSON", func(t *testing.T) {
if _, err := ParseOpenCodeUsage([]byte(`not-json`), now); err == nil {
t.Fatal("expected error for invalid JSON")
}
})
}
func TestFetchOpenCodeUsageRaw_FallbackHTTP(t *testing.T) {
var sawAuth, sawUA, sawAccept string
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/usage" {
t.Errorf("unexpected path: %s", r.URL.Path)
http.NotFound(w, r)
return
}
sawAuth = r.Header.Get("Authorization")
sawUA = r.Header.Get("User-Agent")
sawAccept = r.Header.Get("Accept")
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"usage":{"rolling":{"status":"ok","percent":4,"resetsAt":"2026-09-17T06:58:53.171Z"}}}`))
}))
defer ts.Close()
SetHostCaller(nil)
SetDefaultHTTPClient(ts.Client())
defer func() {
SetDefaultHTTPClient(&http.Client{Timeout: 15 * time.Second})
}()
body, status, err := FetchOpenCodeUsageRaw(context.Background(), ts.URL, "sk-test-key", "")
if err != nil {
t.Fatalf("FetchOpenCodeUsageRaw error: %v", err)
}
if status != http.StatusOK {
t.Errorf("status = %d, want 200", status)
}
if len(body) == 0 {
t.Fatal("expected non-empty body")
}
if sawAuth != "Bearer sk-test-key" {
t.Errorf("Authorization = %q, want Bearer sk-test-key", sawAuth)
}
if sawAccept != "application/json" {
t.Errorf("Accept = %q, want application/json", sawAccept)
}
if !strings.Contains(sawUA, "cliproxy-plugin-commandcode/") {
t.Errorf("User-Agent = %q, want cliproxy-plugin-commandcode/<version>", sawUA)
}
usage, errParse := ParseOpenCodeUsage(body, time.Time{})
if errParse != nil {
t.Fatalf("ParseOpenCodeUsage error: %v", errParse)
}
if usage.Windows.Rolling.Percent != 4 {
t.Errorf("rolling percent = %v, want 4", usage.Windows.Rolling.Percent)
}
}
func TestFetchOpenCodeUsageRaw_BaseTrailingSlash(t *testing.T) {
requests := 0
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests++
if r.URL.Path != "/usage" {
t.Errorf("path = %q, want /usage (trailing slash trimmed)", r.URL.Path)
}
_, _ = w.Write([]byte(`{"usage":{"rolling":{"status":"ok","percent":1,"resetsAt":""}}}`))
}))
defer ts.Close()
SetHostCaller(nil)
SetDefaultHTTPClient(ts.Client())
defer func() {
SetDefaultHTTPClient(&http.Client{Timeout: 15 * time.Second})
}()
if _, _, err := FetchOpenCodeUsageRaw(context.Background(), ts.URL+"/", "sk-key", ""); err != nil {
t.Fatalf("error: %v", err)
}
if requests != 1 {
t.Fatalf("requests = %d, want 1", requests)
}
}
func TestFetchOpenCodeUsageRaw_MissingKey(t *testing.T) {
_, status, err := FetchOpenCodeUsageRaw(context.Background(), "", "", "")
if err == nil {
t.Fatal("expected error for missing key")
}
if status != http.StatusBadRequest {
t.Errorf("status = %d, want 400", status)
}
}
func TestFetchOpenCodeUsageRaw_EmptyKeyAfterTrim(t *testing.T) {
_, status, err := FetchOpenCodeUsageRaw(context.Background(), "", " ", "")
if err == nil {
t.Fatal("expected error for whitespace-only key")
}
if status != http.StatusBadRequest {
t.Errorf("status = %d, want 400", status)
}
}
func TestFetchOpenCodeUsageRaw_UpstreamNon200(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"error":"invalid api key"}`))
}))
defer ts.Close()
SetHostCaller(nil)
SetDefaultHTTPClient(ts.Client())
defer func() {
SetDefaultHTTPClient(&http.Client{Timeout: 15 * time.Second})
}()
body, status, err := FetchOpenCodeUsageRaw(context.Background(), ts.URL, "sk-bad", "")
if err != nil {
t.Fatalf("expected nil transport error for non-200 upstream, got %v", err)
}
if status != http.StatusUnauthorized {
t.Errorf("status = %d, want 401", status)
}
if string(body) != `{"error":"invalid api key"}` {
t.Errorf("body = %q", string(body))
}
}
func TestMaskAPIKey(t *testing.T) {
tests := []struct {
name string
key string
want string
}{
{"empty", "", ""},
{"normal key", "sk-LongExampleKqYB", "sk-L…KqYB"},
{"exactly 8 chars", "12345678", "1234…5678"},
{"7 chars fully masked", "1234567", "***"},
{"1 char", "x", "***"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := MaskAPIKey(tt.key); got != tt.want {
t.Errorf("MaskAPIKey(%q) = %q, want %q", tt.key, got, tt.want)
}
})
}
}
// Double-key isolation: one key succeeds, the other gets a 401 — the failure
// must be contained in its own result, must not abort the loop, and the raw
// key must never appear in any result field.
func TestQueryOpenCodeKeys_IsolationAndOrder(t *testing.T) {
var authOrder []string
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authOrder = append(authOrder, r.Header.Get("Authorization"))
switch r.Header.Get("Authorization") {
case "Bearer sk-good-AAAA":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(mockOpencodeUsageJSON))
default:
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"error":"invalid api key"}`))
}
}))
defer ts.Close()
SetHostCaller(nil)
SetDefaultHTTPClient(ts.Client())
defer func() {
SetDefaultHTTPClient(&http.Client{Timeout: 15 * time.Second})
}()
keys := []string{"sk-good-AAAA", "sk-bad-BBBB"}
results := QueryOpenCodeKeys(context.Background(), ts.URL, keys, "")
if len(results) != 2 {
t.Fatalf("len(results) = %d, want 2", len(results))
}
// Order preserved: requests issued in input order.
if len(authOrder) != 2 || authOrder[0] != "Bearer sk-good-AAAA" || authOrder[1] != "Bearer sk-bad-BBBB" {
t.Errorf("request order = %v, want sequential input order", authOrder)
}
ok := results[0]
if !ok.OK || ok.StatusCode != http.StatusOK {
t.Errorf("results[0] = %+v, want OK=true status=200", ok)
}
if ok.Windows == nil {
t.Fatal("results[0].Windows = nil, want non-nil on success")
}
if ok.Windows.Rolling.Percent != 4 || ok.Windows.Weekly.Percent != 46 || ok.Windows.Monthly.Percent != 23 {
t.Errorf("results[0] percents = %v/%v/%v, want 4/46/23",
ok.Windows.Rolling.Percent, ok.Windows.Weekly.Percent, ok.Windows.Monthly.Percent)
}
if ok.KeyID != MaskAPIKey("sk-good-AAAA") {
t.Errorf("results[0].KeyID = %q, want masked id %q", ok.KeyID, MaskAPIKey("sk-good-AAAA"))
}
bad := results[1]
if bad.OK {
t.Errorf("results[1].OK = true, want false (401 must not abort the loop)")
}
if bad.Windows != nil {
t.Errorf("results[1].Windows = %+v, want nil on failure", bad.Windows)
}
if bad.StatusCode != http.StatusUnauthorized {
t.Errorf("results[1].StatusCode = %d, want 401", bad.StatusCode)
}
if !strings.Contains(bad.Error, "opencode upstream returned 401") {
t.Errorf("results[1].Error = %q, want it to mention the upstream 401", bad.Error)
}
// Raw keys must never leak into any serialized result field.
raw, _ := json.Marshal(results)
if strings.Contains(string(raw), "sk-good-AAAA") || strings.Contains(string(raw), "sk-bad-BBBB") {
t.Errorf("serialized results leak a raw key: %s", string(raw))
}
}
func TestQueryOpenCodeKeys_EmptyKeyInList(t *testing.T) {
SetHostCaller(nil)
results := QueryOpenCodeKeys(context.Background(), "", []string{""}, "")
if len(results) != 1 {
t.Fatalf("len(results) = %d, want 1", len(results))
}
if results[0].OK || results[0].StatusCode != http.StatusBadRequest {
t.Errorf("results[0] = %+v, want local 400 result", results[0])
}
}
func TestFetchOpenCodeUsageRaw_HostCaller(t *testing.T) {
mockResponsePayload := []byte(`{"usage":{"rolling":{"status":"ok","percent":7,"resetsAt":"2026-09-17T06:58:53Z"}}}`)
var sawMethod, sawURL string
var sawHeaders map[string][]string
SetHostCaller(func(method string, payload []byte) ([]byte, error) {
if method != "host.http.do" {
t.Errorf("method = %s, want host.http.do", method)
}
var req HostHTTPRequest
if err := json.Unmarshal(payload, &req); err != nil {
t.Fatalf("unmarshal HostHTTPRequest error: %v", err)
}
sawMethod, sawURL, sawHeaders = req.Method, req.URL, req.Headers
hostResp := HostHTTPResponse{
StatusCode: http.StatusOK,
Body: mockResponsePayload,
}
respJSON, _ := json.Marshal(hostResp)
return json.Marshal(Envelope{OK: true, Result: respJSON})
})
defer SetHostCaller(nil)
body, status, err := FetchOpenCodeUsageRaw(context.Background(), "https://opencode.example/v1", "sk-host-key", "cb-123")
if err != nil {
t.Fatalf("FetchOpenCodeUsageRaw with hostCaller error: %v", err)
}
if status != http.StatusOK {
t.Errorf("status = %d, want 200", status)
}
if string(body) != string(mockResponsePayload) {
t.Errorf("body = %s, want %s", string(body), string(mockResponsePayload))
}
if sawMethod != http.MethodGet {
t.Errorf("host request method = %s, want GET", sawMethod)
}
if sawURL != "https://opencode.example/v1/usage" {
t.Errorf("host request url = %s, want https://opencode.example/v1/usage", sawURL)
}
auth := sawHeaders["Authorization"]
if len(auth) == 0 || auth[0] != "Bearer sk-host-key" {
t.Errorf("host request Authorization = %v, want Bearer sk-host-key", auth)
}
}
// v0.5.0: the /alpha endpoints authenticate with a Bearer Provider API key
// instead of the session cookie. The URL must be /alpha/billing/credits and
// no Cookie header may be sent.
func TestFetchCommandCodeCreditsAlphaRaw_FallbackHTTP(t *testing.T) {
var sawAuth, sawCookie, sawAccept, sawUA string
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/alpha/billing/credits" {
t.Errorf("unexpected path: %s", r.URL.Path)
http.NotFound(w, r)
return
}
sawAuth = r.Header.Get("Authorization")
sawCookie = r.Header.Get("Cookie")
sawAccept = r.Header.Get("Accept")
sawUA = r.Header.Get("User-Agent")
// Alpha credits omit opensourceMonthlyCredits (field difference vs
// the internal endpoint); formatCredits must tolerate that.
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"credits":{"monthlyCredits":700},"windowLimits":{"fiveHour":{"used":1,"cap":10}}}`))
}))
defer ts.Close()
SetHostCaller(nil)
SetDefaultHTTPClient(ts.Client())
defer func() {
SetDefaultHTTPClient(&http.Client{Timeout: 15 * time.Second})
}()
body, status, err := FetchCommandCodeCreditsAlphaRaw(context.Background(), ts.URL, "user_test-key", "")
if err != nil {
t.Fatalf("FetchCommandCodeCreditsAlphaRaw error: %v", err)
}
if status != http.StatusOK {
t.Errorf("status = %d, want 200", status)
}
if sawAuth != "Bearer user_test-key" {
t.Errorf("Authorization = %q, want Bearer user_test-key", sawAuth)
}
if sawCookie != "" {
t.Errorf("Cookie = %q, want no Cookie header on the /alpha path", sawCookie)
}
if sawAccept != "application/json" {
t.Errorf("Accept = %q, want application/json", sawAccept)
}
if !strings.Contains(sawUA, "cliproxy-plugin-commandcode/") {
t.Errorf("User-Agent = %q, want cliproxy-plugin-commandcode/<version>", sawUA)
}
usage, errParse := ParseAndFormatUsage(body, nil, time.Time{})
if errParse != nil {
t.Fatalf("ParseAndFormatUsage error: %v", errParse)
}
if usage.Credits.MonthlyCredits != 700 {
t.Errorf("MonthlyCredits = %v, want 700", usage.Credits.MonthlyCredits)
}
if usage.Credits.OpensourceMonthlyCredits != 0 {
t.Errorf("OpensourceMonthlyCredits = %v, want 0 (field absent in alpha payload)", usage.Credits.OpensourceMonthlyCredits)
}
// total = monthly + 0 when opensourceMonthlyCredits is missing.
if usage.Credits.TotalCredits != 700 {
t.Errorf("TotalCredits = %v, want 700 (= monthly when opensource field absent)", usage.Credits.TotalCredits)
}
}
func TestFetchCommandCodeUsageSummaryAlphaRaw_FallbackHTTP(t *testing.T) {
var sawAuth, sawCookie string
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/alpha/usage/summary" {
t.Errorf("unexpected path: %s", r.URL.Path)
http.NotFound(w, r)
return
}
sawAuth = r.Header.Get("Authorization")
sawCookie = r.Header.Get("Cookie")
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"totalMonthlyCredits": 123}`))
}))
defer ts.Close()
SetHostCaller(nil)
SetDefaultHTTPClient(ts.Client())
defer func() {
SetDefaultHTTPClient(&http.Client{Timeout: 15 * time.Second})
}()
body, status, err := FetchCommandCodeUsageSummaryAlphaRaw(context.Background(), ts.URL+"/", "user_test-key", "")
if err != nil {
t.Fatalf("FetchCommandCodeUsageSummaryAlphaRaw error: %v", err)
}
if status != http.StatusOK {
t.Errorf("status = %d, want 200", status)
}
if sawAuth != "Bearer user_test-key" {
t.Errorf("Authorization = %q, want Bearer user_test-key", sawAuth)
}
if sawCookie != "" {
t.Errorf("Cookie = %q, want no Cookie header on the /alpha path", sawCookie)
}
var summary UpstreamUsageSummaryResponse
if err := json.Unmarshal(body, &summary); err != nil {
t.Fatalf("unmarshal summary error: %v", err)
}
if summary.TotalMonthlyCredits != 123 {
t.Errorf("TotalMonthlyCredits = %v, want 123", summary.TotalMonthlyCredits)
}
}
func TestFetchCommandCodeCreditsAlphaRaw_MissingKey(t *testing.T) {
SetHostCaller(nil)
for _, key := range []string{"", " "} {
_, status, err := FetchCommandCodeCreditsAlphaRaw(context.Background(), "", key, "")
if err == nil {
t.Fatalf("key %q: expected error for missing key", key)
}
if status != http.StatusBadRequest {
t.Errorf("key %q: status = %d, want 400", key, status)
}
if !strings.Contains(err.Error(), "missing commandcode_api_key") {
t.Errorf("key %q: error = %q, want it to mention missing commandcode_api_key", key, err.Error())
}
}
}
+217
View File
@@ -0,0 +1,217 @@
// pagecheck: syntax-check the embedded quota page JS (guards against
// parse-time SyntaxErrors like duplicate const that break the whole page).
const fs = require("fs");
const src = fs.readFileSync("plugin/quota_page.go", "utf8");
const m = src.match(/const QuotaPageHTML = `([\s\S]*)`/);
if (!m) {
console.error("pagecheck: QuotaPageHTML not found");
process.exit(1);
}
const js = [...m[1].matchAll(/<script>([\s\S]*?)<\/script>/g)]
.map((x) => x[1])
.join("\n");
if (!js.trim()) {
console.error("pagecheck: no <script> content found");
process.exit(1);
}
try {
new Function(js);
} catch (e) {
console.error("pagecheck: embedded JS SyntaxError:", e.message);
process.exit(1);
}
console.log("pagecheck: embedded JS syntax OK");
// --- Undeclared-identifier audit (guards against ReferenceErrors like the
// v0.4.4 `monthlyTargetTime is not defined` bug: a bare identifier read in
// updateTimers() that was never declared and only existed as a global
// property accidentally created by renderUsage()).
//
// Approach (deliberately simple/grep-style, no DOM execution):
// 1. strip comments and string literals
// 2. collect every var/let/const/function declaration name + function/
// callback/catch parameter
// 3. flag candidates: bare assignment targets (x =, x +=, x++, ...),
// bare if()/while() condition identifiers, and for-loop init identifiers
// 4. whitelist known globals; anything left is reported and fails the check
const whitelist = new Set([
// browser builtins referenced by the page
"document", "window", "localStorage", "sessionStorage", "fetch",
"setInterval", "setTimeout", "clearInterval", "clearTimeout", "console",
"alert", "Date", "Math", "Number", "String", "Boolean", "Array",
"Object", "JSON", "parseInt", "parseFloat", "isNaN", "Promise", "Error",
"escape", "unescape", "navigator", "location", "history", "URL",
"URLSearchParams", "FormData", "Headers", "Request", "Response",
"Intl", "Map", "Set", "AbortController", "requestAnimationFrame",
"cancelAnimationFrame", "structuredClone", "globalThis", "arguments",
]);
function stripLiterals(source) {
// Remove comments, string literals, and regex literals; replace with
// harmless placeholders so identifier scanning never sees string content.
// Template literals are NOT fully discarded: their ${...} interpolations
// are kept as "( ... )" so identifiers inside them stay visible.
//
// A "/" only starts a regex literal when it appears in expression
// position (previous significant token is an operator/open bracket or a
// keyword such as return/typeof). Otherwise it is division.
let out = "";
let i = 0;
const n = source.length;
// Stack of lexer contexts. Each entry: { type: "expr", depth: number } or
// { type: "tmpl" }. The initial code runs in a never-ending expr context.
const stack = [{ type: "expr", depth: Infinity }];
// Last significant (non-whitespace) emitted char + last identifier word,
// used for the regex-vs-division heuristic.
let lastSig = "";
let lastWord = "";
const KEYWORDS_BEFORE_REGEX = new Set([
"return", "typeof", "instanceof", "in", "of", "new", "delete", "void",
"do", "else", "case", "throw", "await", "yield",
]);
const emit = (text) => {
for (const ch of text) {
if (/\s/.test(ch)) continue;
if (/[A-Za-z0-9_$]/.test(ch)) {
lastWord = /[A-Za-z0-9_$]/.test(lastSig) ? lastWord + ch : ch;
} else {
lastWord = "";
}
lastSig = ch;
}
out += text;
};
const regexAllowed = () =>
lastSig === "" ||
"(,=:[!&|?+-*/%<>~^;{".includes(lastSig) ||
KEYWORDS_BEFORE_REGEX.has(lastWord);
while (i < n) {
const ctx = stack[stack.length - 1];
const c = source[i];
const next = source[i + 1];
if (ctx.type === "tmpl") {
// Inside template literal text: skip until ` or ${ ... }
if (c === "\\") { i += 2; continue; }
if (c === "`") { i++; stack.pop(); emit(" "); continue; }
if (c === "$" && next === "{") {
i += 2;
stack.push({ type: "expr", depth: 0 });
emit(" ( ");
continue;
}
i++;
continue;
}
// code context (top-level or template ${...} expression)
if (c === "/" && next === "/") {
while (i < n && source[i] !== "\n") i++;
} else if (c === "/" && next === "*") {
i += 2;
while (i < n && !(source[i] === "*" && source[i + 1] === "/")) i++;
i += 2;
} else if (c === "/" && regexAllowed()) {
// regex literal: skip to unescaped closing / (not inside [...])
i++;
let inClass = false;
while (i < n) {
if (source[i] === "\\") { i += 2; continue; }
if (source[i] === "[") { inClass = true; i++; continue; }
if (source[i] === "]") { inClass = false; i++; continue; }
if (source[i] === "/" && !inClass) { i++; break; }
if (source[i] === "\n") break; // malformed; bail out safely
i++;
}
while (i < n && /[a-z]/i.test(source[i])) i++; // flags
emit(" / ");
} else if (c === '"' || c === "'") {
const quote = c;
i++;
while (i < n) {
if (source[i] === "\\") { i += 2; continue; }
if (source[i] === quote) { i++; break; }
if (source[i] === "\n") break;
i++;
}
emit(" " + quote + quote + " ");
} else if (c === "`") {
i++;
stack.push({ type: "tmpl" });
emit(" ");
} else {
if (c === "{") ctx.depth++;
if (c === "}") {
if (ctx.depth === 0) {
// closes a template interpolation: back into template text
stack.pop();
i++;
emit(" ) ");
continue;
}
ctx.depth--;
}
emit(c);
i++;
}
}
return out;
}
function collectDeclarations(clean) {
const declared = new Set();
const addParamList = (raw) => {
for (const p of raw.split(",")) {
const name = p.trim().split(/[\s=]/)[0].replace(/^\.\.\./, "");
if (/^[A-Za-z_$][\w$]*$/.test(name)) declared.add(name);
}
};
for (const m of clean.matchAll(/\b(?:var|let|const)\s+([A-Za-z_$][\w$]*)/g))
declared.add(m[1]);
// function declarations/expressions: name + params
for (const m of clean.matchAll(/\bfunction\s*([A-Za-z_$][\w$]*)?\s*\(([^()]*)\)/g)) {
if (m[1]) declared.add(m[1]);
addParamList(m[2]);
}
// arrow functions: (a, b) => and a =>
for (const m of clean.matchAll(/\(\s*([^()]*?)\s*\)\s*=>/g)) addParamList(m[1]);
for (const m of clean.matchAll(/(?<![\w$.(])\b([A-Za-z_$][\w$]*)\s*=>/g)) declared.add(m[1]);
// catch (e) and destructuring catch
for (const m of clean.matchAll(/\bcatch\s*\(?\s*\{?\s*([A-Za-z_$][\w$]*)/g)) declared.add(m[1]);
return declared;
}
const clean = stripLiterals(js);
const declared = collectDeclarations(clean);
const flagged = new Set();
// 1. bare assignment targets / updates: x =, x +=, x++, x--, x ??=
for (const m of clean.matchAll(/(?:^|[{};\n])\s*([A-Za-z_$][\w$]*)\s*(?:=[^=>]|[+*\/%-]?=[^=]|\+\+|\-\-)/gm)) {
const name = m[1];
if (!declared.has(name) && !whitelist.has(name)) flagged.add(name);
}
// 2. bare if()/while() condition identifiers
for (const m of clean.matchAll(/\b(?:if|while)\s*\(\s*(!*)\s*([A-Za-z_$][\w$]*)\s*(?:\)|&&|\|\||\?)/g)) {
const name = m[2];
if (!declared.has(name) && !whitelist.has(name)) flagged.add(name);
}
// 3. for-loop init without let/var: for (i = 0; ...)
for (const m of clean.matchAll(/\bfor\s*\(\s*([A-Za-z_$][\w$]*)\s*=[^=]/g)) {
const name = m[1];
if (!declared.has(name) && !whitelist.has(name)) flagged.add(name);
}
if (flagged.size > 0) {
console.error("pagecheck: undeclared identifier(s) referenced in embedded JS:");
for (const name of [...flagged].sort()) console.error(" - " + name);
console.error(
"pagecheck: fix by declaring with let/const (see v0.4.5 monthlyTargetTime bug); " +
"if this is a false positive, extend scripts/pagecheck.js"
);
process.exit(1);
}
console.log(
"pagecheck: undeclared-identifier scan OK (" + declared.size + " declarations checked)"
);