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
+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,