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
+67 -2
View File
@@ -10,8 +10,10 @@
* junk skipped, nothing stored when one fails) or written in the dialog, a
* file uploaded, sharing refused in local mode, visibility for a server-mode
* developer, the admin's installs switch, same-origin writes, Claude Docs
* docs captured at their birth, documents captured at a session's stop, and
* unowned rows claimed, declined and assigned.
* docs captured at their birth, documents captured at a session's stop,
* unowned rows claimed, declined and assigned, and a skill that follows its
* source folder (a newer change wins over a Hub edit) with Update offered to
* an installed copy that differs.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -338,6 +340,69 @@ describe("skills", () => {
assert.equal(empty.status, 400);
});
it("follows its source folder and offers Update to an installed copy", async () => {
const src = path.join(ws, ".claude", "skills", "notes");
fs.mkdirSync(src, { recursive: true });
fs.writeFileSync(path.join(src, "SKILL.md"), "---\nname: notes\ndescription: v1\n---\nOne.\n");
const r = await request("POST", "/api/artifacts/skill", { laneId: lane.id, name: "notes" });
assert.equal(r.status, 201, r.text);
const id = r.body.item.id;
const listed = async () =>
(await request("GET", "/api/artifacts?kind=skill")).body.items.find((i) => i.id === id);
const raw = async () => (await request("GET", `/api/artifacts/${id}/raw?path=SKILL.md`)).text;
assert.equal((await request("POST", `/api/artifacts/${id}/install`, {})).status, 200);
let item = await listed();
assert.equal(item.installed, true);
assert.equal(item.updateAvailable, false);
const installs = item.installs;
// Unchanged folder: nothing taken.
assert.deepEqual(artifacts.syncSkillSources({ force: true }), []);
// The owner changes it: a new snapshot, and the installed copy is behind.
const snapBefore = db.prepare("SELECT snapshot FROM artifacts WHERE id = ?").get(id).snapshot;
fs.writeFileSync(path.join(src, "SKILL.md"), "---\nname: notes\ndescription: v2\n---\nTwo.\n");
fs.writeFileSync(path.join(src, "extra.md"), "more\n");
const changed = artifacts.syncSkillSources({ force: true });
assert.deepEqual(
changed.map((c) => c.id),
[id]
);
assert.match(await raw(), /Two\./);
assert.ok(!fs.existsSync(path.join(process.env.DASHBOARD_DATA_DIR, "artifacts", snapBefore)));
item = await listed();
assert.equal(item.description, "v2");
assert.ok(item.files.some((f) => f.path === "extra.md"));
assert.equal(item.updateAvailable, true);
// Update: the copy is replaced (old one moved aside), not counted again.
const up = await request("POST", `/api/artifacts/${id}/install`, { update: true });
assert.equal(up.status, 200, up.text);
const target = path.join(process.env.CLAUDE_HOME, "skills", "notes");
assert.match(fs.readFileSync(path.join(target, "SKILL.md"), "utf8"), /Two\./);
item = await listed();
assert.equal(item.updateAvailable, false);
assert.equal(item.installs, installs);
// A Hub edit is not undone by the unchanged folder; a newer folder change wins.
const edit = await request("PATCH", `/api/artifacts/${id}`, {
skill: { name: "notes", description: "hub", content: "Edited in the Hub." },
});
assert.equal(edit.status, 200, edit.text);
artifacts.syncSkillSources({ force: true });
assert.match(await raw(), /Edited in the Hub\./);
fs.writeFileSync(
path.join(src, "SKILL.md"),
"---\nname: notes\ndescription: v3\n---\nThree.\n"
);
artifacts.syncSkillSources({ force: true });
assert.match(await raw(), /Three\./);
// Update needs an installed copy.
await request("POST", `/api/artifacts/${id}/uninstall`, {});
const none = await request("POST", `/api/artifacts/${id}/install`, { update: true });
assert.equal(none.body.error.code, "ENOTINSTALLED");
});
it("refuses a skill holding a symlink or a secret-looking file", async () => {
const bad = path.join(ws, ".claude", "skills", "leaky");
fs.mkdirSync(bad, { recursive: true });
+6
View File
@@ -964,9 +964,15 @@ try {
// (`captureOutputs`), not pinned by hand. `suggested_owner_id`: for a row with
// no owner, the user whose folder the session ran in — they may claim it or
// say it is not theirs (`claim`/`decline`); an admin assigns any (`assign`).
// `version`: a skill snapshot's content hash; `source_hash`: its source
// folder's hash when last taken — a skill follows its owner's folder and an
// installer whose copy differs from `version` is offered Update
// (`syncSkillSources`/`updateCheck`). NULL until first computed.
for (const [col, ddl] of [
["auto", "auto INTEGER NOT NULL DEFAULT 0"],
["suggested_owner_id", "suggested_owner_id INTEGER"],
["version", "version TEXT"],
["source_hash", "source_hash TEXT"],
]) {
try {
db.prepare(`SELECT ${col} FROM artifacts LIMIT 1`).get();
+287 -17
View File
@@ -226,7 +226,11 @@ function list(req, { scope = "all", kind, q, laneId, sessionId, limit = 200 } =
).all(...params);
const owners = new Map();
const installed = installedCheck(req);
const out = rows.map((r) => ({ ...view(r, req, owners), installed: installed(r) }));
const updatable = updateCheck(req);
const out = rows.map((r) => {
const isIn = installed(r);
return { ...view(r, req, owners), installed: isIn, updateAvailable: isIn && updatable(r) };
});
return scope === "mine" && req?.user ? out.filter((a) => a.mine || a.installed) : out;
}
@@ -753,7 +757,7 @@ function walkSkill(dir) {
total += st.size;
if (files.length >= SKILL_MAX_FILES) throw fail("ETOOBIG", "the skill has too many files");
if (total > SKILL_MAX_BYTES) throw fail("ETOOBIG", "the skill is larger than 5 MB");
files.push({ abs: childAbs, path: childRel, size: st.size });
files.push({ abs: childAbs, path: childRel, size: st.size, mtimeMs: st.mtimeMs });
}
};
walk(dir, "", 0);
@@ -822,6 +826,7 @@ function storeSkill(req, { name, files, title, description, laneId = null }) {
const snap = newSnapshotDir();
let total = 0;
const recorded = [];
const hashed = [];
try {
for (const f of files) {
const data = typeof f.data === "function" ? f.data() : f.data;
@@ -831,6 +836,7 @@ function storeSkill(req, { name, files, title, description, laneId = null }) {
fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
fs.writeFileSync(target, data, { mode: 0o600 });
recorded.push({ path: f.path, size: data.length });
hashed.push({ path: f.path, data });
}
} catch (err) {
dropSnapshot(snap.id);
@@ -845,20 +851,30 @@ function storeSkill(req, { name, files, title, description, laneId = null }) {
} catch {
/* no frontmatter */
}
return view(
insert({
kind: "skill",
title: cleanTitle(title, name),
description: cleanDescription(description) || summary.slice(0, DESCRIPTION_MAX),
owner_id: req?.user ? req.user.id : null,
lane_id: laneId,
source: name,
snapshot: snap.id,
files: JSON.stringify(recorded),
size: total,
}),
req
);
const row = insert({
kind: "skill",
title: cleanTitle(title, name),
description: cleanDescription(description) || summary.slice(0, DESCRIPTION_MAX),
owner_id: req?.user ? req.user.id : null,
lane_id: laneId,
source: name,
snapshot: snap.id,
files: JSON.stringify(recorded),
size: total,
version: contentHash(hashed),
});
// The owner's folder as it is now: only a LATER change to it is taken
// (`syncOne`), so an upload is never replaced by an older local copy.
try {
const h = sourceHash(row);
if (h) {
sql("UPDATE artifacts SET source_hash = ? WHERE id = ?").run(h, row.id);
row.source_hash = h;
}
} catch {
/* no folder */
}
return view(row, req);
}
// ── skill: written or uploaded ────────────────────────────────────────────
@@ -1569,10 +1585,22 @@ function editSkill(req, row, { name, description, content }) {
throw err;
}
const desc = frontmatterField(text, "description");
// The source folder as it is now, so this edit is not undone by it (`syncOne`).
const folder = sourceHash({ ...row, source: n });
sql(
`UPDATE artifacts SET source = ?, snapshot = ?, files = ?, size = ?, description = ?,
version = ?, source_hash = ?,
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`
).run(n, snap.id, JSON.stringify(next), total, cleanDescription(desc), row.id);
).run(
n,
snap.id,
JSON.stringify(next),
total,
cleanDescription(desc),
contentHash(all),
folder,
row.id
);
dropSnapshot(row.snapshot);
}
@@ -1756,6 +1784,237 @@ async function installSkill(req, row, { replace = false } = {}) {
return { installed: paths.join(", "), paths, replaced: taken.length > 0 };
}
// ── skill versions: follow the owner's folder, offer installers an Update ──
/** A skill's content id: sha256 over its files (path + bytes), path order. */
function contentHash(files) {
const h = crypto.createHash("sha256");
for (const f of [...files].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))) {
h.update(f.path);
h.update("\0");
h.update(crypto.createHash("sha256").update(f.data).digest());
}
return h.digest("hex").slice(0, 16);
}
/** A skill row's snapshot version (`artifacts.version`), computed once for an older row. */
function snapshotVersion(row) {
if (row.version) return row.version;
if (row.kind !== "skill" || !row.snapshot || !SNAPSHOT_RE.test(row.snapshot)) return null;
try {
const files = (row.files ? JSON.parse(row.files) : []).map((f) => ({
path: f.path,
data: fs.readFileSync(path.join(root(), row.snapshot, ...f.path.split("/"))),
}));
const v = contentHash(files);
sql("UPDATE artifacts SET version = ? WHERE id = ?").run(v, row.id);
row.version = v;
return v;
} catch {
return null;
}
}
// realpath → { sig, hash }: a folder is read again only once a file's name,
// size or mtime changed (a stat walk is cheap; reading 5 MB per poll is not).
const folderCache = new Map();
const FOLDER_CACHE_MAX = 2000;
/**
* A skill folder as it is on disk now — `{ real, hash, files, read() }` — or
* null when it is missing, has no SKILL.md, is a link out of `top`, or breaks
* a snapshot rule (`walkSkill`: no symlink, no secret, ≤ 5 MB). `read()`
* answers its files' bytes, a member's only as theirs (`readFileSafe`).
*/
function folderState(dir, top) {
let real;
let topReal;
try {
real = fs.realpathSync(dir);
topReal = fs.realpathSync(top);
} catch {
return null;
}
if (!inside(topReal, real) || path.basename(real) !== path.basename(dir)) return null;
let walked;
try {
walked = walkSkill(real);
} catch {
return null;
}
if (!walked.files.some((f) => f.path === "SKILL.md")) return null;
const sig = walked.files
.map((f) => `${f.path}:${f.size}:${f.mtimeMs}`)
.sort()
.join("|");
const read = () => {
const runAs = require("./run-as");
return walked.files.map((f) => ({
path: f.path,
data: runAs.readFileSafe(f.abs, null, { max: SKILL_MAX_BYTES }),
}));
};
const hit = folderCache.get(real);
if (hit && hit.sig === sig) return { real, hash: hit.hash, files: walked.files, read };
let hash;
try {
hash = contentHash(read());
} catch {
return null;
}
folderCache.delete(real);
folderCache.set(real, { sig, hash });
if (folderCache.size > FOLDER_CACHE_MAX) folderCache.delete(folderCache.keys().next().value);
return { real, hash, files: walked.files, read };
}
/**
* Where a skill row came from: `{dir, top}` — its workspace's
* `.claude/skills/<name>` when it was taken from one, else its OWNER's
* `~/.claude/skills/<name>` (the host's in local mode). Null for a row with no
* owner in server mode, or an owner with no Claude config of their own.
*/
function sourceDirOf(row) {
if (row.kind !== "skill" || !row.source || !SKILL_NAME_RE.test(row.source)) return null;
if (row.lane_id != null) {
const lane = sql("SELECT cwd FROM lanes WHERE id = ?").get(row.lane_id);
if (!lane?.cwd) return null;
return { dir: path.join(lane.cwd, ".claude", "skills", row.source), top: lane.cwd };
}
let claudeHome;
if (row.owner_id == null) {
if (isServer()) return null;
claudeHome = getClaudeHome();
} else {
const user = sql("SELECT id, username, role FROM users WHERE id = ?").get(row.owner_id);
if (!user) return null;
try {
claudeHome = ownAccount({ user }).claudeHome;
} catch {
return null;
}
}
return { dir: path.join(claudeHome, "skills", row.source), top: claudeHome };
}
/** The hash of a row's source folder now, or null when it has none. */
function sourceHash(row) {
const src = sourceDirOf(row);
return src ? (folderState(src.dir, src.top)?.hash ?? null) : null;
}
/**
* Take a skill row's NEW source folder as its snapshot, when the folder
* changed since it was last taken (`source_hash`) and differs from the
* snapshot. A change made in the Hub (`editSkill`) records the folder as it
* was then, so it is not undone until the folder itself changes again: the
* newer change wins. A new snapshot directory, then the old one dropped —
* never a write into the one being read. Answers whether the row changed.
*/
function syncOne(row) {
const src = sourceDirOf(row);
if (!src) return false;
const st = folderState(src.dir, src.top);
if (!st || st.hash === row.source_hash) return false;
if (st.hash === snapshotVersion(row)) {
sql("UPDATE artifacts SET source_hash = ? WHERE id = ?").run(st.hash, row.id);
return false;
}
let files;
try {
files = st.read();
checkSkillFiles(files);
} catch {
return false;
}
const snap = newSnapshotDir();
let total = 0;
const next = [];
try {
for (const f of files) {
const target = path.join(snap.dir, ...f.path.split("/"));
fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
fs.writeFileSync(target, f.data, { mode: 0o600 });
total += f.data.length;
next.push({ path: f.path, size: f.data.length });
}
} catch {
dropSnapshot(snap.id);
return false;
}
// The description follows SKILL.md's only while it still says what the old one did.
const md = (list) => list.find((f) => f.path === "SKILL.md");
let oldSummary = "";
try {
oldSummary = frontmatterField(
fs.readFileSync(path.join(root(), row.snapshot, "SKILL.md"), "utf8"),
"description"
).slice(0, DESCRIPTION_MAX);
} catch {
/* none */
}
const newSummary = frontmatterField(md(files)?.data.toString("utf8") ?? "", "description");
const description =
!row.description || row.description === oldSummary
? cleanDescription(newSummary) || row.description
: row.description;
sql(
`UPDATE artifacts SET snapshot = ?, files = ?, size = ?, version = ?, source_hash = ?,
description = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`
).run(snap.id, JSON.stringify(next), total, st.hash, st.hash, description, row.id);
if (row.snapshot && SNAPSHOT_RE.test(row.snapshot)) dropSnapshot(row.snapshot);
return true;
}
const SYNC_EVERY_MS = 15 * 1000;
let lastSync = 0;
/**
* Bring every skill row up to its source folder (`syncOne`), at most once per
* `SYNC_EVERY_MS` unless `force`. Runs when the Hub is listed. Answers the
* rows that changed, for the route to announce.
*/
function syncSkillSources({ force = false } = {}) {
if (!force && Date.now() - lastSync < SYNC_EVERY_MS) return [];
lastSync = Date.now();
const changedRows = [];
for (const row of sql(
"SELECT * FROM artifacts WHERE kind = 'skill' AND snapshot IS NOT NULL"
).all()) {
try {
if (syncOne(row)) changedRows.push(sql("SELECT * FROM artifacts WHERE id = ?").get(row.id));
} catch {
/* one bad row never stops the rest */
}
}
return changedRows;
}
/**
* `row => boolean`: an installed skill whose copy in the viewer's
* `~/.claude/skills/<name>` (any account it runs as) differs from the row's
* newest snapshot — the list offers **Update** for it. Pure reads.
*/
function updateCheck(req) {
let accounts = null;
return (row) => {
if (row.kind !== "skill" || !row.source) return false;
const version = snapshotVersion(row);
if (!version) return false;
if (accounts === null) {
try {
accounts = installAccounts(req);
} catch {
accounts = [];
}
}
return accounts.some((a) => {
const st = folderState(path.join(a.claudeHome, "skills", row.source), a.claudeHome);
return !!st && st.hash !== version;
});
};
}
let execImpl = promisify(require("node:child_process").execFile);
function __setExecImpl(fn) {
execImpl = fn;
@@ -1815,6 +2074,16 @@ async function installPlugin(req, row) {
async function install(req, id, opts = {}) {
const row = load(req, id);
installable(req, row);
// Update: an installed skill's copies replaced by the newest snapshot (the
// old ones moved aside like any replace). Not another install: no count, no
// second row in who-installed.
if (opts.update) {
if (row.kind !== "skill") throw fail("EBADKIND", "only a skill is updated here");
if (!installedCheck(req)(row)) throw fail("ENOTINSTALLED", `${row.source} is not installed`);
const result = await installSkill(req, row, { replace: true });
if (req?.user && row.owner_id !== req.user.id) audit(req, row, "updated");
return result;
}
const result =
row.kind === "skill" ? await installSkill(req, row, opts) : await installPlugin(req, row);
sql("UPDATE artifacts SET installs = installs + 1 WHERE id = ?").run(row.id);
@@ -1947,6 +2216,7 @@ module.exports = {
update,
remove,
snapshotFile,
syncSkillSources,
install,
uninstall,
installsEnabled,
+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);
})