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:
@@ -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
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user