diff --git a/CHANGELOG.md b/CHANGELOG.md index b595ade..6901860 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,55 @@ All notable changes to SkillHub will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.5.0] - 2026-08-26 + +A second adversarial review, this time aimed at the health report itself. The +dominant defect was a different shape: spending the reader's attention on things +that were not wrong, and offering buttons for work that could not be done. + +### Changed + +- `undo` now names the size of what it is about to reverse and waits for `--yes` + when a backup session covers more than one write. A run of `describe`, + `categorize` or `note` inside 30 minutes shares one session, so a single + `undo` could take back dozens of hand-written blurbs — and when that batch + also created `overrides.json`, reversing it removed the file with no copy to + restore and no redo. The count used to be printed after the fact. +- The published package no longer ships the local design workspace. `files` + names `web/index.html` instead of all of `web/`, taking the tarball from + 11.2 MB unpacked back to 315 kB. + +### Fixed + +- Underscored directory names are no longer reported. `my_diary` loads and + triggers exactly like `my-diary`, and calling it broken spent the report's + highest severity on Skills that work. +- The trash lists its contents when one entry is a dangling link. `statSync` + followed the link, threw, and emptied the whole listing — telling the user + their trash was empty while their uninstalled Skill sat in it. +- A path carrying somebody else's username reads as what it is. These arrive + inside third-party Skills, cannot usefully be edited locally, and now sit in + the background notes instead of the list of decisions to make. +- Backup copies (`.bak`, `.orig`, `.old`, `~`) are no longer scanned for + hardcoded paths. No Agent loads them, so a finding there asked the user to fix + a file nobody reads. Credential scanning still covers them. +- Findings that nothing can act on no longer claim to be fixable. Redundant + Codex links and empty SSOT directories are advisory, and the recommendation + now gives the command to run. +- A prerelease is superseded by its own release. `0.4.0-beta.1` compared equal + to `0.4.0`, so anyone on a prerelease was told they were current on the day + the stable version shipped. +- A leaderboard missing its seed repositories expires in an hour instead of a + week, so one rate-limited moment no longer leaves the list short for days. + +### Documentation + +- Both READMEs list every mutating command, say that the dashboard runs in the + foreground and stops with Ctrl-C, and state plainly that the read-only-looking + commands still create directories, refresh a cache, and check npm once a day. +- `skill/SKILL.md` carries the full command list, including the seven mutating + commands an agent previously had no way to learn, and the `undo` batching trap. + ## [0.4.0] - 2026-08-25 An adversarial pre-release review of the whole project. The dominant defect was diff --git a/README.md b/README.md index a347551..6fd9c97 100644 --- a/README.md +++ b/README.md @@ -80,12 +80,36 @@ None of this edits the contents of a Skill. Writes are limited to links and to S The dashboard and the CLI work on their own: ```bash -skillhub open # local dashboard +skillhub open # local dashboard — runs in the foreground, Ctrl-C to stop skillhub scan # what is installed skillhub pending # what still needs a description or a category skillhub doctor # health report ``` +Changing things: + +```bash +skillhub link # let one agent read one Skill +skillhub unlink # stop it reading that Skill +skillhub describe "..." # write the Chinese blurb +skillhub categorize "..." # set the category +skillhub note "..." # write a private note +skillhub update # git pull --ff-only for that Skill +skillhub remove --yes # move it to ~/.agents/_trash/ +skillhub trash # list the trash +skillhub trash restore # put one back +skillhub backups # list backup sessions +skillhub undo # reverse the last one +``` + +Every command above records a backup session first, so `skillhub undo` can take it +back. A run of `describe`/`categorize`/`note` inside 30 minutes shares one session: +`undo` then covers the whole batch and asks for `--yes` before doing it. + +`scan`, `pending`, `doctor` and a bare `sync` never touch a Skill, but they are not +pure reads either — they create `~/.agents/skills/` and `~/.skillhub/` if missing, +refresh the local registry cache, and check npm once a day for a newer SkillHub. + ## Running from source ```bash diff --git a/README.zh-CN.md b/README.zh-CN.md index 443940f..b05ca89 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -80,12 +80,36 @@ New-Item -ItemType Junction -Path "$env:USERPROFILE\.claude\skills\skillhub" -Ta 面板和命令行本身就能独立工作: ```bash -skillhub open # 打开本地面板 +skillhub open # 打开本地面板(前台运行,Ctrl-C 停止) skillhub scan # 看装了什么 skillhub pending # 看还有哪些缺中文介绍或没分类 skillhub doctor # 出一份体检报告 ``` +会改东西的命令: + +```bash +skillhub link <名字> # 让某个 agent 能读某个 Skill +skillhub unlink <名字> # 取消 +skillhub describe <名字> "..." # 写中文介绍 +skillhub categorize <名字> "..." # 归类 +skillhub note <名字> "..." # 写私人备注 +skillhub update <名字> # 对该 Skill 执行 git pull --ff-only +skillhub remove <名字> --yes # 移进 ~/.agents/_trash/ +skillhub trash # 看回收站 +skillhub trash restore <条目> # 还原一条 +skillhub backups # 看备份会话 +skillhub undo # 撤销上一次 +``` + +上面每条命令都会先记一次备份,`skillhub undo` 能退回去。注意:30 分钟内连续执行的 +`describe`/`categorize`/`note` 会合并成同一个备份会话,`undo` 一次就是整批一起退, +所以它会先报数量并要求你加 `--yes`。 + +`scan`、`pending`、`doctor` 和不带参数的 `sync` 不碰任何 Skill,但也不是纯只读:它们会 +在缺失时创建 `~/.agents/skills/` 和 `~/.skillhub/`、刷新本地清单缓存,并且每天向 npm +查一次有没有新版 SkillHub。 + ## 从源码运行 ```bash diff --git a/package-lock.json b/package-lock.json index 5fb0008..7a12fbe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,21 +1,24 @@ { "name": "@wsiwsii/skillhub", - "version": "0.4.0", + "version": "0.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@wsiwsii/skillhub", - "version": "0.4.0", + "version": "0.5.0", "license": "MIT", "dependencies": { "@hono/node-server": "^1.13.0", "hono": "^4.6.0" }, "bin": { - "skill-hub": "bin/skillhub", "skillhub": "bin/skillhub" }, + "devDependencies": { + "thinking-orbs": "^0.3.1", + "three": "^0.185.1" + }, "engines": { "node": ">=20" } @@ -40,6 +43,34 @@ "engines": { "node": ">=16.9.0" } + }, + "node_modules/react": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/thinking-orbs": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/thinking-orbs/-/thinking-orbs-0.3.1.tgz", + "integrity": "sha512-3BG1aeB1RUTxItCml/BBuIz5JRM4kZqGuyx+vouv0fXTtcR9ZNoKjWGneHPx94y74GxgArwJZ1qbJR5dt54kSw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "react": ">=18.0.0" + } + }, + "node_modules/three": { + "version": "0.185.1", + "resolved": "https://registry.npmjs.org/three/-/three-0.185.1.tgz", + "integrity": "sha512-5aojFCXKwnjBRZvUnt3WFfEcvUJgkN5LlijRFN95hMy8WVkG4I0QNcJE+OuWvuJ0bOdStrbfXn0pkd6/QyiAlg==", + "dev": true, + "license": "MIT" } } } diff --git a/package.json b/package.json index bec4dd7..9c368ea 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@wsiwsii/skillhub", - "version": "0.4.0", + "version": "0.5.0", "description": "Local-first, portable manager and health doctor for AI Agent Skills", "type": "module", "bin": { @@ -32,7 +32,7 @@ "server/", "skill/", "src/", - "web/", + "web/index.html", "LICENSE", "CHANGELOG.md", "README.md", @@ -52,5 +52,9 @@ "dependencies": { "@hono/node-server": "^1.13.0", "hono": "^4.6.0" + }, + "devDependencies": { + "thinking-orbs": "^0.3.1", + "three": "^0.185.1" } } diff --git a/server/hot.mjs b/server/hot.mjs index a354d64..7c2150b 100644 --- a/server/hot.mjs +++ b/server/hot.mjs @@ -5,6 +5,10 @@ import { classifySkill } from "../src/core/classify.mjs"; const TTL_MS = 7 * 24 * 3600 * 1000; // 1 week +// 一份缺了官方仓库的榜单不值得存一周。seed 抓不到几乎总是一次性的限流,一小时后 +// 重来就好;按整周缓存等于让一次限流把榜单残废七天。 +const PARTIAL_TTL_MS = 3600 * 1000; + const SEED_REPOS = [ "anthropics/skills", "openai/skills", @@ -101,11 +105,13 @@ export async function fetchHot(customHome = null) { console.error("Rising search failed:", e.message); } + let missingSeeds = 0; for (const full of SEED_REPOS) { if (byRepo.has(full)) continue; try { byRepo.set(full, await gh("/repos/" + full)); } catch (e) { + missingSeeds += 1; console.error("Seed fetch failed:", full, e.message); } } @@ -137,6 +143,7 @@ export async function fetchHot(customHome = null) { const data = { fetchedAt: new Date().toISOString(), totalScanned: byRepo.size, + partial: missingSeeds > 0, categories, }; @@ -152,7 +159,7 @@ export async function getHot({ force = false, customHome = null } = {}) { if (!force && existsSync(cacheFile)) { try { const cached = JSON.parse(readFileSync(cacheFile, "utf-8")); - if (Date.now() - new Date(cached.fetchedAt).getTime() < TTL_MS) { + if (Date.now() - new Date(cached.fetchedAt).getTime() < (cached.partial ? PARTIAL_TTL_MS : TTL_MS)) { let registry = { skills: {} }; if (existsSync(paths.REGISTRY_FILE)) { try { diff --git a/server/server.mjs b/server/server.mjs index 2b9fc38..bd933bc 100644 --- a/server/server.mjs +++ b/server/server.mjs @@ -61,15 +61,18 @@ export function createApp(customHome = null) { return { ...registry, agentMeta }; } - // The page is entirely self-contained — no CDN, no remote font, no external - // image — so it can be locked down completely. A good part of what it renders - // comes out of third-party SKILL.md files, and until now escapeHtml in the - // browser was the only thing standing between that text and the DOM. + // The shipped page is entirely self-contained — no CDN, no remote font, no + // external image — so it can be locked down completely. A good part of what it + // renders comes out of third-party SKILL.md files, and escapeHtml in the + // browser is the only other thing standing between that text and the DOM. app.use("/*", async (c, next) => { await next(); + // /preview is the local design workspace; it is not part of the published + // package, so this branch never fires for an installed copy. + const isPreview = c.req.path.startsWith("/preview"); c.header( "Content-Security-Policy", - "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'" + `default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors ${isPreview ? "'self'" : "'none'"}` ); c.header("X-Content-Type-Options", "nosniff"); c.header("Referrer-Policy", "no-referrer"); diff --git a/skill/SKILL.md b/skill/SKILL.md index d5d25ce..b3b83df 100644 --- a/skill/SKILL.md +++ b/skill/SKILL.md @@ -16,6 +16,31 @@ description: >- - **卸载不是删除**,东西进 `~/.agents/_trash/`,随时能恢复。 - 每次写操作都留备份,`skillhub undo` 回滚上一次。 +## 命令清单 + +只读:`scan`(看装了什么,加 `--compact` 给出精简 JSON)、`pending`(缺中文介绍或分类的)、`doctor`(体检,加 `--all` 连上游管理的一起列)、`agents`(列出各 agent)、`sync`(只看计划)、`backups`、`trash`、`check-update`。 + +会改东西: + +```bash +skillhub link <名字> # 建立软链,让该 agent 能读 +skillhub unlink <名字> # 移除软链 +skillhub describe <名字> "中文介绍" +skillhub categorize <名字> "分类名" +skillhub note <名字> "备注" # 传空串即清空 +skillhub update <名字> # git pull --ff-only +skillhub remove <名字> --yes # 移进回收站,必须带 --yes +skillhub trash restore <条目名> # 从回收站还原 +skillhub sync --apply # 只补缺失的链接 +skillhub sync --fix-broken # 只清理坏链接 +skillhub undo # 撤销上一次;批量写入时要加 --yes +skillhub agents on|off # 启用或隐藏一个 agent +``` + +所有命令都支持 `--json`,失败时也返回 JSON 并以退出码 1 结束。 + +**关于 `undo` 的一个坑**:30 分钟内连续执行的 `describe`/`categorize`/`note` 会合并成同一个备份会话。这时 `undo` 一次退的是整批,不是最后一条,而且中文介绍无法再恢复。所以它会先报出数量并要求 `--yes`——看到这个提示要先跟用户确认,不要直接补 `--yes` 重跑。 + ## 执行流程 ### Step 0 确保命令可用 diff --git a/src/cli.mjs b/src/cli.mjs index bb945c1..3dd0f0b 100644 --- a/src/cli.mjs +++ b/src/cli.mjs @@ -5,7 +5,7 @@ import { getPaths, getAgentDirs, isAgentVisible, ROOT_DIR } from "./core/paths.m import { buildRegistry, loadUserOverrides } from "./core/registry.mjs"; import { buildSyncPlan, applySyncPlan } from "./core/sync.mjs"; import { runDoctor, isDefaultReportItem } from "./core/doctor/index.mjs"; -import { undoLastBackup, listBackups } from "./core/backup.mjs"; +import { undoLastBackup, listBackups, peekLastBackup } from "./core/backup.mjs"; import { toggleAgent, removeSkill, updateSkill, setMetadataOverride, setAgentVisibility, listTrash, restoreTrash, @@ -331,6 +331,26 @@ async function main() { } case "undo": { + // A run of writes shares one session, so "undo the last thing" can mean + // taking back eighty-four blurbs. Worse, when that batch also created + // overrides.json, reversing it removes the file and there is no copy to + // put back and no redo. Say the number before doing it, not after. + const next = peekLastBackup(paths.BACKUPS_DIR); + const batched = next?.manifest.batchedWrites || 1; + if (batched > 1 && !args.includes("--yes")) { + const lines = [ + `这次撤销会一起回退 ${batched} 次写入(备份会话 ${next.id}),不只是最后一次。`, + `它们会回到这一批开始之前的状态,手写的中文介绍和备注无法再恢复。`, + `确认请加 --yes: skillhub undo --yes`, + ]; + if (jsonMode) { + console.log(JSON.stringify({ ok: false, needsConfirmation: true, batchedWrites: batched, sessionId: next.id }, null, 2)); + } else { + for (const line of lines) console.error(line); + } + process.exitCode = 1; + return; + } const result = undoLastBackup(paths.BACKUPS_DIR); if (jsonMode) { console.log(JSON.stringify(result, null, 2)); diff --git a/src/core/backup.mjs b/src/core/backup.mjs index d44b989..e87841e 100644 --- a/src/core/backup.mjs +++ b/src/core/backup.mjs @@ -403,11 +403,24 @@ export function undoLastBackup(backupsDir) { return withOverridesLock(overridesFile, () => undoLastBackupLocked(backupsDir)); } -function undoLastBackupLocked(backupsDir) { - const backups = listBackups(backupsDir); - const target = backups.find((backup) => - !backup.manifest.undoneAt && backup.manifest.operations.length > 0 +/** + * The session `undo` would reverse next, without reversing it. Callers that + * need to warn before acting — a batch of writes shares one session, so one + * undo can take back dozens of hand-written blurbs — ask here first. + * + * Shared with undoLastBackupLocked so "which session is next" has one answer. + */ +export function peekLastBackup(backupsDir) { + if (!existsSync(backupsDir)) return null; + return ( + listBackups(backupsDir).find( + (backup) => !backup.manifest.undoneAt && backup.manifest.operations.length > 0 + ) || null ); +} + +function undoLastBackupLocked(backupsDir) { + const target = peekLastBackup(backupsDir); if (!target) { // Not a failure: a fresh install has nothing recorded yet, and calling that diff --git a/src/core/doctor/index.mjs b/src/core/doctor/index.mjs index 370a44b..3b60d8f 100644 --- a/src/core/doctor/index.mjs +++ b/src/core/doctor/index.mjs @@ -5,6 +5,7 @@ import { lstatSync, realpathSync, } from "node:fs"; +import { homedir } from "node:os"; import { join, relative } from "node:path"; import { getPaths, getAgentDirs } from "../paths.mjs"; import { buildSyncPlan } from "../sync.mjs"; @@ -38,6 +39,21 @@ function isPlaceholderHome(sample) { return PLACEHOLDER_HOME_NAMES.has(leaf.toLowerCase()); } +// 这台机器属于谁。带着别人用户名的路径是随第三方 Skill 一起装进来的:不归用户改, +// 改了下次更新还会被覆盖。它仍然值得看见,但不该占用「需要你判断」那份清单。 +// 用系统家目录而不是 SKILL_HUB_HOME——问的是「这是不是我的名字」,跟测试用的临时 +// 家目录无关。 +const OWN_HOME_LEAF = (homedir().split(/[/\\]/).filter(Boolean).pop() || "").toLowerCase(); + +function isForeignHome(sample) { + const leaf = (sample.split(/[/\\]/).filter(Boolean).pop() || "").toLowerCase(); + return Boolean(OWN_HOME_LEAF) && leaf !== OWN_HOME_LEAF; +} + +// `.bak` / `.orig` / `foo~` 这类副本没有任何 Agent 会加载。报它里面的路径,等于让 +// 用户去改一个根本没人读的文件。凭据扫描不走这条豁免——磁盘上的 key 仍然是 key。 +const BACKUP_FILE_RE = /(?:\.bak[^/\\]*|\.orig|\.old|~)$/i; + function isEnvTemplate(fileName) { return ENV_TEMPLATE_SUFFIXES.some((suffix) => fileName.endsWith(suffix)); } @@ -151,8 +167,8 @@ function scanSkillDirectory(dirPath) { } const match = content.match(/(\/Users\/[a-zA-Z0-9._-]+|\/home\/[a-zA-Z0-9._-]+|[A-Z]:\\Users\\[a-zA-Z0-9._-]+)/); - if (match && !isPlaceholderHome(match[1])) { - hardcodedHomes.push({ file: relPath, sample: match[1] }); + if (match && !isPlaceholderHome(match[1]) && !BACKUP_FILE_RE.test(entry.name)) { + hardcodedHomes.push({ file: relPath, sample: match[1], foreign: isForeignHome(match[1]) }); } if (relPath === "SKILL.md") brokenLinks.push(...findBrokenInternalLinks(dirPath, content)); @@ -195,10 +211,16 @@ export function isDefaultReportItem(issue) { * normal thing to do, and reading that as "everything here is upstream" hid * every finding in the whole library behind the `--all` flag. */ -// Agent Skills names are lowercase words joined by hyphens. A directory that -// breaks the shape still gets scanned and linked, and still produces a trigger -// word — one the user may not be able to type, as with a space in it. -const VALID_SKILL_NAME = /^[a-z0-9]+(-[a-z0-9]+)*$/; +// Agent Skills names are lowercase words joined by hyphens or underscores. A +// directory that breaks the shape still gets scanned and linked, and still +// produces a trigger word — one the user may not be able to type, as with a +// space in it. +// +// Underscores are here because they work. Requiring hyphens flagged every +// underscore-named Skill with the report's highest severity while those Skills +// loaded and triggered perfectly well, which is the one thing a health check +// must never do: spend Tier A on something that is not broken. +const VALID_SKILL_NAME = /^[a-z0-9]+([-_][a-z0-9]+)*$/; function isThirdParty(s) { if (!s) return false; @@ -285,8 +307,8 @@ function inspectSkill(name, s, issues, location = "") { skill: name, path: s.path, title: "目录名不符合 Skill 命名规范", - reason: `"${name}" 不是小写字母加连字符的形式,触发词会变成 "/${name}",部分 agent 加载不了,含空格时斜杠命令也敲不出来`, - recommendation: `把目录改名为小写连字符形式,例如 "${name.toLowerCase().replace(/[_\s]+/g, "-").replace(/[^a-z0-9-]/g, "") || "my-skill"}"`, + reason: `"${name}" 不是小写字母加连字符或下划线的形式,触发词会变成 "/${name}",部分 agent 加载不了,含空格时斜杠命令也敲不出来`, + recommendation: `把目录改名为小写形式,例如 "${name.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9_-]/g, "") || "my-skill"}"`, fixable: false, }); } @@ -358,9 +380,15 @@ function inspectSkill(name, s, issues, location = "") { skill: name, path: join(s.path, hc.file), title: `硬编码绝对路径 (${hc.file})`, - reason: `检测到个人绝对路径: ${hc.sample}`, - recommendation: "改用相对路径或环境变量", + reason: hc.foreign + ? `写的是别人的路径 ${hc.sample},不是你的` + : `检测到个人绝对路径: ${hc.sample}`, + recommendation: hc.foreign + ? "这是 Skill 作者自己的路径,改了下次更新会被覆盖。除非它真的影响你使用,否则不用管" + : "改用相对路径或环境变量", fixable: false, + // 别人的路径不是用户要做的决定,降为背景信息,--all 仍然看得到。 + ...(hc.foreign ? { decision: false } : {}), }); } @@ -484,8 +512,12 @@ export function runDoctor(registry, customHome = null) { path: act.path, title: "Codex 冗余软链接", reason: act.note || "Codex 已原生扫描 SSOT", - recommendation: "可安全清理该软链接", - fixable: true, + // Nothing can act on this. `sync --apply` only creates links, + // `--fix-broken` only removes broken ones, and the dashboard offers the + // same two. Claiming it is fixable sent the reader looking for a button + // that does not exist. + recommendation: `可安全清理:手动删除该软链接即可(rm "${act.path}")`, + fixable: false, action: act, }); } else if (act.kind === "remove-empty-dir") { @@ -496,8 +528,9 @@ export function runDoctor(registry, customHome = null) { skill: act.skill, title: "SSOT 空目录", reason: "目录内无任何文件且无 SKILL.md", - recommendation: "清理空文件夹", - fixable: true, + // Same as above: no command and no button reaches this action. + recommendation: `清理空文件夹:手动删除即可(rmdir "${act.path}")`, + fixable: false, action: act, }); } else if (act.kind === "report-not-a-skill") { @@ -581,7 +614,9 @@ export function runDoctor(registry, customHome = null) { for (const issue of issues) { if (issue.owned === undefined) issue.owned = true; - issue.decision = !BACKGROUND_RULES.has(issue.id); + // 规则整体是不是背景信息由 BACKGROUND_RULES 定;单条发现可以自己先声明, + // 用于同一条规则里只有部分命中不需要用户决定的情况(比如别人的绝对路径)。 + if (issue.decision === undefined) issue.decision = !BACKGROUND_RULES.has(issue.id); } // Sort by Tier (A -> B -> C) diff --git a/src/core/ops.mjs b/src/core/ops.mjs index 44e1abb..b7a13b7 100644 --- a/src/core/ops.mjs +++ b/src/core/ops.mjs @@ -3,7 +3,6 @@ import { readdirSync, mkdirSync, renameSync, - statSync, lstatSync, mkdtempSync, rmSync, @@ -554,22 +553,34 @@ export function listTrash(customHome = null) { if (!existsSync(paths.TRASH)) return []; assertManagedHomePath(paths.TRASH, paths, true); + let entries; try { - const entries = readdirSync(paths.TRASH, { withFileTypes: true }); - return entries.map((e) => { - const full = join(paths.TRASH, e.name); - const stat = statSync(full); - return { - name: e.name, - entry: e.name, - isDir: e.isDirectory(), - size: stat.size, - mtime: stat.mtime.toISOString(), - }; - }); + entries = readdirSync(paths.TRASH, { withFileTypes: true }); } catch { return []; } + + const items = []; + for (const e of entries) { + // lstat, and per entry. statSync follows the link, so one dangling entry — + // which is exactly what uninstalling a link-only Skill leaves behind — + // threw and emptied the whole listing. The user was then told their trash + // was empty while their data sat in it. + let stat; + try { + stat = lstatSync(join(paths.TRASH, e.name)); + } catch { + continue; + } + items.push({ + name: e.name, + entry: e.name, + isDir: e.isDirectory(), + size: stat.size, + mtime: stat.mtime.toISOString(), + }); + } + return items; } export function restoreTrash(entryName, customHome = null) { diff --git a/src/core/update-check.mjs b/src/core/update-check.mjs index 2aa5fe4..e614c2c 100644 --- a/src/core/update-check.mjs +++ b/src/core/update-check.mjs @@ -49,6 +49,13 @@ export function isNewer(current, candidate) { return compareVersions(current, candidate) === 1; } +// The tag that versionParts throws away. `0.4.0-beta.1` and `0.4.0` have the +// same numbers, so without this they compared equal and anyone running a +// prerelease was told they were up to date the day the stable version shipped. +function prereleaseTag(v) { + return String(v || "").replace(/^v/, "").split("-").slice(1).join("-"); +} + function compareVersions(v1, v2) { // Returns 1 if v2 > v1, -1 if v1 > v2, 0 if equal const p1 = versionParts(v1); @@ -60,7 +67,14 @@ function compareVersions(v1, v2) { if (num2 > num1) return 1; if (num1 > num2) return -1; } - return 0; + + // Same numbers: a prerelease ranks below the release it leads to (semver). + const tag1 = prereleaseTag(v1); + const tag2 = prereleaseTag(v2); + if (tag1 === tag2) return 0; + if (tag1 && !tag2) return 1; + if (!tag1 && tag2) return -1; + return tag2 > tag1 ? 1 : -1; } export async function checkSelfUpdate({ force = false, customHome = null, repo = DEFAULT_REPO } = {}) { diff --git a/test/cli.test.mjs b/test/cli.test.mjs index 1c68a55..f15960c 100644 --- a/test/cli.test.mjs +++ b/test/cli.test.mjs @@ -216,5 +216,17 @@ test("backups and undo say how many writes a session covers", (t) => { // "1 op" is true and useless: undoing this takes back three blurbs. assert.match(run(home, ["backups"]), /合并了 3 次写入/); - assert.match(run(home, ["undo"]), /合并了 3 次写入/); + + // Saying so afterwards is too late — the blurbs are already gone and there is + // no redo. A batched undo has to announce the count and wait for --yes. + let refusal; + try { + run(home, ["undo"]); + assert.fail("a batched undo must not run without --yes"); + } catch (error) { + refusal = String(error.stderr || ""); + } + assert.match(refusal, /一起回退 3 次写入/); + + assert.match(run(home, ["undo", "--yes"]), /合并了 3 次写入/); }); diff --git a/test/doctor.test.mjs b/test/doctor.test.mjs index 1ebb45a..936917d 100644 --- a/test/doctor.test.mjs +++ b/test/doctor.test.mjs @@ -199,6 +199,14 @@ test("rules cover the three ways a Skill is installed but unusable", (t) => { const ssot = join(tmp, ".agents", "skills"); // 1. A directory name outside the naming shape still gets a trigger word. + // A space is the case that actually bites: "/my diary" is not a command + // anyone can type. + const spaced = join(ssot, "my diary"); + mkdirSync(spaced, { recursive: true }); + writeFileSync(join(spaced, "SKILL.md"), "---\nname: my diary\ndescription: d\n---\n"); + + // 1b. Underscores are a naming style, not a fault. These load and trigger + // normally, so the report must leave them alone. const underscored = join(ssot, "my_diary"); mkdirSync(underscored, { recursive: true }); writeFileSync(join(underscored, "SKILL.md"), "---\nname: my_diary\ndescription: d\n---\n"); @@ -222,7 +230,7 @@ test("rules cover the three ways a Skill is installed but unusable", (t) => { const issues = runDoctor(buildRegistry(tmp), tmp); const invalid = issues.filter((i) => i.id === "invalid-skill-name"); - assert.deepEqual(invalid.map((i) => i.skill), ["my_diary"]); + assert.deepEqual(invalid.map((i) => i.skill), ["my diary"]); assert.equal(invalid[0].tier, "A"); const collisions = issues.filter((i) => i.id === "duplicate-trigger-name"); diff --git a/test/update-check.test.mjs b/test/update-check.test.mjs index ce8a456..d95213c 100644 --- a/test/update-check.test.mjs +++ b/test/update-check.test.mjs @@ -64,8 +64,12 @@ test("a prerelease version still compares", () => { // Number("0-beta") is NaN, and every comparison against NaN is false, so a // prerelease build answered "no newer version" no matter what was published. assert.equal(isNewer("0.4.0-beta.1", "0.4.1"), true); - assert.equal(isNewer("0.4.0-beta.1", "0.4.0"), false, "the release itself is not newer than its prerelease"); - assert.equal(isNewer("0.4.0", "0.4.0-beta.1"), false); + // Semver ranks 0.4.0-beta.1 below 0.4.0, so the day the stable version ships + // is exactly the day a prerelease user needs to hear about it. Answering + // "already up to date" there left them stranded on a build nobody supports. + assert.equal(isNewer("0.4.0-beta.1", "0.4.0"), true, "the release supersedes its own prerelease"); + assert.equal(isNewer("0.4.0-beta.1", "0.4.0-beta.2"), true, "a later prerelease is still an update"); + assert.equal(isNewer("0.4.0", "0.4.0-beta.1"), false, "never walk a user back onto a prerelease"); assert.equal(isNewer("0.4.0", "1.0.0"), true); assert.equal(isNewer("v0.4.0", "0.4.0"), false, "a leading v is not a version difference"); }); diff --git a/web/index.html b/web/index.html index 07778c2..d326932 100644 --- a/web/index.html +++ b/web/index.html @@ -1072,66 +1072,117 @@

⚙ 使用哪些 Agent

const row = iss => `
Tier ${iss.tier} - ${escapeHtml(iss.title)} ${escapeHtml(iss.skill || iss.path || "")}
${escapeHtml(iss.reason)} → ${escapeHtml(iss.recommendation)}
`; - const rows = mine.map(row).join(""); - const backgroundRows = background.length ? ` -
- - 另有 ${background.length} 项背景信息(描述偏长、文件偏大等,不需要你决定什么) - - ${background.map(row).join("")} -
` : ""; - const upstreamRows = upstream.length ? ` -
- - 另有 ${upstream.length} 项来自随上游更新的 Skill(本地改动会被下次升级覆盖) + + // 同一条规则命中十几次时,逐条平铺出来只会把真正不同的那两三条淹掉。按规则 + // 归组:组名说清这类问题是什么、涉及几个 Skill,展开才看具体是哪些。 + const groupRules = (list) => { + const m = new Map(); + for (const i of list) { + if (!m.has(i.id)) m.set(i.id, { tier: i.tier, title: i.title, items: [] }); + m.get(i.id).items.push(i); + } + return [...m.values()].sort((a, b) => b.items.length - a.items.length); + }; + // 标题里的 "(SKILL.md)" 是单条的细节,放到组名上就成了误导。 + const groupTitle = (g) => g.title.replace(/\s*[((][^))]*[))]\s*$/, ""); + const groupBlock = (g) => g.items.length === 1 ? row(g.items[0]) : ` +
+ + Tier ${g.tier} + ${escapeHtml(groupTitle(g))} + · ${g.items.length} 个 Skill - ${upstream.map(row).join("")} -
` : ""; + ${g.items.map(row).join("")} +
`; + const foldedList = (list) => groupRules(list).map(groupBlock).join(""); + + // 按钮做的事和上面列的问题是两回事:按钮只动软链,一个 Skill 的内容都不碰。 + // 以前这两件事挤在同一个弹窗里没有分界,报告说 27 项、按钮说 2 条,看的人 + // 只能猜这个按钮会不会动那 27 项。 + const jobs = [ + brokenCount && { + id: "sync-clean-broken", kind: "fix-broken-link", primary: false, + label: `移除损坏链接(${brokenCount} 条)`, + done: "清理损坏链接", + desc: `删掉 ${brokenCount} 条指向已消失目标的软链`, + }, + linkCount && { + id: "sync-apply", kind: "link", primary: true, + label: `补全缺失链接(${linkCount} 条)`, + done: "补全链接", + desc: `新建 ${linkCount} 条软链,让对应 Agent 能读到已有的 Skill`, + }, + ].filter(Boolean); + + const jobSection = jobs.length ? ` +
+
可以一键处理
+
    + ${jobs.map(j => `
  • ${escapeHtml(j.desc)}
  • `).join("")} +
+
+ 只新建或删除软链,不会修改任何 Skill 的内容;执行前会写备份,可以 skillhub undo 撤销。 +
+
` : ` +
+ 没有待同步的链接动作,下面的按钮也就没有可做的事。 +
`; showModal(`

🩺 体检与同步报告

-

- 体检发现 ${mine.length} 项需要你判断的。执行前请核对操作范围;可回滚的操作会写入备份记录。 -

-
- ${rows || '
✅ 没有需要你决定的问题
'} - ${backgroundRows} - ${upstreamRows} + ${jobSection} +
+ 体检发现 ${mine.length} 项需要你判断 + — 下面这些都没有一键修复,按钮不会碰它们,需要你自己处理 +
+
+ ${foldedList(mine) || '
✅ 没有需要你决定的问题
'} + ${background.length ? ` +
+ + 另有 ${background.length} 项背景信息(描述偏长、文件偏大、别人的绝对路径等,不需要你决定什么) + + ${foldedList(background)} +
` : ""} + ${upstream.length ? ` +
+ + 另有 ${upstream.length} 项来自随上游更新的 Skill(本地改动会被下次升级覆盖) + + ${foldedList(upstream)} +
` : ""}
- ${brokenCount ? `` : ""} - ${linkCount ? `` : ""} + ${jobs.map(j => ``).join("")}
`); $("#sync-close").onclick = closeModal; - const runSync = async (button, kind, successLabel) => { + // 按钮文案只在 jobs 里写一次:失败时要把它复原,两处各写一份迟早会对不上。 + const runSync = async (button, job) => { button.disabled = true; button.textContent = "执行中…"; try { const r = await api("/api/sync/apply", { method: "POST", - body: JSON.stringify({ kinds: [kind] }), + body: JSON.stringify({ kinds: [job.kind] }), }); - toast(`${successLabel}:成功 ${r.succeeded} 项`, "ok"); + toast(`${job.done}:成功 ${r.succeeded} 项`, "ok"); closeModal(); await load({ rescanHealth: true }); } catch (e) { const d = e.data || {}; const detail = (d.results || []).filter(x => !x.ok).map(x => x.error).filter(Boolean).join(";"); - toast(`${successLabel}失败:${detail || e.message}`, "error"); + toast(`${job.done}失败:${detail || e.message}`, "error"); button.disabled = false; - button.textContent = kind === "link" - ? `补全缺失链接(${linkCount} 条)` - : `移除损坏链接(${brokenCount} 条)`; + button.textContent = job.label; } }; - const applyBtn = $("#sync-apply"); - if (applyBtn) applyBtn.onclick = () => runSync(applyBtn, "link", "补全链接"); - const cleanBtn = $("#sync-clean-broken"); - if (cleanBtn) cleanBtn.onclick = () => runSync(cleanBtn, "fix-broken-link", "清理损坏链接"); + for (const job of jobs) { + const btn = $("#" + job.id); + if (btn) btn.onclick = () => runSync(btn, job); + } } catch (e) { closeModal(); toast(e.message, "error"); } }