feat(artifacts): storefront layout and Uninstall for installed skills/plugins

- Artifacts page laid out like an app store: hero search, category chips,
  shelves by kind, app-icon cards with one primary pill and a detail sheet
- POST /api/artifacts/:id/uninstall: a skill's copies in ~/.claude/skills and
  ~/.agents/skills are moved to ~/.claude/agenthub-replaced-skills/
  (-uninstalled-<time>), never deleted; a plugin runs
  `claude plugin uninstall --scope user --keep-data` as the caller;
  ENOTINSTALLED when nothing is there; allowed while installs are off
- Uninstall on the card, the ⋯ menu and the detail sheet, after a confirm
This commit is contained in:
2026-10-21 16:10:30 +07:00
parent 383b9ae231
commit c042519bc9
11 changed files with 1066 additions and 235 deletions
File diff suppressed because it is too large Load Diff
+48 -4
View File
@@ -10,8 +10,9 @@
* server cannot read opens the by-hand form), a skill is uploaded from one
* Markdown file under the name the user types, or written in the dialog.
* A card's right-click menu runs the same actions (Preview, Delete). A file
* the sandbox cannot show (Office, zip, PDF) offers Download only, and the
* Add menu closes on Escape or a click outside it.
* the sandbox cannot show (Office, zip, PDF) offers Download only, the
* Add menu closes on Escape or a click outside it, the front page is shelved
* by kind until a category is picked, and a card opens its detail sheet.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
@@ -32,6 +33,7 @@ vi.mock("../../lib/api", () => ({
update: vi.fn(),
remove: vi.fn(),
install: vi.fn(),
uninstall: vi.fn(),
installs: vi.fn(),
setInstallsEnabled: vi.fn(),
skills: vi.fn(),
@@ -294,6 +296,30 @@ describe("Artifacts", () => {
await waitFor(() => expect(api.artifacts.install).toHaveBeenLastCalledWith(2, true));
});
it("uninstalls an installed plugin only after a confirmation", async () => {
vi.mocked(api.artifacts.list).mockResolvedValue({
items: [
item({ id: 4, kind: "plugin", title: "tool", source: "tool@market", installed: true }),
],
});
vi.mocked(api.artifacts.uninstall).mockReset();
open();
const card = await screen.findByTestId("artifact-4");
const user = userEvent.setup();
vi.mocked(confirmDialog).mockResolvedValueOnce(false);
await user.click(within(card).getByRole("button", { name: "Uninstall" }));
await waitFor(() => expect(confirmDialog).toHaveBeenCalled());
expect(api.artifacts.uninstall).not.toHaveBeenCalled();
vi.mocked(confirmDialog).mockResolvedValueOnce(true);
vi.mocked(api.artifacts.uninstall).mockResolvedValueOnce({
uninstalled: "tool@market",
paths: [],
});
await user.click(within(card).getByRole("button", { name: "Uninstall" }));
await waitFor(() => expect(api.artifacts.uninstall).toHaveBeenCalledWith(4));
});
it("installs a skill only after a confirmation, and asks before replacing", async () => {
const user = userEvent.setup();
open();
@@ -315,8 +341,8 @@ describe("Artifacts", () => {
it("previews a file in a sandboxed iframe that may run nothing", async () => {
const user = userEvent.setup();
open();
await screen.findByText("report.html");
await user.click(screen.getAllByRole("button", { name: "Preview" })[0]!);
const card = await screen.findByTestId("artifact-1");
await user.click(within(card).getByRole("button", { name: "Preview" }));
const frame = await screen.findByTestId("artifact-preview");
expect(frame.getAttribute("sandbox")).toBe("");
expect(frame.getAttribute("src")).toBe("/api/artifacts/1/raw?path=report.html");
@@ -425,4 +451,22 @@ describe("Artifacts", () => {
await user.click(screen.getByText("report.html"));
expect(screen.queryByRole("button", { name: "Skill" })).toBeNull();
});
it("shelves the front page by kind, and a card opens its detail sheet", async () => {
const user = userEvent.setup();
open();
await screen.findByText("deploy");
const shelves = screen.getAllByRole("heading", { level: 2 }).map((h) => h.textContent);
expect(shelves).toEqual(["Skills1", "Files1"]);
await user.click(screen.getAllByRole("button", { name: "See all" })[0]!);
await waitFor(() =>
expect(api.artifacts.list).toHaveBeenLastCalledWith(
expect.objectContaining({ kind: "skill" })
)
);
await user.click(screen.getByText("deploy"));
const sheet = await screen.findByTestId("artifact-detail");
expect(within(sheet).getByText("/deploy")).toBeInTheDocument();
expect(within(sheet).getByRole("button", { name: "Install" })).toBeInTheDocument();
});
});