opencode-skills-doctor 0.4.2 → 0.5.1
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/README.md +36 -3
- package/docs/README.zh-CN.md +36 -3
- package/package.json +1 -1
- package/src/cli.mjs +136 -1
- package/src/desktop.mjs +80 -0
- package/src/html.mjs +4 -0
- package/src/i18n.mjs +54 -0
- package/src/install.mjs +46 -10
- package/src/plan.mjs +142 -0
package/README.md
CHANGED
|
@@ -20,9 +20,10 @@ Local scanner, linter, fixer, updater and **OpenCode plugin** for agent skills
|
|
|
20
20
|
| **What it finds** | shadowed IDs, duplicates, long descriptions, oversized bodies, per-step token budget, broken file references, invalid frontmatter, empty bodies, stale skills |
|
|
21
21
|
| **What it fixes** | archives shadowed definitions (reversible), hides low-frequency skills, syncs catalogs with drift detection |
|
|
22
22
|
| **Reports** | terminal, JSON, or a self-contained HTML report (`--html [file] --open`) |
|
|
23
|
+
| **Plan** | exact token savings: trim descriptions and/or hide skills, projected over N steps |
|
|
23
24
|
| **Cost** | CLI: 0 tokens. Plugin: ~191 tokens/step for the tool definition, plus a one-time warning per session |
|
|
24
25
|
| **Requirements** | Node ≥ 20, zero runtime dependencies |
|
|
25
|
-
| **Quality** |
|
|
26
|
+
| **Quality** | 55 tests, CI on ubuntu/macos/windows × Node 20/22/24 |
|
|
26
27
|
|
|
27
28
|
## Why
|
|
28
29
|
|
|
@@ -102,6 +103,23 @@ skills-doctor check --html report.html --open
|
|
|
102
103
|
|
|
103
104
|
The plugin and MCP tool accept the same thing as an `html` argument, so the agent can ask you where to save it — for example: *"save the report to D:\reports\skills.html"*.
|
|
104
105
|
|
|
106
|
+
### `plan` (how many tokens can I actually save?)
|
|
107
|
+
|
|
108
|
+
```powershell
|
|
109
|
+
skills-doctor plan
|
|
110
|
+
skills-doctor plan --budget 800 --desc-target 160 --steps 200
|
|
111
|
+
skills-doctor plan --json
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
It answers "how much can I cut, exactly?":
|
|
115
|
+
|
|
116
|
+
- only **advertised** definitions cost tokens — archiving shadowed duplicates saves 0, and the plan says so instead of pretending;
|
|
117
|
+
- **Option A** trims every description longer than `--desc-target` (default 160 chars, cut at sentence boundaries) and computes the exact saving;
|
|
118
|
+
- **Option B** hides the fewest skills needed to reach the budget (still loadable with `@id`; picking the largest savings first is optimal for "minimal hides");
|
|
119
|
+
- **Recommended** trims first, then hides, with no double counting, and projects the saving over `--steps` model steps (default 100).
|
|
120
|
+
|
|
121
|
+
`check` prints a one-line pointer when you are over budget, and the HTML report shows the same potential-savings card.
|
|
122
|
+
|
|
105
123
|
### `scan`
|
|
106
124
|
|
|
107
125
|
Lists every skill with its source, precedence, whether the model can see it, and token cost.
|
|
@@ -246,6 +264,21 @@ It registers the same `skills_doctor` tool. 1.x plugins cannot register commands
|
|
|
246
264
|
|
|
247
265
|
> The package root entry is the V2 plugin and requires OpenCode 2.x. The CLI works on both lines.
|
|
248
266
|
|
|
267
|
+
### OpenCode Desktop
|
|
268
|
+
|
|
269
|
+
The desktop app bundles the same OpenCode core and reads the **same config directory** as the CLI (`~/.config/opencode`; its UI settings live elsewhere), so one install covers both:
|
|
270
|
+
|
|
271
|
+
```powershell
|
|
272
|
+
npx opencode-skills-doctor install --client desktop
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
- detects the app in the standard install locations and reads its bundled core version from `resources/app.asar` (e.g. 1.17.11), then uses the matching plugin API;
|
|
276
|
+
- writes the same plugin entry and `/skills-doctor`, `/skills-report`, `/skills-upgrade` commands as the CLI install — restart the desktop app afterwards;
|
|
277
|
+
- `--client auto` (default) prefers the CLI version when both are installed; `--client opencode` skips desktop detection;
|
|
278
|
+
- `uninstall --client desktop` reverses it.
|
|
279
|
+
|
|
280
|
+
Verified on Windows: app at `%LOCALAPPDATA%\Programs\@opencode-aidesktop\OpenCode.exe`, sharing config with the `opencode` CLI.
|
|
281
|
+
|
|
249
282
|
### Deep integration (one command)
|
|
250
283
|
|
|
251
284
|
```powershell
|
|
@@ -468,13 +501,13 @@ Executing code is free, but the tool definition is part of the model's context.
|
|
|
468
501
|
|
|
469
502
|
## Roadmap
|
|
470
503
|
|
|
471
|
-
- v0.
|
|
504
|
+
- v0.6: CI governance (baseline that only reports new issues, official GitHub Action), content-aware suggestions (which skills are never used), catalog signature verification
|
|
472
505
|
- Later: V1/Claude skill migration report
|
|
473
506
|
|
|
474
507
|
## Development and contributing
|
|
475
508
|
|
|
476
509
|
```powershell
|
|
477
|
-
npm test #
|
|
510
|
+
npm test # 55 tests (node test/doctor.test.mjs)
|
|
478
511
|
npm run bench # lint timing for 100/500/1000 skills
|
|
479
512
|
```
|
|
480
513
|
|
package/docs/README.zh-CN.md
CHANGED
|
@@ -20,9 +20,10 @@ OpenCode skill 的**本地检测器 + 更新器 + 修复器**,并附带 **Open
|
|
|
20
20
|
| **能查** | 同 ID 遮蔽、重复、超长描述、超大正文、每步 token 预算、断链引用、非法 frontmatter、空正文、陈旧 skill |
|
|
21
21
|
| **能修** | 归档遮蔽定义(可逆)、隐藏低频 skill、catalog 同步并检测 drift |
|
|
22
22
|
| **报告** | 终端、JSON,或自包含 HTML(`--html [file] --open`) |
|
|
23
|
+
| **规划** | 精确 token 节省:精简描述 / 隐藏 skill,按 N 步折算 |
|
|
23
24
|
| **成本** | CLI 0 token;插件工具定义约 191/步,另有每会话一次预警 |
|
|
24
25
|
| **要求** | Node ≥ 20,零运行时依赖 |
|
|
25
|
-
| **质量** |
|
|
26
|
+
| **质量** | 55 个测试,ubuntu/macos/windows × Node 20/22/24 CI |
|
|
26
27
|
|
|
27
28
|
## 为什么需要它
|
|
28
29
|
|
|
@@ -102,6 +103,23 @@ skills-doctor check --html report.html --open
|
|
|
102
103
|
|
|
103
104
|
插件和 MCP 工具同样接受 `html` 参数,所以可以让 agent 先问你要存哪里——例如:*"把报告存到 D:\reports\skills.html"*。
|
|
104
105
|
|
|
106
|
+
### `plan`(到底能省多少 token?)
|
|
107
|
+
|
|
108
|
+
```powershell
|
|
109
|
+
skills-doctor plan
|
|
110
|
+
skills-doctor plan --budget 800 --desc-target 160 --steps 200
|
|
111
|
+
skills-doctor plan --json
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
回答「能省多少、怎么省」:
|
|
115
|
+
|
|
116
|
+
- 只有**会展示给模型**的定义才花 token —— 归档遮蔽重复定义省 0,方案里会明说,不糊弄;
|
|
117
|
+
- **方案 A**:把所有超过 `--desc-target`(默认 160 字符,按句子边界截断)的 description 精简,算出精确节省;
|
|
118
|
+
- **方案 B**:用最少的隐藏数量把总量压进预算(仍可 `@id` 加载;按单项节省从大到小取,对「最少隐藏数」是最优的);
|
|
119
|
+
- **推荐方案**:先精简再隐藏,不重复计算,并按 `--steps`(默认 100)折算总节省。
|
|
120
|
+
|
|
121
|
+
超预算时 `check` 会打印一行提示,HTML 报告里有同样的「potential savings」卡片。
|
|
122
|
+
|
|
105
123
|
### `scan`
|
|
106
124
|
|
|
107
125
|
列出所有 skill 及其来源、优先级、是否会被模型看到、token 占用。
|
|
@@ -246,6 +264,21 @@ export { SkillsDoctorPlugin } from "opencode-skills-doctor/v1"
|
|
|
246
264
|
|
|
247
265
|
> 包根入口是 V2 插件,需要 OpenCode 2.x;CLI 两条线都能用。
|
|
248
266
|
|
|
267
|
+
### OpenCode Desktop
|
|
268
|
+
|
|
269
|
+
桌面版内置的是同一套 OpenCode core,读的也是**同一个配置目录**(`~/.config/opencode`;桌面 UI 的设置另存),所以一次安装两边都生效:
|
|
270
|
+
|
|
271
|
+
```powershell
|
|
272
|
+
npx opencode-skills-doctor install --client desktop
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
- 在标准安装位置探测桌面版,并从 `resources/app.asar` 读出内置 core 版本(例如 1.17.11),据此选择对应插件 API;
|
|
276
|
+
- 写入与 CLI 相同的插件条目和 `/skills-doctor`、`/skills-report`、`/skills-upgrade` 命令 —— 装完重启桌面应用;
|
|
277
|
+
- `--client auto`(默认)在同时装了 CLI 时优先 CLI 版本;`--client opencode` 跳过桌面探测;
|
|
278
|
+
- `uninstall --client desktop` 反向撤销。
|
|
279
|
+
|
|
280
|
+
已在 Windows 验证:应用位于 `%LOCALAPPDATA%\Programs\@opencode-aidesktop\OpenCode.exe`,与 `opencode` CLI 共用配置。
|
|
281
|
+
|
|
249
282
|
### 一键深度集成
|
|
250
283
|
|
|
251
284
|
```powershell
|
|
@@ -468,13 +501,13 @@ token 是启发式估算,1200 是建议预算。可以用 `--budget` 或 `.ski
|
|
|
468
501
|
|
|
469
502
|
## Roadmap
|
|
470
503
|
|
|
471
|
-
- v0.
|
|
504
|
+
- v0.6:CI 治理(baseline 只报新增问题、官方 GitHub Action)、内容层建议(哪些 skill 从未被使用)、catalog 签名校验
|
|
472
505
|
- 后续:V1/Claude skill 兼容迁移报告
|
|
473
506
|
|
|
474
507
|
## 开发与贡献
|
|
475
508
|
|
|
476
509
|
```powershell
|
|
477
|
-
npm test #
|
|
510
|
+
npm test # 55 个测试(node test/doctor.test.mjs)
|
|
478
511
|
npm run bench # 100/500/1000 个 skill 的 lint 耗时
|
|
479
512
|
```
|
|
480
513
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-skills-doctor",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.1",
|
|
4
4
|
"description": "Audit, fix and update agent skills: token cost, broken references, shadowed duplicates, HTML reports, catalog sync. CLI, OpenCode plugin, MCP server; works with OpenCode, Claude Code, Codex, Grok CLI and Cursor skill layouts.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
package/src/cli.mjs
CHANGED
|
@@ -10,6 +10,7 @@ import { loadConfig } from "./config.mjs"
|
|
|
10
10
|
import { startMcpServer } from "./mcp.mjs"
|
|
11
11
|
import { installOpenCode, uninstallOpenCode } from "./install.mjs"
|
|
12
12
|
import { writeHtmlReport, openReport } from "./html.mjs"
|
|
13
|
+
import { planBudget } from "./plan.mjs"
|
|
13
14
|
import {
|
|
14
15
|
checkForUpdate,
|
|
15
16
|
resolveMode,
|
|
@@ -70,18 +71,21 @@ function parseArgs(argv) {
|
|
|
70
71
|
into: null,
|
|
71
72
|
checkOnly: false,
|
|
72
73
|
prune: false,
|
|
74
|
+
client: "auto",
|
|
73
75
|
configDir: null,
|
|
74
76
|
noNpm: false,
|
|
75
77
|
html: null,
|
|
76
78
|
open: false,
|
|
77
79
|
apply: false,
|
|
78
80
|
updateCheck: true,
|
|
81
|
+
descTarget: 160,
|
|
82
|
+
steps: 100,
|
|
79
83
|
help: false,
|
|
80
84
|
version: false,
|
|
81
85
|
}
|
|
82
86
|
|
|
83
87
|
const args = [...argv]
|
|
84
|
-
const commands = new Set(["check", "scan", "update", "fix", "install", "uninstall", "upgrade", "mcp", "help"])
|
|
88
|
+
const commands = new Set(["check", "scan", "plan", "update", "fix", "install", "uninstall", "upgrade", "mcp", "help"])
|
|
85
89
|
if (args[0] && commands.has(args[0])) {
|
|
86
90
|
options.command = args.shift()
|
|
87
91
|
}
|
|
@@ -101,6 +105,12 @@ function parseArgs(argv) {
|
|
|
101
105
|
case "--stale-days":
|
|
102
106
|
options.staleDays = Number(args[++i])
|
|
103
107
|
break
|
|
108
|
+
case "--desc-target":
|
|
109
|
+
options.descTarget = Number(args[++i])
|
|
110
|
+
break
|
|
111
|
+
case "--steps":
|
|
112
|
+
options.steps = Number(args[++i])
|
|
113
|
+
break
|
|
104
114
|
case "--strict":
|
|
105
115
|
options.strict = true
|
|
106
116
|
break
|
|
@@ -139,6 +149,10 @@ function parseArgs(argv) {
|
|
|
139
149
|
case "--config-dir":
|
|
140
150
|
options.configDir = path.resolve(args[++i] ?? ".")
|
|
141
151
|
break
|
|
152
|
+
case "--client": {
|
|
153
|
+
options.client = String(args[++i] ?? "").toLowerCase()
|
|
154
|
+
break
|
|
155
|
+
}
|
|
142
156
|
case "--no-npm":
|
|
143
157
|
options.noNpm = true
|
|
144
158
|
break
|
|
@@ -279,6 +293,92 @@ function printCheck(audit, result, options, t) {
|
|
|
279
293
|
console.log(bold(t("check.hints")))
|
|
280
294
|
for (const rule of usedRules) console.log(dim(` - ${t(HINT_KEYS[rule])}`))
|
|
281
295
|
}
|
|
296
|
+
|
|
297
|
+
if (audit.summary.advertisedTokens > audit.budget) {
|
|
298
|
+
const plan = planBudget(audit)
|
|
299
|
+
if (plan.recommended.savings > 0) {
|
|
300
|
+
console.log("")
|
|
301
|
+
console.log(yellow(t("check.planHint", { tokens: plan.recommended.savings })))
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function printPlan(plan, t) {
|
|
307
|
+
console.log(bold(t("plan.title")))
|
|
308
|
+
console.log(dim(t("plan.current", { tokens: plan.current, count: plan.advertised })))
|
|
309
|
+
console.log(dim(t("plan.budget", { budget: plan.budget, gap: plan.gap })))
|
|
310
|
+
console.log(dim(t("plan.steps", { steps: plan.steps })))
|
|
311
|
+
console.log("")
|
|
312
|
+
|
|
313
|
+
const sessionLine = (savings) => {
|
|
314
|
+
if (plan.steps > 0 && savings > 0) console.log(dim(t("plan.session", { tokens: savings * plan.steps, steps: plan.steps })))
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if (plan.current <= plan.budget) {
|
|
318
|
+
console.log(green(t("plan.under")))
|
|
319
|
+
if (plan.trim.count) {
|
|
320
|
+
console.log("")
|
|
321
|
+
console.log(bold(t("plan.trimTitle", { chars: plan.descTarget })))
|
|
322
|
+
console.log(
|
|
323
|
+
t("plan.trimLine", {
|
|
324
|
+
count: plan.trim.count,
|
|
325
|
+
savings: plan.trim.savings,
|
|
326
|
+
final: Math.max(0, plan.current - plan.trim.savings),
|
|
327
|
+
}),
|
|
328
|
+
)
|
|
329
|
+
sessionLine(plan.trim.savings)
|
|
330
|
+
}
|
|
331
|
+
return
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
console.log(bold(t("plan.trimTitle", { chars: plan.descTarget })))
|
|
335
|
+
if (plan.trim.count) {
|
|
336
|
+
console.log(
|
|
337
|
+
t("plan.trimLine", {
|
|
338
|
+
count: plan.trim.count,
|
|
339
|
+
savings: plan.trim.savings,
|
|
340
|
+
final: Math.max(0, plan.current - plan.trim.savings),
|
|
341
|
+
}),
|
|
342
|
+
)
|
|
343
|
+
for (const item of plan.trim.items.slice(0, 10)) {
|
|
344
|
+
console.log(dim(t("plan.trimItem", { id: item.id, saving: item.saving })))
|
|
345
|
+
}
|
|
346
|
+
if (plan.trim.items.length > 10) console.log(dim(t("plan.more", { count: plan.trim.items.length - 10 })))
|
|
347
|
+
} else {
|
|
348
|
+
console.log(dim(t("plan.trimNone")))
|
|
349
|
+
}
|
|
350
|
+
console.log("")
|
|
351
|
+
|
|
352
|
+
console.log(bold(t("plan.hideTitle")))
|
|
353
|
+
if (plan.hideOnly.count) {
|
|
354
|
+
console.log(
|
|
355
|
+
t("plan.hideLine", {
|
|
356
|
+
count: plan.hideOnly.count,
|
|
357
|
+
savings: plan.hideOnly.savings,
|
|
358
|
+
final: plan.hideOnly.final,
|
|
359
|
+
}),
|
|
360
|
+
)
|
|
361
|
+
} else {
|
|
362
|
+
console.log(dim(t("plan.hideNone")))
|
|
363
|
+
}
|
|
364
|
+
console.log("")
|
|
365
|
+
|
|
366
|
+
console.log(bold(t("plan.recTitle")))
|
|
367
|
+
console.log(
|
|
368
|
+
t("plan.recLine", {
|
|
369
|
+
trims: plan.trim.count,
|
|
370
|
+
hides: plan.recommended.hides.length,
|
|
371
|
+
final: plan.recommended.final,
|
|
372
|
+
savings: plan.recommended.savings,
|
|
373
|
+
}),
|
|
374
|
+
)
|
|
375
|
+
for (const item of plan.recommended.hides) {
|
|
376
|
+
console.log(dim(t("plan.hideItem", { id: item.id, tokens: item.tokens })))
|
|
377
|
+
}
|
|
378
|
+
if (plan.recommended.hidesEverything) console.log(yellow(t("plan.hidesAll")))
|
|
379
|
+
sessionLine(plan.recommended.savings)
|
|
380
|
+
console.log("")
|
|
381
|
+
console.log(dim(t("plan.next")))
|
|
282
382
|
}
|
|
283
383
|
|
|
284
384
|
function printScan(discovered, options, managed, t) {
|
|
@@ -491,6 +591,12 @@ export async function main(argv) {
|
|
|
491
591
|
process.exit(2)
|
|
492
592
|
}
|
|
493
593
|
|
|
594
|
+
if (!["auto", "opencode", "desktop"].includes(options.client)) {
|
|
595
|
+
console.error("unsupported --client: expected auto, opencode or desktop")
|
|
596
|
+
process.exitCode = 2
|
|
597
|
+
return
|
|
598
|
+
}
|
|
599
|
+
|
|
494
600
|
const cwd = path.resolve(options.root)
|
|
495
601
|
const loaded = loadConfig(cwd, options.config)
|
|
496
602
|
const lang = normalizeLang(options.lang) ?? normalizeLang(loaded.config.lang) ?? detectLang()
|
|
@@ -515,6 +621,7 @@ export async function main(argv) {
|
|
|
515
621
|
configDir: options.configDir ?? undefined,
|
|
516
622
|
dryRun: options.dryRun,
|
|
517
623
|
installPackage: !options.noNpm,
|
|
624
|
+
client: options.client,
|
|
518
625
|
})
|
|
519
626
|
if (options.json) {
|
|
520
627
|
console.log(JSON.stringify(result, null, 2))
|
|
@@ -522,6 +629,12 @@ export async function main(argv) {
|
|
|
522
629
|
console.log(bold(t("install.title")))
|
|
523
630
|
console.log(dim(t("install.version", { version: result.version ?? "unknown" })))
|
|
524
631
|
console.log(dim(t("install.configDir", { dir: result.configDir })))
|
|
632
|
+
console.log(dim(t("install.client", { client: result.client?.mode ?? options.client })))
|
|
633
|
+
if (result.client?.desktop) {
|
|
634
|
+
console.log(
|
|
635
|
+
dim(t("install.desktop", { version: result.client.desktop.version ?? "unknown", exe: result.client.desktop.exe })),
|
|
636
|
+
)
|
|
637
|
+
}
|
|
525
638
|
if (result.dryRun) console.log(dim(t("install.dryRun")))
|
|
526
639
|
for (const action of result.actions) console.log(` · ${action}`)
|
|
527
640
|
for (const error of result.errors) console.log(red(t("install.error", { error })))
|
|
@@ -537,6 +650,7 @@ export async function main(argv) {
|
|
|
537
650
|
configDir: options.configDir ?? undefined,
|
|
538
651
|
dryRun: options.dryRun,
|
|
539
652
|
removePackage: !options.noNpm,
|
|
653
|
+
client: options.client,
|
|
540
654
|
})
|
|
541
655
|
if (options.json) {
|
|
542
656
|
console.log(JSON.stringify(result, null, 2))
|
|
@@ -544,6 +658,12 @@ export async function main(argv) {
|
|
|
544
658
|
console.log(bold(t("uninstall.title")))
|
|
545
659
|
console.log(dim(t("install.version", { version: result.version ?? "unknown" })))
|
|
546
660
|
console.log(dim(t("install.configDir", { dir: result.configDir })))
|
|
661
|
+
console.log(dim(t("install.client", { client: result.client?.mode ?? options.client })))
|
|
662
|
+
if (result.client?.desktop) {
|
|
663
|
+
console.log(
|
|
664
|
+
dim(t("install.desktop", { version: result.client.desktop.version ?? "unknown", exe: result.client.desktop.exe })),
|
|
665
|
+
)
|
|
666
|
+
}
|
|
547
667
|
if (result.dryRun) console.log(dim(t("install.dryRun")))
|
|
548
668
|
for (const action of result.actions) console.log(` · ${action}`)
|
|
549
669
|
for (const error of result.errors) console.log(red(t("install.error", { error })))
|
|
@@ -592,6 +712,21 @@ export async function main(argv) {
|
|
|
592
712
|
return
|
|
593
713
|
}
|
|
594
714
|
|
|
715
|
+
if (options.command === "plan") {
|
|
716
|
+
const audit = auditSkills({
|
|
717
|
+
cwd,
|
|
718
|
+
includeGlobal: options.global,
|
|
719
|
+
lang,
|
|
720
|
+
configPath: options.config,
|
|
721
|
+
budget: options.budget,
|
|
722
|
+
staleDays: options.staleDays,
|
|
723
|
+
})
|
|
724
|
+
const plan = planBudget(audit, { descTarget: options.descTarget, steps: options.steps })
|
|
725
|
+
if (options.json) console.log(JSON.stringify(plan, null, 2))
|
|
726
|
+
else printPlan(plan, t)
|
|
727
|
+
return
|
|
728
|
+
}
|
|
729
|
+
|
|
595
730
|
if (options.command === "check") {
|
|
596
731
|
const audit = auditSkills({
|
|
597
732
|
cwd,
|
package/src/desktop.mjs
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import fs from "node:fs"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
import os from "node:os"
|
|
4
|
+
|
|
5
|
+
const APP_NAME = "OpenCode"
|
|
6
|
+
|
|
7
|
+
/** 各平台 OpenCode Desktop 的常见安装位置。 */
|
|
8
|
+
export function desktopCandidates(platform = process.platform, env = process.env, home = os.homedir()) {
|
|
9
|
+
if (platform === "win32") {
|
|
10
|
+
const local = env.LOCALAPPDATA || path.join(home, "AppData", "Local")
|
|
11
|
+
return [
|
|
12
|
+
path.join(local, "Programs", "@opencode-aidesktop", "OpenCode.exe"),
|
|
13
|
+
path.join(local, "Programs", "opencode", "OpenCode.exe"),
|
|
14
|
+
path.join(local, "Programs", "opencode-desktop", "OpenCode.exe"),
|
|
15
|
+
path.join(local, "opencode-desktop", "OpenCode.exe"),
|
|
16
|
+
]
|
|
17
|
+
}
|
|
18
|
+
if (platform === "darwin") {
|
|
19
|
+
return ["/Applications/OpenCode.app/Contents/MacOS/OpenCode", path.join(home, "Applications", "OpenCode.app", "Contents", "MacOS", "OpenCode")]
|
|
20
|
+
}
|
|
21
|
+
return ["/usr/bin/opencode-desktop", "/usr/local/bin/opencode-desktop", "/opt/OpenCode/opencode-desktop", "/opt/opencode-desktop/opencode-desktop"]
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function asarPathFor(exe) {
|
|
25
|
+
if (process.platform === "darwin" || exe.includes(".app")) {
|
|
26
|
+
return path.join(path.dirname(path.dirname(path.dirname(path.dirname(exe)))), "Resources", "app.asar")
|
|
27
|
+
}
|
|
28
|
+
return path.join(path.dirname(exe), "resources", "app.asar")
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* 读取 app.asar 里的 package.json.version(不解包整个 asar)。
|
|
33
|
+
* asar 头结构:[u32 4][u32 headerSize][u32 stringSize][u32 jsonSize][json][content…]
|
|
34
|
+
*/
|
|
35
|
+
export function readAsarVersion(asarFile) {
|
|
36
|
+
let fd
|
|
37
|
+
try {
|
|
38
|
+
fd = fs.openSync(asarFile, "r")
|
|
39
|
+
const head = Buffer.alloc(16)
|
|
40
|
+
fs.readSync(fd, head, 0, 16, 0)
|
|
41
|
+
const headerSize = head.readUInt32LE(4)
|
|
42
|
+
const jsonSize = head.readUInt32LE(12)
|
|
43
|
+
if (!jsonSize || jsonSize > 64 * 1024 * 1024) return null
|
|
44
|
+
const json = Buffer.alloc(jsonSize)
|
|
45
|
+
fs.readSync(fd, json, 0, jsonSize, 16)
|
|
46
|
+
const header = JSON.parse(json.toString("utf8"))
|
|
47
|
+
const entry = header?.files?.["package.json"]
|
|
48
|
+
if (!entry || typeof entry.offset !== "string") return null
|
|
49
|
+
const size = Number(entry.size ?? 0)
|
|
50
|
+
if (!Number.isFinite(size) || size <= 0 || size > 1024 * 1024) return null
|
|
51
|
+
const contentStart = 8 + headerSize
|
|
52
|
+
const data = Buffer.alloc(size)
|
|
53
|
+
fs.readSync(fd, data, 0, size, contentStart + Number(entry.offset))
|
|
54
|
+
const pkg = JSON.parse(data.toString("utf8"))
|
|
55
|
+
return typeof pkg.version === "string" ? pkg.version : null
|
|
56
|
+
} catch {
|
|
57
|
+
return null
|
|
58
|
+
} finally {
|
|
59
|
+
if (fd !== undefined) {
|
|
60
|
+
try {
|
|
61
|
+
fs.closeSync(fd)
|
|
62
|
+
} catch {}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* 探测本机是否装了 OpenCode Desktop,并读取它内置 core 的版本。
|
|
69
|
+
* Desktop 与 CLI 共用 ~/.config/opencode,所以插件/命令配置不需要第二份。
|
|
70
|
+
*/
|
|
71
|
+
export function detectDesktop({ platform = process.platform, env = process.env, home = os.homedir() } = {}) {
|
|
72
|
+
for (const exe of desktopCandidates(platform, env, home)) {
|
|
73
|
+
if (!fs.existsSync(exe)) continue
|
|
74
|
+
const version = readAsarVersion(asarPathFor(exe))
|
|
75
|
+
return { installed: true, exe, version, asar: asarPathFor(exe) }
|
|
76
|
+
}
|
|
77
|
+
return { installed: false, exe: null, version: null, asar: null }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export const DESKTOP_APP = APP_NAME
|
package/src/html.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from "node:fs"
|
|
2
2
|
import path from "node:path"
|
|
3
3
|
import { spawn } from "node:child_process"
|
|
4
|
+
import { planBudget } from "./plan.mjs"
|
|
4
5
|
|
|
5
6
|
function esc(value) {
|
|
6
7
|
return String(value ?? "").replace(/[&<>"']/g, (char) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[char])
|
|
@@ -13,6 +14,7 @@ export function renderHtml(audit, { title = "skills-doctor report", now = new Da
|
|
|
13
14
|
const summary = audit.summary
|
|
14
15
|
const counts = audit.counts
|
|
15
16
|
const budget = audit.budget
|
|
17
|
+
const plan = planBudget(audit)
|
|
16
18
|
const ratio = budget > 0 ? Math.min(1.5, summary.advertisedTokens / (budget * 2)) : 0
|
|
17
19
|
const barWidth = Math.min(100, Math.round(ratio * 100))
|
|
18
20
|
const barClass = summary.advertisedTokens > budget * 2 ? "over" : summary.advertisedTokens > budget ? "warn" : "ok"
|
|
@@ -89,6 +91,7 @@ export function renderHtml(audit, { title = "skills-doctor report", now = new Da
|
|
|
89
91
|
<div class="card"><div class="k">unique IDs</div><div class="v">${summary.unique}</div></div>
|
|
90
92
|
<div class="card"><div class="k">advertised</div><div class="v">${summary.advertised}</div></div>
|
|
91
93
|
<div class="card"><div class="k">description tokens / step</div><div class="v">${summary.advertisedTokens}</div></div>
|
|
94
|
+
<div class="card"><div class="k">potential savings / step</div><div class="v">${plan.recommended.savings}</div></div>
|
|
92
95
|
<div class="card error"><div class="k">errors</div><div class="v">${counts.errors}</div></div>
|
|
93
96
|
<div class="card warn"><div class="k">warnings</div><div class="v">${counts.warnings}</div></div>
|
|
94
97
|
<div class="card info"><div class="k">info</div><div class="v">${counts.infos}</div></div>
|
|
@@ -97,6 +100,7 @@ export function renderHtml(audit, { title = "skills-doctor report", now = new Da
|
|
|
97
100
|
<h2>Context budget</h2>
|
|
98
101
|
<div class="bar"><span class="${barClass}"></span></div>
|
|
99
102
|
<div class="sub">${summary.advertisedTokens} tokens vs budget ${budget}${summary.advertisedTokens > budget * 2 ? " (over 2x)" : summary.advertisedTokens > budget ? " (over)" : " (ok)"}</div>
|
|
103
|
+
<div class="sub">plan: trim ${plan.trim.count} + hide ${plan.recommended.hides.length} → ~${plan.recommended.final} tokens/step (saves ~${plan.recommended.savings}; ~${plan.recommended.stepsSavings} over ${plan.steps} steps)</div>
|
|
100
104
|
|
|
101
105
|
<h2>Findings (${audit.findings.length})</h2>
|
|
102
106
|
<div class="toolbar">
|
package/src/i18n.mjs
CHANGED
|
@@ -7,6 +7,7 @@ const dict = {
|
|
|
7
7
|
|
|
8
8
|
Usage:
|
|
9
9
|
skills-doctor check [options] check sources, conflicts and context budget (default)
|
|
10
|
+
skills-doctor plan [options] estimate how many tokens you can save, and how
|
|
10
11
|
skills-doctor scan [options] list every skill with its source and precedence
|
|
11
12
|
skills-doctor fix [options] archive shadowed definitions (safe structural fixes)
|
|
12
13
|
skills-doctor update [options] compare and download skills from HTTP catalogs
|
|
@@ -19,6 +20,8 @@ Options:
|
|
|
19
20
|
--root <dir> working directory (default: current directory)
|
|
20
21
|
--no-global skip ~/.claude, ~/.agents and ~/.config/opencode
|
|
21
22
|
--budget <n> tokens allowed for advertised descriptions (default 1200)
|
|
23
|
+
--desc-target <n> plan: target description length in chars (default 160)
|
|
24
|
+
--steps <n> plan: project savings over n model steps (default 100)
|
|
22
25
|
--stale-days <n> report skills not updated in n days (default: off)
|
|
23
26
|
--strict exit non-zero on warnings too
|
|
24
27
|
--json print JSON
|
|
@@ -33,6 +36,7 @@ Options:
|
|
|
33
36
|
--html [file] check: write a self-contained HTML report (default: skills-report.html)
|
|
34
37
|
--open check: open the HTML report after writing it
|
|
35
38
|
--config-dir <dir> install/uninstall: OpenCode config directory (default: ~/.config/opencode)
|
|
39
|
+
--client <name> install/uninstall: auto | opencode | desktop (default auto)
|
|
36
40
|
--no-npm install/uninstall: do not run npm (1.x)
|
|
37
41
|
--apply upgrade: run npm install -g opencode-skills-doctor@latest
|
|
38
42
|
--no-update-check skip the automatic update check for this run
|
|
@@ -56,6 +60,27 @@ Exit codes: 0 ok; 1 errors (and warnings with --strict); 2 bad arguments or runt
|
|
|
56
60
|
"check.summary": "{errors} error, {warnings} warn, {infos} info",
|
|
57
61
|
"check.strict": " (--strict: warnings also fail)",
|
|
58
62
|
"check.hints": "Hints:",
|
|
63
|
+
"check.planHint": " ! potential: about {tokens} tokens/step can be saved; run `skills-doctor plan` for the breakdown",
|
|
64
|
+
|
|
65
|
+
"plan.title": "skills-doctor plan",
|
|
66
|
+
"plan.current": " current: ~{tokens} tokens/step ({count} advertised skills)",
|
|
67
|
+
"plan.budget": " budget: {budget} gap: {gap}",
|
|
68
|
+
"plan.steps": " projection: {steps} model steps",
|
|
69
|
+
"plan.under": "Under budget — no changes needed.",
|
|
70
|
+
"plan.trimTitle": "Option A — trim descriptions to {chars} chars (stays discoverable)",
|
|
71
|
+
"plan.trimLine": " {count} skills, saves ~{savings} tokens/step → ~{final} tokens",
|
|
72
|
+
"plan.trimNone": " nothing to trim.",
|
|
73
|
+
"plan.trimItem": " - {id} (−{saving})",
|
|
74
|
+
"plan.hideTitle": "Option B — hide skills (autoinvoke: false, still loadable with @id)",
|
|
75
|
+
"plan.hideLine": " hide {count} skills, saves ~{savings} tokens/step → ~{final} tokens",
|
|
76
|
+
"plan.hideNone": " no hide needed.",
|
|
77
|
+
"plan.recTitle": "Recommended",
|
|
78
|
+
"plan.recLine": " trim {trims} + hide {hides} → ~{final} tokens/step (saves ~{savings})",
|
|
79
|
+
"plan.hideItem": " - hide {id} (−{tokens})",
|
|
80
|
+
"plan.hidesAll": " ! this would hide every skill; consider raising the budget instead.",
|
|
81
|
+
"plan.session": " over {steps} steps that saves ~{tokens} tokens",
|
|
82
|
+
"plan.more": " …and {count} more",
|
|
83
|
+
"plan.next": "Next: hide with `skills-doctor fix --hide <ids>`, or trim descriptions and keep discoverability.",
|
|
59
84
|
|
|
60
85
|
"scan.title": "skills-doctor {version} — scan",
|
|
61
86
|
"scan.workdir": " working dir: {dir}",
|
|
@@ -87,6 +112,8 @@ Exit codes: 0 ok; 1 errors (and warnings with --strict); 2 bad arguments or runt
|
|
|
87
112
|
"install.title": "OpenCode integration",
|
|
88
113
|
"install.version": " OpenCode: {version}",
|
|
89
114
|
"install.configDir": " config dir: {dir}",
|
|
115
|
+
"install.client": " client: {client}",
|
|
116
|
+
"install.desktop": " OpenCode Desktop: {version} ({exe})",
|
|
90
117
|
"install.dryRun": " (dry run: nothing is written)",
|
|
91
118
|
"install.error": " ✗ {error}",
|
|
92
119
|
"install.failed": "Integration failed.",
|
|
@@ -192,6 +219,7 @@ Exit codes: 0 ok; 1 errors (and warnings with --strict); 2 bad arguments or runt
|
|
|
192
219
|
|
|
193
220
|
用法:
|
|
194
221
|
skills-doctor check [选项] 检查来源、冲突与上下文预算(默认命令)
|
|
222
|
+
skills-doctor plan [选项] 估算能省多少 token、怎么省
|
|
195
223
|
skills-doctor scan [选项] 列出所有 skill 及其来源/优先级
|
|
196
224
|
skills-doctor fix [选项] 归档被遮蔽的定义(安全的结构性修复)
|
|
197
225
|
skills-doctor update [选项] 对比并拉取 HTTP catalog 里的 skill
|
|
@@ -204,6 +232,8 @@ Exit codes: 0 ok; 1 errors (and warnings with --strict); 2 bad arguments or runt
|
|
|
204
232
|
--root <dir> 以该目录为工作目录(默认当前目录)
|
|
205
233
|
--no-global 不扫描 ~/.claude、~/.agents、~/.config/opencode
|
|
206
234
|
--budget <n> 模型每步可看的 description token 预算(默认 1200)
|
|
235
|
+
--desc-target <n> plan: description 目标字符数(默认 160)
|
|
236
|
+
--steps <n> plan: 按 n 个模型步骤折算节省量(默认 100)
|
|
207
237
|
--stale-days <n> 超过 n 天未更新的 skill 给出提示(默认关闭)
|
|
208
238
|
--strict 有 warning 也返回非 0
|
|
209
239
|
--json 输出 JSON
|
|
@@ -218,6 +248,7 @@ Exit codes: 0 ok; 1 errors (and warnings with --strict); 2 bad arguments or runt
|
|
|
218
248
|
--html [file] check: 生成自包含 HTML 报告(默认 skills-report.html)
|
|
219
249
|
--open check: 写完报告后用浏览器打开
|
|
220
250
|
--config-dir <dir> install/uninstall: OpenCode 配置目录(默认 ~/.config/opencode)
|
|
251
|
+
--client <name> install/uninstall: auto | opencode | desktop(默认 auto)
|
|
221
252
|
--no-npm install/uninstall: 不执行 npm(1.x)
|
|
222
253
|
--apply upgrade: 执行 npm install -g opencode-skills-doctor@latest
|
|
223
254
|
--no-update-check 本次运行跳过自动更新检查
|
|
@@ -241,6 +272,27 @@ Exit codes: 0 ok; 1 errors (and warnings with --strict); 2 bad arguments or runt
|
|
|
241
272
|
"check.summary": "{errors} error, {warnings} warn, {infos} info",
|
|
242
273
|
"check.strict": "(--strict:warning 也会非 0 退出)",
|
|
243
274
|
"check.hints": "建议:",
|
|
275
|
+
"check.planHint": " ! 预计每步还能省约 {tokens} tokens;运行 `skills-doctor plan` 看具体方案",
|
|
276
|
+
|
|
277
|
+
"plan.title": "skills-doctor 省 token 方案",
|
|
278
|
+
"plan.current": " 当前: 约 {tokens} tokens/步({count} 个会展示的 skill)",
|
|
279
|
+
"plan.budget": " 预算: {budget} 差距: {gap}",
|
|
280
|
+
"plan.steps": " 折算: {steps} 个模型步骤",
|
|
281
|
+
"plan.under": "已在预算内,无需改动。",
|
|
282
|
+
"plan.trimTitle": "方案 A —— 精简 description 到 {chars} 字符(仍可被发现)",
|
|
283
|
+
"plan.trimLine": " {count} 个 skill,每步省约 {savings} tokens → 约 {final} tokens",
|
|
284
|
+
"plan.trimNone": " 没有需要精简的。",
|
|
285
|
+
"plan.trimItem": " - {id}(−{saving})",
|
|
286
|
+
"plan.hideTitle": "方案 B —— 隐藏 skill(autoinvoke: false,仍可 @id 加载)",
|
|
287
|
+
"plan.hideLine": " 隐藏 {count} 个,每步省约 {savings} tokens → 约 {final} tokens",
|
|
288
|
+
"plan.hideNone": " 无需隐藏。",
|
|
289
|
+
"plan.recTitle": "推荐方案",
|
|
290
|
+
"plan.recLine": " 精简 {trims} 个 + 隐藏 {hides} 个 → 约 {final} tokens/步(省约 {savings})",
|
|
291
|
+
"plan.hideItem": " - 隐藏 {id}(−{tokens})",
|
|
292
|
+
"plan.hidesAll": " ! 这样会把所有 skill 都隐藏;建议改为提高预算。",
|
|
293
|
+
"plan.session": " 按 {steps} 步计算,共省约 {tokens} tokens",
|
|
294
|
+
"plan.more": " …还有 {count} 个",
|
|
295
|
+
"plan.next": "下一步:用 `skills-doctor fix --hide <ids>` 隐藏,或精简 description 保留可发现性。",
|
|
244
296
|
|
|
245
297
|
"scan.title": "skills-doctor {version} — scan",
|
|
246
298
|
"scan.workdir": " 工作目录: {dir}",
|
|
@@ -272,6 +324,8 @@ Exit codes: 0 ok; 1 errors (and warnings with --strict); 2 bad arguments or runt
|
|
|
272
324
|
"install.title": "OpenCode 集成",
|
|
273
325
|
"install.version": " OpenCode: {version}",
|
|
274
326
|
"install.configDir": " 配置目录: {dir}",
|
|
327
|
+
"install.client": " 客户端: {client}",
|
|
328
|
+
"install.desktop": " OpenCode Desktop: {version}({exe})",
|
|
275
329
|
"install.dryRun": " (dry run:不会写入任何内容)",
|
|
276
330
|
"install.error": " ✗ {error}",
|
|
277
331
|
"install.failed": "集成失败。",
|
package/src/install.mjs
CHANGED
|
@@ -3,6 +3,7 @@ import path from "node:path"
|
|
|
3
3
|
import os from "node:os"
|
|
4
4
|
import { spawnSync, execFileSync } from "node:child_process"
|
|
5
5
|
import { stripJsonc } from "./discover.mjs"
|
|
6
|
+
import { detectDesktop } from "./desktop.mjs"
|
|
6
7
|
|
|
7
8
|
export const PACKAGE_NAME = "opencode-skills-doctor"
|
|
8
9
|
const PLUGIN_FILE = "skills-doctor.js"
|
|
@@ -106,7 +107,13 @@ function installPackage(dir) {
|
|
|
106
107
|
* - 2.x:写入 plugins 数组(OpenCode 自己从 npm 拉包)
|
|
107
108
|
* - 1.x:在配置目录安装包、写本地插件入口、写 /skills-doctor 命令、补插件数组
|
|
108
109
|
*/
|
|
109
|
-
export function installOpenCode({
|
|
110
|
+
export function installOpenCode({
|
|
111
|
+
configDir = defaultConfigDir(),
|
|
112
|
+
version = null,
|
|
113
|
+
dryRun = false,
|
|
114
|
+
installPackage: withPackage = true,
|
|
115
|
+
client = "auto",
|
|
116
|
+
} = {}) {
|
|
110
117
|
const dir = path.resolve(configDir)
|
|
111
118
|
const actions = []
|
|
112
119
|
const errors = []
|
|
@@ -125,14 +132,31 @@ export function installOpenCode({ configDir = defaultConfigDir(), version = null
|
|
|
125
132
|
}
|
|
126
133
|
}
|
|
127
134
|
|
|
128
|
-
const
|
|
135
|
+
const desktop = client === "opencode" ? { installed: false, exe: null, version: null } : detectDesktop()
|
|
136
|
+
if (client === "desktop" && !desktop.installed) {
|
|
137
|
+
errors.push("OpenCode Desktop not found in the standard install locations")
|
|
138
|
+
act("install OpenCode Desktop first (https://opencode.ai/download), or use --client auto")
|
|
139
|
+
return { version: null, major: null, configDir: dir, configFile: cfgFile, actions, errors, dryRun, client: { mode: client, desktop: null } }
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const resolvedVersion = version ?? detectVersion() ?? desktop.version
|
|
129
143
|
const inferredMajor = config.plugin !== undefined ? 1 : 2
|
|
130
144
|
const major = resolvedVersion ? Number(resolvedVersion.split(".")[0]) : inferredMajor
|
|
145
|
+
const clientInfo = {
|
|
146
|
+
mode: client,
|
|
147
|
+
desktop: desktop.installed ? { exe: desktop.exe, version: desktop.version } : null,
|
|
148
|
+
}
|
|
149
|
+
if (desktop.installed) act(`OpenCode Desktop ${desktop.version ?? "unknown"} detected at ${desktop.exe}`)
|
|
150
|
+
if (desktop.installed) act("desktop and CLI share the same config directory")
|
|
131
151
|
|
|
132
152
|
if (dryRun) dry(`config dir: ${dir}`)
|
|
133
153
|
if (!fs.existsSync(dir)) {
|
|
134
|
-
if (!dryRun)
|
|
135
|
-
|
|
154
|
+
if (!dryRun) {
|
|
155
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
156
|
+
act(`created config dir ${dir}`)
|
|
157
|
+
} else {
|
|
158
|
+
dry(`create config dir ${dir}`)
|
|
159
|
+
}
|
|
136
160
|
}
|
|
137
161
|
|
|
138
162
|
const stamp = new Date().toISOString().replace(/[:.]/g, "-")
|
|
@@ -151,7 +175,7 @@ export function installOpenCode({ configDir = defaultConfigDir(), version = null
|
|
|
151
175
|
act(`plugins already contains "${PACKAGE_NAME}"`)
|
|
152
176
|
}
|
|
153
177
|
act("restart OpenCode; the /skills-doctor command and skills_doctor tool will be available")
|
|
154
|
-
return { version: resolvedVersion, major, configDir: dir, configFile: cfgFile, actions, errors, dryRun }
|
|
178
|
+
return { version: resolvedVersion, major, configDir: dir, configFile: cfgFile, actions, errors, dryRun, client: clientInfo }
|
|
155
179
|
}
|
|
156
180
|
|
|
157
181
|
// 1.x
|
|
@@ -226,7 +250,7 @@ export function installOpenCode({ configDir = defaultConfigDir(), version = null
|
|
|
226
250
|
}
|
|
227
251
|
|
|
228
252
|
act("restart OpenCode; the skills_doctor tool and /skills-doctor command will be available")
|
|
229
|
-
return { version: resolvedVersion, major, configDir: dir, configFile: cfgFile, actions, errors, dryRun }
|
|
253
|
+
return { version: resolvedVersion, major, configDir: dir, configFile: cfgFile, actions, errors, dryRun, client: clientInfo }
|
|
230
254
|
}
|
|
231
255
|
|
|
232
256
|
function removePackage(dir) {
|
|
@@ -243,7 +267,13 @@ function removePackage(dir) {
|
|
|
243
267
|
* 撤销 install:移除配置里的插件条目、删除安装器生成的文件、可选卸载 npm 包。
|
|
244
268
|
* 只删除「由安装器生成」的文件,用户自己改过的一律保留并提示。
|
|
245
269
|
*/
|
|
246
|
-
export function uninstallOpenCode({
|
|
270
|
+
export function uninstallOpenCode({
|
|
271
|
+
configDir = defaultConfigDir(),
|
|
272
|
+
version = null,
|
|
273
|
+
dryRun = false,
|
|
274
|
+
removePackage: withPackage = true,
|
|
275
|
+
client = "auto",
|
|
276
|
+
} = {}) {
|
|
247
277
|
const dir = path.resolve(configDir)
|
|
248
278
|
const actions = []
|
|
249
279
|
const errors = []
|
|
@@ -262,7 +292,13 @@ export function uninstallOpenCode({ configDir = defaultConfigDir(), version = nu
|
|
|
262
292
|
}
|
|
263
293
|
}
|
|
264
294
|
|
|
265
|
-
const
|
|
295
|
+
const desktop = client === "opencode" ? { installed: false, exe: null, version: null } : detectDesktop()
|
|
296
|
+
const clientInfo = {
|
|
297
|
+
mode: client,
|
|
298
|
+
desktop: desktop.installed ? { exe: desktop.exe, version: desktop.version } : null,
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const resolvedVersion = version ?? detectVersion() ?? desktop.version
|
|
266
302
|
const inferredMajor = config.plugin !== undefined ? 1 : 2
|
|
267
303
|
const major = resolvedVersion ? Number(resolvedVersion.split(".")[0]) : inferredMajor
|
|
268
304
|
const stamp = new Date().toISOString().replace(/[:.]/g, "-")
|
|
@@ -280,7 +316,7 @@ export function uninstallOpenCode({ configDir = defaultConfigDir(), version = nu
|
|
|
280
316
|
act(`"${PACKAGE_NAME}" is not configured in plugins`)
|
|
281
317
|
}
|
|
282
318
|
act("restart OpenCode to finish removal")
|
|
283
|
-
return { version: resolvedVersion, major, configDir: dir, configFile: cfgFile, actions, errors, dryRun }
|
|
319
|
+
return { version: resolvedVersion, major, configDir: dir, configFile: cfgFile, actions, errors, dryRun, client: clientInfo }
|
|
284
320
|
}
|
|
285
321
|
|
|
286
322
|
const pluginFile = path.join(dir, "plugins", PLUGIN_FILE)
|
|
@@ -338,5 +374,5 @@ export function uninstallOpenCode({ configDir = defaultConfigDir(), version = nu
|
|
|
338
374
|
}
|
|
339
375
|
|
|
340
376
|
act("restart OpenCode to finish removal")
|
|
341
|
-
return { version: resolvedVersion, major, configDir: dir, configFile: cfgFile, actions, errors, dryRun }
|
|
377
|
+
return { version: resolvedVersion, major, configDir: dir, configFile: cfgFile, actions, errors, dryRun, client: clientInfo }
|
|
342
378
|
}
|
package/src/plan.mjs
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { estimateTokens } from "./parse.mjs"
|
|
2
|
+
import { resolveWinners } from "./lint.mjs"
|
|
3
|
+
|
|
4
|
+
export const DEFAULT_DESC_TARGET = 160
|
|
5
|
+
export const DEFAULT_STEPS = 100
|
|
6
|
+
|
|
7
|
+
function costOf(skill, description = skill.description) {
|
|
8
|
+
return estimateTokens(`${skill.id} ${skill.displayName} ${description}`)
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* 把 description 截到目标长度,优先在句子边界断开(中英文标点都算)。
|
|
13
|
+
* 纯函数:相同输入永远得到相同输出,便于在报告里预览「如果这么改会省多少」。
|
|
14
|
+
*/
|
|
15
|
+
export function trimDescription(text, limit = DEFAULT_DESC_TARGET) {
|
|
16
|
+
const value = String(text ?? "")
|
|
17
|
+
if (value.length <= limit) return value
|
|
18
|
+
const slice = value.slice(0, limit)
|
|
19
|
+
const cut = Math.max(
|
|
20
|
+
slice.lastIndexOf("。"),
|
|
21
|
+
slice.lastIndexOf("."),
|
|
22
|
+
slice.lastIndexOf("!"),
|
|
23
|
+
slice.lastIndexOf("?"),
|
|
24
|
+
slice.lastIndexOf(";"),
|
|
25
|
+
slice.lastIndexOf(";"),
|
|
26
|
+
slice.lastIndexOf("\n"),
|
|
27
|
+
)
|
|
28
|
+
const trimmed = cut >= limit * 0.5 ? value.slice(0, cut + 1).trim() : slice.trim()
|
|
29
|
+
return trimmed || slice.trim()
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** 在候选中选「数量最少」的一组,使节省量 ≥ gap;按单项节省降序取前缀即为最优。 */
|
|
33
|
+
function pickMinimal(items, gap) {
|
|
34
|
+
if (gap <= 0) return { count: 0, savings: 0, items: [], enough: true }
|
|
35
|
+
const sorted = [...items].sort((a, b) => b.tokens - a.tokens || a.id.localeCompare(b.id))
|
|
36
|
+
const picked = []
|
|
37
|
+
let savings = 0
|
|
38
|
+
for (const item of sorted) {
|
|
39
|
+
if (savings >= gap) break
|
|
40
|
+
picked.push(item)
|
|
41
|
+
savings += item.tokens
|
|
42
|
+
}
|
|
43
|
+
return { count: picked.length, savings, items: picked, enough: savings >= gap }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* 预算规划:当前每步消耗多少、精简到多少、隐藏多少、最终能省多少。
|
|
48
|
+
*
|
|
49
|
+
* 规则:
|
|
50
|
+
* - 只有会被「展示给模型」的定义才有成本;被遮蔽的重复定义省不出 token(诚实很重要)。
|
|
51
|
+
* - 精简 description 不改变可发现性;隐藏(autoinvoke: false)省掉整条成本,但仍可用 @id 加载。
|
|
52
|
+
* - 推荐方案先精简所有超长描述,再按「单项节省最大」贪心隐藏,直到进入预算。
|
|
53
|
+
* 被隐藏的技能若也被精简过,其节省按原始成本计,不会重复计算。
|
|
54
|
+
*/
|
|
55
|
+
export function planBudget(
|
|
56
|
+
audit,
|
|
57
|
+
{ budget = audit.budget, descTarget = DEFAULT_DESC_TARGET, steps = DEFAULT_STEPS } = {},
|
|
58
|
+
) {
|
|
59
|
+
const target = Number.isFinite(budget) && budget > 0 ? budget : audit.budget
|
|
60
|
+
const winners = [...resolveWinners(audit.skills).values()]
|
|
61
|
+
const advertised = winners.filter((skill) => skill.advertised)
|
|
62
|
+
|
|
63
|
+
const current = advertised.reduce((sum, skill) => sum + costOf(skill), 0)
|
|
64
|
+
const gap = Math.max(0, current - target)
|
|
65
|
+
|
|
66
|
+
// 1) 精简:所有 description 超过目标的,按句子边界截断
|
|
67
|
+
const trimMap = new Map()
|
|
68
|
+
const trims = []
|
|
69
|
+
for (const skill of advertised) {
|
|
70
|
+
if (skill.description.length <= descTarget) continue
|
|
71
|
+
const preview = trimDescription(skill.description, descTarget)
|
|
72
|
+
const before = costOf(skill)
|
|
73
|
+
const after = costOf(skill, preview)
|
|
74
|
+
const saving = Math.max(0, before - after)
|
|
75
|
+
if (!saving) continue
|
|
76
|
+
trimMap.set(skill.id, { preview, cost: after })
|
|
77
|
+
trims.push({
|
|
78
|
+
id: skill.id,
|
|
79
|
+
file: skill.file,
|
|
80
|
+
before,
|
|
81
|
+
after,
|
|
82
|
+
saving,
|
|
83
|
+
preview,
|
|
84
|
+
})
|
|
85
|
+
}
|
|
86
|
+
trims.sort((a, b) => b.saving - a.saving || a.id.localeCompare(b.id))
|
|
87
|
+
const trimSavings = trims.reduce((sum, item) => sum + item.saving, 0)
|
|
88
|
+
|
|
89
|
+
// 2) 只隐藏(不精简)的方案
|
|
90
|
+
const hideCandidates = advertised.map((skill) => ({
|
|
91
|
+
id: skill.id,
|
|
92
|
+
file: skill.file,
|
|
93
|
+
tokens: costOf(skill),
|
|
94
|
+
}))
|
|
95
|
+
const hideOnly = pickMinimal(hideCandidates, gap)
|
|
96
|
+
|
|
97
|
+
// 3) 推荐方案:先精简,再在「精简后的成本」上隐藏
|
|
98
|
+
const afterTrim = advertised.map((skill) => ({
|
|
99
|
+
id: skill.id,
|
|
100
|
+
file: skill.file,
|
|
101
|
+
tokens: trimMap.get(skill.id)?.cost ?? costOf(skill),
|
|
102
|
+
originalTokens: costOf(skill),
|
|
103
|
+
}))
|
|
104
|
+
const postTrimTotal = afterTrim.reduce((sum, item) => sum + item.tokens, 0)
|
|
105
|
+
const recommendedHides = pickMinimal(afterTrim, Math.max(0, postTrimTotal - target))
|
|
106
|
+
const hideSavings = recommendedHides.savings
|
|
107
|
+
const savings = trimSavings + hideSavings
|
|
108
|
+
const final = Math.max(0, current - savings)
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
cwd: audit.cwd,
|
|
112
|
+
budget: target,
|
|
113
|
+
descTarget,
|
|
114
|
+
steps,
|
|
115
|
+
current,
|
|
116
|
+
gap,
|
|
117
|
+
advertised: advertised.length,
|
|
118
|
+
trim: {
|
|
119
|
+
count: trims.length,
|
|
120
|
+
savings: trimSavings,
|
|
121
|
+
items: trims,
|
|
122
|
+
},
|
|
123
|
+
hideOnly: {
|
|
124
|
+
count: hideOnly.count,
|
|
125
|
+
savings: hideOnly.savings,
|
|
126
|
+
final: Math.max(0, current - hideOnly.savings),
|
|
127
|
+
enough: hideOnly.enough,
|
|
128
|
+
items: hideOnly.items,
|
|
129
|
+
},
|
|
130
|
+
recommended: {
|
|
131
|
+
trims: trims.map((item) => item.id),
|
|
132
|
+
hides: recommendedHides.items,
|
|
133
|
+
trimSavings,
|
|
134
|
+
hideSavings,
|
|
135
|
+
savings,
|
|
136
|
+
final,
|
|
137
|
+
reachable: final <= target,
|
|
138
|
+
stepsSavings: savings * steps,
|
|
139
|
+
hidesEverything: advertised.length > 0 && recommendedHides.count === advertised.length,
|
|
140
|
+
},
|
|
141
|
+
}
|
|
142
|
+
}
|