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 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
@@ -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
+ }