From e7060a5e4208268729011f1c73eafc484e23cca4 Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Fri, 30 Oct 2026 14:20:18 +0700 Subject: [PATCH] feat(artifacts): Share again takes a skill's newest version; a same-named upload updates it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A shared skill followed its owner's ~/.claude/skills folder only, so one uploaded from a browser (or living in a plugin) stayed at its first snapshot for good. Share again (a skill's ⋯ menu and detail sheet) now takes the owner's copy on the server — its source folder, else the same-named skill of a plugin they installed at user scope — and shares it; with no copy it asks for the folder and uploads it onto that row (`id`). An upload whose skill is named like one the uploader already has replaces that row's version instead of adding a twin. Installers are then offered Update, as before. --- CLAUDE.md | 4 +- client/src/i18n/locales/en/artifacts.json | 6 + client/src/i18n/locales/vi/artifacts.json | 6 + client/src/lib/api.ts | 17 +- client/src/pages/Artifacts.tsx | 75 +++++++- client/src/pages/__tests__/Artifacts.test.tsx | 42 +++++ docs/API.md | 4 +- server/__tests__/artifacts.test.js | 99 +++++++++++ server/lib/artifacts.js | 164 +++++++++++++++++- server/openapi-extra/artifacts.js | 3 +- server/openapi-extra/ops-more.js | 20 +++ server/routes/artifacts.js | 15 ++ 12 files changed, 436 insertions(+), 19 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index f540fed1..4c457e82 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # Claude Code Working Guide -_Last synced: v2.8.44 (2026-10-30) — `discard` takes the Changes panel's ticked files and blocks (`hunks`), never every file._ 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.45 (2026-10-30) — Artifacts **Share again** retakes a skill's newest version; a same-named upload updates the row._ 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. @@ -60,7 +60,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 — 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. +- **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; **Share again** (`POST /api/artifacts/:id/reshare`) takes the owner's folder on the server — else a user-scope plugin's same-named skill — on a click, and an upload named like the uploader's own skill, or carrying `id`, replaces that row's snapshot instead of adding a twin) — `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. Billing has one more owner: a session NO workspace owns belongs to the user of the Linux account it ran as (`sessions.account` — the hook's peer, else its folder; `server/lib/session-account.js`, the host's account → the admin marked `users.host_account`, else the only admin), so a script's `claude -p` counts in Today, the Users page and spend quotas — a workspace's ownership always wins over it. - **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 a0fbd7a6..34be8092 100644 --- a/client/src/i18n/locales/en/artifacts.json +++ b/client/src/i18n/locales/en/artifacts.json @@ -28,6 +28,12 @@ "unshare": "Unshare", "shareDone": "Shared to the Hub", "unshareDone": "Removed from the Hub", + "reshare": "Share again", + "reshareDone": "New version shared — people who installed it are offered Update", + "reshareSame": "Already the newest version — shared", + "reshareAskTitle": "Choose the {{name}} folder again", + "reshareAskBody": "This server has no copy of {{name}}. Choose the skill's folder (the one holding SKILL.md): it replaces this version, and people who installed it are offered Update. For a .zip, upload it through Add skill → Upload: a skill with the same name updates your existing one.", + "resharePick": "Choose folder", "open": "Open", "copyLink": "Copy link", "preview": "Preview", diff --git a/client/src/i18n/locales/vi/artifacts.json b/client/src/i18n/locales/vi/artifacts.json index ac83cc48..233b828c 100644 --- a/client/src/i18n/locales/vi/artifacts.json +++ b/client/src/i18n/locales/vi/artifacts.json @@ -28,6 +28,12 @@ "unshare": "Bỏ chia sẻ", "shareDone": "Đã chia sẻ lên Hub", "unshareDone": "Đã gỡ khỏi Hub", + "reshare": "Share lại", + "reshareDone": "Đã share bản mới — người đã cài sẽ thấy nút Cập nhật", + "reshareSame": "Đã là bản mới nhất — đã share", + "reshareAskTitle": "Chọn lại thư mục {{name}}", + "reshareAskBody": "Server không có bản của {{name}}. Chọn thư mục của skill (thư mục chứa SKILL.md): bản này sẽ được thay, và người đã cài sẽ thấy nút Cập nhật. Với file .zip, tải lên qua Thêm skill → Tải lên: skill trùng tên sẽ cập nhật mục bạn đã có.", + "resharePick": "Chọn thư mục", "open": "Mở", "copyLink": "Sao chép link", "preview": "Xem", diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts index 849fe237..9c857d2e 100644 --- a/client/src/lib/api.ts +++ b/client/src/lib/api.ts @@ -3324,16 +3324,17 @@ export const api = { * several skills at once (one artifact each). `path` keeps a folder's layout. */ uploadSkills: ( files: { file: File; path: string }[], - fields: { name?: string; title?: string; description?: string } = {} + /** `id`: Share again — the upload replaces that skill row's version. */ + fields: { name?: string; title?: string; description?: string; id?: string } = {} ) => { const form = new FormData(); for (const f of files) form.append("files", f.file, f.file.name); form.append("paths", JSON.stringify(files.map((f) => f.path))); for (const [k, v] of Object.entries(fields)) if (v) form.append(k, v); - return request<{ items: Artifact[] }>("/artifacts/skill/upload", { - method: "POST", - body: form, - }); + return request<{ items: (Artifact & { replaced?: boolean; changed?: boolean })[] }>( + "/artifacts/skill/upload", + { method: "POST", body: form } + ); }, /** One file from the user's computer, kept like a pinned one (≤ 25 MB). */ uploadFile: (file: File, fields: { title?: string; description?: string } = {}) => { @@ -3372,6 +3373,12 @@ export const api = { method: "PATCH", body: JSON.stringify(body), }), + /** POST /api/artifacts/:id/reshare — Share again: the owner's skill folder on + * the server becomes the new version (409 ENOSOURCE when it has none). */ + reshare: (id: number) => + request<{ item: Artifact; changed: boolean }>(`/artifacts/${id}/reshare`, { + method: "POST", + }), /** `kept`: a shared one left your library but stays in the Hub. */ remove: (id: number) => request<{ ok: true; kept: boolean }>(`/artifacts/${id}`, { method: "DELETE" }), diff --git a/client/src/pages/Artifacts.tsx b/client/src/pages/Artifacts.tsx index 0454432b..cf556f3c 100644 --- a/client/src/pages/Artifacts.tsx +++ b/client/src/pages/Artifacts.tsx @@ -28,7 +28,9 @@ * **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. 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 + * change there as a new snapshot when the Hub is listed; **Share again** + * takes its folder on the server — or a plugin's copy — on a click, else + * asks for the folder and uploads it onto the same row); 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); @@ -277,6 +279,53 @@ export function Artifacts() { } }; + // Share again: the server's copy of the skill, else the folder picked again + // (uploaded onto the same row). Either way installers are offered Update. + const reshareInput = useRef(null); + const reshareRow = useRef(null); + useEffect(() => { + reshareInput.current?.setAttribute("webkitdirectory", ""); + }, []); + const reshared = (changed: boolean) => { + toast.success(t(changed ? "reshareDone" : "reshareSame")); + load(); + }; + const reshare = async (a: Artifact) => { + try { + const r = await api.artifacts.reshare(a.id); + reshared(r.changed); + } catch (err) { + if ((err as { code?: string }).code !== "ENOSOURCE") return toast.error(err); + if ( + !(await confirmDialog({ + title: t("reshareAskTitle", { name: a.source ?? a.title }), + message: t("reshareAskBody", { name: a.source ?? a.title }), + confirmLabel: t("resharePick"), + })) + ) + return; + reshareRow.current = a; + reshareInput.current?.click(); + } + }; + const reshareUpload = async (list: FileList | null) => { + const a = reshareRow.current; + reshareRow.current = null; + if (!a || !list?.length) return; + try { + const r = await api.artifacts.uploadSkills( + Array.from(list).map((file) => ({ + file, + path: (file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name, + })), + { id: String(a.id) } + ); + reshared(r.items.some((i) => i.changed !== false)); + } catch (err) { + toast.error(err); + } + }; + const [renaming, setRenaming] = useState(null); const remove = async (a: Artifact) => { @@ -442,6 +491,7 @@ export function Artifacts() { const actionsFor = (a: Artifact): CardActions => ({ onOpen: () => setDetailId(a.id), onShare: () => void share(a), + onReshare: () => void reshare(a), onRename: () => { setDetailId(null); setRenaming(a); @@ -572,6 +622,17 @@ export function Artifacts() { return (
+ { + void reshareUpload(e.target.files); + e.target.value = ""; + }} + />

