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"],
]);
});
});
+102 -30
View File
@@ -33,8 +33,10 @@
* `~/.claude/agenthub-replaced-skills/`, never deleted. A plugin is
* installed without `-y`, so one whose marketplace declares an install
* command fails rather than runs unseen. An admin can turn installs off
* for everyone (`setInstallsEnabled`). Share, unshare and install are
* written to `audit_log`.
* for everyone (`setInstallsEnabled`). Uninstall (`uninstall`) takes it out
* of the same accounts again — a skill moved aside the same way, a plugin
* `claude plugin uninstall --keep-data` — and is allowed with installs off.
* Share, unshare, install and uninstall are written to `audit_log`.
* - Editing a skill's name, description or instructions (`editSkill`) writes
* a NEW snapshot and swaps the row to it; the old snapshot is dropped, and
* copies already installed stay as they were (Reinstall picks it up).
@@ -1409,6 +1411,31 @@ function installedCheck(req) {
};
}
/**
* Move installed skill folders (`{acct, claudeHome, target}`) aside to
* `~/.claude/agenthub-replaced-skills/<name>[-agents][-uninstalled]-<stamp>`,
* as their account — a replace or an uninstall never deletes a user's copy.
*/
async function moveAside(taken, row, label = "") {
const runAs = require("./run-as");
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
for (const t of taken) {
const tag = t.target.startsWith(t.claudeHome + path.sep) ? "" : "-agents";
const aside = path.join(
t.claudeHome,
"agenthub-replaced-skills",
`${row.source}${tag}${label}-${stamp}`
);
if (t.acct) {
runAs.mkdirAs(t.acct, path.dirname(aside));
await runAs.writeAsAsync(t.acct, t.target, ["move", aside]);
} else {
fs.mkdirSync(path.dirname(aside), { recursive: true });
fs.renameSync(t.target, aside);
}
}
}
/**
* Install a skill snapshot as `<name>` into every skills folder of every
* account the viewer's sessions run as (`installAccounts` × `skillDirsOf`).
@@ -1428,22 +1455,7 @@ async function installSkill(req, row, { replace = false } = {}) {
"replacing needs the updated helper: run `agenthub server install-system` once"
);
}
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
for (const t of taken) {
const tag = t.target.startsWith(t.claudeHome + path.sep) ? "" : "-agents";
const aside = path.join(
t.claudeHome,
"agenthub-replaced-skills",
`${row.source}${tag}-${stamp}`
);
if (t.acct) {
runAs.mkdirAs(t.acct, path.dirname(aside));
await runAs.writeAsAsync(t.acct, t.target, ["move", aside]);
} else {
fs.mkdirSync(path.dirname(aside), { recursive: true });
fs.renameSync(t.target, aside);
}
}
await moveAside(taken, row);
const files = row.files ? JSON.parse(row.files) : [];
const contents = files.map((f) => ({
rel: f.path.split("/"),
@@ -1470,14 +1482,9 @@ function __setExecImpl(fn) {
execImpl = fn;
}
/** `claude plugin marketplace add` + `claude plugin install --scope user`, as the viewer. */
async function installPlugin(req, row) {
/** `args => Promise`: runs `claude <args>` as the viewer's own account. */
function claudeAsViewer(req) {
const { acct } = ownAccount(req);
const steps = [
["plugin", "marketplace", "add", validMarket(row.market)],
["plugin", "install", "--scope", "user", row.source],
];
if (!PLUGIN_RE.test(row.source)) throw fail("EBADPLUGIN", "plugin: name@marketplace");
let run;
if (acct) {
const runAs = require("./run-as");
@@ -1493,11 +1500,23 @@ async function installPlugin(req, row) {
if (process.env.CLAUDE_HOME) env.CLAUDE_CONFIG_DIR = getClaudeHome();
run = (args) => execImpl("claude", args, { timeout: 300_000, encoding: "utf8", env });
}
const lastLine = (e) =>
String(e?.stderr || e?.stdout || e?.message || "")
.trim()
.split("\n")
.pop();
return run;
}
const lastLine = (e) =>
String(e?.stderr || e?.stdout || e?.message || "")
.trim()
.split("\n")
.pop();
/** `claude plugin marketplace add` + `claude plugin install --scope user`, as the viewer. */
async function installPlugin(req, row) {
const steps = [
["plugin", "marketplace", "add", validMarket(row.market)],
["plugin", "install", "--scope", "user", row.source],
];
if (!PLUGIN_RE.test(row.source)) throw fail("EBADPLUGIN", "plugin: name@marketplace");
const run = claudeAsViewer(req);
try {
await run(steps[0]);
} catch (e) {
@@ -1527,6 +1546,58 @@ async function install(req, id, opts = {}) {
return result;
}
/**
* Take an installed skill or plugin out of the viewer's own accounts again
* (`installAccounts`). A skill's folders are moved aside like a replace
* (`moveAside`, `-uninstalled`), never deleted; a plugin is `claude plugin
* uninstall --scope user --keep-data`, as the viewer, so its data dir stays.
* Allowed while installs are off — it only removes. `ENOTINSTALLED` when
* there is nothing of it to remove.
*/
async function uninstall(req, id) {
const row = load(req, id);
if (row.kind !== "skill" && row.kind !== "plugin") {
throw fail("EBADKIND", "only a skill or a plugin is uninstalled");
}
let result;
if (row.kind === "skill") {
const runAs = require("./run-as");
const taken = installAccounts(req)
.flatMap((a) => skillDirsOf(a).map((dir) => ({ ...a, target: path.join(dir, row.source) })))
.filter((t) => exists(t.target));
if (!taken.length) throw fail("ENOTINSTALLED", `${row.source} is not installed`);
if (taken.some((t) => t.acct) && !runAs.writeAsCanMove()) {
throw fail(
"EUPGRADE",
"uninstalling needs the updated helper: run `agenthub server install-system` once"
);
}
await moveAside(taken, row, "-uninstalled");
result = { uninstalled: row.source, paths: taken.map((t) => t.target) };
} else {
if (!PLUGIN_RE.test(row.source)) throw fail("EBADPLUGIN", "plugin: name@marketplace");
try {
await claudeAsViewer(req)([
"plugin",
"uninstall",
"--scope",
"user",
"--keep-data",
row.source,
]);
} catch (e) {
const msg = lastLine(e);
if (/not (found|installed)/i.test(msg)) {
throw fail("ENOTINSTALLED", `${row.source} is not installed`);
}
throw fail("EPLUGIN", `uninstall: ${msg}`);
}
result = { uninstalled: row.source, paths: [] };
}
if (req?.user && row.owner_id !== req.user.id) audit(req, row, "uninstalled");
return result;
}
/**
* Who installed artifact `id`, newest first: `{items: [{user, mine, at,
* replaced, paths?}], unrecorded}`. The owner, staff and local mode see every
@@ -1593,6 +1664,7 @@ module.exports = {
remove,
snapshotFile,
install,
uninstall,
installsEnabled,
setInstallsEnabled,
__setExecImpl,
+8 -1
View File
@@ -7,7 +7,7 @@
* rename/share/unshare, delete, read a snapshot file (preview/download, under
* a `sandbox` CSP with `nosniff`, so nothing shared runs on the dashboard's
* origin), and install a shared skill or plugin into the caller's own
* account. Every write is same-origin. A path in a request only ever names a
* account (or uninstall it from there again). Every write is same-origin. A path in a request only ever names a
* file inside a workspace (pin, under the read path's guards) or one of a
* snapshot's own recorded files.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
@@ -30,6 +30,7 @@ const STATUS = {
EFOREIGN: 403,
EINSTALLSOFF: 403,
EEXISTS: 409,
ENOTINSTALLED: 409,
ENOACCOUNT: 409,
EUPGRADE: 409,
ENOTSHARED: 409,
@@ -361,4 +362,10 @@ router.post(
})
);
router.post(
"/:id/uninstall",
sameOriginGuard,
wrap(async (req, res) => res.json(await artifacts.uninstall(req, req.params.id)))
);
module.exports = router;