diff --git a/CLAUDE.md b/CLAUDE.md index 56e5938e..4b5ae194 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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: `/trash//`), 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 `/trash//`, 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-` 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 `/artifacts//` 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 `/artifacts//` 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 `/workspace-context//` 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 `/workspace-prompts//` (a member's: `prompt-/` 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=` — 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 ` (`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=` — 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=` (the `=` form: `-w [name]` eats the prompt otherwise) and Claude Code makes, populates and cleans up `/.claude/worktrees/`; 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. diff --git a/client/src/i18n/locales/en/artifacts.json b/client/src/i18n/locales/en/artifacts.json index 0952c21e..10072fc5 100644 --- a/client/src/i18n/locales/en/artifacts.json +++ b/client/src/i18n/locales/en/artifacts.json @@ -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}}" } diff --git a/client/src/i18n/locales/vi/artifacts.json b/client/src/i18n/locales/vi/artifacts.json index 6a765841..6183cc45 100644 --- a/client/src/i18n/locales/vi/artifacts.json +++ b/client/src/i18n/locales/vi/artifacts.json @@ -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}}" } diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts index fd2a7ea9..257679b7 100644 --- a/client/src/lib/api.ts +++ b/client/src/lib/api.ts @@ -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(`/artifacts/${id}/installs`), /** One of the snapshot's own files, served sandboxed. */ diff --git a/client/src/pages/Artifacts.tsx b/client/src/pages/Artifacts.tsx index bd93e67c..afe2d07a 100644 --- a/client/src/pages/Artifacts.tsx +++ b/client/src/pages/Artifacts.tsx @@ -1,7 +1,11 @@ /** * @file Artifacts.tsx * @description `/artifacts` — the Artifacts hub (`/api/artifacts`, - * server/lib/artifacts.js): claude.ai pages sessions published, pinned + * server/lib/artifacts.js), laid out like an app store: a hero with the + * search, category chips, the unfiltered front page shelved by kind (Skills, + * Plugins, Pages, Files — "See all" picks the category), each artifact an + * app-icon card with one primary pill (Install / Installed / Open / Preview / + * Download) and a detail sheet a click on the card opens. It holds claude.ai pages sessions published, pinned * workspace files (or one uploaded from the computer), skills (picked, * uploaded as a file/folder/zip, or written — `ArtifactAddPanels.tsx`) and * plugins (read from a marketplace link, or typed). One list holds the @@ -9,7 +13,9 @@ * **All · My library · Hub** filter narrows it. A shared skill or plugin is * installed into the viewer's own account (after a confirmation that lists * what will be written or run); one already installed there shows - * **Installed** instead of the Install button (a skill keeps **Reinstall**, + * **Installed** instead of the Install button, with **Uninstall** beside it + * (a skill's copies moved aside, a plugin `claude plugin uninstall`; a skill + * also keeps **Reinstall**, * which replaces the user's copy after a confirmation). Editing a skill * edits its SKILL.md (name, description, instructions — a new snapshot); * its preview opens with what it installs as and when Claude uses it. Deleting a shared @@ -32,15 +38,20 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { Check, + ChevronRight, + Copy, Download, ExternalLink, Eye, FileText, Globe, + MoreHorizontal, Package, + PackageMinus, Pencil, Plus, RotateCcw, + Search, Share2, Sparkles, Trash2, @@ -72,7 +83,16 @@ import { import { MarkdownPreview, isMarkdown } from "../components/MarkdownPreview"; const KIND_ICON = { page: Globe, file: FileText, skill: Sparkles, plugin: Package } as const; -const KINDS: ArtifactKind[] = ["page", "file", "skill", "plugin"]; +// The storefront's shelf order: what installs first, what is read after. +const SHELVES: ArtifactKind[] = ["skill", "plugin", "page", "file"]; +// Each kind's app-icon tile. White glyphs sit on a fixed gradient, so they +// read the same in every theme. +const KIND_TILE: Record = { + skill: "from-violet-500 to-fuchsia-600", + plugin: "from-emerald-500 to-teal-600", + page: "from-sky-500 to-blue-600", + file: "from-amber-500 to-orange-600", +}; function fmtBytes(n: number | null): string { if (!n) return ""; @@ -81,11 +101,55 @@ function fmtBytes(n: number | null): string { return `${(n / 1024 / 1024).toFixed(1)} MB`; } +const fmtDate = (iso: string) => + new Date(iso).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" }); + const IMAGE = /\.(png|jpe?g|gif|webp|svg)$/i; // What the sandboxed preview can show: the server's typed list minus PDF. const PREVIEWABLE = /\.(html?|md|markdown|txt|log|csv|tsv|json|ya?ml|sh|py|js|ts|png|jpe?g|gif|webp|svg|mp4|webm|mp3|wav)$/i; +/** What a row can do, worked out once for its card, menu and detail sheet. */ +function capabilities(a: Artifact) { + const previewPath = a.kind === "skill" ? "SKILL.md" : (a.files[0]?.path ?? ""); + return { + previewPath, + canPreview: + a.files.length > 0 && + (a.kind === "skill" || (a.kind === "file" && PREVIEWABLE.test(previewPath))), + canDownload: a.kind === "file" && a.files.length > 0, + installable: a.kind === "skill" || a.kind === "plugin", + // Its owner removed it from their library; it lives on in the Hub only. + hubOnly: a.mine && a.shared && !a.inLibrary, + }; +} + +/** The rounded "app icon" every artifact wears: its kind's glyph on its colour. */ +function AppIcon({ kind, size = "md" }: { kind: ArtifactKind; size?: "sm" | "md" | "lg" }) { + const Icon = KIND_ICON[kind]; + const box = { + sm: "h-6 w-6 rounded-lg", + md: "h-12 w-12 rounded-2xl", + lg: "h-20 w-20 rounded-[1.6rem]", + }[size]; + const glyph = { sm: "h-3.5 w-3.5", md: "h-6 w-6", lg: "h-10 w-10" }[size]; + return ( + + + + ); +} + +const pill = + "inline-flex shrink-0 items-center gap-1 rounded-full px-3.5 py-1 text-xs font-semibold transition-colors disabled:cursor-not-allowed disabled:opacity-50"; +const pillPrimary = `${pill} bg-accent-solid text-white hover:bg-accent-solid-hover`; +const pillSoft = `${pill} bg-surface-3 text-fg-primary hover:bg-surface-4`; +const iconBtn = + "flex h-7 w-7 items-center justify-center rounded-full text-fg-muted transition-colors hover:bg-surface-3 hover:text-fg-primary"; + export function Artifacts() { const { t } = useTranslation("artifacts"); const [settings, setSettings] = useState(null); @@ -97,6 +161,8 @@ export function Artifacts() { const [addMenu, setAddMenu] = useState(false); const addMenuRef = useRef(null); const [preview, setPreview] = useState<{ item: Artifact; path: string } | null>(null); + // By id, so the open sheet shows the row as the latest list has it. + const [detailId, setDetailId] = useState(null); useEffect(() => { api.artifacts @@ -183,6 +249,7 @@ export function Artifacts() { try { const r = await api.artifacts.remove(a.id); if (r.kept) toast.success(t("deleteKept")); + setDetailId((id) => (id === a.id ? null : id)); load(); } catch (err) { toast.error(err); @@ -252,18 +319,97 @@ export function Artifacts() { load(); }; + // Taken out of the user's own accounts again; a skill's copies are moved to + // ~/.claude/agenthub-replaced-skills/, never deleted. + const uninstall = async (a: Artifact) => { + if ( + !(await confirmDialog({ + title: t("uninstallTitle", { title: a.title }), + message: t(a.kind === "skill" ? "uninstallSkillBody" : "uninstallPluginBody", { + name: a.source, + }), + confirmLabel: t("uninstall"), + })) + ) + return; + try { + const r = await api.artifacts.uninstall(a.id); + toast.success(t("uninstalled", { name: r.uninstalled })); + } catch (err) { + toast.error(err); + } + load(); + }; + + const actionsFor = (a: Artifact): CardActions => ({ + onOpen: () => setDetailId(a.id), + onShare: () => void share(a), + onRename: () => { + setDetailId(null); + setRenaming(a); + }, + onDelete: () => void remove(a), + onInstall: () => void install(a), + onReinstall: () => void reinstall(a), + onUninstall: () => void uninstall(a), + onPreview: (path) => { + setDetailId(null); + setPreview({ item: a, path }); + }, + }); + + const card = (a: Artifact) => ( + + ); + + // The storefront's front page — everything, unsearched — is shelved by + // kind; a category or a search is one flat grid of results. + const shelves = + items && !kind && !q.trim() + ? SHELVES.map((k) => ({ kind: k, rows: items.filter((a) => a.kind === k) })).filter( + (s) => s.rows.length > 0 + ) + : null; + const detail = detailId === null ? null : (items?.find((a) => a.id === detailId) ?? null); + return ( -
-
-
-

