Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 25 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name> <agent> # let one agent read one Skill
skillhub unlink <name> <agent> # stop it reading that Skill
skillhub describe <name> "..." # write the Chinese blurb
skillhub categorize <name> "..." # set the category
skillhub note <name> "..." # write a private note
skillhub update <name> # git pull --ff-only for that Skill
skillhub remove <name> --yes # move it to ~/.agents/_trash/
skillhub trash # list the trash
skillhub trash restore <entry> # 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
Expand Down
26 changes: 25 additions & 1 deletion README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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> # 让某个 agent 能读某个 Skill
skillhub unlink <名字> <agent> # 取消
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
Expand Down
37 changes: 34 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 6 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down Expand Up @@ -32,7 +32,7 @@
"server/",
"skill/",
"src/",
"web/",
"web/index.html",
"LICENSE",
"CHANGELOG.md",
"README.md",
Expand All @@ -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"
}
}
9 changes: 8 additions & 1 deletion server/hot.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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);
}
}
Expand Down Expand Up @@ -137,6 +143,7 @@ export async function fetchHot(customHome = null) {
const data = {
fetchedAt: new Date().toISOString(),
totalScanned: byRepo.size,
partial: missingSeeds > 0,
categories,
};

Expand All @@ -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 {
Expand Down
13 changes: 8 additions & 5 deletions server/server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
25 changes: 25 additions & 0 deletions skill/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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> # 建立软链,让该 agent 能读
skillhub unlink <名字> <agent> # 移除软链
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 <key> on|off # 启用或隐藏一个 agent
```

所有命令都支持 `--json`,失败时也返回 JSON 并以退出码 1 结束。

**关于 `undo` 的一个坑**:30 分钟内连续执行的 `describe`/`categorize`/`note` 会合并成同一个备份会话。这时 `undo` 一次退的是整批,不是最后一条,而且中文介绍无法再恢复。所以它会先报出数量并要求 `--yes`——看到这个提示要先跟用户确认,不要直接补 `--yes` 重跑。

## 执行流程

### Step 0 确保命令可用
Expand Down
22 changes: 21 additions & 1 deletion src/cli.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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));
Expand Down
21 changes: 17 additions & 4 deletions src/core/backup.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading