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
+19 -1
View File
@@ -3,7 +3,7 @@
* claude.ai pages (link allowlist, hook capture, one row per URL), workspace
* file candidates and pins (a snapshot, served sandboxed), skills (snapshot,
* symlink/secret refusal, install into the caller's ~/.claude and ~/.agents skills with
* replace moving the old one aside), plugins (marketplace allowlist, install
* replace and uninstall moving the old one aside), plugins (marketplace allowlist, install
* argv; a marketplace link read into its plugin list, the same plugin added
* twice kept as one row, key/market edited), skills uploaded (one Markdown
* file, a folder, a zip, several skills at once — name and frontmatter set,
@@ -200,6 +200,18 @@ describe("skills", () => {
const old = fs.readdirSync(aside).find((n) => !n.includes("-agents-"));
assert.equal(fs.readFileSync(path.join(aside, old, "mine.txt"), "utf8"), "keep me");
assert.ok(fs.readdirSync(aside).some((n) => n.startsWith("deploy-agents-")));
// Uninstall moves both copies aside too, never deletes them.
const gone = await request("POST", `/api/artifacts/${id}/uninstall`, {});
assert.equal(gone.status, 200, gone.text);
assert.deepEqual(gone.body.paths, [target, shared]);
assert.ok(!fs.existsSync(target) && !fs.existsSync(shared));
assert.equal((await listed()).installed, false);
const kept = fs.readdirSync(aside).filter((n) => n.includes("-uninstalled-"));
assert.equal(kept.length, 2);
const none = await request("POST", `/api/artifacts/${id}/uninstall`, {});
assert.equal(none.status, 409);
assert.equal(none.body.error.code, "ENOTINSTALLED");
});
it("edits a skill's name, description and instructions into a new snapshot", async () => {
@@ -271,6 +283,12 @@ describe("plugins", () => {
["claude", "plugin", "marketplace", "add", "o/r"],
["claude", "plugin", "install", "--scope", "user", "tool@market"],
]);
calls.length = 0;
const un = await request("POST", `/api/artifacts/${r.body.item.id}/uninstall`, {});
assert.equal(un.status, 200, un.text);
assert.deepEqual(calls, [
["claude", "plugin", "uninstall", "--scope", "user", "--keep-data", "tool@market"],
]);
});
});