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
+2 -2
View File
@@ -1,6 +1,6 @@
# Claude Code Working Guide
_Last synced: v2.8.13 (2026-10-21) — realtime chat (bridge, `/ws-chat`) removed; the terminal chat covers omp/Cursor/agy (agy's step log as a transcript), permission prompts as cards, Stop from the pane's busy line, Codex `/model`·`/permissions` pickers, `transcript_changed` push; artifacts kept._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session.
_Last synced: v2.8.14 (2026-10-21) — Artifacts: Uninstall for an installed skill (moved aside, never deleted) or plugin (`claude plugin uninstall --keep-data`) beside Reinstall._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session.
## Project mission
- Maintain a reliable local-first dashboard for Claude Code session monitoring.
@@ -59,7 +59,7 @@ A **workspace** — called a **lane** in the API, CLI, database and code, which
- **The File Explorer (`server/lib/file-explorer.js`, `/api/lanes/:id/fs/*`) reuses the read path's guards and never replaces silently.** Every path is `lanePath` + the PARENT `resolveInLane`-confined with the last segment kept as a name (so a symlink is moved/deleted as a link, never followed); never in or under `.git`, never a secret, never the workspace folder itself. Writes run as the folder's account — a member's only through `write-as` (`move`/`copy` modes; `writeAsAsync` streams an upload to its stdin; a batch upload of many small files is ONE `extract` of a tar the server writes itself — folders and regular files only, names it checked, `tar -k`) — and a move/copy never crosses accounts (`sameOwner`, `EFOREIGN`). A taken name is settled by `conflict` (`fail` → 409 with `names`, `skip`, `keep`, `replace` = the old one to the trash first). Delete is a move into the account's trash (member: `~/.local/share/agenthub/trash/`, host: `<data dir>/trash/<user id>/`), restorable 7 days. `write-as` is root-installed: a change to it needs `agenthub server install-system` again (`EUPGRADE` until then). A user's HOME is browsed through the same module inside `inHome` (`/api/homes/:who/fs/*`, `server/lib/homes.js` picks the folder): every dot entry at the TOP of a home stays hidden and refused — through a symlink, a download, a zip or as a copy source too — and another user's home is an admin's READ-ONLY, audited view (`EREADONLY`) — except its TRASH, whose restore/purge an admin may run (`TRASH_W` in `server/routes/homes.js`, the owner's trash via `trashKey`, audited with the item id); keep that exception to the trash routes only. A home's Trash is its USER's one recycle bin (`listUserTrash`: home-account store + host store `<data dir>/trash/<user id>/`, items tagged `store`). Do not add a delete that skips the trash, an overwrite without `conflict`, or a request path that is not run through `locate`/`folder`.
- **The whole-file editor and the Files tab's upload are the two non-git writes on the lane file surface, and both live outside `git-ops.js` on purpose.** `git-ops.js` only touches paths `git status` reports as changed; `lane-file-write.js` may save any existing text file, so it must reuse the READ path's guards (`lanePath`, `resolveInLane`, `isSecretOnDisk`) and never be weaker than them, and it saves only over the `baseSha` it was opened at — do not let it create files or touch a binary. `lane-file-transfer.js`'s upload (`POST /api/lanes/:id/git/upload`, issue #78) is the opposite and only that: it CREATES one file (any bytes, ≤ 25 MB) under the same guards, into an existing folder, refuses a taken name (`EEXISTS`, a symlink included) and anything into or named `.git` (a hook there runs on the dashboard's next git call), and writes as the folder's member through `write-as` under S3 — do not let it overwrite, make folders or take a path from anything but `dir` + one plain name. Its `GET …/git/download` twin streams any file through `openSafe` as a `sandbox`ed attachment. The tree, expand-all and quick-open read the disk (`walkFolder`) for a folder that is not a repo.
- **Server tests never touch the user's tmux.** Run them through `npm run test:server` (`scripts/test-server.js`), never a bare `node --test server/__tests__/*.test.js` from inside a lane pane — the suites create and kill real `agenthub-lane-<id>` sessions.
- **An artifact is shared as a SNAPSHOT and installed only into the viewer's own account.** `server/lib/artifacts.js` (`/api/artifacts`): a pinned file or a skill is copied once into `<data dir>/artifacts/<random>/` and every viewer reads only that copy (an owner's edit of a skill writes a NEW snapshot and drops the old one — never a write into the one being read) — `raw` serves only a file the snapshot recorded, under a `sandbox` CSP + `nosniff`; a `page` is a `https://claude.ai/…` link only. A developer sees their own rows plus shared ones; only the owner or an admin changes one. Install writes a skill into BOTH `~/.claude/skills/` and the cross-tool `~/.agents/skills/` of every account the CALLER's sessions run as (`installAccounts`: their S3 member home, plus the host's for an admin, whose host-folder workspaces run as the host; S3: as them through `write-as`), never over an existing one without `replace` (the old one is moved to `~/.claude/agenthub-replaced-skills/`, never deleted), and runs a plugin's `claude plugin marketplace add`/`install --scope user` as the caller, never with `-y`; an admin's switch (Settings → AI tools, `ArtifactsHubSettings`) turns installs off. An uploaded or written skill is snapshotted under the same checks (paths relative, no `..`, no secret names, zip sizes read before inflating). `POST /api/artifacts/plugin/inspect` clones a user-named https link only to read `marketplace.json`, with every credential helper OFF — never send a login with it. Do not add a route that serves a live file of another user, installs into someone else's account, or takes a plugin command from the request.
- **An artifact is shared as a SNAPSHOT and installed only into the viewer's own account.** `server/lib/artifacts.js` (`/api/artifacts`): a pinned file or a skill is copied once into `<data dir>/artifacts/<random>/` and every viewer reads only that copy (an owner's edit of a skill writes a NEW snapshot and drops the old one — never a write into the one being read) — `raw` serves only a file the snapshot recorded, under a `sandbox` CSP + `nosniff`; a `page` is a `https://claude.ai/…` link only. A developer sees their own rows plus shared ones; only the owner or an admin changes one. Install writes a skill into BOTH `~/.claude/skills/` and the cross-tool `~/.agents/skills/` of every account the CALLER's sessions run as (`installAccounts`: their S3 member home, plus the host's for an admin, whose host-folder workspaces run as the host; S3: as them through `write-as`), never over an existing one without `replace` (the old one is moved to `~/.claude/agenthub-replaced-skills/`, never deleted), and runs a plugin's `claude plugin marketplace add`/`install --scope user` as the caller, never with `-y`; Uninstall (`POST /api/artifacts/:id/uninstall`) takes it out of the same accounts — a skill MOVED to `agenthub-replaced-skills/`, never deleted, a plugin `claude plugin uninstall --scope user --keep-data`; an admin's switch (Settings → AI tools, `ArtifactsHubSettings`) turns installs off. An uploaded or written skill is snapshotted under the same checks (paths relative, no `..`, no secret names, zip sizes read before inflating). `POST /api/artifacts/plugin/inspect` clones a user-named https link only to read `marketplace.json`, with every credential helper OFF — never send a login with it. Do not add a route that serves a live file of another user, installs into someone else's account, or takes a plugin command from the request.
- **A workspace's instructions, extra folders and context files are lane facts, and a pane reads them only from the lane's rows.** The one addition is the workspace OWNER's own agent instructions (`user_prefs.instructions`, `PUT /api/me/instructions`, Settings → AI tools), which `launchExtras` puts first — keyed by `lanes.owner_id` (`local` in local mode), never by whoever clicked Start. `lanes.instructions` and `lane_sources` (`server/lib/lane-sources.js`) reach a pane through `launchExtras(laneId, tool)`, which `spawnRun` calls ITSELF — so neither spawn path can take them from a request — and every write is same-origin (`server/routes/lane-sources.js`). A folder must be absolute, exist, be a directory, not be or contain the OS home (`validateHomeDir`) and not be the workspace's own folder; it is the user's, and AgentHub never deletes it. An uploaded file is stored under `<data dir>/workspace-context/<lane id>/` under a name AgentHub derives (never a path from the request), and those copies are the only thing here AgentHub deletes (`ownsFile`): with their row, and with the workspace in `deleteLane`. Claude's `--add-dir` is VARIADIC, so it goes first in the argv where `--permission-mode` ends it, and the text (instructions + context file list + extra folders) is written at every spawn as the ONLY file `CLAUDE.md` of `<data dir>/workspace-prompts/<lane id>/` (a member's: `prompt-<lane id>/` in their handoff dir, via `write-as`), which goes in as one more `--add-dir` with `CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD=1` in the pane env — never `--append-system-prompt-file`: a custom system prompt is a launch flag Claude cannot carry over a restart, so it refused `/tui` in every pane with instructions (side effect: a CLAUDE.md in an extra folder is now read too); Codex gets one `--add-dir` per directory and `-c developer_instructions=<JSON string>` — the text itself up to 6 KB, else a line pointing at that file. Only `claude`/`codex` take them. Never write them into the user's repo (CLAUDE.md/AGENTS.md).
- **A workspace's sessions are the ones it OWNS, never the ones that ran in its folder.** `sessions.lane_id` is stamped by `bindRunSession` (`server/routes/hooks.js`) for the session a dashboard pane holds — first writer wins, a nested `claude -p` excluded — and no route sets it. A Codex/Antigravity session (their hooks are opt-in, so a pane may never learn its session) is also stamped by `claimToolSessions` (`server/lib/dashboard-runs.js`, called by both pollers and once at start): the lane of the newest same-tool dashboard run in the session's folder that was live when the session started, never a Claude session. A new Claude pane is started with `--session-id <uuid>` (`spawnRun`), so its `dashboard_runs.session_id` is known before any hook and a hook that carries no `run_id` (a hook handler older than `AGENTHUB_RUN_ID` forwarding) is still stamped from that run (`laneForSession`). Every workspace session list reads `GET /api/sessions?lane=<id>` — which also counts a still-unstamped session one of the lane's `dashboard_runs` holds, because hooks reach only the lowest-port dashboard on a shared DB and that one may be an older build that never stamps — and shows nothing else (no "other sessions in this folder"). Do not go back to listing a workspace by `cwd`, and do not guess ownership for old history.
- **A session's worktree is `claude --worktree`'s, resolved from the session row — and removing it never forces.** A Worktree start passes `--worktree=<server-made name>` (the `=` form: `-w [name]` eats the prompt otherwise) and Claude Code makes, populates and cleans up `<repo>/.claude/worktrees/<name>`; AgentHub only excludes `/.claude/worktrees/` at the repo root. A git route, a resume or a removal may work in a session's worktree only through `sessionWorktree` (`server/lib/session-worktree.js`: the session owned by the lane, its cwd not the lane's folder, registered by `git worktree list`) — `?session=` on the git routes answers `ENOWORKTREE` rather than falling back to the main folder. `POST /api/lanes/:id/session-worktree/remove` (same-origin, `confirm: true`, refused while a live pane holds the session) is the ONE dashboard path that deletes a worktree: `git worktree remove` with no `--force`, `git branch -d` never `-D`, a live-pid lock refused. Do not add a force, and do not let it touch a lane's own folder.
+33 -2
View File
@@ -70,7 +70,8 @@
"form.save": "Add",
"form.cancel": "Cancel",
"added": "Added",
"files": "{{count}} files",
"files_one": "{{count}} file",
"files_other": "{{count}} files",
"close": "Close",
"fromSession": "From a session",
"form.saveEdit": "Save",
@@ -129,6 +130,12 @@
"reinstallHint": "Write this version over your installed copy",
"reinstallTitle": "Reinstall “{{title}}”?",
"reinstallBody": "Your installed ~/.claude/skills/{{name}}/ and ~/.agents/skills/{{name}}/ are replaced with this version. The current copy is moved to ~/.claude/agenthub-replaced-skills/, not deleted.",
"uninstall": "Uninstall",
"uninstallHint": "Remove it from your account",
"uninstallTitle": "Uninstall “{{title}}”?",
"uninstallSkillBody": "~/.claude/skills/{{name}}/ and ~/.agents/skills/{{name}}/ are moved to ~/.claude/agenthub-replaced-skills/, not deleted. Sessions already open keep it until they reload.",
"uninstallPluginBody": "Runs claude plugin uninstall {{name}} for your account; the plugin's data folder is kept. Open sessions keep it until /reload-plugins.",
"uninstalled": "Uninstalled {{name}}",
"deleteBodyShared": "It is shared, so it also disappears from the Hub for everyone. To keep it but stop sharing, use Unshare instead. Anything already installed from it stays installed.",
"deleteBodyKeep": "It leaves your library but stays in the Hub, because it is shared. To take it off the Hub too, use Remove from Hub afterwards (or Unshare first).",
"deleteKept": "Removed from your library — still in the Hub",
@@ -143,5 +150,29 @@
"preview.frontmatter": "Frontmatter (SKILL.md header)",
"preview.whenUsed": "When Claude uses it",
"preview.noDescription": "No description",
"preview.installsAs": "Installs as ~/.claude/skills/{{slug}}/ and ~/.agents/skills/{{slug}}/"
"preview.installsAs": "Installs as ~/.claude/skills/{{slug}}/ and ~/.agents/skills/{{slug}}/",
"blurb.skill": "Reusable instructions Claude follows",
"blurb.plugin": "Bundles of skills, commands and hooks",
"blurb.page": "Pages your sessions published on claude.ai",
"blurb.file": "Documents and outputs worth keeping",
"clearSearch": "Clear search",
"categories": "Categories",
"seeAll": "See all",
"results_one": "{{count}} result",
"results_other": "{{count}} results",
"empty.title": "Your library is empty",
"empty.hubTitle": "The Hub is quiet",
"empty.searchTitle": "No matches",
"empty.search": "Nothing matches this search or category — try other words, or pick All.",
"details": "Details",
"more": "More actions",
"about": "About",
"stat.kind": "Type",
"stat.installs": "Installs",
"stat.files": "Files",
"stat.size": "Size",
"stat.updated": "Updated",
"detail.runAs": "Run it as",
"detail.market": "Marketplace",
"detail.added": "Added {{date}}"
}
+33 -2
View File
@@ -70,7 +70,8 @@
"form.save": "Thêm",
"form.cancel": "Huỷ",
"added": "Đã thêm",
"files": "{{count}} file",
"files_one": "{{count}} file",
"files_other": "{{count}} file",
"close": "Đóng",
"fromSession": "Từ một phiên",
"form.saveEdit": "Lưu",
@@ -129,6 +130,12 @@
"reinstallHint": "Ghi bản này đè lên bản bạn đã cài",
"reinstallTitle": "Cài lại “{{title}}”?",
"reinstallBody": "Bản đã cài ở ~/.claude/skills/{{name}}/ và ~/.agents/skills/{{name}}/ sẽ được thay bằng bản này. Bản hiện tại được chuyển vào ~/.claude/agenthub-replaced-skills/, không bị xoá.",
"uninstall": "Gỡ cài đặt",
"uninstallHint": "Gỡ khỏi tài khoản của bạn",
"uninstallTitle": "Gỡ cài đặt “{{title}}”?",
"uninstallSkillBody": "~/.claude/skills/{{name}}/ và ~/.agents/skills/{{name}}/ sẽ được chuyển vào ~/.claude/agenthub-replaced-skills/, không bị xoá. Các phiên đang mở vẫn giữ nó cho tới khi tải lại.",
"uninstallPluginBody": "Chạy claude plugin uninstall {{name}} cho tài khoản của bạn; thư mục dữ liệu của plugin được giữ lại. Các phiên đang mở vẫn giữ nó cho tới khi /reload-plugins.",
"uninstalled": "Đã gỡ {{name}}",
"deleteBodyShared": "Mục này đang được chia sẻ nên cũng sẽ biến mất khỏi Hub với mọi người. Muốn giữ lại nhưng thôi chia sẻ thì dùng Bỏ chia sẻ. Những gì đã cài từ nó vẫn được giữ.",
"deleteBodyKeep": "Mục này rời khỏi thư viện của bạn nhưng vẫn ở trên Hub vì đang được chia sẻ. Muốn gỡ khỏi Hub luôn thì sau đó bấm Gỡ khỏi Hub (hoặc Bỏ chia sẻ trước).",
"deleteKept": "Đã xoá khỏi thư viện — vẫn còn trên Hub",
@@ -143,5 +150,29 @@
"preview.frontmatter": "Frontmatter (phần đầu SKILL.md)",
"preview.whenUsed": "Khi nào Claude dùng",
"preview.noDescription": "Chưa có mô tả",
"preview.installsAs": "Cài vào ~/.claude/skills/{{slug}}/ và ~/.agents/skills/{{slug}}/"
"preview.installsAs": "Cài vào ~/.claude/skills/{{slug}}/ và ~/.agents/skills/{{slug}}/",
"blurb.skill": "Bộ hướng dẫn tái sử dụng cho Claude",
"blurb.plugin": "Gói skill, lệnh và hook",
"blurb.page": "Trang mà phiên đã publish lên claude.ai",
"blurb.file": "Tài liệu và kết quả đáng giữ lại",
"clearSearch": "Xoá tìm kiếm",
"categories": "Danh mục",
"seeAll": "Xem tất cả",
"results_one": "{{count}} kết quả",
"results_other": "{{count}} kết quả",
"empty.title": "Thư viện đang trống",
"empty.hubTitle": "Hub chưa có gì",
"empty.searchTitle": "Không tìm thấy",
"empty.search": "Không có mục nào khớp tìm kiếm hoặc danh mục này — thử từ khác, hoặc chọn Tất cả.",
"details": "Chi tiết",
"more": "Thao tác khác",
"about": "Giới thiệu",
"stat.kind": "Loại",
"stat.installs": "Lượt cài",
"stat.files": "File",
"stat.size": "Dung lượng",
"stat.updated": "Cập nhật",
"detail.runAs": "Gọi bằng",
"detail.market": "Marketplace",
"detail.added": "Thêm lúc {{date}}"
}
+5
View File
@@ -3007,6 +3007,11 @@ export const api = {
method: "POST",
body: JSON.stringify({ replace }),
}),
/** POST /api/artifacts/:id/uninstall — a skill moved aside, a plugin `claude plugin uninstall`. */
uninstall: (id: number) =>
request<{ uninstalled: string; paths: string[] }>(`/artifacts/${id}/uninstall`, {
method: "POST",
}),
/** GET /api/artifacts/:id/installs — who installed it, when, and where. */
installs: (id: number) => request<ArtifactInstalls>(`/artifacts/${id}/installs`),
/** One of the snapshot's own files, served sandboxed. */
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();
});
});
+3 -1
View File
@@ -3050,6 +3050,7 @@ PATCH /api/artifacts/:id {title?, description?, shared?, s
DELETE /api/artifacts/:id {ok, kept} — kept: a shared row of yours left your library but stays in the Hub
GET /api/artifacts/:id/raw?path=&download=1 one snapshot file (sandbox CSP, nosniff)
POST /api/artifacts/:id/install {replace?} → {installed, replaced?}
POST /api/artifacts/:id/uninstall → {uninstalled, paths}
GET /api/artifacts/:id/installs {items: [{user, mine, at, replaced, paths?}], unrecorded}
```
@@ -3063,7 +3064,8 @@ What agents produced, per OWNER (`server/lib/artifacts.js`, plan: `docs/plans/ar
- **plugin** — only the spec and its marketplace source (`EBADPLUGIN`, `EBADMARKET`: `owner/repo`, a `validateRemoteUrl` URL, or an https link to a `marketplace.json`); no files. A GitHub web link becomes `owner/repo`, and a forge's `…/src/…`, `…/tree/…`, `…/blob/…` suffix or trailing slash is trimmed. The same `key` added again by its owner updates that row. **inspect** reads the marketplace's `.claude-plugin/marketplace.json` to list its plugins: a git link is cloned shallow, blob-less and without checkout into a temp dir that is removed after, with every credential helper switched off and no prompt (a repository that needs a login is `502 ENEEDLOGIN`; type the key by hand — installing then uses each user's own git login), https only (an ssh link is `400 ESSHMARKET`); a repo with only a `plugin.json` is `400 ENOTMARKET`; unreachable `502 EMARKETFETCH`. It reaches any https host a signed-in user names, like `publish` does — no login is ever sent with it.
- **Visibility** — local mode: every row, no sharing (`400 ELOCALMODE`). Server mode: a developer sees their own rows and shared ones, admin/analyst every row; only the owner or an admin changes or deletes one (`403 EFORBIDDEN`). `shared` puts a row in every user's **Hub**. The page shows one list (`scope=all`: own rows plus shared ones) with an **All · My library · Hub** filter. Deleting a SHARED row of your own only takes it out of your library (`in_library = 0`, `inLibrary: false`, `{kept: true}`; it leaves `scope=mine` and shows as **Hub only**); unsharing such a row then deletes it (`PATCH` answers `{item, removed: true}`). Deleting an unshared row, or an admin deleting someone else's, deletes it outright.
- **raw** — only a file the snapshot recorded (`404 ENOFILE` otherwise), never a request path; served with `Content-Security-Policy: …; sandbox` and `nosniff`, unknown types as an attachment.
- **install** — a skill or plugin (`EBADKIND`), into the CALLER's own account: a skill is written to `~/.claude/skills/<name>/` AND `~/.agents/skills/<name>/` (the folder Codex, Gemini/Antigravity, OpenCode and Cursor read) of every account the caller's sessions run as — under S3 their member home (as them through `write-as`), plus the host's home for an admin, whose host-folder workspaces run as the host. The answer is `{installed, paths, replaced}` (`installed` = the paths joined). A name taken in any of them is `409 EEXISTS` unless `replace: true`, which moves each old one to `~/.claude/agenthub-replaced-skills/<name>[-agents]-<time>/`. A plugin runs `claude plugin marketplace add <market>` then `claude plugin install --scope user <key>` as the caller, never with `-y`. `403 EINSTALLSOFF` while an admin has turned installs off. A list row's `installed` is true when that skill (`~/.claude/skills/<name>`) or plugin (a key of `~/.claude/plugins/installed_plugins.json`) is already in EVERY one of those accounts — the page then shows **Installed** instead of Install; it is a pure read, and an account that cannot be resolved or read counts as not installed. Every install is also a row of `artifact_installs` (who, when, the folders written, whether it replaced); `GET …/installs` lists them newest first — the owner, staff and local mode see every install, anyone else only their own, and `paths` goes only to the installer and an admin (a plugin has none: it is `claude plugin install --scope user`). `unrecorded` is what `installs` counted before that table existed. The card's "N installs" opens this list. Share, unshare, a shared row's delete and every install by someone else are written to `audit_log`.
- **install** — a skill or plugin (`EBADKIND`), into the CALLER's own account: a skill is written to `~/.claude/skills/<name>/` AND `~/.agents/skills/<name>/` (the folder Codex, Gemini/Antigravity, OpenCode and Cursor read) of every account the caller's sessions run as — under S3 their member home (as them through `write-as`), plus the host's home for an admin, whose host-folder workspaces run as the host. The answer is `{installed, paths, replaced}` (`installed` = the paths joined). A name taken in any of them is `409 EEXISTS` unless `replace: true`, which moves each old one to `~/.claude/agenthub-replaced-skills/<name>[-agents]-<time>/`. A plugin runs `claude plugin marketplace add <market>` then `claude plugin install --scope user <key>` as the caller, never with `-y`. `403 EINSTALLSOFF` while an admin has turned installs off. A list row's `installed` is true when that skill (`~/.claude/skills/<name>`) or plugin (a key of `~/.claude/plugins/installed_plugins.json`) is already in EVERY one of those accounts — the page then shows **Installed** instead of Install; it is a pure read, and an account that cannot be resolved or read counts as not installed. Every install is also a row of `artifact_installs` (who, when, the folders written, whether it replaced); `GET …/installs` lists them newest first — the owner, staff and local mode see every install, anyone else only their own, and `paths` goes only to the installer and an admin (a plugin has none: it is `claude plugin install --scope user`). `unrecorded` is what `installs` counted before that table existed. The card's "N installs" opens this list. Share, unshare, a shared row's delete and every install or uninstall by someone else are written to `audit_log`.
- **uninstall** — takes an installed skill or plugin out of the same accounts again (`EBADKIND` otherwise; allowed while installs are off). A skill's folders that exist (both skills folders of each account) are MOVED to `~/.claude/agenthub-replaced-skills/<name>[-agents]-uninstalled-<time>/`, never deleted; a plugin runs `claude plugin uninstall --scope user --keep-data <key>` as the caller (its data dir stays). Nothing of it there is `409 ENOTINSTALLED`. The answer is `{uninstalled, paths}` (`paths` = the skill folders moved; empty for a plugin). The `installs` count and `artifact_installs` history are left as they were. The page shows **Uninstall** (card icon, ⋯ menu, detail sheet) beside **Installed**.
- Every write is same-origin. The `artifact_changed` broadcast (`{id, ownerId, shared}`) reaches everyone for a shared row, else its owner and staff.
### Query Explorer
+1 -1
View File
File diff suppressed because one or more lines are too long
+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;