{t("title")}

@@ -1066,6 +1127,8 @@ function EmptyState({ type CardActions = { onOpen: () => void; onShare: () => void; + /** A skill of your own: its newest version, shared (installers get Update). */ + onReshare: () => void; onRename: () => void; onDelete: () => void; onInstall: () => void; @@ -1237,6 +1300,7 @@ function ArtifactMenu({ menu, onOpen, onShare, + onReshare, onRename, onDelete, onInstall, @@ -1297,6 +1361,9 @@ function ArtifactMenu({ {t(hubOnly ? "hubRemove" : a.shared ? "unshare" : "share")} )} + {server && a.kind === "skill" && ( + {t("reshare")} + )} {t("rename")} {!hubOnly && ( @@ -1447,6 +1514,7 @@ function DetailDialog({ canInstall, onClose, onShare, + onReshare, onRename, onDelete, onInstall, @@ -1611,6 +1679,11 @@ function DetailDialog({ {t(hubOnly ? "hubRemove" : a.shared ? "unshare" : "share")} )} + {server && a.kind === "skill" && ( + + )} diff --git a/client/src/pages/__tests__/Artifacts.test.tsx b/client/src/pages/__tests__/Artifacts.test.tsx index a3066713..7c6aad98 100644 --- a/client/src/pages/__tests__/Artifacts.test.tsx +++ b/client/src/pages/__tests__/Artifacts.test.tsx @@ -46,6 +46,7 @@ vi.mock("../../lib/api", () => ({ addPlugin: vi.fn(), inspectMarket: vi.fn(), uploadSkills: vi.fn(), + reshare: vi.fn(), writeSkill: vi.fn(), uploadFile: vi.fn(), candidates: vi.fn(), @@ -394,6 +395,47 @@ describe("Artifacts", () => { expect(calls[calls.length - 1]?.[0].title).toMatch(/Remove “hub-only” from the Hub/); }); + it("Share again takes the server's copy, else uploads the picked folder onto that skill", async () => { + vi.mocked(api.artifacts.uploadSkills).mockClear(); + vi.mocked(api.artifacts.settings).mockResolvedValue({ + serverMode: true, + canShare: true, + installsEnabled: true, + canManage: true, + }); + vi.mocked(api.artifacts.list).mockResolvedValue({ + items: [item({ id: 2, kind: "skill", title: "sg-flow", source: "sg-flow", shared: true })], + }); + vi.mocked(api.artifacts.reshare).mockResolvedValueOnce({ + item: item({ id: 2 }), + changed: true, + }); + const user = userEvent.setup(); + open(); + const card = await screen.findByTestId("artifact-2"); + await menuAction(user, "Share again", card); + await waitFor(() => expect(api.artifacts.reshare).toHaveBeenCalledWith(2)); + expect(api.artifacts.uploadSkills).not.toHaveBeenCalled(); + + // No copy on the server: the folder is asked for and lands on THIS row. + vi.mocked(api.artifacts.reshare).mockRejectedValueOnce( + Object.assign(new Error("no copy"), { code: "ENOSOURCE" }) + ); + vi.mocked(confirmDialog).mockResolvedValueOnce(true); + vi.mocked(api.artifacts.uploadSkills).mockResolvedValueOnce({ + items: [{ ...item({ id: 2 }), changed: true }], + }); + await menuAction(user, "Share again", card); + await waitFor(() => expect(confirmDialog).toHaveBeenCalled()); + const md = new File(["---\nname: sg-flow\n---\n"], "SKILL.md"); + await user.upload(screen.getByTestId("artifact-reshare-folder"), md); + await waitFor(() => + expect(api.artifacts.uploadSkills).toHaveBeenCalledWith([{ file: md, path: "SKILL.md" }], { + id: "2", + }) + ); + }); + it("offers Update for an installed skill its owner changed, after a confirmation", async () => { vi.mocked(api.artifacts.list).mockResolvedValue({ items: [ diff --git a/docs/API.md b/docs/API.md index ac97dd0c..e73e171d 100644 --- a/docs/API.md +++ b/docs/API.md @@ -3156,7 +3156,7 @@ POST /api/artifacts/page {url, title?, description?} POST /api/artifacts/file {laneId, path, title?, sessionId?} POST /api/artifacts/file/upload multipart: files (one), title?, description? → {item} POST /api/artifacts/skill {name, laneId?, title?, description?} -POST /api/artifacts/skill/upload multipart: files, paths (JSON, each file's relative path), name?, title?, description? → {items} +POST /api/artifacts/skill/upload multipart: files, paths (JSON, each file's relative path), name?, title?, description?, id? → {items} — a skill named like one the caller already has becomes its new version (`replaced`, `changed`); `id` = Share again onto that row POST /api/artifacts/skill/new {name, description, content, title?} → {item, items} POST /api/artifacts/plugin/inspect {market} → {market, name, description, plugins: [{name, key, description, version, category}]} POST /api/artifacts/plugin {key: "name@marketplace", market: "owner/repo" | git URL | marketplace.json URL, title?, description?} @@ -3166,6 +3166,7 @@ DELETE /api/artifacts/:id {ok, kept} — kept: a shared row GET /api/artifacts/:id/raw?path=&download=1 one snapshot file (sandbox CSP, nosniff) POST /api/artifacts/:id/install {replace?, update?} → {installed, replaced?} POST /api/artifacts/:id/uninstall → {uninstalled, paths} +POST /api/artifacts/:id/reshare → {item, changed} — Share again (skill, owner): the owner's folder on this server becomes the new version and the row is shared; 409 ENOSOURCE when the server has no copy GET /api/artifacts/:id/installs {items: [{user, mine, at, replaced, paths?}], unrecorded} POST /api/artifacts/:id/claim → {item} — the suggested user, or an admin POST /api/artifacts/:id/decline → {id, declined} — the suggested user @@ -3177,6 +3178,7 @@ What agents produced, per OWNER (`server/lib/artifacts.js`, plan: `docs/plans/ar - **page** — a claude.ai link (`https://claude.ai/…` only, `400 EBADURL`), one row per owner and link. A session's `Artifact` tool publish — or a Claude Docs `batch` that creates a doc (`container.create`; its title is the doc's name, later batches are skipped) — is captured from its PostToolUse hook (a doc's own `frame.artifactUrl`; for a publish the first `https://claude.ai/…artifact/…` in `tool_response`), owned by the session's workspace owner — or, for a session no workspace owns (a member's `claude` started from their own terminal under S3), by the member whose account sent the hook (by its connection's uid, never the body); the host account names nobody, so such a page from the host stays unowned, with `suggested_owner_id` set to the owner of the workspace whose folder holds the session's `cwd` (else, under S3, the member whose home it is). - **file** — `candidates` are the documents (html, md, pdf, images, csv, office…) the workspace's sessions wrote with Write/Edit/MultiEdit/NotebookEdit, inside its folder. **Captured at Stop:** when a session a workspace owns sends `Stop` or `SessionEnd`, each `.pptx`/`.docx`/`.xlsx`/`.pdf`/`.html` in the workspace it created or changed since it started (a repo's untracked or modified files — an HTML file only when untracked — or a plain folder's files; at most 20 per stop, ≤ 25 MB) is snapshotted into the workspace owner's library as `auto: true` (shown **Auto**), off the hook's response path. The same file at the mtime its snapshot recorded is skipped; a changed one replaces that row's snapshot; a file pinned by hand is left alone. A pin is read under the download route's guards (`lanePath`, `resolveInLane`, `ESECRET`, ≤ 25 MB, `413 ETOOBIG`) and SNAPSHOTTED to `/artifacts//`. - **skill** — the caller's own `~/.claude/skills/` (their member home under S3; the host's for an admin or in local mode, else `409 ENOACCOUNT`) or a workspace's `.claude/skills/`. Snapshotted whole: ≤ 300 files, ≤ 5 MB, a symlink anywhere is `ESYMLINK`, a secret-looking file name (`.env`, `*.pem`, `id_rsa`, `.netrc`…, or a data file named `*token*`/`*secret*`/`*password*`/`*credential*` — the word alone does not count in Markdown, source code or images, e.g. `design-tokens.md`) `403 ESECRET`. +- **Share again** (a skill's ⋯ menu and detail sheet, its owner, server mode) — `reshare` takes the owner's skill folder on THIS server as the new snapshot: the row's source folder, else the same-named `skills/` of a plugin the owner installed at user scope (`installed_plugins.json`, the most recently updated one, never outside their `~/.claude/plugins/`), and shares the row; installers are then offered **Update**. With no copy on the server (a skill uploaded from a browser) it answers `409 ENOSOURCE` and the page asks for the skill's folder, uploaded with `id` onto that row. An upload whose skill is named like one the uploader already has (not a workspace's) also replaces that row's version rather than adding a twin. - **file upload** — one file from the user's computer (≤ 25 MB, `413 ETOOBIG`), snapshotted under its own base name (no folder kept). - **skill upload / new** — a skill made from files rather than picked: one Markdown/text file of any name becomes `SKILL.md`; several files or a picked/dropped folder (`paths` keeps the layout) need a `SKILL.md` (at the top or in one folder, which is stripped — `400 ENOSKILLMD` otherwise); a single `.zip`/`.skill` is unpacked (sizes checked from its directory first, a symlink entry `ESYMLINK`, `__MACOSX`/`.DS_Store`/`.git`/`node_modules` skipped); an upload holding several skill folders makes one artifact per skill, named by its folder, and stores NOTHING if any of them fails. The `name` the user typed (only for a single skill; else the frontmatter `name:`, the folder or the file name) is made a folder name (`skillSlug`: marks dropped, other characters → `-`; `400 EBADNAME` if nothing is left) and written into `SKILL.md`'s frontmatter as `name:`; a `description` given replaces the frontmatter's, and a skill with none anywhere is `400 ENODESCRIPTION`. Paths are relative with no `..` (`EBADPATH`); the same limits and `ESECRET` as a picked skill. `new` writes `SKILL.md` from `content` the same way. - **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. diff --git a/server/__tests__/artifacts.test.js b/server/__tests__/artifacts.test.js index 509a2373..f0e16009 100644 --- a/server/__tests__/artifacts.test.js +++ b/server/__tests__/artifacts.test.js @@ -665,6 +665,105 @@ describe("skills from files or written", () => { assert.equal(bad.body.error.code, "EBADNAME"); }); + it("updates the uploader's same-named skill instead of adding a twin", async () => { + const md = (v) => `---\nname: sg-again\ndescription: version ${v}\n---\nBody ${v}.\n`; + const first = await upload("/api/artifacts/skill/upload", [["sg-again/SKILL.md", md(1)]]); + assert.equal(first.status, 201, JSON.stringify(first.body)); + const id = first.body.items[0].id; + const again = await upload("/api/artifacts/skill/upload", [["sg-again/SKILL.md", md(2)]]); + assert.equal(again.status, 201, JSON.stringify(again.body)); + assert.equal(again.body.items[0].id, id); + assert.equal(again.body.items[0].replaced, true); + assert.equal(again.body.items[0].changed, true); + assert.equal(again.body.items[0].description, "version 2"); + assert.match(snapText(again.body.items[0]), /Body 2\./); + const rows = db + .prepare("SELECT COUNT(*) AS n FROM artifacts WHERE kind = 'skill' AND source = 'sg-again'") + .get(); + assert.equal(rows.n, 1); + const same = await upload("/api/artifacts/skill/upload", [["sg-again/SKILL.md", md(2)]]); + assert.equal(same.body.items[0].changed, false); + }); + + it("Share again: an uploaded skill asks for its folder, which replaces that row", async () => { + const md = (v) => `---\nname: sg-up\ndescription: d\n---\nUp ${v}.\n`; + const first = await upload("/api/artifacts/skill/upload", [["sg-up/SKILL.md", md(1)]]); + const id = first.body.items[0].id; + const no = await request("POST", `/api/artifacts/${id}/reshare`); + assert.equal(no.status, 409); + assert.equal(no.body.error.code, "ENOSOURCE"); + // The folder picked again, under any folder name: onto THIS row. + const r = await upload("/api/artifacts/skill/upload", [["renamed/SKILL.md", md(2)]], { + id: String(id), + }); + assert.equal(r.status, 201, JSON.stringify(r.body)); + assert.equal(r.body.items[0].id, id); + assert.equal(r.body.items[0].source, "sg-up"); + assert.match(snapText(r.body.items[0]), /^---\nname: sg-up\n[\s\S]*Up 2\./); + const two = await upload( + "/api/artifacts/skill/upload", + [ + ["p/a/SKILL.md", "---\nname: a\ndescription: d\n---\n"], + ["p/b/SKILL.md", "---\nname: b\ndescription: d\n---\n"], + ], + { id: String(id) } + ); + assert.equal(two.body.error.code, "EBADUPLOAD"); + }); + + it("Share again takes the owner's folder, else the skill of an installed plugin", async () => { + const home = process.env.CLAUDE_HOME; + // An uploaded skill whose name a user-scope plugin also ships. + const first = await upload("/api/artifacts/skill/upload", [ + ["sg-plug/SKILL.md", "---\nname: sg-plug\ndescription: d\n---\nOld.\n"], + ]); + const id = first.body.items[0].id; + const writePlugin = (ver, text) => { + const at = path.join(home, "plugins", "cache", "m", "p", ver); + fs.mkdirSync(path.join(at, "skills", "sg-plug"), { recursive: true }); + fs.writeFileSync(path.join(at, "skills", "sg-plug", "SKILL.md"), text); + return at; + }; + const v1 = writePlugin("1.0.0", "---\nname: sg-plug\ndescription: d\n---\nPlugin one.\n"); + const v2 = writePlugin("2.0.0", "---\nname: sg-plug\ndescription: d\n---\nPlugin two.\n"); + fs.writeFileSync( + path.join(home, "plugins", "installed_plugins.json"), + JSON.stringify({ + version: 2, + plugins: { + "p@m": [{ scope: "user", installPath: v2, lastUpdated: "2026-10-30T00:00:00Z" }], + "old@m": [{ scope: "user", installPath: v1, lastUpdated: "2026-01-01T00:00:00Z" }], + "proj@m": [ + { scope: "project", installPath: "/etc", lastUpdated: "2027-01-01T00:00:00Z" }, + ], + }, + }) + ); + const r = await request("POST", `/api/artifacts/${id}/reshare`); + assert.equal(r.status, 200, r.text); + assert.equal(r.body.changed, true); + assert.match(snapText(r.body.item), /Plugin two\./); + assert.equal((await request("POST", `/api/artifacts/${id}/reshare`)).body.changed, false); + + // The owner's own ~/.claude/skills folder outranks a plugin's copy. + const own = path.join(home, "skills", "sg-plug"); + fs.mkdirSync(own, { recursive: true }); + fs.writeFileSync( + path.join(own, "SKILL.md"), + "---\nname: sg-plug\ndescription: d\n---\nMine.\n" + ); + const mine = await request("POST", `/api/artifacts/${id}/reshare`); + assert.equal(mine.body.changed, true); + assert.match(snapText(mine.body.item), /Mine\./); + // Recorded as the folder's state: the 15 s sync does not take it twice. + assert.deepEqual( + artifacts.syncSkillSources({ force: true }).filter((c) => c.id === id), + [] + ); + fs.rmSync(path.join(home, "plugins"), { recursive: true, force: true }); + fs.rmSync(own, { recursive: true, force: true }); + }); + it("keeps an uploaded file as a snapshot, by its own base name", async () => { const r = await upload("/api/artifacts/file/upload", [["report.html", "

x

"]], { title: "Report", diff --git a/server/lib/artifacts.js b/server/lib/artifacts.js index ba9676ce..8c51b4ca 100644 --- a/server/lib/artifacts.js +++ b/server/lib/artifacts.js @@ -1125,8 +1125,20 @@ function saveSkillFiles(req, { name, files, title, description }) { * `paths`). Answers every skill it made. Nothing is stored unless every * skill in the upload passes. */ -function uploadSkills(req, { uploads, name, title, description }) { +function uploadSkills(req, { uploads, name, title, description, id }) { + // `id`: Share again for a skill the server has no copy of — the upload + // replaces THAT row (one skill, named as the row is). + let onto = null; + if (id != null && id !== "") { + onto = load(req, id); + if (!canChange(req, onto)) throw fail("EFORBIDDEN", "only its owner changes an artifact"); + if (onto.kind !== "skill") throw fail("EBADKIND", "only a skill is shared again"); + name = onto.source; + } const found = skillsFromUpload(uploads || [], { name, description }); + if (onto && found.length !== 1) { + throw fail("EBADUPLOAD", "choose the folder or .zip of this one skill"); + } const typed = typeof name === "string" && name.trim(); const prepared = found.map((sk) => ({ name: validSkillName(found.length === 1 && typed ? typed : sk.fallbackName || ""), @@ -1140,16 +1152,46 @@ function uploadSkills(req, { uploads, name, title, description }) { withFrontmatter(md.data.toString("utf8"), p.name, found.length === 1 ? description : ""); checkSkillFiles(p.files); } - return prepared.map((p) => - saveSkillFiles(req, { + return prepared.map((p) => { + // A skill its uploader already has (same name, not a workspace's) gets + // the new version on that row — installers see Update — never a twin. + const own = onto || ownSkillRow(req, p.name); + if (own) return replaceUploaded(req, own, p, found.length === 1 ? description : ""); + return saveSkillFiles(req, { name: p.name, files: p.files, title: found.length === 1 ? title : "", description: found.length === 1 ? description : "", - }) + }); + }); +} + +/** The caller's newest skill row of that name taken from no workspace, or null. */ +function ownSkillRow(req, name) { + const owner = req?.user ? req.user.id : null; + return ( + sql( + `SELECT * FROM artifacts WHERE kind = 'skill' AND source = ? AND lane_id IS NULL + AND snapshot IS NOT NULL AND owner_id IS ? ORDER BY updated_at DESC, id DESC LIMIT 1` + ).get(name, owner) || null ); } +/** An uploaded skill onto an existing row: same frontmatter rules as a new one. */ +function replaceUploaded(req, row, { name, files }, description) { + const md = files.find((f) => f.path === "SKILL.md"); + const text = md.data.toString("utf8"); + if (text.includes("\uFFFD")) throw fail("EBADSKILL", "SKILL.md is not a UTF-8 text file"); + const fixed = Buffer.from(withFrontmatter(text, name, description), "utf8"); + const all = [{ path: "SKILL.md", data: fixed }, ...files.filter((f) => f !== md)]; + checkSkillFiles(all); + const changed = contentHash(all) !== snapshotVersion(row); + // The owner's folder as it is now (like `storeSkill`): only a LATER change + // to it replaces this upload. + if (changed) replaceSnapshot(row, all, sourceHash(row) ?? row.source_hash); + return { ...shareNow(req, row.id, changed), replaced: true, changed }; +} + /** A skill written in the dialog: a name, a description and its instructions. */ function writeSkill(req, { name, description, content, title }) { const n = validSkillName(name); @@ -1936,6 +1978,23 @@ function syncOne(row) { } catch { return false; } + try { + replaceSnapshot(row, files, st.hash); + } catch { + return false; + } + return true; +} + +/** + * Point a skill row at a NEW snapshot holding `files` (`[{path, data}]`, already + * checked), then drop the old one — never a write into the snapshot being + * read. Installers then see Update (`updateCheck`). `sourceHash` is the + * source folder's state to record, so `syncOne` takes only a LATER change. + * The description follows SKILL.md's only while it still says what the old + * one did. + */ +function replaceSnapshot(row, files, sourceHash) { const snap = newSnapshotDir(); let total = 0; const next = []; @@ -1947,11 +2006,10 @@ function syncOne(row) { total += f.data.length; next.push({ path: f.path, size: f.data.length }); } - } catch { + } catch (err) { dropSnapshot(snap.id); - return false; + throw err; } - // The description follows SKILL.md's only while it still says what the old one did. const md = (list) => list.find((f) => f.path === "SKILL.md"); let oldSummary = ""; try { @@ -1970,9 +2028,96 @@ function syncOne(row) { sql( `UPDATE artifacts SET snapshot = ?, files = ?, size = ?, version = ?, source_hash = ?, description = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?` - ).run(snap.id, JSON.stringify(next), total, st.hash, st.hash, description, row.id); + ).run( + snap.id, + JSON.stringify(next), + total, + contentHash(files), + sourceHash ?? null, + description, + row.id + ); if (row.snapshot && SNAPSHOT_RE.test(row.snapshot)) dropSnapshot(row.snapshot); - return true; +} + +/** + * The owner's skill folder on THIS server as it is now, for Share again: + * the row's own source folder (`sourceDirOf`), else — a skill not taken from + * a workspace — the same-named skill of a plugin the owner installed at user + * scope (`installed_plugins.json`; the most recently updated when several + * have it), never outside the owner's `~/.claude/plugins/`. Answers + * `{st, fromSource}` (`folderState`) or null when the server has no copy (an + * uploaded skill: its owner picks the folder again). + */ +function machineSkill(row) { + const src = sourceDirOf(row); + const st = src && folderState(src.dir, src.top); + if (st) return { st, fromSource: true }; + if (!src || row.lane_id != null) return null; + const top = path.join(src.top, "plugins"); + let installed = {}; + try { + const user = sql("SELECT id, username, role FROM users WHERE id = ?").get(row.owner_id); + const file = path.join(top, "installed_plugins.json"); + const text = + user && ownAccount({ user }).acct + ? require("./run-as").readFileSafe(file, "utf8", { max: 4 << 20 }) + : fs.readFileSync(file, "utf8"); + installed = JSON.parse(text)?.plugins || {}; + } catch { + return null; + } + let best = null; + for (const entries of Object.values(installed)) { + for (const e of Array.isArray(entries) ? entries : []) { + if (!e || e.scope !== "user" || typeof e.installPath !== "string") continue; + const st2 = folderState(path.join(e.installPath, "skills", row.source), top); + const at = Date.parse(e.lastUpdated || e.installedAt || "") || 0; + if (st2 && (!best || at > best.at)) best = { st: st2, at }; + } + } + return best ? { st: best.st, fromSource: false } : null; +} + +/** + * Share again: take the owner's skill folder on this server as the row's new + * snapshot (`machineSkill`) and share it (server mode), so installers are + * offered Update. ENOSOURCE when the server has no copy — the client then + * asks for the folder or .zip and uploads it onto this row (`uploadSkills` + * with `id`). Answers `{item, changed}`. + */ +function reshareSkill(req, id) { + const row = load(req, id); + if (!canChange(req, row)) throw fail("EFORBIDDEN", "only its owner changes an artifact"); + if (row.kind !== "skill") throw fail("EBADKIND", "only a skill is shared again"); + const found = machineSkill(row); + if (!found) { + throw fail("ENOSOURCE", "this server has no copy of the skill: choose its folder or .zip"); + } + const files = found.st.read(); + checkSkillFiles(files); + const changed = found.st.hash !== snapshotVersion(row); + if (changed) { + replaceSnapshot(row, files, found.fromSource ? found.st.hash : row.source_hash); + } else if (found.fromSource) { + sql("UPDATE artifacts SET source_hash = ? WHERE id = ?").run(found.st.hash, row.id); + } + return { item: shareNow(req, row.id, changed), changed }; +} + +/** Share a row (server mode, when it is not yet) and answer its view; `audit` names a new version. */ +function shareNow(req, id, changed) { + const row = sql("SELECT * FROM artifacts WHERE id = ?").get(id); + if (isServer() && row.shared !== 1) { + sql( + `UPDATE artifacts SET shared = 1, shared_at = strftime('%Y-%m-%dT%H:%M:%fZ','now'), + updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?` + ).run(id); + audit(req, row, "shared"); + } else if (changed && row.shared === 1) { + audit(req, row, "updated"); + } + return view(sql("SELECT * FROM artifacts WHERE id = ?").get(id), req); } const SYNC_EVERY_MS = 15 * 1000; @@ -2215,6 +2360,7 @@ module.exports = { availableSkills, addSkill, uploadSkills, + reshareSkill, writeSkill, uploadFile, skillSlug, diff --git a/server/openapi-extra/artifacts.js b/server/openapi-extra/artifacts.js index aeeba801..ad8bfa01 100644 --- a/server/openapi-extra/artifacts.js +++ b/server/openapi-extra/artifacts.js @@ -238,7 +238,7 @@ const paths = { summary: "Skills from files: a file, a folder, a .zip/.skill or several skills", operationId: "uploadArtifactSkills", description: - "`paths` is a JSON array of each file's relative path. Zip sizes are read before inflating; nothing is stored if any skill fails.", + "`paths` is a JSON array of each file's relative path. Zip sizes are read before inflating; nothing is stored if any skill fails. A skill whose name matches one the caller already has (not a workspace's) becomes that row's new version (`replaced: true`, `changed`) instead of a second row; `id` (Share again) puts the ONE uploaded skill onto that row, under the row's name.", requestBody: multipart( { files: { type: "array", items: { type: "string", format: "binary" } }, @@ -246,6 +246,7 @@ const paths = { name: { type: "string" }, title: { type: "string" }, description: { type: "string" }, + id: { type: "string", description: "Share again: the skill row the upload replaces." }, }, ["files"] ), diff --git a/server/openapi-extra/ops-more.js b/server/openapi-extra/ops-more.js index 912f2f58..5c3bb1ae 100644 --- a/server/openapi-extra/ops-more.js +++ b/server/openapi-extra/ops-more.js @@ -1113,6 +1113,26 @@ const paths = { }, }, }, + "/api/artifacts/{id}/reshare": { + post: { + tags: ["Artifacts"], + summary: "Share again: a skill's newest version from its owner's folder on this server", + operationId: "reshareArtifact", + description: + "The owner's skill folder (the row's source folder, else the same-named skill of a plugin the owner installed at user scope) becomes the new snapshot, and the row is shared (server mode); installers are then offered Update. 409 ENOSOURCE when the server has no copy: upload the folder with `id` on /api/artifacts/skill/upload. Same-origin only.", + parameters: [pathParam("id", "Artifact id.")], + responses: { + 200: ok("Taken.", { + item: { type: "object" }, + changed: { type: "boolean" }, + }), + 400: errs("EBADKIND."), + 403: errs("EFORBIDDEN."), + 404: errs("ENOTFOUND."), + 409: errs("ENOSOURCE."), + }, + }, + }, "/api/artifacts/{id}/claim": { post: { tags: ["Artifacts"], diff --git a/server/routes/artifacts.js b/server/routes/artifacts.js index 03a5887f..dd5e9abb 100644 --- a/server/routes/artifacts.js +++ b/server/routes/artifacts.js @@ -39,6 +39,7 @@ const STATUS = { ENEEDLOGIN: 502, ETOOBIG: 413, EOWNED: 409, + ENOSOURCE: 409, }; function sendError(res, err) { @@ -229,6 +230,7 @@ router.post( name: b.name, title: b.title, description: b.description, + id: b.id, }); for (const item of items) changed(item); res.status(201).json({ items }); @@ -284,6 +286,19 @@ router.patch( }) ); +// Share again: the owner's skill folder on this server becomes the new +// version (installers see Update); 409 ENOSOURCE when the server has none — +// the client then uploads the folder onto this row (`/skill/upload` + `id`). +router.post( + "/:id/reshare", + sameOriginGuard, + wrap((req, res) => { + const out = artifacts.reshareSkill(req, req.params.id); + changed(out.item); + res.json(out); + }) +); + router.delete( "/:id", sameOriginGuard,