{t("title")}

-

{t(server ? "subtitleServer" : "subtitle")}

-
-
+
+
+
+
+
+
+ + {SHELVES.map((k) => ( + + ))} + +
+

{t("title")}

+

+ {t(server ? "subtitleServer" : "subtitle")} +

+
+
- ); - })} +
+ {SHELVES.map((k) => ( + + ))}
)}
+
+
+ {(["", ...SHELVES] as const).map((k) => { + const on = kind === k; + const Icon = k ? KIND_ICON[k] : null; + return ( + + ); + })} +
{server && (
{(["all", "mine", "hub"] as const).map((v) => ( @@ -309,10 +506,10 @@ export function Artifacts() { type="button" aria-pressed={tab === v} onClick={() => setTab(v)} - className={`rounded px-2.5 py-1 text-xs ${ + className={`rounded-full px-3 py-1 text-xs font-medium transition-colors ${ tab === v - ? "bg-surface-4 text-fg-primary" - : "text-fg-secondary hover:bg-surface-3 hover:text-fg-primary" + ? "bg-surface-1 text-fg-primary shadow-sm" + : "text-fg-secondary hover:text-fg-primary" }`} > {t(`tab.${v}`)} @@ -320,57 +517,62 @@ export function Artifacts() { ))}
)} -
- {(["", ...KINDS] as const).map((k) => ( - - ))} -
- setQ(e.target.value)} - placeholder={t("search")} - aria-label={t("search")} - className="ml-auto w-56 rounded border border-border bg-surface-1 px-2 py-1 text-sm text-fg-primary" - />
{items === null ? ( -

{t("loading")}

+ ) : items.length === 0 ? ( -

- {t(tab === "hub" ? "empty.hub" : "empty.mine")} -

- ) : ( -
    - {items.map((a) => ( - void share(a)} - onRename={() => setRenaming(a)} - onDelete={() => void remove(a)} - onInstall={() => void install(a)} - onReinstall={() => void reinstall(a)} - onPreview={(path) => setPreview({ item: a, path })} - /> + setAdding(k)} + /> + ) : shelves ? ( +
    + {shelves.map((s) => ( +
    +
    +
    +

    + {t(`kind.${s.kind}`)} + + {s.rows.length} + +

    +

    {t(`blurb.${s.kind}`)}

    +
    + +
    +
      {s.rows.map(card)}
    +
    ))} -
+
+ ) : ( +
+

{t("results", { count: items.length })}

+
    {items.map(card)}
+
)} + {detail && ( + setDetailId(null)} + {...actionsFor(detail)} + /> + )} {adding && setAdding(null)} onAdded={load} />} {preview && setPreview(null)} />} {renaming && ( @@ -387,155 +589,343 @@ export function Artifacts() { ); } -function ArtifactCard({ - item: a, - server, - canInstall, - onShare, - onRename, - onDelete, - onInstall, - onReinstall, - onPreview, +function SkeletonGrid({ label }: { label: string }) { + return ( +
+ {label} +
    + {Array.from({ length: 6 }, (_, i) => ( +
  • +
    + + + + + +
    + + +
  • + ))} +
+
+ ); +} + +function EmptyState({ + hub, + searching, + onAdd, }: { - item: Artifact; - server: boolean; - canInstall: boolean; + hub: boolean; + searching: boolean; + onAdd: (k: ArtifactKind) => void; +}) { + const { t } = useTranslation("artifacts"); + return ( +
+ + {searching ? : } + +

+ {t(searching ? "empty.searchTitle" : hub ? "empty.hubTitle" : "empty.title")} +

+

+ {searching ? t("empty.search") : t(hub ? "empty.hub" : "empty.mine")} +

+ {!hub && !searching && ( +
+ {SHELVES.map((k) => ( + + ))} +
+ )} +
+ ); +} + +type CardActions = { + onOpen: () => void; onShare: () => void; onRename: () => void; onDelete: () => void; onInstall: () => void; onReinstall: () => void; + onUninstall: () => void; + onPreview: (path: string) => void; +}; + +/** The one primary pill on a card / detail sheet, App Store "Get" style. */ +function PrimaryAction({ + item: a, + canInstall, + onInstall, + onPreview, +}: { + item: Artifact; + canInstall: boolean; + onInstall: () => void; onPreview: (path: string) => void; }) { const { t } = useTranslation("artifacts"); - const Icon = KIND_ICON[a.kind]; + const c = capabilities(a); + if (a.installed) + return ( + + + {t("installedBadge")} + + ); + if (c.installable) + return ( + + ); + if (a.kind === "page" && a.url) + return ( + + {t("open")} + + + ); + if (c.canPreview) + return ( + + ); + if (c.canDownload) + return ( + + {t("download")} + + ); + return null; +} + +function ArtifactCard({ + item: a, + server, + canInstall, + onOpen, + onShare, + onRename, + onDelete, + onInstall, + onReinstall, + onUninstall, + onPreview, +}: CardActions & { + item: Artifact; + server: boolean; + canInstall: boolean; +}) { + const { t } = useTranslation("artifacts"); const menu = useContextMenu(); const pick = (fn: () => void) => () => { menu.close(); fn(); }; - // Its owner removed it from their library; it lives on in the Hub only. - const hubOnly = a.mine && a.shared && !a.inLibrary; - const previewPath = a.kind === "skill" ? "SKILL.md" : (a.files[0]?.path ?? ""); - const canPreview = - a.files.length > 0 && - (a.kind === "skill" || (a.kind === "file" && PREVIEWABLE.test(previewPath))); - const btn = - "flex items-center gap-1 rounded px-1.5 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"; + const { previewPath, canPreview, canDownload, installable, hubOnly } = capabilities(a); + // The primary pill already offers Preview/Download for a plain file. + const primaryIsPreview = !installable && a.kind !== "page" && canPreview; return (
  • menu.open(e, null)} > -
    - -
    -

    - {a.title} -

    -

    - {t(`kindOne.${a.kind}`)} - {a.source ? ` · ${a.source}` : ""} - {a.size ? ` · ${fmtBytes(a.size)}` : ""} -

    -
    - {a.shared && ( - - {t(hubOnly ? "hubOnly" : "shared")} +
    + +
    - {a.description &&

    {a.description}

    } -

    - {server && a.owner && !a.mine ? `${t("by", { name: a.owner })} · ` : ""} - {new Date(a.createdAt).toLocaleString()} - {a.installs > 0 && ( - <> - {" · "} - - - )} -

    -
    - {a.kind === "page" && a.url && ( - - - {t("open")} - - )} - {canPreview && ( - - )} - {a.kind === "file" && a.files.length > 0 && ( - - - {t("download")} - - )} - {a.installed && ( - - - {t("installedBadge")} + {(a.description || a.shared) && ( +
    + {a.shared && ( + + + {t(hubOnly ? "hubOnly" : "shared")} + + )} + {a.description && ( +

    + {a.description} +

    + )} +
    + )} +
    + + {server && a.owner && !a.mine && ( + <> + + {a.owner.slice(0, 1)} + + {t("by", { name: a.owner })} · + + )} + + {fmtDate(a.createdAt)} - )} - {a.installed && a.kind === "skill" && ( - - )} - {(a.kind === "skill" || a.kind === "plugin") && !a.installed && ( - - )} - + {a.installs > 0 && ( + <> + · + + + )} + + + {canPreview && !primaryIsPreview && ( + + )} + {canDownload && primaryIsPreview && ( + + + + )} + {a.installed && a.kind === "skill" && ( + + )} + {a.installed && ( + + )} {server && a.canChange && ( - )} {a.canChange && ( <> - {!hubOnly && ( - )} )} +
    {menu.menu && ( + {t("details")} {a.kind === "page" && a.url && ( <> window.open(a.url!, "_blank", "noopener,noreferrer"))}> @@ -547,7 +937,7 @@ function ArtifactCard({ {canPreview && ( onPreview(previewPath))}>{t("preview")} )} - {a.kind === "file" && a.files.length > 0 && ( + {canDownload && ( { window.location.href = api.artifacts.rawUrl(a.id, a.files[0]?.path, true); @@ -556,7 +946,7 @@ function ArtifactCard({ {t("download")} )} - {(a.kind === "skill" || a.kind === "plugin") && !a.installed && ( + {installable && !a.installed && ( {t("install")} @@ -566,6 +956,11 @@ function ArtifactCard({ {t("reinstall")} )} + {a.installed && ( + + {t("uninstall")} + + )} {a.canChange && ( <> @@ -588,6 +983,229 @@ function ArtifactCard({ ); } +/** An artifact's own page, App Store style: icon and Get button up top, a + * strip of facts, what it is, how it installs, and what it carries. */ +function DetailDialog({ + item: a, + server, + canInstall, + onClose, + onShare, + onRename, + onDelete, + onInstall, + onReinstall, + onUninstall, + onPreview, +}: CardActions & { + item: Artifact; + server: boolean; + canInstall: boolean; + onClose: () => void; +}) { + const { t } = useTranslation("artifacts"); + const { canPreview, canDownload, hubOnly } = capabilities(a); + const secondary = + "inline-flex items-center gap-1.5 rounded-full border border-border px-3 py-1 text-xs font-medium text-fg-secondary hover:bg-surface-3 hover:text-fg-primary disabled:opacity-50"; + const stats: { label: string; value: React.ReactNode }[] = [ + { label: t("stat.kind"), value: t(`kindOne.${a.kind}`) }, + ...(a.kind === "skill" || a.kind === "plugin" + ? [{ label: t("stat.installs"), value: a.installs > 0 ? : "0" }] + : []), + ...(a.files.length ? [{ label: t("stat.files"), value: String(a.files.length) }] : []), + ...(a.size ? [{ label: t("stat.size"), value: fmtBytes(a.size) }] : []), + { label: t("stat.updated"), value: fmtDate(a.updatedAt) }, + ]; + return ( + +
    +
    + +
    +

    {a.title}

    +

    + {server && a.owner && !a.mine ? t("by", { name: a.owner }) : t(`kindOne.${a.kind}`)} + {a.source ? ` · ${a.source}` : ""} +

    + {a.shared && ( + + + {t(hubOnly ? "hubOnly" : "shared")} + + )} +
    + + {a.installed && a.kind === "skill" && ( + + )} + {a.installed && ( + + )} + {canPreview && a.kind !== "file" && ( + + )} + {canDownload && canPreview && ( + + + {t("download")} + + )} + {a.kind === "page" && a.url && ( + + )} +
    +
    +
    + +
    + {stats.map((s) => ( +
    +
    + {s.label} +
    +
    {s.value}
    +
    + ))} +
    + +
    +

    + {t(a.kind === "skill" ? "preview.whenUsed" : "about")} +

    +

    + {a.description || {t("preview.noDescription")}} +

    +
    + + {a.kind === "skill" && a.source && ( +
    +

    + {t("detail.runAs")} + + /{a.source} + +

    +

    + {t("preview.installsAs", { slug: a.source })} +

    +
    + )} + {a.kind === "plugin" && ( +
    +

    + {t("form.pluginKey")}: {a.source} +

    + {a.market && ( +

    + {t("detail.market")}: {a.market} +

    + )} +
    + )} + + {a.files.length > 0 && a.kind !== "page" && ( +
    +

    + {t("files", { count: a.files.length })} +

    +
      + {a.files.map((f) => ( +
    • + + + {f.path} + + {fmtBytes(f.size)} + {PREVIEWABLE.test(f.path) && ( + + )} + + + +
    • + ))} +
    +
    + )} + +

    + {t("detail.added", { date: new Date(a.createdAt).toLocaleString() })} +

    + + {a.canChange && ( +
    + {server && ( + + )} + + {!hubOnly && ( + + )} +
    + )} +
    +
    + ); +} + /** "N installs" on a card; a click lists who installed it, when, and where. */ function InstallsButton({ item }: { item: Artifact }) { const { t } = useTranslation("artifacts"); @@ -698,12 +1316,15 @@ function Modal({ onClose, children, wide = false, + size, flush = false, }: { title: string; onClose: () => void; children: React.ReactNode; wide?: boolean; + /** A detail sheet: wider than a form, narrower than a preview. */ + size?: "md"; /** The body draws its own padding (a preview fills the panel edge to edge). */ flush?: boolean; }) { @@ -722,7 +1343,7 @@ function Modal({ aria-modal="true" aria-label={title} onClick={(e) => e.stopPropagation()} - className={`flex max-h-[90vh] w-full ${wide ? "max-w-6xl" : "max-w-lg"} flex-col overflow-hidden rounded-xl border border-border bg-surface-1 shadow-2xl shadow-black/50`} + className={`flex max-h-[90vh] w-full ${wide ? "max-w-6xl" : size === "md" ? "max-w-2xl" : "max-w-lg"} flex-col overflow-hidden rounded-2xl border border-border bg-surface-1 shadow-2xl shadow-black/50`} >

    {title}

    diff --git a/client/src/pages/__tests__/Artifacts.test.tsx b/client/src/pages/__tests__/Artifacts.test.tsx index 8469f346..aff2a1f2 100644 --- a/client/src/pages/__tests__/Artifacts.test.tsx +++ b/client/src/pages/__tests__/Artifacts.test.tsx @@ -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ĩ */ 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(); + }); }); diff --git a/docs/API.md b/docs/API.md index cf25c28d..dd512b4d 100644 --- a/docs/API.md +++ b/docs/API.md @@ -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//` AND `~/.agents/skills//` (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/[-agents]-