dsh-deepseek-balance-widget 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/lib/index.js ADDED
@@ -0,0 +1,385 @@
1
+ import { launchEnvironmentOf } from "@deepseek-ai/dsh-launch-environment";
2
+
3
+ /** Plugin id; must match the cordis.patch.yml insert id. */
4
+ const name = "deepseek-balance-widget";
5
+
6
+ /** Services required before this plugin can mount its route. */
7
+ const inject = ["webServer", "credentials"];
8
+
9
+ /** DeepSeek official balance endpoint (OpenAI-compatible auth). */
10
+ const BALANCE_URL = "https://api.deepseek.com/user/balance";
11
+
12
+ /** DeepSeek Platform private usage endpoints (require a platform userToken). */
13
+ const PLATFORM = "https://platform.deepseek.com";
14
+ const USAGE_AMOUNT_URL = `${PLATFORM}/api/v0/usage/amount?month=__MONTH__&year=__YEAR__`;
15
+ const USAGE_COST_URL = `${PLATFORM}/api/v0/usage/cost?month=__MONTH__&year=__YEAR__`;
16
+ const PLATFORM_HEADERS = {
17
+ "x-app-version": "1.0.0",
18
+ Accept: "*/*",
19
+ Referer: "https://platform.deepseek.com/usage",
20
+ Origin: "https://platform.deepseek.com",
21
+ "User-Agent":
22
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36"
23
+ };
24
+
25
+ /**
26
+ * Query the DeepSeek balance API for one key.
27
+ * @param {string} apiKey - the resolved DeepSeek API key.
28
+ * @returns the normalized envelope ({ ok, status, data }).
29
+ */
30
+ async function queryBalance(apiKey) {
31
+ let response;
32
+ try {
33
+ response = await fetch(BALANCE_URL, {
34
+ method: "GET",
35
+ headers: {
36
+ Authorization: `Bearer ${apiKey}`,
37
+ Accept: "application/json"
38
+ }
39
+ });
40
+ } catch (error) {
41
+ return { ok: false, status: 0, error: `network: ${String(error?.message ?? error)}` };
42
+ }
43
+ const text = await response.text();
44
+ let data = null;
45
+ try {
46
+ data = JSON.parse(text);
47
+ } catch {
48
+ data = { raw: text.slice(0, 512) };
49
+ }
50
+ return { ok: response.ok, status: response.status, data };
51
+ }
52
+
53
+ /**
54
+ * Resolve the DeepSeek key from the credential seam or launch environment.
55
+ * The credential seam reads THIS machine's own `$DSH_HOME/.credentials.yaml`
56
+ * (or the ambient environment), so every user queries with their own key and
57
+ * sees their own balance. Nothing is ever hardcoded in this plugin.
58
+ * @param {import("@deepseek-ai/cordis").Context} ctx - plugin context.
59
+ * @returns {Promise<string|undefined>} the key, or undefined when unset.
60
+ */
61
+ async function resolveKey(ctx) {
62
+ if (ctx.credentials !== void 0) {
63
+ const hit = await ctx.credentials.resolve("DEEPSEEK_API_KEY");
64
+ if (hit !== void 0 && typeof hit.value === "string" && hit.value.length > 0) return hit.value;
65
+ }
66
+ const ambient = launchEnvironmentOf(ctx).get("DEEPSEEK_API_KEY");
67
+ if (ambient !== void 0 && typeof ambient.value === "string" && ambient.value.length > 0) return ambient.value;
68
+ return void 0;
69
+ }
70
+
71
+ /**
72
+ * Resolve the DeepSeek Platform session token (userToken) from the
73
+ * credential seam or launch environment. Required for the private
74
+ * usage/cost dashboard endpoints; an API key cannot authenticate them.
75
+ * @param {import("@deepseek-ai/cordis").Context} ctx - plugin context.
76
+ * @returns {Promise<string|undefined>} the token, or undefined when unset.
77
+ */
78
+ async function resolvePlatformToken(ctx) {
79
+ if (ctx.credentials !== void 0) {
80
+ const hit = await ctx.credentials.resolve("DEEPSEEK_PLATFORM_TOKEN");
81
+ if (hit !== void 0 && typeof hit.value === "string" && hit.value.length > 0) return hit.value;
82
+ }
83
+ const ambient = launchEnvironmentOf(ctx).get("DEEPSEEK_PLATFORM_TOKEN");
84
+ if (ambient !== void 0 && typeof ambient.value === "string" && ambient.value.length > 0) return ambient.value;
85
+ return void 0;
86
+ }
87
+
88
+ /** @returns {Promise<{code:number,msg?:string,data?:any}|{error:string}>} a JSON-ish body. */
89
+ async function fetchJson(url, token) {
90
+ let response;
91
+ try {
92
+ response = await fetch(url, {
93
+ method: "GET",
94
+ headers: { Authorization: `Bearer ${token}`, ...PLATFORM_HEADERS }
95
+ });
96
+ } catch (error) {
97
+ return { error: `network: ${String(error?.message ?? error)}` };
98
+ }
99
+ const text = await response.text();
100
+ try {
101
+ return JSON.parse(text);
102
+ } catch {
103
+ return { error: `bad json (HTTP ${response.status})` };
104
+ }
105
+ }
106
+
107
+ /** Earliest year to scan for all-time spend (DeepSeek platform launched 2024). */
108
+ const CUMULATIVE_START_YEAR = 2024;
109
+
110
+ /** Per-month cost map ("YYYY-MM" -> CNY). Filled once on first request. */
111
+ let monthlyCostMap = null;
112
+ /** In-flight initial scan guard. */
113
+ let monthlyInitPromise = null;
114
+
115
+ /** Fetch one month's total cost from the cost endpoint. */
116
+ async function monthCostOf(token, month, year) {
117
+ const url = USAGE_COST_URL.replace("__MONTH__", String(month)).replace("__YEAR__", String(year));
118
+ const resp = await fetchJson(url, token);
119
+ if (resp.error !== void 0) throw new Error(resp.error);
120
+ if (resp.code !== void 0 && resp.code !== 0) throw new Error(`platform ${resp.code}: ${resp.msg ?? "unknown"}`);
121
+ const raw = resp?.data?.biz_data;
122
+ const data = Array.isArray(raw) ? raw[0] : raw;
123
+ let total = 0;
124
+ if (data && Array.isArray(data.total) && data.total.length > 0) {
125
+ for (const modelEntry of data.total) {
126
+ for (const entry of modelEntry.usage ?? []) total += Number.parseFloat(entry.amount) || 0;
127
+ }
128
+ }
129
+ return Math.round(total * 100) / 100;
130
+ }
131
+
132
+ /** One-time full scan 2024-01..current month; fills monthlyCostMap. */
133
+ async function ensureMonthlyMap(token) {
134
+ if (monthlyCostMap !== null) return;
135
+ if (monthlyInitPromise === null) {
136
+ monthlyInitPromise = (async () => {
137
+ const map = {};
138
+ const now = new Date();
139
+ for (let y = CUMULATIVE_START_YEAR; y <= now.getFullYear(); y++) {
140
+ const mEnd = y === now.getFullYear() ? now.getMonth() + 1 : 12;
141
+ for (let m = 1; m <= mEnd; m++) {
142
+ map[`${y}-${String(m).padStart(2, "0")}`] = await monthCostOf(token, m, y);
143
+ }
144
+ }
145
+ monthlyCostMap = map;
146
+ })();
147
+ }
148
+ try {
149
+ await monthlyInitPromise;
150
+ } finally {
151
+ monthlyInitPromise = null;
152
+ }
153
+ }
154
+
155
+ /**
156
+ * Sum the month map; upserts the running month's fresh cost when given.
157
+ * Past months are billing-final, so only the current month needs updating —
158
+ * this makes the cumulative figure effectively real-time at zero extra cost.
159
+ * @returns {number|null} rounded CNY total, or null before the map is ready.
160
+ */
161
+ function cumulativeFromMap(key, value) {
162
+ if (monthlyCostMap === null) return null;
163
+ if (key !== void 0) monthlyCostMap[key] = value;
164
+ let total = 0;
165
+ for (const k of Object.keys(monthlyCostMap)) total += monthlyCostMap[k];
166
+ return Math.round(total * 100) / 100;
167
+ }
168
+
169
+ /**
170
+ * Fetch and normalize the current-month usage from the platform dashboard.
171
+ * @param {string} token - platform userToken.
172
+ * @param {number} month - 1..12.
173
+ * @param {number} year - e.g. 2026.
174
+ * @returns normalized envelope ({ ok, status, data }).
175
+ */
176
+ async function queryPlatformUsage(token, month, year) {
177
+ const [amount, cost] = await Promise.all([
178
+ fetchJson(USAGE_AMOUNT_URL.replace("__MONTH__", String(month)).replace("__YEAR__", String(year)), token),
179
+ fetchJson(USAGE_COST_URL.replace("__MONTH__", String(month)).replace("__YEAR__", String(year)), token)
180
+ ]);
181
+
182
+ const failed = [amount, cost].find((r) => r.error !== void 0);
183
+ if (failed !== void 0) return { ok: false, status: 0, error: failed.error };
184
+
185
+ // Platform private endpoints answer { code, msg, data } — 40002/40003 = bad/expired session.
186
+ for (const r of [amount, cost]) {
187
+ if (r.code !== void 0 && r.code !== 0) {
188
+ return { ok: false, status: 401, error: `platform ${r.code}: ${r.msg ?? "unknown"}` };
189
+ }
190
+ }
191
+
192
+ const bizData = (resp) => {
193
+ const raw = resp?.data?.biz_data;
194
+ return Array.isArray(raw) ? raw[0] : raw;
195
+ };
196
+ const amountData = bizData(amount);
197
+ const costData = bizData(cost);
198
+ if (!amountData || !Array.isArray(amountData.days)) {
199
+ return { ok: false, status: 200, error: "amount response missing days" };
200
+ }
201
+
202
+ const isToken = (t) => typeof t === "string" && /TOKEN/i.test(t);
203
+ const isRequest = (t) => typeof t === "string" && /REQUEST|REQ_|_REQ|COUNT|CALLS?/i.test(t);
204
+
205
+ // Prefer biz_data.total (month aggregate); fall back to summing the days.
206
+ const usageEntries = () => {
207
+ const entries = [];
208
+ const collect = (modelEntries) => {
209
+ for (const modelEntry of modelEntries ?? []) {
210
+ for (const entry of modelEntry.usage ?? []) entries.push(entry);
211
+ }
212
+ };
213
+ if (Array.isArray(amountData.total) && amountData.total.length > 0) {
214
+ collect(amountData.total);
215
+ } else {
216
+ for (const day of amountData.days ?? []) collect(day.data);
217
+ }
218
+ return entries;
219
+ };
220
+
221
+ let tokens = 0;
222
+ let requests = 0;
223
+ for (const entry of usageEntries()) {
224
+ const v = Number.parseFloat(entry.amount) || 0;
225
+ if (isToken(entry.type)) tokens += Math.round(v);
226
+ else if (isRequest(entry.type)) requests += Math.round(v);
227
+ }
228
+
229
+ let costValue = 0;
230
+ const sumUsage = (modelEntries) => {
231
+ for (const modelEntry of modelEntries ?? []) {
232
+ for (const entry of modelEntry.usage ?? []) costValue += Number.parseFloat(entry.amount) || 0;
233
+ }
234
+ };
235
+ if (costData) {
236
+ // biz_data.total is the month aggregate; fall back to summing the days.
237
+ if (Array.isArray(costData.total) && costData.total.length > 0) {
238
+ sumUsage(costData.total);
239
+ } else {
240
+ for (const day of costData.days ?? []) sumUsage(day.data);
241
+ }
242
+ }
243
+ costValue = Math.round(costValue * 100) / 100;
244
+
245
+ // 今日数据:平台实时接口(by_api_key),按 [今日 0 点 GMT+8, 明日 0 点) 的小时桶求和。
246
+ // (月度接口的逐日明细滞后一天,今天的数据要查实时端点才能拿到。)
247
+ const TZ_SHANGHAI = 8 * 3600; // 秒
248
+ const shMidnightSec = Math.floor((Date.now() + TZ_SHANGHAI * 1000) / 86400000) * 86400 - TZ_SHANGHAI;
249
+ const todayStart = shMidnightSec;
250
+ const todayEnd = todayStart + 86400;
251
+ const todayKey = (ep) => `${PLATFORM}/api/v0/usage/by_api_key/${ep}?start=${todayStart}&end=${todayEnd}&tz=${TZ_SHANGHAI}`;
252
+ let todayCost = 0;
253
+ let todayTokens = 0;
254
+ let todayRequests = 0;
255
+ const [todayAmount, todayCostResp] = await Promise.all([
256
+ fetchJson(todayKey("amount"), token),
257
+ fetchJson(todayKey("cost"), token)
258
+ ]);
259
+ const tA = todayAmount?.data?.biz_data;
260
+ if (tA && Array.isArray(tA.series)) {
261
+ for (const series of tA.series) {
262
+ for (const bucket of series.buckets ?? []) {
263
+ const u = bucket.usage ?? {};
264
+ for (const k of Object.keys(u)) {
265
+ const v = Number.parseFloat(u[k]) || 0;
266
+ if (isToken(k)) todayTokens += Math.round(v);
267
+ else if (isRequest(k)) todayRequests += Math.round(v);
268
+ }
269
+ }
270
+ }
271
+ }
272
+ const tC = todayCostResp?.data?.biz_data;
273
+ if (tC) {
274
+ const seriesList = Array.isArray(tC.series)
275
+ ? tC.series
276
+ : Array.isArray(tC.data)
277
+ ? tC.data.flatMap((d) => d.series ?? [])
278
+ : [];
279
+ for (const series of seriesList) {
280
+ for (const bucket of series.buckets ?? []) todayCost += Number.parseFloat(bucket.cost) || 0;
281
+ }
282
+ }
283
+ todayCost = Math.round(todayCost * 100) / 100;
284
+
285
+ // 累计消费 = 全月份成本求和;当月数值用本次实时结果更新,过去月份账目已定。
286
+ const monthKey = `${year}-${String(month).padStart(2, "0")}`;
287
+ const cumulativeValue = cumulativeFromMap(monthKey, costValue);
288
+
289
+ return {
290
+ ok: true,
291
+ status: 200,
292
+ data: {
293
+ month,
294
+ year,
295
+ period: "本月",
296
+ apiKey: "API Key 全部",
297
+ totalSpend: `¥${costValue.toFixed(2)}`,
298
+ cumulativeSpend: cumulativeValue === null ? null : `¥${cumulativeValue.toFixed(2)}`,
299
+ todaySpend: todayCost.toFixed(2),
300
+ todayTokens: String(todayTokens).replace(/\B(?=(\d{3})+(?!\d))/g, ","),
301
+ todayRequests: String(todayRequests).replace(/\B(?=(\d{3})+(?!\d))/g, ","),
302
+ requestCount: String(requests).replace(/\B(?=(\d{3})+(?!\d))/g, ","),
303
+ tokens: String(tokens).replace(/\B(?=(\d{3})+(?!\d))/g, ",")
304
+ }
305
+ };
306
+ }
307
+
308
+ /**
309
+ * cordis plugin apply: register the balance route on the shared webserver.
310
+ * @param {import("@deepseek-ai/cordis").Context} ctx - plugin context.
311
+ */
312
+ function apply(ctx) {
313
+ ctx.effect(() => {
314
+ const dispose = ctx.webServer.register({
315
+ kind: "prefix",
316
+ path: "/deepseek-balance",
317
+ handler: async (req, res) => {
318
+ const url = new URL(req.url ?? "/", "http://x");
319
+ if (req.method !== "GET" || url.pathname !== "/deepseek-balance") {
320
+ res.writeHead(404, { "content-type": "application/json; charset=utf-8" });
321
+ res.end(JSON.stringify({ ok: false, error: "not found" }));
322
+ return;
323
+ }
324
+ try {
325
+ const key = await resolveKey(ctx);
326
+ if (key === void 0) {
327
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
328
+ res.end(JSON.stringify({ ok: false, error: "DEEPSEEK_API_KEY 未配置(请在 ~/.dsh/.credentials.yaml 设置)" }));
329
+ return;
330
+ }
331
+ const result = await queryBalance(key);
332
+ // 入口不展示货币符号:把币种字段清空,任何版本的前端都不会拼出 ¥。
333
+ if (result.ok && Array.isArray(result.data?.balance_infos)) {
334
+ for (const info of result.data.balance_infos) info.currency = "";
335
+ }
336
+ res.writeHead(result.ok ? 200 : 502, { "content-type": "application/json; charset=utf-8" });
337
+ res.end(JSON.stringify(result));
338
+ } catch (error) {
339
+ res.writeHead(500, { "content-type": "application/json; charset=utf-8" });
340
+ res.end(JSON.stringify({ ok: false, error: String(error?.message ?? error) }));
341
+ }
342
+ }
343
+ });
344
+ return dispose;
345
+ }, "deepseek-balance-widget: route");
346
+
347
+ ctx.effect(() => {
348
+ const dispose = ctx.webServer.register({
349
+ kind: "prefix",
350
+ path: "/deepseek-usage",
351
+ handler: async (req, res) => {
352
+ const url = new URL(req.url ?? "/", "http://x");
353
+ if (req.method !== "GET" || url.pathname !== "/deepseek-usage") {
354
+ res.writeHead(404, { "content-type": "application/json; charset=utf-8" });
355
+ res.end(JSON.stringify({ ok: false, error: "not found" }));
356
+ return;
357
+ }
358
+ try {
359
+ const token = await resolvePlatformToken(ctx);
360
+ if (token === void 0) {
361
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
362
+ res.end(JSON.stringify({ ok: false, error: "DEEPSEEK_PLATFORM_TOKEN 未配置(请登录 platform.deepseek.com 后设置,用于实时用量统计)" }));
363
+ return;
364
+ }
365
+ const now = new Date();
366
+ // 首次请求做一次全月份扫描(之后只在内存里更新当月,零额外请求)。
367
+ try {
368
+ await ensureMonthlyMap(token);
369
+ } catch {
370
+ // 扫描失败时本轮累计值保持 null,客户端回退到快照;下轮重试。
371
+ }
372
+ const result = await queryPlatformUsage(token, now.getMonth() + 1, now.getFullYear());
373
+ res.writeHead(result.ok ? 200 : 502, { "content-type": "application/json; charset=utf-8" });
374
+ res.end(JSON.stringify(result));
375
+ } catch (error) {
376
+ res.writeHead(500, { "content-type": "application/json; charset=utf-8" });
377
+ res.end(JSON.stringify({ ok: false, error: String(error?.message ?? error) }));
378
+ }
379
+ }
380
+ });
381
+ return dispose;
382
+ }, "deepseek-balance-widget: usage route");
383
+ }
384
+
385
+ export { name, inject, apply };
package/package.json ADDED
@@ -0,0 +1,65 @@
1
+ {
2
+ "name": "dsh-deepseek-balance-widget",
3
+ "version": "1.0.0",
4
+ "type": "module",
5
+ "description": "DeepSeek API balance widget for the dsh web sidebar: a live, auto-refreshing balance pill (balance / today spend / today tokens) with a detail popover (cumulative spend, monthly usage, request count). Every user sees only their own balance — keys are resolved per-machine from the local credential seam, never hardcoded.",
6
+ "keywords": [
7
+ "dsh",
8
+ "dsh-plugin",
9
+ "deepseek",
10
+ "deepseek-harness",
11
+ "balance",
12
+ "usage",
13
+ "sidebar",
14
+ "widget"
15
+ ],
16
+ "main": "lib/index.js",
17
+ "exports": {
18
+ ".": "./lib/index.js",
19
+ "./client": "./lib/client.js",
20
+ "./package.json": "./package.json"
21
+ },
22
+ "files": [
23
+ "lib",
24
+ "cordis.patch.yml",
25
+ "skills",
26
+ "README.md",
27
+ "README_EN.md",
28
+ "LICENSE",
29
+ "scripts"
30
+ ],
31
+ "engines": {
32
+ "node": ">=18"
33
+ },
34
+ "repository": {
35
+ "type": "git",
36
+ "url": "https://github.com/crazy-L118/dsh-deepseek-balance-widget"
37
+ },
38
+ "publishConfig": {
39
+ "access": "public"
40
+ },
41
+ "license": "MIT",
42
+ "dsh": {
43
+ "bundle": {
44
+ "patch": "./cordis.patch.yml"
45
+ },
46
+ "client": {
47
+ "inject": [
48
+ "@deepseek-ai/dsh-client-runtime"
49
+ ],
50
+ "platform": "web"
51
+ }
52
+ },
53
+ "dependencies": {
54
+ "@deepseek-ai/dsh-credentials": "^0.0.1-rc.1",
55
+ "@deepseek-ai/dsh-launch-environment": "^0.0.1-rc.3"
56
+ },
57
+ "peerDependencies": {
58
+ "@deepseek-ai/cordis": "^4.0.1"
59
+ },
60
+ "peerDependenciesMeta": {
61
+ "@deepseek-ai/cordis": {
62
+ "optional": true
63
+ }
64
+ }
65
+ }
@@ -0,0 +1,127 @@
1
+ #Requires -Version 5.1
2
+ <#
3
+ .SYNOPSIS
4
+ Install dsh-deepseek-balance-widget into the local dsh web profile.
5
+
6
+ .DESCRIPTION
7
+ By default this copies the plugin source (this repo) to
8
+ $DSH_HOME/custom-plugins/dsh-deepseek-balance-widget and installs it via
9
+ `dsh plugin --profile web add "file:..."`.
10
+
11
+ With -FromNpm it skips the copy and installs the published package straight
12
+ from the npm registry (`dsh plugin --profile web add dsh-deepseek-balance-widget`).
13
+
14
+ Either way it also registers the plugin in dsh.profile.bundles inside the
15
+ profile package.json.
16
+
17
+ Usage (Windows PowerShell):
18
+ powershell -ExecutionPolicy Bypass -File .\scripts\install.ps1 # from local repo
19
+ powershell -ExecutionPolicy Bypass -File .\scripts\install.ps1 -FromNpm # from npm registry
20
+ #>
21
+ [CmdletBinding()]
22
+ param(
23
+ [switch]$FromNpm
24
+ )
25
+ $ErrorActionPreference = "Stop"
26
+
27
+ $pluginName = "dsh-deepseek-balance-widget"
28
+
29
+ $dshHome = if ($env:DSH_HOME) { $env:DSH_HOME } else { Join-Path $HOME ".dsh" }
30
+ $customPluginsDir = Join-Path $dshHome "custom-plugins"
31
+ $pluginDir = Join-Path $customPluginsDir $pluginName
32
+ $profileDir = Join-Path $dshHome "profiles" "web"
33
+ $profilePkg = Join-Path $profileDir "package.json"
34
+
35
+ # 0. dsh on PATH?
36
+ if (-not (Get-Command dsh -ErrorAction SilentlyContinue)) {
37
+ Write-Host "[error] 'dsh' not found on PATH. Install DeepSeek Harness first." -ForegroundColor Red
38
+ exit 1
39
+ }
40
+
41
+ # 1. web profile initialized?
42
+ if (-not (Test-Path $profilePkg)) {
43
+ Write-Host "[error] web profile not found: $profilePkg" -ForegroundColor Red
44
+ Write-Host " Run 'dsh web' (or 'dsh --profile web --help') once to initialize the profile, then re-run this script."
45
+ exit 1
46
+ }
47
+
48
+ # 2. copy plugin source (local mode only)
49
+ if ($FromNpm) {
50
+ Write-Host "==> Installing from npm registry (no local copy)"
51
+ } else {
52
+ $source = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
53
+ Write-Host "==> Copying plugin to $pluginDir"
54
+ New-Item -ItemType Directory -Force -Path $customPluginsDir | Out-Null
55
+ if (Test-Path $pluginDir) { Remove-Item -Recurse -Force $pluginDir }
56
+ Copy-Item -Recurse -Force -Path $source -Destination $pluginDir
57
+ Get-ChildItem -Force $pluginDir -ErrorAction SilentlyContinue |
58
+ Where-Object { $_.Name -in @("node_modules", ".git") } |
59
+ Remove-Item -Recurse -Force -ErrorAction SilentlyContinue
60
+ }
61
+
62
+ # 3. install via dsh plugin (pnpm add)
63
+ if ($FromNpm) {
64
+ Write-Host "==> Installing into web profile: dsh plugin --profile web add $pluginName"
65
+ } else {
66
+ $fileSpec = "file:" + ($pluginDir -replace "\\", "/")
67
+ Write-Host "==> Installing into web profile: dsh plugin --profile web add $fileSpec"
68
+ }
69
+ Push-Location $profileDir
70
+ try {
71
+ if ($FromNpm) {
72
+ & dsh plugin --profile web add $pluginName
73
+ } else {
74
+ & dsh plugin --profile web add ("file:" + ($pluginDir -replace "\\", "/"))
75
+ }
76
+ if ($LASTEXITCODE -ne 0) { throw "dsh plugin add failed (exit code $LASTEXITCODE)" }
77
+ } finally {
78
+ Pop-Location
79
+ }
80
+
81
+ # 4. register in dsh.profile.bundles (idempotent safety net).
82
+ # Modern dsh auto-registers any installed dependency that declares
83
+ # dsh.bundle after `dsh plugin add`, so this usually reports
84
+ # "Already registered". It only matters for older dsh versions or when
85
+ # the package was installed with raw pnpm/npm instead of `dsh plugin`.
86
+ Write-Host "==> Registering in dsh.profile.bundles"
87
+ $raw = [System.IO.File]::ReadAllText($profilePkg)
88
+ $pattern = '"bundles"\s*:\s*\[[^\]]*?\]'
89
+ $m = [regex]::Match($raw, $pattern)
90
+ if (-not $m.Success) {
91
+ throw "Could not locate the dsh.profile.bundles array in $profilePkg. Please add `"$pluginName`" to it manually."
92
+ }
93
+ $arrayText = $m.Value
94
+ if ($arrayText -match '"dsh-deepseek-balance-widget"') {
95
+ Write-Host " Already registered, skipping."
96
+ } else {
97
+ # Insert before the closing bracket; make sure there is a separating comma.
98
+ $newArrayText = $arrayText.Substring(0, $arrayText.Length - 1).TrimEnd()
99
+ if (-not $newArrayText.EndsWith(",")) { $newArrayText += "," }
100
+ $newArrayText += "`n `"$pluginName`"]"
101
+ $raw = $raw.Substring(0, $m.Index) + $newArrayText + $raw.Substring($m.Index + $m.Length)
102
+ $utf8NoBom = New-Object System.Text.UTF8Encoding($false)
103
+ [System.IO.File]::WriteAllText($profilePkg, $raw, $utf8NoBom)
104
+ Write-Host " + Added $pluginName to bundles"
105
+ }
106
+
107
+ # 5. install the AI skill (idempotent): local copy first, npm-installed fallback
108
+ Write-Host "==> Installing AI skill"
109
+ $skillSource = Join-Path $pluginDir "skills\dsh-deepseek-balance-widget\SKILL.md"
110
+ if (-not (Test-Path $skillSource)) {
111
+ $skillSource = Join-Path $profileDir ("node_modules\" + $pluginName + "\skills\dsh-deepseek-balance-widget\SKILL.md")
112
+ }
113
+ if (Test-Path $skillSource) {
114
+ $skillDir = Join-Path $dshHome "skills\dsh-deepseek-balance-widget"
115
+ New-Item -ItemType Directory -Force -Path $skillDir | Out-Null
116
+ Copy-Item -Force $skillSource (Join-Path $skillDir "SKILL.md")
117
+ Write-Host " + Skill ready at $skillDir (AI picks it up in new sessions)"
118
+ } else {
119
+ Write-Host " (no skill source found, skipping)"
120
+ }
121
+
122
+ Write-Host ""
123
+ Write-Host "Installation complete. Next steps:"
124
+ Write-Host " 1. Edit $dshHome\.credentials.yaml and add (if not already set):"
125
+ Write-Host " DEEPSEEK_API_KEY: sk-xxxx # required - balance"
126
+ Write-Host " DEEPSEEK_PLATFORM_TOKEN: xxxx # optional - live usage (userToken from platform.deepseek.com)"
127
+ Write-Host " 2. Restart dsh web. The balance widget appears in the sidebar."
@@ -0,0 +1,103 @@
1
+ #!/usr/bin/env bash
2
+ # Install dsh-deepseek-balance-widget into the local dsh web profile.
3
+ #
4
+ # By default this copies the plugin source (this repo) to
5
+ # $DSH_HOME/custom-plugins/dsh-deepseek-balance-widget and installs it via
6
+ # `dsh plugin --profile web add "file:..."`.
7
+ #
8
+ # With --from-npm it skips the copy and installs the published package
9
+ # straight from the npm registry.
10
+ #
11
+ # Either way it also registers the plugin in dsh.profile.bundles inside the
12
+ # profile package.json.
13
+ #
14
+ # Usage:
15
+ # bash scripts/install.sh # from local repo
16
+ # bash scripts/install.sh --from-npm # from npm registry
17
+ set -euo pipefail
18
+
19
+ FROM_NPM=0
20
+ if [ "${1:-}" = "--from-npm" ]; then FROM_NPM=1; fi
21
+
22
+ PLUGIN_NAME="dsh-deepseek-balance-widget"
23
+ DSH_HOME="${DSH_HOME:-$HOME/.dsh}"
24
+ CUSTOM_PLUGINS="$DSH_HOME/custom-plugins"
25
+ PLUGIN_DIR="$CUSTOM_PLUGINS/$PLUGIN_NAME"
26
+ PROFILE_DIR="$DSH_HOME/profiles/web"
27
+ PROFILE_PKG="$PROFILE_DIR/package.json"
28
+
29
+ if ! command -v dsh >/dev/null 2>&1; then
30
+ echo "[error] 'dsh' not found on PATH. Install DeepSeek Harness first." >&2
31
+ exit 1
32
+ fi
33
+
34
+ if [ ! -f "$PROFILE_PKG" ]; then
35
+ echo "[error] web profile not found: $PROFILE_PKG" >&2
36
+ echo " Run 'dsh web' (or 'dsh --profile web --help') once to initialize the profile, then re-run this script." >&2
37
+ exit 1
38
+ fi
39
+
40
+ if [ "$FROM_NPM" = "1" ]; then
41
+ echo "==> Installing from npm registry (no local copy)"
42
+ else
43
+ SOURCE="$(cd "$(dirname "$0")/.." && pwd)"
44
+ echo "==> Copying plugin to $PLUGIN_DIR"
45
+ mkdir -p "$CUSTOM_PLUGINS"
46
+ rm -rf "$PLUGIN_DIR"
47
+ cp -R "$SOURCE" "$PLUGIN_DIR"
48
+ rm -rf "$PLUGIN_DIR/node_modules" "$PLUGIN_DIR/.git"
49
+ fi
50
+
51
+ echo "==> Installing into web profile"
52
+ if [ "$FROM_NPM" = "1" ]; then
53
+ ( cd "$PROFILE_DIR" && dsh plugin --profile web add "$PLUGIN_NAME" )
54
+ else
55
+ ( cd "$PROFILE_DIR" && dsh plugin --profile web add "file:$PLUGIN_DIR" )
56
+ fi
57
+
58
+ # Idempotent safety net: modern dsh auto-registers any installed dependency
59
+ # that declares dsh.bundle after `dsh plugin add`, so this usually prints
60
+ # "Already registered". It only matters for older dsh versions or when the
61
+ # package was installed with raw pnpm/npm instead of `dsh plugin`.
62
+ echo "==> Registering in dsh.profile.bundles"
63
+ node - "$PROFILE_PKG" "$PLUGIN_NAME" <<'NODEEOF'
64
+ const fs = require("fs");
65
+ const pkgPath = process.argv[2];
66
+ const pluginName = process.argv[3];
67
+ const data = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
68
+ const profile = data.dsh && data.dsh.profile;
69
+ const bundles = profile && Array.isArray(profile.bundles) ? profile.bundles : null;
70
+ if (bundles === null) {
71
+ console.error("[error] dsh.profile.bundles not found in " + pkgPath +
72
+ " - add \"" + pluginName + "\" to it manually.");
73
+ process.exit(1);
74
+ }
75
+ if (bundles.includes(pluginName)) {
76
+ console.log(" Already registered, skipping.");
77
+ } else {
78
+ bundles.push(pluginName);
79
+ fs.writeFileSync(pkgPath, JSON.stringify(data, null, 2) + "\n", "utf8");
80
+ console.log(" + Added " + pluginName + " to bundles");
81
+ }
82
+ NODEEOF
83
+
84
+ # Install the AI skill (idempotent): local copy first, npm-installed fallback
85
+ echo "==> Installing AI skill"
86
+ SKILL_SRC="$PLUGIN_DIR/skills/dsh-deepseek-balance-widget/SKILL.md"
87
+ if [ ! -f "$SKILL_SRC" ]; then
88
+ SKILL_SRC="$PROFILE_DIR/node_modules/$PLUGIN_NAME/skills/dsh-deepseek-balance-widget/SKILL.md"
89
+ fi
90
+ if [ -f "$SKILL_SRC" ]; then
91
+ mkdir -p "$DSH_HOME/skills/dsh-deepseek-balance-widget"
92
+ cp -f "$SKILL_SRC" "$DSH_HOME/skills/dsh-deepseek-balance-widget/SKILL.md"
93
+ echo " + Skill ready at $DSH_HOME/skills/dsh-deepseek-balance-widget (AI picks it up in new sessions)"
94
+ else
95
+ echo " (no skill source found, skipping)"
96
+ fi
97
+
98
+ echo ""
99
+ echo "Installation complete. Next steps:"
100
+ echo " 1. Edit $DSH_HOME/.credentials.yaml and add (if not already set):"
101
+ echo " DEEPSEEK_API_KEY: sk-xxxx # required - balance"
102
+ echo " DEEPSEEK_PLATFORM_TOKEN: xxxx # optional - live usage (userToken from platform.deepseek.com)"
103
+ echo " 2. Restart dsh web. The balance widget appears in the sidebar."