feat(artifacts): a shared skill follows its owner's folder; installers get Update

A skill in the Hub was a snapshot taken once: the owner changing
~/.claude/skills/<name> changed nothing there, and an installer had to
uninstall and install again to get a newer version.

- syncSkillSources (on GET /api/artifacts, at most every 15 s): a skill row
  taken from a folder — the owner's ~/.claude/skills/<name>, or the
  workspace's .claude/skills/<name> — is brought up to it. Only a folder
  whose names/sizes/mtimes changed is read again, with a pick's checks (no
  symlink, no secret, <= 5 MB, a member's files as theirs); a change is a
  NEW snapshot, the old one dropped, and an artifact_changed broadcast.
- artifacts.version (snapshot content hash) and source_hash (the folder's
  when last taken, additive columns): an upload or a Hub edit records the
  folder as it is then, so only a LATER folder change replaces it.
- A list row's updateAvailable: installed, and the viewer's copy differs
  from version. The page shows Update (a pack: Update N); install with
  {update: true} replaces the copies (old ones moved aside) and is not
  counted as another install.
This commit is contained in:
2026-10-23 15:10:49 +07:00
parent 244f999538
commit 72aef2426c
11 changed files with 526 additions and 30 deletions
+4
View File
@@ -75,6 +75,9 @@ router.get(
"/",
wrap((req, res) => {
const q = req.query;
// A skill follows its owner's folder: take what changed there first
// (throttled), and tell every open Hub page.
for (const row of artifacts.syncSkillSources()) changed(row);
res.json({
items: artifacts.list(req, {
scope: q.scope,
@@ -358,6 +361,7 @@ router.post(
wrap(async (req, res) => {
const result = await artifacts.install(req, req.params.id, {
replace: req.body?.replace === true,
update: req.body?.update === true,
});
res.json(result);
})