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
+10 -1
View File
@@ -214,5 +214,14 @@
"pack.installBody": "Each one is written into your own account. A skill you already have under the same name is left as it is:",
"pack.installed": "Installed {{ok}} · already there {{skipped}} · failed {{failed}}",
"section.unshared": "Not shared by their owners",
"section.unsharedHint": "Other users' private items — only admins see these"
"section.unsharedHint": "Other users' private items — only admins see these",
"update": "Update",
"updateHint": "Its owner changed it since you installed it",
"updateTitle": "Update “{{title}}”?",
"updateBody": "Your copy of {{name}} is replaced by the newest version. The current one is moved to ~/.claude/agenthub-replaced-skills/, not deleted.",
"updated": "Updated {{name}}",
"pack.updateN": "Update {{count}}",
"pack.updateTitle": "Update {{count}} skills of {{prefix}}-*?",
"pack.updateBody": "Each copy is replaced by the newest version; the current ones are moved to ~/.claude/agenthub-replaced-skills/:",
"pack.updated": "Updated {{ok}} · failed {{failed}}"
}
+10 -1
View File
@@ -212,5 +212,14 @@
"pack.installBody": "Mỗi skill được ghi vào tài khoản của bạn. Skill nào bạn đã có cùng tên thì giữ nguyên:",
"pack.installed": "Đã cài {{ok}} · đã có sẵn {{skipped}} · lỗi {{failed}}",
"section.unshared": "Chủ chưa chia sẻ",
"section.unsharedHint": "Mục riêng tư của người khác — chỉ admin thấy"
"section.unsharedHint": "Mục riêng tư của người khác — chỉ admin thấy",
"update": "Cập nhật",
"updateHint": "Người tạo đã sửa skill này từ khi bạn cài",
"updateTitle": "Cập nhật “{{title}}”?",
"updateBody": "Bản {{name}} của bạn sẽ được thay bằng phiên bản mới nhất. Bản hiện tại được chuyển sang ~/.claude/agenthub-replaced-skills/, không bị xóa.",
"updated": "Đã cập nhật {{name}}",
"pack.updateN": "Cập nhật {{count}}",
"pack.updateTitle": "Cập nhật {{count}} skill của bộ {{prefix}}-*?",
"pack.updateBody": "Mỗi bản sẽ được thay bằng phiên bản mới nhất; bản hiện tại được chuyển sang ~/.claude/agenthub-replaced-skills/:",
"pack.updated": "Đã cập nhật {{ok}} · lỗi {{failed}}"
}
+10
View File
@@ -3143,6 +3143,13 @@ export const api = {
method: "POST",
body: JSON.stringify({ replace }),
}),
/** POST /api/artifacts/:id/install `{update: true}` — an installed skill's
* copies replaced by its newest version (the old ones moved aside). */
updateInstalled: (id: number) =>
request<{ installed: string; replaced?: boolean }>(`/artifacts/${id}/install`, {
method: "POST",
body: JSON.stringify({ update: true }),
}),
/** POST /api/artifacts/:id/uninstall — a skill moved aside, a plugin `claude plugin uninstall`. */
uninstall: (id: number) =>
request<{ uninstalled: string; paths: string[] }>(`/artifacts/${id}/uninstall`, {
@@ -4423,6 +4430,9 @@ export interface Artifact {
inLibrary: boolean;
/** List rows only: a skill/plugin already in every account the viewer's sessions run as. */
installed?: boolean;
/** List rows only: an installed skill whose copy differs from its newest
* version (its owner changed it since) — the list offers Update. */
updateAvailable?: boolean;
/** file: captured when its session stopped, not pinned by hand. */
auto?: boolean;
/** Server mode: nobody owns it yet. */
+98 -4
View File
@@ -27,8 +27,10 @@
* what will be written or run); one already installed there shows
* **Installed** instead of the Install button; **Uninstall** (a skill's
* copies moved aside, a plugin `claude plugin uninstall`) is offered only in
* the card's ⋯ menu. There is no Reinstall: to take a newer version, uninstall
* and install again. Editing a skill
* the card's ⋯ menu. A skill follows its owner's folder (the server takes a
* change there as a new snapshot when the Hub is listed); an installed copy
* that differs from the newest version shows **Update** (a pack: "Update N"),
* which replaces it after a confirmation, the old copy moved aside. Editing a skill
* edits its SKILL.md (name, description, instructions — a new snapshot);
* its preview opens with what it installs as and when Claude uses it. Deleting a shared
* row of your own only takes it out of your library — it stays in the Hub
@@ -337,6 +339,52 @@ export function Artifacts() {
load();
};
// The newest version over an installed copy; the old copy is moved aside.
const updateInstalled = async (a: Artifact) => {
if (
!(await confirmDialog({
title: t("updateTitle", { title: a.title }),
message: t("updateBody", { name: a.source }),
confirmLabel: t("update"),
destructive: false,
}))
)
return;
try {
await api.artifacts.updateInstalled(a.id);
toast.success(t("updated", { name: a.source }));
} catch (err) {
toast.error(err);
}
load();
};
// A pack's skills that have a newer version, after one confirmation.
const updatePack = async (p: Pack) => {
const todo = p.rows.filter((a) => a.updateAvailable);
if (
!(await confirmDialog({
title: t("pack.updateTitle", { prefix: p.prefix, count: todo.length }),
message: `${t("pack.updateBody")} ${todo.map((a) => a.source || a.title).join(", ")}`,
confirmLabel: t("update"),
destructive: false,
}))
)
return;
let failed = 0;
for (const a of todo) {
try {
await api.artifacts.updateInstalled(a.id);
} catch {
failed++;
}
}
const summary = t("pack.updated", { ok: todo.length - failed, failed });
if (failed) toast.error(summary);
else toast.success(summary);
load();
};
// A pack's missing skills, after ONE confirmation that names them all; a
// skill already there under that name is left alone (never replaced here).
const installAll = async (p: Pack) => {
@@ -399,6 +447,7 @@ export function Artifacts() {
},
onDelete: () => void remove(a),
onInstall: () => void install(a),
onUpdate: () => void updateInstalled(a),
onUninstall: () => void uninstall(a),
onPreview: (path) => {
setDetailId(null);
@@ -475,6 +524,7 @@ export function Artifacts() {
server={server}
canInstall={!!settings?.installsEnabled}
onInstallAll={() => void installAll(p)}
onUpdateAll={() => void updatePack(p)}
render={(a) => render(a, "list", true)}
/>
);
@@ -871,17 +921,20 @@ function PackRow({
server,
canInstall,
onInstallAll,
onUpdateAll,
render,
}: {
pack: Pack;
server: boolean;
canInstall: boolean;
onInstallAll: () => void;
onUpdateAll: () => void;
render: (a: Artifact) => React.ReactNode;
}) {
const { t } = useTranslation("artifacts");
const [open, setOpen] = useState(false);
const missing = p.rows.filter((a) => !a.installed).length;
const behind = p.rows.filter((a) => a.updateAvailable).length;
const installs = p.rows.reduce((n, a) => n + a.installs, 0);
return (
<li data-testid={`pack-${p.prefix}`}>
@@ -918,7 +971,17 @@ function PackRow({
.filter(Boolean)
.join(" · ")}
</span>
{missing === 0 ? (
{missing === 0 && behind > 0 ? (
<button
type="button"
className={smallPrimary}
disabled={!canInstall}
title={canInstall ? t("updateHint") : t("installsOff")}
onClick={onUpdateAll}
>
{t("pack.updateN", { count: behind })}
</button>
) : missing === 0 ? (
<span className="badge border-status-success/30 bg-status-success/10 text-status-success">
{t("installedBadge")}
</span>
@@ -1005,6 +1068,7 @@ type CardActions = {
onRename: () => void;
onDelete: () => void;
onInstall: () => void;
onUpdate: () => void;
onUninstall: () => void;
onPreview: (path: string) => void;
};
@@ -1014,15 +1078,30 @@ function PrimaryAction({
item: a,
canInstall,
onInstall,
onUpdate,
onPreview,
}: {
item: Artifact;
canInstall: boolean;
onInstall: () => void;
onUpdate: () => void;
onPreview: (path: string) => void;
}) {
const { t } = useTranslation("artifacts");
const c = capabilities(a);
if (a.updateAvailable)
return (
<button
type="button"
className={smallPrimary}
disabled={!canInstall}
title={canInstall ? t("updateHint") : t("installsOff")}
onClick={onUpdate}
data-testid="artifact-update"
>
{t("update")}
</button>
);
if (a.installed)
return (
<span
@@ -1160,6 +1239,7 @@ function ArtifactMenu({
onRename,
onDelete,
onInstall,
onUpdate,
onUninstall,
onPreview,
}: ItemProps & { menu: ReturnType<typeof useContextMenu<null>> }) {
@@ -1198,6 +1278,11 @@ function ArtifactMenu({
{t("install")}
</MenuItem>
)}
{a.updateAvailable && (
<MenuItem disabled={!canInstall} onSelect={pick(onUpdate)}>
{t("update")}
</MenuItem>
)}
{a.installed && (
<MenuItem danger onSelect={pick(onUninstall)}>
{t("uninstall")}
@@ -1272,7 +1357,13 @@ function ArtifactRow({ inPack, ...props }: ItemProps & { inPack?: boolean }) {
{/* A pack's row already says whose it is. */}
<Meta item={a} server={server && !inPack} date={false} />
</span>
<PrimaryAction item={a} canInstall={canInstall} onInstall={onInstall} onPreview={onPreview} />
<PrimaryAction
item={a}
canInstall={canInstall}
onInstall={onInstall}
onUpdate={props.onUpdate}
onPreview={onPreview}
/>
<MoreButton onOpen={(e) => menu.open(e, null)} />
<ArtifactMenu {...props} menu={menu} />
</li>
@@ -1324,6 +1415,7 @@ function ArtifactCard({ as = "li", ...props }: ItemProps & { as?: "li" | "div" }
item={a}
canInstall={canInstall}
onInstall={onInstall}
onUpdate={props.onUpdate}
onPreview={onPreview}
/>
</div>
@@ -1357,6 +1449,7 @@ function DetailDialog({
onRename,
onDelete,
onInstall,
onUpdate,
onPreview,
}: CardActions & {
item: Artifact;
@@ -1396,6 +1489,7 @@ function DetailDialog({
item={a}
canInstall={canInstall}
onInstall={onInstall}
onUpdate={onUpdate}
onPreview={onPreview}
/>
{canPreview && a.kind !== "file" && (
@@ -16,6 +16,7 @@
* Rows carry one primary action; Preview, Share and Edit live in the ⋯ menu.
* A section shows 30 entries before "Show more", and three or more skills of
* one owner sharing a name prefix are one pack row that installs them all.
* An installed skill whose owner changed it shows Update (after a confirmation).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
@@ -36,6 +37,7 @@ vi.mock("../../lib/api", () => ({
update: vi.fn(),
remove: vi.fn(),
install: vi.fn(),
updateInstalled: vi.fn(),
uninstall: vi.fn(),
installs: vi.fn(),
setInstallsEnabled: vi.fn(),
@@ -382,6 +384,32 @@ describe("Artifacts", () => {
expect(calls[calls.length - 1]?.[0].title).toMatch(/Remove “hub-only” from the Hub/);
});
it("offers Update for an installed skill its owner changed, after a confirmation", async () => {
vi.mocked(api.artifacts.list).mockResolvedValue({
items: [
item({
id: 2,
kind: "skill",
title: "deploy",
source: "deploy",
installed: true,
updateAvailable: true,
}),
],
});
vi.mocked(api.artifacts.updateInstalled).mockResolvedValue({ installed: "/h/x" });
const user = userEvent.setup();
open();
const card = await screen.findByTestId("artifact-2");
expect(within(card).queryByTestId("artifact-installed")).toBeNull();
vi.mocked(confirmDialog).mockResolvedValueOnce(false);
await user.click(within(card).getByRole("button", { name: "Update" }));
expect(api.artifacts.updateInstalled).not.toHaveBeenCalled();
vi.mocked(confirmDialog).mockResolvedValueOnce(true);
await user.click(within(card).getByRole("button", { name: "Update" }));
await waitFor(() => expect(api.artifacts.updateInstalled).toHaveBeenCalledWith(2));
});
it("shows Installed instead of Install for a skill already installed", async () => {
vi.mocked(api.artifacts.list).mockResolvedValue({
items: [item({ id: 2, kind: "skill", title: "deploy", source: "deploy", installed: true })],