dsh-opencode-go-usage 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +114 -0
- package/README.zh.md +108 -0
- package/cordis.patch.yml +12 -0
- package/lib/client.js +191 -0
- package/lib/index.js +234 -0
- package/package.json +44 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 opencode-go-usage contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# dsh-opencode-go-usage
|
|
2
|
+
|
|
3
|
+
English | [中文](README.zh.md)
|
|
4
|
+
|
|
5
|
+
A [DSH](https://github.com/deepseek-ai/deepseek-harness) (DeepSeek Harness) plugin that watches your **OpenCode GO plan** quota — the $10/month subscription that gives you usage limits on open-source models (rolling 5-hour, weekly, and monthly windows).
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- **Sidebar widget** — a live widget pinned at the bottom of the DSH web sidebar (`sidebar.footer.action` slot) showing three usage bars: rolling (5h), weekly, and monthly, each with a relative countdown to its window reset. When the sidebar is collapsed it shrinks to a compact percentage badge.
|
|
10
|
+
- **`/opencode-go` chat command** — prints the same numbers as text inside any conversation.
|
|
11
|
+
- **Same-origin proxy** — the host registers `GET /opencode-go/usage`, forwards to the official GO gateway with your API key. The key never reaches the browser and no CORS is involved.
|
|
12
|
+
|
|
13
|
+
## How it works
|
|
14
|
+
|
|
15
|
+
The plugin is a **dual-half DSH package**:
|
|
16
|
+
|
|
17
|
+
| half | file | role |
|
|
18
|
+
|---|---|---|
|
|
19
|
+
| host (Node) | `lib/index.js` | registers the `/opencode-go/usage` web route (`ctx.webServer`) and the `/opencode-go` command (`ctx.commands`); resolves the key through DSH credentials; caches the upstream call (30 s) |
|
|
20
|
+
| browser | `lib/client.js` | a hand-authored `window.__ModuleLoader__.load({ id, factory })` bundle that registers into the `sidebar.footer.action` list slot and polls the same-origin route every 60 s |
|
|
21
|
+
|
|
22
|
+
`package.json` declares `"dsh": { "client": { "platform": "web" } }`, so DSH's client-modules node half scans it into the browser boot graph (`window.__DSH_BOOT__`) and serves the bundle at `/plugins/dsh-opencode-go-usage/client.js`.
|
|
23
|
+
|
|
24
|
+
### How the sidebar widget loads under the official install
|
|
25
|
+
|
|
26
|
+
`dsh plugin add` installs the package into the profile, which satisfies the host
|
|
27
|
+
half (routes, command, settings). DSH's client-modules scanner can only resolve
|
|
28
|
+
browser bundles from its own installation directory, so a profile-installed
|
|
29
|
+
third-party package would normally lose its browser half — this plugin avoids
|
|
30
|
+
that by **self-hosting** its bundle: the host registers the
|
|
31
|
+
`/dsh-opencode-go-usage/client.js` route and injects its boot-graph row through the
|
|
32
|
+
official `webServer.tapIndex` API. The sidebar widget therefore works from any
|
|
33
|
+
installation location.
|
|
34
|
+
|
|
35
|
+
## Requirements
|
|
36
|
+
|
|
37
|
+
- DSH installed and the `web` profile booted at least once (`~/.dsh/profiles/web` exists)
|
|
38
|
+
- Node.js ≥ 18 (for `fetch`)
|
|
39
|
+
- An OpenCode GO subscription and its API key
|
|
40
|
+
|
|
41
|
+
## Install (official DSH flow)
|
|
42
|
+
|
|
43
|
+
Requirements: DSH installed with the `web` profile booted once, Node.js ≥ 18,
|
|
44
|
+
an OpenCode GO subscription.
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
# 1. install the package into your web profile (pnpm; enable via corepack if needed)
|
|
48
|
+
dsh plugin --profile web add dsh-opencode-go-usage
|
|
49
|
+
|
|
50
|
+
# 2. store your GO API key as a DSH credential
|
|
51
|
+
# (create the key at https://opencode.ai/auth)
|
|
52
|
+
# → add to ~/.dsh/.credentials.yaml:
|
|
53
|
+
# OPENCODE_GO_API_KEY: sk-...
|
|
54
|
+
|
|
55
|
+
# 3. restart `dsh web` and hard-refresh the browser page
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
That's it for the quota widget and `/opencode-go` command. The CLI reconciles
|
|
59
|
+
the package's `dsh.bundle.patch` into the profile's bundle stack automatically
|
|
60
|
+
— no manual `cordis.patch.yml` editing, no symlinks.
|
|
61
|
+
|
|
62
|
+
Not published on npm yet? Install from a checkout instead:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
dsh plugin --profile web add /path/to/dsh-opencode-go-usage
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
> New to DSH plugins? Follow the [user guide](docs/INSTALL.zh.md) (Chinese, step-by-step).
|
|
69
|
+
|
|
70
|
+
## Usage
|
|
71
|
+
|
|
72
|
+
- **Widget**: read it. Collapsed sidebar → percentage badge; expanded → three progress bars with reset countdowns.
|
|
73
|
+
- **Command**: `/opencode-go` in any conversation prints the three windows as text.
|
|
74
|
+
|
|
75
|
+
## Config reference
|
|
76
|
+
|
|
77
|
+
| key | default | description |
|
|
78
|
+
|---|---|---|
|
|
79
|
+
| key | default | description |
|
|
80
|
+
|---|---|---|
|
|
81
|
+
| `apiKeyEnv` | `OPENCODE_GO_API_KEY` | credential reference / env var name for the API key |
|
|
82
|
+
| `baseUrl` | `https://opencode.ai/zen/go` | gateway base URL |
|
|
83
|
+
| `cacheMs` | `30000` | host-side upstream cache TTL |
|
|
84
|
+
|
|
85
|
+
## The usage API
|
|
86
|
+
|
|
87
|
+
`GET https://opencode.ai/zen/go/v1/usage` with `Authorization: Bearer <key>`:
|
|
88
|
+
|
|
89
|
+
```json
|
|
90
|
+
{
|
|
91
|
+
"usage": {
|
|
92
|
+
"rolling": { "status": "ok", "percent": 0, "resetsAt": "2026-08-14T07:51:13Z" },
|
|
93
|
+
"weekly": { "status": "ok", "percent": 1, "resetsAt": "2026-08-17T00:00:00Z" },
|
|
94
|
+
"monthly": { "status": "ok", "percent": 22, "resetsAt": "2026-08-21T13:05:13Z" }
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## Developing / modifying the widget
|
|
100
|
+
|
|
101
|
+
The browser half is a **hand-authored factory bundle** (`window.__ModuleLoader__.load`), because out-of-tree client plugins have no public build pipeline yet. It may only `require()` modules from the shell module table (`react`, `react/jsx-runtime`, and the registered client packages). Edit `lib/client.js` directly, then restart `dsh web` and refresh the page — the bundle revision hash changes and the shell loads the new file.
|
|
102
|
+
|
|
103
|
+
Host changes (`lib/index.js`) need only a `dsh web` restart.
|
|
104
|
+
|
|
105
|
+
## Troubleshooting
|
|
106
|
+
|
|
107
|
+
- **Widget missing after restart** → hard-refresh the page (`Cmd/Ctrl+Shift+R`); the boot graph is injected per page load.
|
|
108
|
+
- **`/opencode-go/usage` returns 502 with "no API key"** → configure the key in `~/.dsh/.credentials.yaml`.
|
|
109
|
+
- **Gateway 401/403** → the key is invalid or the subscription lapsed; check the credential.
|
|
110
|
+
- **Widget shows an error string** → hover the collapsed badge or read the error line in the expanded widget.
|
|
111
|
+
|
|
112
|
+
## License
|
|
113
|
+
|
|
114
|
+
MIT
|
package/README.zh.md
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# dsh-opencode-go-usage
|
|
2
|
+
|
|
3
|
+
[English](README.md) | 中文
|
|
4
|
+
|
|
5
|
+
[DSH](https://github.com/deepseek-ai/deepseek-harness)(DeepSeek Harness)插件:监控你的 **OpenCode GO 套餐**额度 —— 10 美元/月的订阅,按模型提供滚动 5 小时 / 每周 / 每月三个窗口的用量限额。
|
|
6
|
+
|
|
7
|
+
## 功能
|
|
8
|
+
|
|
9
|
+
- **侧边栏小组件**:常驻 DSH Web 侧边栏底部(`sidebar.footer.action` 槽位),三条用量进度条:滚动窗口(5h)、周窗口、月窗口,每条带重置倒计时;侧边栏收起时收缩为紧凑的百分比徽标。
|
|
10
|
+
- **`/opencode-go` 聊天命令**:在对话中以文本输出同样的三个窗口数字。
|
|
11
|
+
- **同源代理**:host 端注册 `GET /opencode-go/usage`,携带你的 API key 转发到官方 GO 网关。key 永不进入浏览器,也没有 CORS 问题。
|
|
12
|
+
|
|
13
|
+
## 工作原理
|
|
14
|
+
|
|
15
|
+
这是一个**双端 DSH 插件包**:
|
|
16
|
+
|
|
17
|
+
| 端 | 文件 | 职责 |
|
|
18
|
+
|---|---|---|
|
|
19
|
+
| host(Node) | `lib/index.js` | 注册 `/opencode-go/usage` Web 路由(`ctx.webServer`)与 `/opencode-go` 命令(`ctx.commands`);通过 DSH credentials 解析 key;上游调用带 30s 缓存 |
|
|
20
|
+
| 浏览器 | `lib/client.js` | 手写的 `window.__ModuleLoader__.load({ id, factory })` bundle,注册进 `sidebar.footer.action` 列表槽,每 60s 轮询同源路由 |
|
|
21
|
+
|
|
22
|
+
`package.json` 声明了 `"dsh": { "client": { "platform": "web" } }`,DSH 的 client-modules 节点端会把它扫描进浏览器启动图(`window.__DSH_BOOT__`),并在 `/plugins/dsh-opencode-go-usage/client.js` 提供该 bundle。
|
|
23
|
+
|
|
24
|
+
### 官方安装下小组件如何加载
|
|
25
|
+
|
|
26
|
+
`dsh plugin add` 把包装进 profile,满足 host 端(路由、命令、设置)。DSH 的
|
|
27
|
+
client-modules 扫描器只能从 DSH 自己的安装目录解析浏览器 bundle,所以装在
|
|
28
|
+
profile 里的第三方包通常拿不到浏览器端——本插件通过**自托管**解决:host 端
|
|
29
|
+
注册 `/dsh-opencode-go-usage/client.js` 路由,并用官方 `webServer.tapIndex` API
|
|
30
|
+
把 boot graph 行注入页面。因此侧边栏小组件在任何安装位置都能工作。
|
|
31
|
+
|
|
32
|
+
## 环境要求
|
|
33
|
+
|
|
34
|
+
- 已安装 DSH 且 `web` profile 至少启动过一次(`~/.dsh/profiles/web` 存在)
|
|
35
|
+
- Node.js ≥ 18(需要 `fetch`)
|
|
36
|
+
- OpenCode GO 订阅及其 API key
|
|
37
|
+
|
|
38
|
+
## 安装(官方 DSH 流程)
|
|
39
|
+
|
|
40
|
+
前置:已装 DSH 且 `web` profile 至少启动过一次;Node.js ≥ 18;有 OpenCode GO 订阅。
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
# 1. 安装到 web profile(依赖 pnpm,没有的话先 corepack enable)
|
|
44
|
+
dsh plugin --profile web add dsh-opencode-go-usage
|
|
45
|
+
|
|
46
|
+
# 2. 把 GO API key 存为 DSH credential
|
|
47
|
+
# (key 在 https://opencode.ai/auth 创建)
|
|
48
|
+
# → 在 ~/.dsh/.credentials.yaml 加一行:
|
|
49
|
+
# OPENCODE_GO_API_KEY: sk-...
|
|
50
|
+
|
|
51
|
+
# 3. 重启 dsh web,浏览器硬刷新(Cmd/Ctrl+Shift+R)
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
到这一步,百分比小组件和 `/opencode-go` 命令就生效了。CLI 会自动把包的
|
|
55
|
+
`dsh.bundle.patch` 合并进 profile 的 bundle 层栈——**不需要手改
|
|
56
|
+
cordis.patch.yml,不需要软链**。
|
|
57
|
+
|
|
58
|
+
还没发布 npm?从本地目录安装:
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
dsh plugin --profile web add /path/to/dsh-opencode-go-usage
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
> 第一次装 DSH 插件?直接看[使用者指南](docs/INSTALL.zh.md),一步步照着做就行。
|
|
65
|
+
|
|
66
|
+
## 使用
|
|
67
|
+
|
|
68
|
+
- **小组件**:直接看。侧边栏收起 → 百分比徽标;展开 → 三条进度条 + 重置倒计时。
|
|
69
|
+
- **命令**:任意对话里输入 `/opencode-go` 输出三个窗口的文本。
|
|
70
|
+
|
|
71
|
+
## 配置项
|
|
72
|
+
|
|
73
|
+
| 键 | 默认 | 说明 |
|
|
74
|
+
|---|---|---|
|
|
75
|
+
| `apiKeyEnv` | `OPENCODE_GO_API_KEY` | API key 的 credential 引用 / 环境变量名 |
|
|
76
|
+
| `baseUrl` | `https://opencode.ai/zen/go` | 网关 base URL |
|
|
77
|
+
| `cacheMs` | `30000` | host 端上游缓存 TTL |
|
|
78
|
+
|
|
79
|
+
## 用量 API
|
|
80
|
+
|
|
81
|
+
`GET https://opencode.ai/zen/go/v1/usage`,带 `Authorization: Bearer <key>`:
|
|
82
|
+
|
|
83
|
+
```json
|
|
84
|
+
{
|
|
85
|
+
"usage": {
|
|
86
|
+
"rolling": { "status": "ok", "percent": 0, "resetsAt": "2026-08-14T07:51:13Z" },
|
|
87
|
+
"weekly": { "status": "ok", "percent": 1, "resetsAt": "2026-08-17T00:00:00Z" },
|
|
88
|
+
"monthly": { "status": "ok", "percent": 22, "resetsAt": "2026-08-21T13:05:13Z" }
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## 开发 / 修改小组件
|
|
94
|
+
|
|
95
|
+
浏览器端是**手写的 factory bundle**(`window.__ModuleLoader__.load`),因为目前第三方 client 插件还没有公开的构建流水线。它只能 `require()` shell 模块表里的模块(`react`、`react/jsx-runtime` 以及已注册的 client 包)。直接改 `lib/client.js`,然后重启 `dsh web` 并刷新页面 —— bundle 的 revision hash 会变化,shell 会加载新文件。
|
|
96
|
+
|
|
97
|
+
host 端(`lib/index.js`)的改动只需重启 `dsh web`。
|
|
98
|
+
|
|
99
|
+
## 常见问题
|
|
100
|
+
|
|
101
|
+
- **重启后小组件不出现** → 硬刷新页面(`Cmd/Ctrl+Shift+R`);启动图是按页面加载注入的。
|
|
102
|
+
- **`/opencode-go/usage` 返回 502 且提示 "no API key"** → 在 `~/.dsh/.credentials.yaml` 配置 key。
|
|
103
|
+
- **网关 401/403** → key 无效或订阅过期;检查 credential。
|
|
104
|
+
- **小组件显示错误串** → 悬停收起态徽标,或展开态小组件里的错误行。
|
|
105
|
+
|
|
106
|
+
## License
|
|
107
|
+
|
|
108
|
+
MIT
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# dsh-opencode-go-usage — profile bundle patch.
|
|
2
|
+
#
|
|
3
|
+
# `dsh plugin --profile web add dsh-opencode-go-usage` installs this package and,
|
|
4
|
+
# because package.json declares `dsh.bundle.patch`, reconciles it into the
|
|
5
|
+
# profile's `dsh.profile.bundles` layer stack automatically — no manual
|
|
6
|
+
# cordis.patch.yml editing. This file is that bundle layer: it inserts the
|
|
7
|
+
# plugin entry with schema defaults; user overrides live in the settings
|
|
8
|
+
# namespace (Web Settings → Plugins → Plugin configuration) or in
|
|
9
|
+
# `~/.dsh/settings.yaml` under `dsh-opencode-go-usage`.
|
|
10
|
+
- insert:
|
|
11
|
+
- id: dsh-opencode-go-usage
|
|
12
|
+
name: dsh-opencode-go-usage
|
package/lib/client.js
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// dsh-opencode-go-usage — browser half.
|
|
2
|
+
//
|
|
3
|
+
// Hand-authored client bundle in the DSH module-loader factory format:
|
|
4
|
+
// `window.__ModuleLoader__.load({ id, factory })`. The factory receives the
|
|
5
|
+
// module-table `require`, so only shell-externalized modules may be imported
|
|
6
|
+
// (react / react/jsx-runtime are in the static table). It registers a widget
|
|
7
|
+
// into the sidebar's `sidebar.footer.action` list slot and polls the
|
|
8
|
+
// same-origin proxy route owned by the host half — the GO API key never
|
|
9
|
+
// enters the browser.
|
|
10
|
+
window.__ModuleLoader__.load({
|
|
11
|
+
id: "dsh-opencode-go-usage",
|
|
12
|
+
factory: (require) => {
|
|
13
|
+
var module = { exports: {} };
|
|
14
|
+
var exports = module.exports;
|
|
15
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
16
|
+
|
|
17
|
+
let react_jsx_runtime = require("react/jsx-runtime");
|
|
18
|
+
let react = require("react");
|
|
19
|
+
|
|
20
|
+
// ── styles (injected once, same pattern as built-in client plugins) ──
|
|
21
|
+
const CSS_ID = "dsh-opencode-go-usage/widget.css";
|
|
22
|
+
if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=\"" + CSS_ID + "\"]") === null) {
|
|
23
|
+
const tag = document.createElement("style");
|
|
24
|
+
tag.dataset.plugin = "dsh-opencode-go-usage";
|
|
25
|
+
tag.dataset.pluginCss = CSS_ID;
|
|
26
|
+
tag.textContent = [
|
|
27
|
+
".ocg-widget{box-sizing:border-box;width:100%;min-width:0;padding:8px 6px;display:flex;flex-direction:column;gap:6px;border-top:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-primary)}",
|
|
28
|
+
".ocg-widget:hover{background:var(--dsw-alias-interactive-bg-hover)}",
|
|
29
|
+
".ocg-head{display:flex;align-items:center;justify-content:space-between;gap:8px;font-size:12px;font-weight:600;line-height:18px}",
|
|
30
|
+
".ocg-refresh{cursor:pointer;border:none;background:none;padding:0;color:var(--dsw-alias-label-secondary);font-size:11px;line-height:18px}",
|
|
31
|
+
".ocg-refresh:hover{color:var(--dsw-alias-label-primary)}",
|
|
32
|
+
".ocg-row{display:flex;flex-direction:column;gap:3px;min-width:0}",
|
|
33
|
+
".ocg-row-label{display:flex;justify-content:space-between;gap:8px;font-size:11px;line-height:16px;color:var(--dsw-alias-label-secondary)}",
|
|
34
|
+
".ocg-row-label b{font-weight:500;color:var(--dsw-alias-label-primary)}",
|
|
35
|
+
".ocg-track{box-sizing:border-box;height:5px;border-radius:3px;background:var(--dsw-alias-border-l2);overflow:hidden}",
|
|
36
|
+
".ocg-fill{height:100%;border-radius:3px;transition:width .4s ease}",
|
|
37
|
+
".ocg-err{font-size:11px;line-height:16px;color:var(--dsw-alias-label-secondary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}",
|
|
38
|
+
".ocg-rail{box-sizing:border-box;width:36px;height:36px;display:flex;align-items:center;justify-content:center;border-radius:8px;font-size:10px;font-weight:700;color:var(--dsw-alias-label-primary);cursor:default}",
|
|
39
|
+
".ocg-rail:hover{background:var(--dsw-alias-interactive-bg-hover)}"
|
|
40
|
+
].join("");
|
|
41
|
+
document.head.appendChild(tag);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ── data helpers ──
|
|
45
|
+
const WINDOWS = [
|
|
46
|
+
{ key: "rolling", label: "滚动窗口", hint: "5 小时" },
|
|
47
|
+
{ key: "weekly", label: "周窗口" },
|
|
48
|
+
{ key: "monthly", label: "月窗口" }
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
/** Short human relative time until `iso`. */
|
|
52
|
+
function timeUntil(iso) {
|
|
53
|
+
const target = new Date(iso).getTime();
|
|
54
|
+
if (!Number.isFinite(target)) return "";
|
|
55
|
+
const diff = target - Date.now();
|
|
56
|
+
if (diff <= 0) return "即将重置";
|
|
57
|
+
const mins = Math.floor(diff / 60000);
|
|
58
|
+
if (mins < 60) return mins + " 分后重置";
|
|
59
|
+
const hours = Math.floor(mins / 60);
|
|
60
|
+
if (hours < 24) return hours + " 小时 " + (mins % 60) + " 分后";
|
|
61
|
+
return Math.floor(hours / 24) + " 天 " + (hours % 24) + " 小时后";
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Fill color by usage percent. */
|
|
65
|
+
function fillColor(percent) {
|
|
66
|
+
if (percent >= 90) return "#ff3b30";
|
|
67
|
+
if (percent >= 70) return "#ff9500";
|
|
68
|
+
return "#34c759";
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ── widget component ──
|
|
72
|
+
/**
|
|
73
|
+
* @param {{ wide?: boolean }} props - owner share from the sidebar
|
|
74
|
+
* (`sidebar.footer.action` is rendered with `{ wide }`).
|
|
75
|
+
*/
|
|
76
|
+
function Widget(props) {
|
|
77
|
+
const [data, setData] = react.useState(null);
|
|
78
|
+
const [error, setError] = react.useState(null);
|
|
79
|
+
const [stamp, setStamp] = react.useState(0);
|
|
80
|
+
|
|
81
|
+
react.useEffect(() => {
|
|
82
|
+
let alive = true;
|
|
83
|
+
const load = async () => {
|
|
84
|
+
try {
|
|
85
|
+
const res = await fetch("/opencode-go/usage", { cache: "no-store" });
|
|
86
|
+
const json = await res.json().catch(() => null);
|
|
87
|
+
if (!alive) return;
|
|
88
|
+
if (!res.ok || json === null || json.error) {
|
|
89
|
+
setError(String((json && json.error) || "HTTP " + res.status));
|
|
90
|
+
setData(null);
|
|
91
|
+
} else {
|
|
92
|
+
setData(json);
|
|
93
|
+
setError(null);
|
|
94
|
+
}
|
|
95
|
+
} catch (e) {
|
|
96
|
+
if (!alive) return;
|
|
97
|
+
setError(String((e && e.message) || e));
|
|
98
|
+
setData(null);
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
load();
|
|
102
|
+
const timer = window.setInterval(load, 60000);
|
|
103
|
+
return () => { alive = false; window.clearInterval(timer); };
|
|
104
|
+
}, [stamp]);
|
|
105
|
+
|
|
106
|
+
const monthly = data && data.usage ? data.usage.monthly : null;
|
|
107
|
+
const pct = monthly && typeof monthly.percent === "number" ? monthly.percent : null;
|
|
108
|
+
|
|
109
|
+
// Collapsed rail: a compact badge with the monthly percentage.
|
|
110
|
+
if (!props.wide) {
|
|
111
|
+
return react_jsx_runtime.jsx("div", {
|
|
112
|
+
className: "ocg-rail",
|
|
113
|
+
title: error ? ("OpenCode GO: " + error) : (pct === null ? "OpenCode GO" : "OpenCode GO 月用量 " + pct + "%"),
|
|
114
|
+
children: pct === null ? "GO" : pct + "%"
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Expanded footer widget: three window progress bars.
|
|
119
|
+
const rows = [];
|
|
120
|
+
for (const w of WINDOWS) {
|
|
121
|
+
const win = data && data.usage ? data.usage[w.key] : null;
|
|
122
|
+
if (!win || typeof win.percent !== "number") continue;
|
|
123
|
+
rows.push(react_jsx_runtime.jsx("div", {
|
|
124
|
+
className: "ocg-row",
|
|
125
|
+
children: [
|
|
126
|
+
react_jsx_runtime.jsx("div", {
|
|
127
|
+
className: "ocg-row-label",
|
|
128
|
+
children: [
|
|
129
|
+
react_jsx_runtime.jsx("b", { children: w.label }),
|
|
130
|
+
react_jsx_runtime.jsx("span", { children: (win.resetsAt ? timeUntil(win.resetsAt) : w.hint) })
|
|
131
|
+
]
|
|
132
|
+
}),
|
|
133
|
+
react_jsx_runtime.jsx("div", {
|
|
134
|
+
className: "ocg-track",
|
|
135
|
+
children: react_jsx_runtime.jsx("div", {
|
|
136
|
+
className: "ocg-fill",
|
|
137
|
+
style: {
|
|
138
|
+
width: Math.max(0, Math.min(100, win.percent)) + "%",
|
|
139
|
+
background: fillColor(win.percent)
|
|
140
|
+
}
|
|
141
|
+
})
|
|
142
|
+
}),
|
|
143
|
+
react_jsx_runtime.jsx("div", {
|
|
144
|
+
className: "ocg-row-label",
|
|
145
|
+
children: [
|
|
146
|
+
react_jsx_runtime.jsx("span", { children: win.status === "ok" ? "正常" : win.status }),
|
|
147
|
+
react_jsx_runtime.jsx("span", { children: win.percent + "%" })
|
|
148
|
+
]
|
|
149
|
+
})
|
|
150
|
+
]
|
|
151
|
+
}, w.key));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return react_jsx_runtime.jsx("div", {
|
|
155
|
+
className: "ocg-widget",
|
|
156
|
+
children: [
|
|
157
|
+
react_jsx_runtime.jsx("div", {
|
|
158
|
+
className: "ocg-head",
|
|
159
|
+
children: [
|
|
160
|
+
react_jsx_runtime.jsx("span", { children: "OpenCode GO" }),
|
|
161
|
+
react_jsx_runtime.jsx("button", {
|
|
162
|
+
className: "ocg-refresh",
|
|
163
|
+
onClick: () => setStamp(stamp + 1),
|
|
164
|
+
children: "刷新"
|
|
165
|
+
})
|
|
166
|
+
]
|
|
167
|
+
}),
|
|
168
|
+
error ? react_jsx_runtime.jsx("div", { className: "ocg-err", children: error }) : null,
|
|
169
|
+
!error && data === null ? react_jsx_runtime.jsx("div", { className: "ocg-err", children: "加载中…" }) : null,
|
|
170
|
+
rows.length > 0 ? react_jsx_runtime.jsx(react.Fragment, { children: rows }) : null
|
|
171
|
+
]
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ── cordis plugin entry ──
|
|
176
|
+
const inject = ["slots"];
|
|
177
|
+
|
|
178
|
+
function apply(ctx) {
|
|
179
|
+
ctx.effect(() => ctx.slots.register({
|
|
180
|
+
name: "sidebar.footer.action",
|
|
181
|
+
id: "dsh-opencode-go-usage",
|
|
182
|
+
order: 0,
|
|
183
|
+
label: "OpenCode GO 用量"
|
|
184
|
+
}, Widget), "dsh-opencode-go-usage: widget registration");
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
exports.apply = apply;
|
|
188
|
+
exports.inject = inject;
|
|
189
|
+
return module.exports;
|
|
190
|
+
}
|
|
191
|
+
});
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* dsh-opencode-go-usage — host half.
|
|
4
|
+
*
|
|
5
|
+
* Watches your OpenCode GO plan quota through the official gateway endpoint
|
|
6
|
+
* (`https://opencode.ai/zen/go/v1/usage`). Proxied through a same-origin route
|
|
7
|
+
* so the browser widget never sees your API key and no CORS is involved.
|
|
8
|
+
*
|
|
9
|
+
* - `GET /opencode-go/usage` — quota proxy (rolling / weekly / monthly window
|
|
10
|
+
* percentages from the official API).
|
|
11
|
+
* - `/opencode-go` chat command — prints the same numbers as text.
|
|
12
|
+
* - Browser bundle self-hosted at `/dsh-opencode-go-usage/client.js` with its boot
|
|
13
|
+
* graph row injected through the official `webServer.tapIndex` API, so the
|
|
14
|
+
* sidebar widget works from any installation location.
|
|
15
|
+
*
|
|
16
|
+
* Installation / configuration (official DSH flows):
|
|
17
|
+
* - Install: `dsh plugin --profile web add dsh-opencode-go-usage` — the
|
|
18
|
+
* `dsh.bundle.patch` declaration makes the CLI reconcile this package into
|
|
19
|
+
* the profile's bundle layer stack automatically (no manual patch edits).
|
|
20
|
+
* - Configure: the `dsh-opencode-go-usage` settings namespace (Web Settings →
|
|
21
|
+
* Plugins → Plugin configuration, or `~/.dsh/settings.yaml`). The API key
|
|
22
|
+
* itself lives in the credentials domain (`OPENCODE_GO_API_KEY`), never in
|
|
23
|
+
* settings.
|
|
24
|
+
*
|
|
25
|
+
* Settings namespace `dsh-opencode-go-usage`:
|
|
26
|
+
* - apiKeyEnv: credential ref / env var for the API key (default OPENCODE_GO_API_KEY)
|
|
27
|
+
* - baseUrl: GO gateway base (default https://opencode.ai/zen/go)
|
|
28
|
+
* - cacheMs: host-side cache TTL (default 30000)
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { readFileSync } from "node:fs";
|
|
32
|
+
import { createHash } from "node:crypto";
|
|
33
|
+
import z from "@deepseek-ai/schemastery";
|
|
34
|
+
|
|
35
|
+
export const name = "dsh-opencode-go-usage";
|
|
36
|
+
|
|
37
|
+
/** Required services: web routes, chat commands, credentials, settings. */
|
|
38
|
+
export const inject = ["webServer", "commands", "credentials", "settings"];
|
|
39
|
+
|
|
40
|
+
/** Settings namespace owned by this plugin. */
|
|
41
|
+
export const namespace = "dsh-opencode-go-usage";
|
|
42
|
+
|
|
43
|
+
const DEFAULT_BASE_URL = "https://opencode.ai/zen/go";
|
|
44
|
+
const DEFAULT_API_KEY_ENV = "OPENCODE_GO_API_KEY";
|
|
45
|
+
const DEFAULT_CACHE_MS = 30_000;
|
|
46
|
+
|
|
47
|
+
/** Settings schema for this plugin's namespace. */
|
|
48
|
+
export const Config = z.object({
|
|
49
|
+
apiKeyEnv: z.string().default(DEFAULT_API_KEY_ENV),
|
|
50
|
+
baseUrl: z.string().default(DEFAULT_BASE_URL),
|
|
51
|
+
cacheMs: z.number().default(DEFAULT_CACHE_MS)
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Resolve the API key through the DSH credentials service
|
|
56
|
+
* (process env, `$DSH_HOME/.credentials.yaml`, `.env` layers).
|
|
57
|
+
* @param {import('@deepseek-ai/cordis').Context} ctx
|
|
58
|
+
* @param {string} refName
|
|
59
|
+
*/
|
|
60
|
+
async function resolveApiKey(ctx, refName) {
|
|
61
|
+
const hit = await ctx.credentials.resolve(refName);
|
|
62
|
+
return hit?.value ?? null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Fetch the GO plan quota payload from the gateway. */
|
|
66
|
+
async function fetchUsage(baseUrl, apiKey) {
|
|
67
|
+
const res = await fetch(`${baseUrl.replace(/\/+$/, "")}/v1/usage`, {
|
|
68
|
+
headers: { Authorization: `Bearer ${apiKey}` },
|
|
69
|
+
signal: AbortSignal.timeout(15_000)
|
|
70
|
+
});
|
|
71
|
+
if (!res.ok) {
|
|
72
|
+
const body = await res.text().catch(() => "");
|
|
73
|
+
throw new Error(`gateway ${res.status}: ${body.slice(0, 300)}`);
|
|
74
|
+
}
|
|
75
|
+
return await res.json();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Human-friendly chat output. */
|
|
79
|
+
function renderUsageText(data) {
|
|
80
|
+
const u = data?.usage;
|
|
81
|
+
if (!u || typeof u !== "object") return "OpenCode GO: unexpected response shape.";
|
|
82
|
+
const lines = ["OpenCode GO 套餐用量:"];
|
|
83
|
+
const defs = [
|
|
84
|
+
["rolling", "滚动窗口 (5h)"],
|
|
85
|
+
["weekly", "周窗口"],
|
|
86
|
+
["monthly", "月窗口"]
|
|
87
|
+
];
|
|
88
|
+
for (const [key, label] of defs) {
|
|
89
|
+
const w = u[key];
|
|
90
|
+
if (!w || typeof w.percent !== "number") continue;
|
|
91
|
+
const status = w.status === "ok" ? "正常" : w.status;
|
|
92
|
+
const resets = w.resetsAt ? `重置于 ${new Date(w.resetsAt).toLocaleString("zh-CN")}` : "";
|
|
93
|
+
lines.push(` ${label}: ${w.percent}% (${status})${resets ? `, ${resets}` : ""}`);
|
|
94
|
+
}
|
|
95
|
+
return lines.join("\n");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Short content hash used as the bundle revision. */
|
|
99
|
+
function shortHash(input) {
|
|
100
|
+
return createHash("sha1").update(input).digest("hex").slice(0, 12);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Inject one graph row into the index.html boot manifest
|
|
105
|
+
* (`window.__DSH_BOOT__ = [...]`), skipping when the id is already present.
|
|
106
|
+
* @param {string} html
|
|
107
|
+
* @param {{ id: string, url: string, rev: string, inject?: string[], immediately?: boolean }} row
|
|
108
|
+
* @returns the transformed html.
|
|
109
|
+
*/
|
|
110
|
+
function injectGraphRow(html, row) {
|
|
111
|
+
const marker = "window.__DSH_BOOT__ = ";
|
|
112
|
+
const start = html.indexOf(marker);
|
|
113
|
+
if (start === -1) return html;
|
|
114
|
+
const bodyStart = start + marker.length;
|
|
115
|
+
const end = html.indexOf("</script>", bodyStart);
|
|
116
|
+
if (end === -1) return html;
|
|
117
|
+
let graph;
|
|
118
|
+
try {
|
|
119
|
+
graph = JSON.parse(html.slice(bodyStart, end).trim());
|
|
120
|
+
} catch {
|
|
121
|
+
return html;
|
|
122
|
+
}
|
|
123
|
+
if (!Array.isArray(graph)) return html;
|
|
124
|
+
if (graph.some((entry) => entry !== null && typeof entry === "object" && entry.id === row.id)) return html;
|
|
125
|
+
graph.push(row);
|
|
126
|
+
// Keep the same escaping client-modules uses so the payload cannot break
|
|
127
|
+
// out of the script element.
|
|
128
|
+
return html.slice(0, bodyStart) + JSON.stringify(graph).replaceAll("<", "\\u003c") + html.slice(end);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* @param {import('@deepseek-ai/cordis').Context} ctx
|
|
133
|
+
* @param {Record<string, unknown>} rawConfig - loader entry config (becomes the settings `base` layer).
|
|
134
|
+
*/
|
|
135
|
+
export function apply(ctx, rawConfig = {}) {
|
|
136
|
+
// Register the settings namespace. The loader entry config (rawConfig) is
|
|
137
|
+
// handed to the settings seam as `base`, so a legacy manual patch keeps
|
|
138
|
+
// working while the user document (settings.yaml / Web UI) wins over it.
|
|
139
|
+
ctx.settings.register(namespace, Config, { base: rawConfig });
|
|
140
|
+
|
|
141
|
+
const readConfig = () => {
|
|
142
|
+
const stored = ctx.settings.get(namespace) ?? {};
|
|
143
|
+
return {
|
|
144
|
+
apiKeyEnv: stored.apiKeyEnv,
|
|
145
|
+
baseUrl: stored.baseUrl,
|
|
146
|
+
cacheMs: stored.cacheMs
|
|
147
|
+
};
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
// Host-side cache: one in-flight promise + a TTL, so several open tabs or
|
|
151
|
+
// the command never hammer the gateway.
|
|
152
|
+
/** @type {{ at: number, promise: Promise<unknown> } | null} */
|
|
153
|
+
let quotaCache = null;
|
|
154
|
+
const quotaOnce = () => {
|
|
155
|
+
const now = Date.now();
|
|
156
|
+
const config = readConfig();
|
|
157
|
+
if (quotaCache !== null && now - quotaCache.at < config.cacheMs) return quotaCache.promise;
|
|
158
|
+
const promise = (async () => {
|
|
159
|
+
const key = await resolveApiKey(ctx, config.apiKeyEnv);
|
|
160
|
+
if (key === null) throw new Error(`OpenCode GO: no API key (set credential ${config.apiKeyEnv})`);
|
|
161
|
+
return await fetchUsage(config.baseUrl, key);
|
|
162
|
+
})();
|
|
163
|
+
quotaCache = { at: now, promise };
|
|
164
|
+
promise.catch(() => { if (quotaCache?.promise === promise) quotaCache = null; });
|
|
165
|
+
return promise;
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
const handleUsage = async (_req, res) => {
|
|
169
|
+
res.setHeader("content-type", "application/json; charset=utf-8");
|
|
170
|
+
res.setHeader("cache-control", "no-store");
|
|
171
|
+
try {
|
|
172
|
+
const data = await quotaOnce();
|
|
173
|
+
res.end(JSON.stringify(data));
|
|
174
|
+
} catch (error) {
|
|
175
|
+
res.statusCode = 502;
|
|
176
|
+
res.end(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }));
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
// Same-origin route the browser widget polls.
|
|
181
|
+
ctx.webServer.register({
|
|
182
|
+
kind: "exact",
|
|
183
|
+
path: "/opencode-go/usage",
|
|
184
|
+
handler: handleUsage
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
// ── self-host the browser bundle ───────────────────────────────────────
|
|
188
|
+
// client-modules can only resolve packages reachable from DSH's own
|
|
189
|
+
// installation; a profile-installed third-party package would lose its
|
|
190
|
+
// browser half. Instead we serve the bundle ourselves and inject its boot
|
|
191
|
+
// graph row through the official index-tap API, so the sidebar widget
|
|
192
|
+
// works from any installation location.
|
|
193
|
+
const bundlePath = new URL("./client.js", import.meta.url);
|
|
194
|
+
let bundleBytes = null;
|
|
195
|
+
try {
|
|
196
|
+
bundleBytes = readFileSync(bundlePath);
|
|
197
|
+
} catch {
|
|
198
|
+
// Package installed without lib/client.js — widget unavailable; the
|
|
199
|
+
// route and command above still work.
|
|
200
|
+
}
|
|
201
|
+
if (bundleBytes !== null) {
|
|
202
|
+
const rev = shortHash(bundleBytes);
|
|
203
|
+
ctx.webServer.register({
|
|
204
|
+
kind: "exact",
|
|
205
|
+
path: "/dsh-opencode-go-usage/client.js",
|
|
206
|
+
handler: async (_req, res) => {
|
|
207
|
+
res.setHeader("content-type", "text/javascript; charset=utf-8");
|
|
208
|
+
res.setHeader("cache-control", "no-cache");
|
|
209
|
+
res.end(bundleBytes);
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
ctx.webServer.tapIndex((html) => injectGraphRow(html, {
|
|
213
|
+
id: "dsh-opencode-go-usage",
|
|
214
|
+
url: `/dsh-opencode-go-usage/client.js?rev=${rev}`,
|
|
215
|
+
rev,
|
|
216
|
+
inject: ["@deepseek-ai/dsh-client-runtime"],
|
|
217
|
+
immediately: true
|
|
218
|
+
}));
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Chat command so the numbers are reachable from a conversation too.
|
|
222
|
+
ctx.commands.register({
|
|
223
|
+
name: "opencode-go",
|
|
224
|
+
description: "show OpenCode GO plan usage (rolling/weekly/monthly windows)",
|
|
225
|
+
handler: async () => {
|
|
226
|
+
try {
|
|
227
|
+
const data = await quotaOnce();
|
|
228
|
+
return { kind: "success", text: renderUsageText(data) };
|
|
229
|
+
} catch (error) {
|
|
230
|
+
return { kind: "error", text: error instanceof Error ? error.message : String(error) };
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-opencode-go-usage",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "DSH (DeepSeek Harness) plugin: OpenCode GO plan quota widget in the sidebar, same-origin usage proxy, and a /opencode-go chat command",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "lib/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./lib/index.js",
|
|
9
|
+
"./client": "./lib/client.js",
|
|
10
|
+
"./package.json": "./package.json"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"lib",
|
|
14
|
+
"cordis.patch.yml"
|
|
15
|
+
],
|
|
16
|
+
"keywords": [
|
|
17
|
+
"dsh",
|
|
18
|
+
"deepseek-harness",
|
|
19
|
+
"opencode",
|
|
20
|
+
"opencode-go",
|
|
21
|
+
"usage",
|
|
22
|
+
"quota",
|
|
23
|
+
"plugin",
|
|
24
|
+
"cordis"
|
|
25
|
+
],
|
|
26
|
+
"engines": {
|
|
27
|
+
"node": ">=18"
|
|
28
|
+
},
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"@deepseek-ai/schemastery": "^3.18.1"
|
|
31
|
+
},
|
|
32
|
+
"license": "MIT",
|
|
33
|
+
"dsh": {
|
|
34
|
+
"bundle": {
|
|
35
|
+
"patch": "./cordis.patch.yml"
|
|
36
|
+
},
|
|
37
|
+
"client": {
|
|
38
|
+
"platform": "web",
|
|
39
|
+
"inject": [
|
|
40
|
+
"@deepseek-ai/dsh-client-runtime"
|
|
41
|
+
]
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|