From 72aef2426c79562109887635841bb20089046802 Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Fri, 23 Oct 2026 15:10:49 +0700 Subject: [PATCH] feat(artifacts): a shared skill follows its owner's folder; installers get Update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A skill in the Hub was a snapshot taken once: the owner changing ~/.claude/skills/ changed nothing there, and an installer had to uninstall and install again to get a newer version. - syncSkillSources (on GET /api/artifacts, at most every 15 s): a skill row taken from a folder — the owner's ~/.claude/skills/, or the workspace's .claude/skills/ — is brought up to it. Only a folder whose names/sizes/mtimes changed is read again, with a pick's checks (no symlink, no secret, <= 5 MB, a member's files as theirs); a change is a NEW snapshot, the old one dropped, and an artifact_changed broadcast. - artifacts.version (snapshot content hash) and source_hash (the folder's when last taken, additive columns): an upload or a Hub edit records the folder as it is then, so only a LATER folder change replaces it. - A list row's updateAvailable: installed, and the viewer's copy differs from version. The page shows Update (a pack: Update N); install with {update: true} replaces the copies (old ones moved aside) and is not counted as another install. --- CLAUDE.md | 4 +- client/src/i18n/locales/en/artifacts.json | 11 +- client/src/i18n/locales/vi/artifacts.json | 11 +- client/src/lib/api.ts | 10 + client/src/pages/Artifacts.tsx | 102 +++++- client/src/pages/__tests__/Artifacts.test.tsx | 28 ++ docs/API.md | 7 +- server/__tests__/artifacts.test.js | 69 +++- server/db.js | 6 + server/lib/artifacts.js | 304 +++++++++++++++++- server/routes/artifacts.js | 4 + 11 files changed, 526 insertions(+), 30 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e2b1d570..42cfb464 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # Claude Code Working Guide -_Last synced: v2.8.23 (2026-10-26) — OpenCode and Grok Build left the catalog, the Kimi quota reader too (`opencode-usage.js` gone, a Grok payload on the Claude hook route is dropped)._ 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.23 (2026-10-26) — OpenCode, Grok Build and the Kimi quota reader left (`opencode-usage.js` gone, a Grok payload on the Claude hook route is dropped); a shared skill follows its owner's folder and a differing installed copy shows Update._ 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`; 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. +- **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 — in the Hub, or in the skill's source folder, which the server takes when the Hub is listed and an installer's differing copy is then offered **Update** — writes a NEW snapshot and drops the old one — never a write into the one being read; the newer of the two changes wins) — `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 e09571e6..a0fbd7a6 100644 --- a/client/src/i18n/locales/en/artifacts.json +++ b/client/src/i18n/locales/en/artifacts.json @@ -214,5 +214,14 @@ "pack.installBody": "Each one is written into your own account. A skill you already have under the same name is left as it is:", "pack.installed": "Installed {{ok}} · already there {{skipped}} · failed {{failed}}", "section.unshared": "Not shared by their owners", - "section.unsharedHint": "Other users' private items — only admins see these" + "section.unsharedHint": "Other users' private items — only admins see these", + "update": "Update", + "updateHint": "Its owner changed it since you installed it", + "updateTitle": "Update “{{title}}”?", + "updateBody": "Your copy of {{name}} is replaced by the newest version. The current one is moved to ~/.claude/agenthub-replaced-skills/, not deleted.", + "updated": "Updated {{name}}", + "pack.updateN": "Update {{count}}", + "pack.updateTitle": "Update {{count}} skills of {{prefix}}-*?", + "pack.updateBody": "Each copy is replaced by the newest version; the current ones are moved to ~/.claude/agenthub-replaced-skills/:", + "pack.updated": "Updated {{ok}} · failed {{failed}}" } diff --git a/client/src/i18n/locales/vi/artifacts.json b/client/src/i18n/locales/vi/artifacts.json index 5c4e2b57..ac83cc48 100644 --- a/client/src/i18n/locales/vi/artifacts.json +++ b/client/src/i18n/locales/vi/artifacts.json @@ -212,5 +212,14 @@ "pack.installBody": "Mỗi skill được ghi vào tài khoản của bạn. Skill nào bạn đã có cùng tên thì giữ nguyên:", "pack.installed": "Đã cài {{ok}} · đã có sẵn {{skipped}} · lỗi {{failed}}", "section.unshared": "Chủ chưa chia sẻ", - "section.unsharedHint": "Mục riêng tư của người khác — chỉ admin thấy" + "section.unsharedHint": "Mục riêng tư của người khác — chỉ admin thấy", + "update": "Cập nhật", + "updateHint": "Người tạo đã sửa skill này từ khi bạn cài", + "updateTitle": "Cập nhật “{{title}}”?", + "updateBody": "Bản {{name}} của bạn sẽ được thay bằng phiên bản mới nhất. Bản hiện tại được chuyển sang ~/.claude/agenthub-replaced-skills/, không bị xóa.", + "updated": "Đã cập nhật {{name}}", + "pack.updateN": "Cập nhật {{count}}", + "pack.updateTitle": "Cập nhật {{count}} skill của bộ {{prefix}}-*?", + "pack.updateBody": "Mỗi bản sẽ được thay bằng phiên bản mới nhất; bản hiện tại được chuyển sang ~/.claude/agenthub-replaced-skills/:", + "pack.updated": "Đã cập nhật {{ok}} · lỗi {{failed}}" } diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts index 9bb23646..5fb9891a 100644 --- a/client/src/lib/api.ts +++ b/client/src/lib/api.ts @@ -3143,6 +3143,13 @@ export const api = { method: "POST", body: JSON.stringify({ replace }), }), + /** POST /api/artifacts/:id/install `{update: true}` — an installed skill's + * copies replaced by its newest version (the old ones moved aside). */ + updateInstalled: (id: number) => + request<{ installed: string; replaced?: boolean }>(`/artifacts/${id}/install`, { + method: "POST", + body: JSON.stringify({ update: true }), + }), /** 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`, { @@ -4423,6 +4430,9 @@ export interface Artifact { inLibrary: boolean; /** List rows only: a skill/plugin already in every account the viewer's sessions run as. */ installed?: boolean; + /** List rows only: an installed skill whose copy differs from its newest + * version (its owner changed it since) — the list offers Update. */ + updateAvailable?: boolean; /** file: captured when its session stopped, not pinned by hand. */ auto?: boolean; /** Server mode: nobody owns it yet. */ diff --git a/client/src/pages/Artifacts.tsx b/client/src/pages/Artifacts.tsx index 20e47c85..047dc2d1 100644 --- a/client/src/pages/Artifacts.tsx +++ b/client/src/pages/Artifacts.tsx @@ -27,8 +27,10 @@ * what will be written or run); one already installed there shows * **Installed** instead of the Install button; **Uninstall** (a skill's * copies moved aside, a plugin `claude plugin uninstall`) is offered only in - * the card's ⋯ menu. There is no Reinstall: to take a newer version, uninstall - * and install again. Editing a skill + * the card's ⋯ menu. A skill follows its owner's folder (the server takes a + * change there as a new snapshot when the Hub is listed); an installed copy + * that differs from the newest version shows **Update** (a pack: "Update N"), + * which replaces it after a confirmation, the old copy moved aside. 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 * row of your own only takes it out of your library — it stays in the Hub @@ -337,6 +339,52 @@ export function Artifacts() { load(); }; + // The newest version over an installed copy; the old copy is moved aside. + const updateInstalled = async (a: Artifact) => { + if ( + !(await confirmDialog({ + title: t("updateTitle", { title: a.title }), + message: t("updateBody", { name: a.source }), + confirmLabel: t("update"), + destructive: false, + })) + ) + return; + try { + await api.artifacts.updateInstalled(a.id); + toast.success(t("updated", { name: a.source })); + } catch (err) { + toast.error(err); + } + load(); + }; + + // A pack's skills that have a newer version, after one confirmation. + const updatePack = async (p: Pack) => { + const todo = p.rows.filter((a) => a.updateAvailable); + if ( + !(await confirmDialog({ + title: t("pack.updateTitle", { prefix: p.prefix, count: todo.length }), + message: `${t("pack.updateBody")} ${todo.map((a) => a.source || a.title).join(", ")}`, + confirmLabel: t("update"), + destructive: false, + })) + ) + return; + let failed = 0; + for (const a of todo) { + try { + await api.artifacts.updateInstalled(a.id); + } catch { + failed++; + } + } + const summary = t("pack.updated", { ok: todo.length - failed, failed }); + if (failed) toast.error(summary); + else toast.success(summary); + load(); + }; + // A pack's missing skills, after ONE confirmation that names them all; a // skill already there under that name is left alone (never replaced here). const installAll = async (p: Pack) => { @@ -399,6 +447,7 @@ export function Artifacts() { }, onDelete: () => void remove(a), onInstall: () => void install(a), + onUpdate: () => void updateInstalled(a), onUninstall: () => void uninstall(a), onPreview: (path) => { setDetailId(null); @@ -475,6 +524,7 @@ export function Artifacts() { server={server} canInstall={!!settings?.installsEnabled} onInstallAll={() => void installAll(p)} + onUpdateAll={() => void updatePack(p)} render={(a) => render(a, "list", true)} /> ); @@ -871,17 +921,20 @@ function PackRow({ server, canInstall, onInstallAll, + onUpdateAll, render, }: { pack: Pack; server: boolean; canInstall: boolean; onInstallAll: () => void; + onUpdateAll: () => void; render: (a: Artifact) => React.ReactNode; }) { const { t } = useTranslation("artifacts"); const [open, setOpen] = useState(false); const missing = p.rows.filter((a) => !a.installed).length; + const behind = p.rows.filter((a) => a.updateAvailable).length; const installs = p.rows.reduce((n, a) => n + a.installs, 0); return (
  • @@ -918,7 +971,17 @@ function PackRow({ .filter(Boolean) .join(" · ")} - {missing === 0 ? ( + {missing === 0 && behind > 0 ? ( + + ) : missing === 0 ? ( {t("installedBadge")} @@ -1005,6 +1068,7 @@ type CardActions = { onRename: () => void; onDelete: () => void; onInstall: () => void; + onUpdate: () => void; onUninstall: () => void; onPreview: (path: string) => void; }; @@ -1014,15 +1078,30 @@ function PrimaryAction({ item: a, canInstall, onInstall, + onUpdate, onPreview, }: { item: Artifact; canInstall: boolean; onInstall: () => void; + onUpdate: () => void; onPreview: (path: string) => void; }) { const { t } = useTranslation("artifacts"); const c = capabilities(a); + if (a.updateAvailable) + return ( + + ); if (a.installed) return ( > }) { @@ -1198,6 +1278,11 @@ function ArtifactMenu({ {t("install")} )} + {a.updateAvailable && ( + + {t("update")} + + )} {a.installed && ( {t("uninstall")} @@ -1272,7 +1357,13 @@ function ArtifactRow({ inPack, ...props }: ItemProps & { inPack?: boolean }) { {/* A pack's row already says whose it is. */} - + menu.open(e, null)} />
  • @@ -1324,6 +1415,7 @@ function ArtifactCard({ as = "li", ...props }: ItemProps & { as?: "li" | "div" } item={a} canInstall={canInstall} onInstall={onInstall} + onUpdate={props.onUpdate} onPreview={onPreview} /> @@ -1357,6 +1449,7 @@ function DetailDialog({ onRename, onDelete, onInstall, + onUpdate, onPreview, }: CardActions & { item: Artifact; @@ -1396,6 +1489,7 @@ function DetailDialog({ item={a} canInstall={canInstall} onInstall={onInstall} + onUpdate={onUpdate} onPreview={onPreview} /> {canPreview && a.kind !== "file" && ( diff --git a/client/src/pages/__tests__/Artifacts.test.tsx b/client/src/pages/__tests__/Artifacts.test.tsx index 3856f02d..0e5d665c 100644 --- a/client/src/pages/__tests__/Artifacts.test.tsx +++ b/client/src/pages/__tests__/Artifacts.test.tsx @@ -16,6 +16,7 @@ * Rows carry one primary action; Preview, Share and Edit live in the ⋯ menu. * A section shows 30 entries before "Show more", and three or more skills of * one owner sharing a name prefix are one pack row that installs them all. + * An installed skill whose owner changed it shows Update (after a confirmation). * @author Nguyễn Ngọc Trí Vĩ */ import { describe, it, expect, vi, beforeEach } from "vitest"; @@ -36,6 +37,7 @@ vi.mock("../../lib/api", () => ({ update: vi.fn(), remove: vi.fn(), install: vi.fn(), + updateInstalled: vi.fn(), uninstall: vi.fn(), installs: vi.fn(), setInstallsEnabled: vi.fn(), @@ -382,6 +384,32 @@ describe("Artifacts", () => { expect(calls[calls.length - 1]?.[0].title).toMatch(/Remove “hub-only” from the Hub/); }); + it("offers Update for an installed skill its owner changed, after a confirmation", async () => { + vi.mocked(api.artifacts.list).mockResolvedValue({ + items: [ + item({ + id: 2, + kind: "skill", + title: "deploy", + source: "deploy", + installed: true, + updateAvailable: true, + }), + ], + }); + vi.mocked(api.artifacts.updateInstalled).mockResolvedValue({ installed: "/h/x" }); + const user = userEvent.setup(); + open(); + const card = await screen.findByTestId("artifact-2"); + expect(within(card).queryByTestId("artifact-installed")).toBeNull(); + vi.mocked(confirmDialog).mockResolvedValueOnce(false); + await user.click(within(card).getByRole("button", { name: "Update" })); + expect(api.artifacts.updateInstalled).not.toHaveBeenCalled(); + vi.mocked(confirmDialog).mockResolvedValueOnce(true); + await user.click(within(card).getByRole("button", { name: "Update" })); + await waitFor(() => expect(api.artifacts.updateInstalled).toHaveBeenCalledWith(2)); + }); + it("shows Installed instead of Install for a skill already installed", async () => { vi.mocked(api.artifacts.list).mockResolvedValue({ items: [item({ id: 2, kind: "skill", title: "deploy", source: "deploy", installed: true })], diff --git a/docs/API.md b/docs/API.md index 5efbb0a1..e5f0c13b 100644 --- a/docs/API.md +++ b/docs/API.md @@ -3119,7 +3119,7 @@ A snapshot freezes a session as it is at that moment into one immutable `session ### Artifacts hub ```http -GET /api/artifacts?scope=all|mine|hub|unowned&kind=page|file|skill|plugin&q= {items} — each with `installed` (default scope `all`) +GET /api/artifacts?scope=all|mine|hub|unowned&kind=page|file|skill|plugin&q= {items} — each with `installed` and `updateAvailable` (default scope `all`) GET /api/artifacts/settings {serverMode, canShare, installsEnabled, canManage} PUT /api/artifacts/settings {installsEnabled: boolean} — admin (any caller in local mode); the UI is Settings → AI tools → Artifacts Hub GET /api/artifacts/candidates?laneId= {items: [{path, sessionId, at, exists, pinned}]} @@ -3136,7 +3136,7 @@ GET /api/artifacts/:id {item} PATCH /api/artifacts/:id {title?, description?, shared?, source?, market?, skill?: {name, description, content}} (source/market: plugin only; skill: a skill's SKILL.md frontmatter name/description and body — written as a new snapshot, other files kept, row `source`/`description` follow) 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/install {replace?, update?} → {installed, replaced?} POST /api/artifacts/:id/uninstall → {uninstalled, paths} GET /api/artifacts/:id/installs {items: [{user, mine, at, replaced, paths?}], unrecorded} POST /api/artifacts/:id/claim → {item} — the suggested user, or an admin @@ -3155,7 +3155,8 @@ What agents produced, per OWNER (`server/lib/artifacts.js`, plan: `docs/plans/ar - **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. `scope=mine` is the caller's own library rows plus the shared skills/plugins of OTHER users that the caller has installed (`installed: true`, `mine: false`); in server mode the page splits EVERY tab by `mine`, others' section first — **All** into *Shared by others* / *Mine*, **Hub** into *Shared by others* / *Shared by me*, **My library** into *Installed from others* / *Mine* — and each section pages 12 cards at a time (client-side). 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. - **Unowned** — server mode: a row with no owner (`unowned: true`). `scope=unowned` lists every one for an admin and, for anyone else, those suggested to them (`suggestedForMe: true`; such a row is also visible to them, `canSeeId`). **claim** makes the suggested user — or an admin — its owner; **decline** drops the suggestion (the row stays for an admin); **assign** `{userId}` is an admin giving it to an enabled user (`400 EBADFIELD` otherwise). All three answer `409 EOWNED` once it has an owner, `400 ELOCALMODE` in local mode, and are written to `audit_log`. The page shows the tab **Unowned** to an admin and **Is this yours?** to a user with suggestions. - **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]-