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:
@@ -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}}"
|
||||
}
|
||||
|
||||
@@ -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}}"
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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 })],
|
||||
|
||||
Reference in New Issue
Block a user