feat(artifacts): a shared skill follows its owner's folder; installers get Update
A skill in the Hub was a snapshot taken once: the owner changing
~/.claude/skills/<name> 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/<name>, or the
workspace's .claude/skills/<name> — 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.
This commit is contained in:
@@ -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: `<data dir>/trash/<user id>/`), restorable 7 days. `write-as` is root-installed: a change to it needs `agenthub server install-system` again (`EUPGRADE` until then). A user's HOME is browsed through the same module inside `inHome` (`/api/homes/:who/fs/*`, `server/lib/homes.js` picks the folder): every dot entry at the TOP of a home stays hidden and refused — through a symlink, a download, a zip or as a copy source too — and another user's home is an admin's READ-ONLY, audited view (`EREADONLY`) — except its TRASH, whose restore/purge an admin may run (`TRASH_W` in `server/routes/homes.js`, the owner's trash via `trashKey`, audited with the item id); keep that exception to the trash routes only. A home's Trash is its USER's one recycle bin (`listUserTrash`: home-account store + host store `<data dir>/trash/<user id>/`, items tagged `store`). Do not add a delete that skips the trash, an overwrite without `conflict`, or a request path that is not run through `locate`/`folder`.
|
||||
- **The whole-file editor and the Files tab's upload are the two non-git writes on the lane file surface, and both live outside `git-ops.js` on purpose.** `git-ops.js` only touches paths `git status` reports as changed; `lane-file-write.js` may save any existing text file, so it must reuse the READ path's guards (`lanePath`, `resolveInLane`, `isSecretOnDisk`) and never be weaker than them, and it saves only over the `baseSha` it was opened at — do not let it create files or touch a binary. `lane-file-transfer.js`'s upload (`POST /api/lanes/:id/git/upload`, issue #78) is the opposite and only that: it CREATES one file (any bytes, ≤ 25 MB) under the same guards, into an existing folder, refuses a taken name (`EEXISTS`, a symlink included) and anything into or named `.git` (a hook there runs on the dashboard's next git call), and writes as the folder's member through `write-as` under S3 — do not let it overwrite, make folders or take a path from anything but `dir` + one plain name. Its `GET …/git/download` twin streams any file through `openSafe` as a `sandbox`ed attachment. The tree, expand-all and quick-open read the disk (`walkFolder`) for a folder that is not a repo.
|
||||
- **Server tests never touch the user's tmux.** Run them through `npm run test:server` (`scripts/test-server.js`), never a bare `node --test server/__tests__/*.test.js` from inside a lane pane — the suites create and kill real `agenthub-lane-<id>` sessions.
|
||||
- **An artifact is shared as a SNAPSHOT and installed only into the viewer's own account.** `server/lib/artifacts.js` (`/api/artifacts`): a pinned file or a skill is copied once into `<data dir>/artifacts/<random>/` and every viewer reads only that copy (an owner's edit of a skill writes a NEW snapshot and drops the old one — never a write into the one being read) — `raw` serves only a file the snapshot recorded, under a `sandbox` CSP + `nosniff`; a `page` is a `https://claude.ai/…` link only. A developer sees their own rows plus shared ones; only the owner or an admin changes one. Install writes a skill into BOTH `~/.claude/skills/` and the cross-tool `~/.agents/skills/` of every account the CALLER's sessions run as (`installAccounts`: their S3 member home, plus the host's for an admin, whose host-folder workspaces run as the host; S3: as them through `write-as`), never over an existing one without `replace` (the old one is moved to `~/.claude/agenthub-replaced-skills/`, never deleted), and runs a plugin's `claude plugin marketplace add`/`install --scope user` as the caller, never with `-y`; 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 `<data dir>/artifacts/<random>/` 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 `<data dir>/workspace-context/<lane id>/` under a name AgentHub derives (never a path from the request), and those copies are the only thing here AgentHub deletes (`ownsFile`): with their row, and with the workspace in `deleteLane`. Claude's `--add-dir` is VARIADIC, so it goes first in the argv where `--permission-mode` ends it, and the text (instructions + context file list + extra folders) is written at every spawn as the ONLY file `CLAUDE.md` of `<data dir>/workspace-prompts/<lane id>/` (a member's: `prompt-<lane id>/` in their handoff dir, via `write-as`), which goes in as one more `--add-dir` with `CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD=1` in the pane env — never `--append-system-prompt-file`: a custom system prompt is a launch flag Claude cannot carry over a restart, so it refused `/tui` in every pane with instructions (side effect: a CLAUDE.md in an extra folder is now read too); Codex gets one `--add-dir` per directory and `-c developer_instructions=<JSON string>` — the text itself up to 6 KB, else a line pointing at that file. Only `claude`/`codex` take them. Never write them into the user's repo (CLAUDE.md/AGENTS.md).
|
||||
- **A workspace's sessions are the ones it OWNS, never the ones that ran in its folder.** `sessions.lane_id` is stamped by `bindRunSession` (`server/routes/hooks.js`) for the session a dashboard pane holds — first writer wins, a nested `claude -p` excluded — and no route sets it. A Codex/Antigravity session (their hooks are opt-in, so a pane may never learn its session) is also stamped by `claimToolSessions` (`server/lib/dashboard-runs.js`, called by both pollers and once at start): the lane of the newest same-tool dashboard run in the session's folder that was live when the session started, never a Claude session. A new Claude pane is started with `--session-id <uuid>` (`spawnRun`), so its `dashboard_runs.session_id` is known before any hook and a hook that carries no `run_id` (a hook handler older than `AGENTHUB_RUN_ID` forwarding) is still stamped from that run (`laneForSession`). Every workspace session list reads `GET /api/sessions?lane=<id>` — which also counts a still-unstamped session one of the lane's `dashboard_runs` holds, because hooks reach only the lowest-port dashboard on a shared DB and that one may be an older build that never stamps — and shows nothing else (no "other sessions in this folder"). Do not go back to listing a workspace by `cwd`, and do not guess ownership for old history.
|
||||
- **A session's worktree is `claude --worktree`'s, resolved from the session row — and removing it never forces.** A Worktree start passes `--worktree=<server-made name>` (the `=` form: `-w [name]` eats the prompt otherwise) and Claude Code makes, populates and cleans up `<repo>/.claude/worktrees/<name>`; AgentHub only excludes `/.claude/worktrees/` at the repo root. A git route, a resume or a removal may work in a session's worktree only through `sessionWorktree` (`server/lib/session-worktree.js`: the session owned by the lane, its cwd not the lane's folder, registered by `git worktree list`) — `?session=` on the git routes answers `ENOWORKTREE` rather than falling back to the main folder. `POST /api/lanes/:id/session-worktree/remove` (same-origin, `confirm: true`, refused while a live pane holds the session) is the ONE dashboard path that deletes a worktree: `git worktree remove` with no `--force`, `git branch -d` never `-D`, a live-pid lock refused. Do not add a force, and do not let it touch a lane's own folder.
|
||||
|
||||
@@ -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}}"
|
||||
}
|
||||
|
||||
@@ -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}}"
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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 (
|
||||
<li data-testid={`pack-${p.prefix}`}>
|
||||
@@ -918,7 +971,17 @@ function PackRow({
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
</span>
|
||||
{missing === 0 ? (
|
||||
{missing === 0 && behind > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
className={smallPrimary}
|
||||
disabled={!canInstall}
|
||||
title={canInstall ? t("updateHint") : t("installsOff")}
|
||||
onClick={onUpdateAll}
|
||||
>
|
||||
{t("pack.updateN", { count: behind })}
|
||||
</button>
|
||||
) : missing === 0 ? (
|
||||
<span className="badge border-status-success/30 bg-status-success/10 text-status-success">
|
||||
{t("installedBadge")}
|
||||
</span>
|
||||
@@ -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 (
|
||||
<button
|
||||
type="button"
|
||||
className={smallPrimary}
|
||||
disabled={!canInstall}
|
||||
title={canInstall ? t("updateHint") : t("installsOff")}
|
||||
onClick={onUpdate}
|
||||
data-testid="artifact-update"
|
||||
>
|
||||
{t("update")}
|
||||
</button>
|
||||
);
|
||||
if (a.installed)
|
||||
return (
|
||||
<span
|
||||
@@ -1160,6 +1239,7 @@ function ArtifactMenu({
|
||||
onRename,
|
||||
onDelete,
|
||||
onInstall,
|
||||
onUpdate,
|
||||
onUninstall,
|
||||
onPreview,
|
||||
}: ItemProps & { menu: ReturnType<typeof useContextMenu<null>> }) {
|
||||
@@ -1198,6 +1278,11 @@ function ArtifactMenu({
|
||||
{t("install")}
|
||||
</MenuItem>
|
||||
)}
|
||||
{a.updateAvailable && (
|
||||
<MenuItem disabled={!canInstall} onSelect={pick(onUpdate)}>
|
||||
{t("update")}
|
||||
</MenuItem>
|
||||
)}
|
||||
{a.installed && (
|
||||
<MenuItem danger onSelect={pick(onUninstall)}>
|
||||
{t("uninstall")}
|
||||
@@ -1272,7 +1357,13 @@ function ArtifactRow({ inPack, ...props }: ItemProps & { inPack?: boolean }) {
|
||||
{/* A pack's row already says whose it is. */}
|
||||
<Meta item={a} server={server && !inPack} date={false} />
|
||||
</span>
|
||||
<PrimaryAction item={a} canInstall={canInstall} onInstall={onInstall} onPreview={onPreview} />
|
||||
<PrimaryAction
|
||||
item={a}
|
||||
canInstall={canInstall}
|
||||
onInstall={onInstall}
|
||||
onUpdate={props.onUpdate}
|
||||
onPreview={onPreview}
|
||||
/>
|
||||
<MoreButton onOpen={(e) => menu.open(e, null)} />
|
||||
<ArtifactMenu {...props} menu={menu} />
|
||||
</li>
|
||||
@@ -1324,6 +1415,7 @@ function ArtifactCard({ as = "li", ...props }: ItemProps & { as?: "li" | "div" }
|
||||
item={a}
|
||||
canInstall={canInstall}
|
||||
onInstall={onInstall}
|
||||
onUpdate={props.onUpdate}
|
||||
onPreview={onPreview}
|
||||
/>
|
||||
</div>
|
||||
@@ -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" && (
|
||||
|
||||
@@ -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ĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
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 })],
|
||||
|
||||
+4
-3
@@ -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/<name>/` AND `~/.agents/skills/<name>/` (the folder Codex, Gemini/Antigravity, OpenCode and Cursor read) of every account the caller's sessions run as — under S3 their member home (as them through `write-as`), plus the host's home for an admin, whose host-folder workspaces run as the host. The answer is `{installed, paths, replaced}` (`installed` = the paths joined). A name taken in any of them is `409 EEXISTS` unless `replace: true`, which moves each old one to `~/.claude/agenthub-replaced-skills/<name>[-agents]-<time>/`. A plugin runs `claude plugin marketplace add <market>` then `claude plugin install --scope user <key>` as the caller, never with `-y`. `403 EINSTALLSOFF` while an admin has turned installs off. A list row's `installed` is true when that skill (`~/.claude/skills/<name>`) or plugin (a key of `~/.claude/plugins/installed_plugins.json`) is already in EVERY one of those accounts — the page then shows **Installed** instead of Install; it is a pure read, and an account that cannot be resolved or read counts as not installed. Every install is also a row of `artifact_installs` (who, when, the folders written, whether it replaced); `GET …/installs` lists them newest first — the owner, staff and local mode see every install, anyone else only their own, and `paths` goes only to the installer and an admin (a plugin has none: it is `claude plugin install --scope user`). `unrecorded` is what `installs` counted before that table existed. The card's "N installs" opens this list. Share, unshare, a shared row's delete and every install or uninstall by someone else are written to `audit_log`.
|
||||
- **install** — a skill or plugin (`EBADKIND`), into the CALLER's own account: a skill is written to `~/.claude/skills/<name>/` AND `~/.agents/skills/<name>/` (the folder Codex, Gemini/Antigravity, OpenCode and Cursor read) of every account the caller's sessions run as — under S3 their member home (as them through `write-as`), plus the host's home for an admin, whose host-folder workspaces run as the host. The answer is `{installed, paths, replaced}` (`installed` = the paths joined). A name taken in any of them is `409 EEXISTS` unless `replace: true`, which moves each old one to `~/.claude/agenthub-replaced-skills/<name>[-agents]-<time>/`. A plugin runs `claude plugin marketplace add <market>` then `claude plugin install --scope user <key>` as the caller, never with `-y`. `403 EINSTALLSOFF` while an admin has turned installs off. A list row's `installed` is true when that skill (`~/.claude/skills/<name>`) or plugin (a key of `~/.claude/plugins/installed_plugins.json`) is already in EVERY one of those accounts — the page then shows **Installed** instead of Install; it is a pure read, and an account that cannot be resolved or read counts as not installed. Every install is also a row of `artifact_installs` (who, when, the folders written, whether it replaced); `GET …/installs` lists them newest first — the owner, staff and local mode see every install, anyone else only their own, and `paths` goes only to the installer and an admin (a plugin has none: it is `claude plugin install --scope user`). `unrecorded` is what `installs` counted before that table existed. The card's "N installs" opens this list.
|
||||
- **a skill follows its source** — a skill row taken from a folder (its owner's `~/.claude/skills/<name>`, or the workspace's `.claude/skills/<name>`; the host's in local mode) is brought up to that folder whenever the Hub is listed (`syncSkillSources`, at most every 15 s; only a folder whose file names, sizes or mtimes changed is read again, through the same checks as a pick — no symlink, no secret, ≤ 5 MB — a member's files as theirs). A change is a NEW snapshot directory, then the old one dropped, and an `artifact_changed` broadcast. `artifacts.version` is a snapshot's content hash and `source_hash` the folder's when last taken: an upload or a Hub edit records the folder as it is then, so only a LATER change to the folder replaces it — the newer change wins. A list row's `updateAvailable` is true when the skill is `installed` and the viewer's `~/.claude/skills/<name>` (any account they run as) differs from `version`; the page then shows **Update**. `install` with `update: true` (a skill, `ENOTINSTALLED` when it is not installed) replaces those copies like `replace: true` — the old ones moved aside — and is not counted as another install. Share, unshare, a shared row's delete and every install or uninstall by someone else are written to `audit_log`.
|
||||
- **uninstall** — takes an installed skill or plugin out of the same accounts again (`EBADKIND` otherwise; allowed while installs are off). A skill's folders that exist (both skills folders of each account) are MOVED to `~/.claude/agenthub-replaced-skills/<name>[-agents]-uninstalled-<time>/`, never deleted; a plugin runs `claude plugin uninstall --scope user --keep-data <key>` as the caller (its data dir stays). Nothing of it there is `409 ENOTINSTALLED`. The answer is `{uninstalled, paths}` (`paths` = the skill folders moved; empty for a plugin). The `installs` count and `artifact_installs` history are left as they were. The page offers **Uninstall** only in the card's ⋯ menu (and its right-click menu); there is no Reinstall — a newer version is uninstalled and installed again.
|
||||
- Every write is same-origin. The `artifact_changed` broadcast (`{id, ownerId, shared}`) reaches everyone for a shared row, else its owner and staff.
|
||||
|
||||
|
||||
@@ -10,8 +10,10 @@
|
||||
* junk skipped, nothing stored when one fails) or written in the dialog, a
|
||||
* file uploaded, sharing refused in local mode, visibility for a server-mode
|
||||
* developer, the admin's installs switch, same-origin writes, Claude Docs
|
||||
* docs captured at their birth, documents captured at a session's stop, and
|
||||
* unowned rows claimed, declined and assigned.
|
||||
* docs captured at their birth, documents captured at a session's stop,
|
||||
* unowned rows claimed, declined and assigned, and a skill that follows its
|
||||
* source folder (a newer change wins over a Hub edit) with Update offered to
|
||||
* an installed copy that differs.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -338,6 +340,69 @@ describe("skills", () => {
|
||||
assert.equal(empty.status, 400);
|
||||
});
|
||||
|
||||
it("follows its source folder and offers Update to an installed copy", async () => {
|
||||
const src = path.join(ws, ".claude", "skills", "notes");
|
||||
fs.mkdirSync(src, { recursive: true });
|
||||
fs.writeFileSync(path.join(src, "SKILL.md"), "---\nname: notes\ndescription: v1\n---\nOne.\n");
|
||||
const r = await request("POST", "/api/artifacts/skill", { laneId: lane.id, name: "notes" });
|
||||
assert.equal(r.status, 201, r.text);
|
||||
const id = r.body.item.id;
|
||||
const listed = async () =>
|
||||
(await request("GET", "/api/artifacts?kind=skill")).body.items.find((i) => i.id === id);
|
||||
const raw = async () => (await request("GET", `/api/artifacts/${id}/raw?path=SKILL.md`)).text;
|
||||
assert.equal((await request("POST", `/api/artifacts/${id}/install`, {})).status, 200);
|
||||
let item = await listed();
|
||||
assert.equal(item.installed, true);
|
||||
assert.equal(item.updateAvailable, false);
|
||||
const installs = item.installs;
|
||||
|
||||
// Unchanged folder: nothing taken.
|
||||
assert.deepEqual(artifacts.syncSkillSources({ force: true }), []);
|
||||
// The owner changes it: a new snapshot, and the installed copy is behind.
|
||||
const snapBefore = db.prepare("SELECT snapshot FROM artifacts WHERE id = ?").get(id).snapshot;
|
||||
fs.writeFileSync(path.join(src, "SKILL.md"), "---\nname: notes\ndescription: v2\n---\nTwo.\n");
|
||||
fs.writeFileSync(path.join(src, "extra.md"), "more\n");
|
||||
const changed = artifacts.syncSkillSources({ force: true });
|
||||
assert.deepEqual(
|
||||
changed.map((c) => c.id),
|
||||
[id]
|
||||
);
|
||||
assert.match(await raw(), /Two\./);
|
||||
assert.ok(!fs.existsSync(path.join(process.env.DASHBOARD_DATA_DIR, "artifacts", snapBefore)));
|
||||
item = await listed();
|
||||
assert.equal(item.description, "v2");
|
||||
assert.ok(item.files.some((f) => f.path === "extra.md"));
|
||||
assert.equal(item.updateAvailable, true);
|
||||
|
||||
// Update: the copy is replaced (old one moved aside), not counted again.
|
||||
const up = await request("POST", `/api/artifacts/${id}/install`, { update: true });
|
||||
assert.equal(up.status, 200, up.text);
|
||||
const target = path.join(process.env.CLAUDE_HOME, "skills", "notes");
|
||||
assert.match(fs.readFileSync(path.join(target, "SKILL.md"), "utf8"), /Two\./);
|
||||
item = await listed();
|
||||
assert.equal(item.updateAvailable, false);
|
||||
assert.equal(item.installs, installs);
|
||||
|
||||
// A Hub edit is not undone by the unchanged folder; a newer folder change wins.
|
||||
const edit = await request("PATCH", `/api/artifacts/${id}`, {
|
||||
skill: { name: "notes", description: "hub", content: "Edited in the Hub." },
|
||||
});
|
||||
assert.equal(edit.status, 200, edit.text);
|
||||
artifacts.syncSkillSources({ force: true });
|
||||
assert.match(await raw(), /Edited in the Hub\./);
|
||||
fs.writeFileSync(
|
||||
path.join(src, "SKILL.md"),
|
||||
"---\nname: notes\ndescription: v3\n---\nThree.\n"
|
||||
);
|
||||
artifacts.syncSkillSources({ force: true });
|
||||
assert.match(await raw(), /Three\./);
|
||||
|
||||
// Update needs an installed copy.
|
||||
await request("POST", `/api/artifacts/${id}/uninstall`, {});
|
||||
const none = await request("POST", `/api/artifacts/${id}/install`, { update: true });
|
||||
assert.equal(none.body.error.code, "ENOTINSTALLED");
|
||||
});
|
||||
|
||||
it("refuses a skill holding a symlink or a secret-looking file", async () => {
|
||||
const bad = path.join(ws, ".claude", "skills", "leaky");
|
||||
fs.mkdirSync(bad, { recursive: true });
|
||||
|
||||
@@ -964,9 +964,15 @@ try {
|
||||
// (`captureOutputs`), not pinned by hand. `suggested_owner_id`: for a row with
|
||||
// no owner, the user whose folder the session ran in — they may claim it or
|
||||
// say it is not theirs (`claim`/`decline`); an admin assigns any (`assign`).
|
||||
// `version`: a skill snapshot's content hash; `source_hash`: its source
|
||||
// folder's hash when last taken — a skill follows its owner's folder and an
|
||||
// installer whose copy differs from `version` is offered Update
|
||||
// (`syncSkillSources`/`updateCheck`). NULL until first computed.
|
||||
for (const [col, ddl] of [
|
||||
["auto", "auto INTEGER NOT NULL DEFAULT 0"],
|
||||
["suggested_owner_id", "suggested_owner_id INTEGER"],
|
||||
["version", "version TEXT"],
|
||||
["source_hash", "source_hash TEXT"],
|
||||
]) {
|
||||
try {
|
||||
db.prepare(`SELECT ${col} FROM artifacts LIMIT 1`).get();
|
||||
|
||||
+287
-17
@@ -226,7 +226,11 @@ function list(req, { scope = "all", kind, q, laneId, sessionId, limit = 200 } =
|
||||
).all(...params);
|
||||
const owners = new Map();
|
||||
const installed = installedCheck(req);
|
||||
const out = rows.map((r) => ({ ...view(r, req, owners), installed: installed(r) }));
|
||||
const updatable = updateCheck(req);
|
||||
const out = rows.map((r) => {
|
||||
const isIn = installed(r);
|
||||
return { ...view(r, req, owners), installed: isIn, updateAvailable: isIn && updatable(r) };
|
||||
});
|
||||
return scope === "mine" && req?.user ? out.filter((a) => a.mine || a.installed) : out;
|
||||
}
|
||||
|
||||
@@ -753,7 +757,7 @@ function walkSkill(dir) {
|
||||
total += st.size;
|
||||
if (files.length >= SKILL_MAX_FILES) throw fail("ETOOBIG", "the skill has too many files");
|
||||
if (total > SKILL_MAX_BYTES) throw fail("ETOOBIG", "the skill is larger than 5 MB");
|
||||
files.push({ abs: childAbs, path: childRel, size: st.size });
|
||||
files.push({ abs: childAbs, path: childRel, size: st.size, mtimeMs: st.mtimeMs });
|
||||
}
|
||||
};
|
||||
walk(dir, "", 0);
|
||||
@@ -822,6 +826,7 @@ function storeSkill(req, { name, files, title, description, laneId = null }) {
|
||||
const snap = newSnapshotDir();
|
||||
let total = 0;
|
||||
const recorded = [];
|
||||
const hashed = [];
|
||||
try {
|
||||
for (const f of files) {
|
||||
const data = typeof f.data === "function" ? f.data() : f.data;
|
||||
@@ -831,6 +836,7 @@ function storeSkill(req, { name, files, title, description, laneId = null }) {
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
|
||||
fs.writeFileSync(target, data, { mode: 0o600 });
|
||||
recorded.push({ path: f.path, size: data.length });
|
||||
hashed.push({ path: f.path, data });
|
||||
}
|
||||
} catch (err) {
|
||||
dropSnapshot(snap.id);
|
||||
@@ -845,20 +851,30 @@ function storeSkill(req, { name, files, title, description, laneId = null }) {
|
||||
} catch {
|
||||
/* no frontmatter */
|
||||
}
|
||||
return view(
|
||||
insert({
|
||||
kind: "skill",
|
||||
title: cleanTitle(title, name),
|
||||
description: cleanDescription(description) || summary.slice(0, DESCRIPTION_MAX),
|
||||
owner_id: req?.user ? req.user.id : null,
|
||||
lane_id: laneId,
|
||||
source: name,
|
||||
snapshot: snap.id,
|
||||
files: JSON.stringify(recorded),
|
||||
size: total,
|
||||
}),
|
||||
req
|
||||
);
|
||||
const row = insert({
|
||||
kind: "skill",
|
||||
title: cleanTitle(title, name),
|
||||
description: cleanDescription(description) || summary.slice(0, DESCRIPTION_MAX),
|
||||
owner_id: req?.user ? req.user.id : null,
|
||||
lane_id: laneId,
|
||||
source: name,
|
||||
snapshot: snap.id,
|
||||
files: JSON.stringify(recorded),
|
||||
size: total,
|
||||
version: contentHash(hashed),
|
||||
});
|
||||
// The owner's folder as it is now: only a LATER change to it is taken
|
||||
// (`syncOne`), so an upload is never replaced by an older local copy.
|
||||
try {
|
||||
const h = sourceHash(row);
|
||||
if (h) {
|
||||
sql("UPDATE artifacts SET source_hash = ? WHERE id = ?").run(h, row.id);
|
||||
row.source_hash = h;
|
||||
}
|
||||
} catch {
|
||||
/* no folder */
|
||||
}
|
||||
return view(row, req);
|
||||
}
|
||||
|
||||
// ── skill: written or uploaded ────────────────────────────────────────────
|
||||
@@ -1569,10 +1585,22 @@ function editSkill(req, row, { name, description, content }) {
|
||||
throw err;
|
||||
}
|
||||
const desc = frontmatterField(text, "description");
|
||||
// The source folder as it is now, so this edit is not undone by it (`syncOne`).
|
||||
const folder = sourceHash({ ...row, source: n });
|
||||
sql(
|
||||
`UPDATE artifacts SET source = ?, snapshot = ?, files = ?, size = ?, description = ?,
|
||||
version = ?, source_hash = ?,
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`
|
||||
).run(n, snap.id, JSON.stringify(next), total, cleanDescription(desc), row.id);
|
||||
).run(
|
||||
n,
|
||||
snap.id,
|
||||
JSON.stringify(next),
|
||||
total,
|
||||
cleanDescription(desc),
|
||||
contentHash(all),
|
||||
folder,
|
||||
row.id
|
||||
);
|
||||
dropSnapshot(row.snapshot);
|
||||
}
|
||||
|
||||
@@ -1756,6 +1784,237 @@ async function installSkill(req, row, { replace = false } = {}) {
|
||||
return { installed: paths.join(", "), paths, replaced: taken.length > 0 };
|
||||
}
|
||||
|
||||
// ── skill versions: follow the owner's folder, offer installers an Update ──
|
||||
|
||||
/** A skill's content id: sha256 over its files (path + bytes), path order. */
|
||||
function contentHash(files) {
|
||||
const h = crypto.createHash("sha256");
|
||||
for (const f of [...files].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))) {
|
||||
h.update(f.path);
|
||||
h.update("\0");
|
||||
h.update(crypto.createHash("sha256").update(f.data).digest());
|
||||
}
|
||||
return h.digest("hex").slice(0, 16);
|
||||
}
|
||||
|
||||
/** A skill row's snapshot version (`artifacts.version`), computed once for an older row. */
|
||||
function snapshotVersion(row) {
|
||||
if (row.version) return row.version;
|
||||
if (row.kind !== "skill" || !row.snapshot || !SNAPSHOT_RE.test(row.snapshot)) return null;
|
||||
try {
|
||||
const files = (row.files ? JSON.parse(row.files) : []).map((f) => ({
|
||||
path: f.path,
|
||||
data: fs.readFileSync(path.join(root(), row.snapshot, ...f.path.split("/"))),
|
||||
}));
|
||||
const v = contentHash(files);
|
||||
sql("UPDATE artifacts SET version = ? WHERE id = ?").run(v, row.id);
|
||||
row.version = v;
|
||||
return v;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// realpath → { sig, hash }: a folder is read again only once a file's name,
|
||||
// size or mtime changed (a stat walk is cheap; reading 5 MB per poll is not).
|
||||
const folderCache = new Map();
|
||||
const FOLDER_CACHE_MAX = 2000;
|
||||
|
||||
/**
|
||||
* A skill folder as it is on disk now — `{ real, hash, files, read() }` — or
|
||||
* null when it is missing, has no SKILL.md, is a link out of `top`, or breaks
|
||||
* a snapshot rule (`walkSkill`: no symlink, no secret, ≤ 5 MB). `read()`
|
||||
* answers its files' bytes, a member's only as theirs (`readFileSafe`).
|
||||
*/
|
||||
function folderState(dir, top) {
|
||||
let real;
|
||||
let topReal;
|
||||
try {
|
||||
real = fs.realpathSync(dir);
|
||||
topReal = fs.realpathSync(top);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!inside(topReal, real) || path.basename(real) !== path.basename(dir)) return null;
|
||||
let walked;
|
||||
try {
|
||||
walked = walkSkill(real);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!walked.files.some((f) => f.path === "SKILL.md")) return null;
|
||||
const sig = walked.files
|
||||
.map((f) => `${f.path}:${f.size}:${f.mtimeMs}`)
|
||||
.sort()
|
||||
.join("|");
|
||||
const read = () => {
|
||||
const runAs = require("./run-as");
|
||||
return walked.files.map((f) => ({
|
||||
path: f.path,
|
||||
data: runAs.readFileSafe(f.abs, null, { max: SKILL_MAX_BYTES }),
|
||||
}));
|
||||
};
|
||||
const hit = folderCache.get(real);
|
||||
if (hit && hit.sig === sig) return { real, hash: hit.hash, files: walked.files, read };
|
||||
let hash;
|
||||
try {
|
||||
hash = contentHash(read());
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
folderCache.delete(real);
|
||||
folderCache.set(real, { sig, hash });
|
||||
if (folderCache.size > FOLDER_CACHE_MAX) folderCache.delete(folderCache.keys().next().value);
|
||||
return { real, hash, files: walked.files, read };
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a skill row came from: `{dir, top}` — its workspace's
|
||||
* `.claude/skills/<name>` when it was taken from one, else its OWNER's
|
||||
* `~/.claude/skills/<name>` (the host's in local mode). Null for a row with no
|
||||
* owner in server mode, or an owner with no Claude config of their own.
|
||||
*/
|
||||
function sourceDirOf(row) {
|
||||
if (row.kind !== "skill" || !row.source || !SKILL_NAME_RE.test(row.source)) return null;
|
||||
if (row.lane_id != null) {
|
||||
const lane = sql("SELECT cwd FROM lanes WHERE id = ?").get(row.lane_id);
|
||||
if (!lane?.cwd) return null;
|
||||
return { dir: path.join(lane.cwd, ".claude", "skills", row.source), top: lane.cwd };
|
||||
}
|
||||
let claudeHome;
|
||||
if (row.owner_id == null) {
|
||||
if (isServer()) return null;
|
||||
claudeHome = getClaudeHome();
|
||||
} else {
|
||||
const user = sql("SELECT id, username, role FROM users WHERE id = ?").get(row.owner_id);
|
||||
if (!user) return null;
|
||||
try {
|
||||
claudeHome = ownAccount({ user }).claudeHome;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return { dir: path.join(claudeHome, "skills", row.source), top: claudeHome };
|
||||
}
|
||||
|
||||
/** The hash of a row's source folder now, or null when it has none. */
|
||||
function sourceHash(row) {
|
||||
const src = sourceDirOf(row);
|
||||
return src ? (folderState(src.dir, src.top)?.hash ?? null) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Take a skill row's NEW source folder as its snapshot, when the folder
|
||||
* changed since it was last taken (`source_hash`) and differs from the
|
||||
* snapshot. A change made in the Hub (`editSkill`) records the folder as it
|
||||
* was then, so it is not undone until the folder itself changes again: the
|
||||
* newer change wins. A new snapshot directory, then the old one dropped —
|
||||
* never a write into the one being read. Answers whether the row changed.
|
||||
*/
|
||||
function syncOne(row) {
|
||||
const src = sourceDirOf(row);
|
||||
if (!src) return false;
|
||||
const st = folderState(src.dir, src.top);
|
||||
if (!st || st.hash === row.source_hash) return false;
|
||||
if (st.hash === snapshotVersion(row)) {
|
||||
sql("UPDATE artifacts SET source_hash = ? WHERE id = ?").run(st.hash, row.id);
|
||||
return false;
|
||||
}
|
||||
let files;
|
||||
try {
|
||||
files = st.read();
|
||||
checkSkillFiles(files);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
const snap = newSnapshotDir();
|
||||
let total = 0;
|
||||
const next = [];
|
||||
try {
|
||||
for (const f of files) {
|
||||
const target = path.join(snap.dir, ...f.path.split("/"));
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
|
||||
fs.writeFileSync(target, f.data, { mode: 0o600 });
|
||||
total += f.data.length;
|
||||
next.push({ path: f.path, size: f.data.length });
|
||||
}
|
||||
} catch {
|
||||
dropSnapshot(snap.id);
|
||||
return false;
|
||||
}
|
||||
// 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 {
|
||||
oldSummary = frontmatterField(
|
||||
fs.readFileSync(path.join(root(), row.snapshot, "SKILL.md"), "utf8"),
|
||||
"description"
|
||||
).slice(0, DESCRIPTION_MAX);
|
||||
} catch {
|
||||
/* none */
|
||||
}
|
||||
const newSummary = frontmatterField(md(files)?.data.toString("utf8") ?? "", "description");
|
||||
const description =
|
||||
!row.description || row.description === oldSummary
|
||||
? cleanDescription(newSummary) || row.description
|
||||
: row.description;
|
||||
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);
|
||||
if (row.snapshot && SNAPSHOT_RE.test(row.snapshot)) dropSnapshot(row.snapshot);
|
||||
return true;
|
||||
}
|
||||
|
||||
const SYNC_EVERY_MS = 15 * 1000;
|
||||
let lastSync = 0;
|
||||
|
||||
/**
|
||||
* Bring every skill row up to its source folder (`syncOne`), at most once per
|
||||
* `SYNC_EVERY_MS` unless `force`. Runs when the Hub is listed. Answers the
|
||||
* rows that changed, for the route to announce.
|
||||
*/
|
||||
function syncSkillSources({ force = false } = {}) {
|
||||
if (!force && Date.now() - lastSync < SYNC_EVERY_MS) return [];
|
||||
lastSync = Date.now();
|
||||
const changedRows = [];
|
||||
for (const row of sql(
|
||||
"SELECT * FROM artifacts WHERE kind = 'skill' AND snapshot IS NOT NULL"
|
||||
).all()) {
|
||||
try {
|
||||
if (syncOne(row)) changedRows.push(sql("SELECT * FROM artifacts WHERE id = ?").get(row.id));
|
||||
} catch {
|
||||
/* one bad row never stops the rest */
|
||||
}
|
||||
}
|
||||
return changedRows;
|
||||
}
|
||||
|
||||
/**
|
||||
* `row => boolean`: an installed skill whose copy in the viewer's
|
||||
* `~/.claude/skills/<name>` (any account it runs as) differs from the row's
|
||||
* newest snapshot — the list offers **Update** for it. Pure reads.
|
||||
*/
|
||||
function updateCheck(req) {
|
||||
let accounts = null;
|
||||
return (row) => {
|
||||
if (row.kind !== "skill" || !row.source) return false;
|
||||
const version = snapshotVersion(row);
|
||||
if (!version) return false;
|
||||
if (accounts === null) {
|
||||
try {
|
||||
accounts = installAccounts(req);
|
||||
} catch {
|
||||
accounts = [];
|
||||
}
|
||||
}
|
||||
return accounts.some((a) => {
|
||||
const st = folderState(path.join(a.claudeHome, "skills", row.source), a.claudeHome);
|
||||
return !!st && st.hash !== version;
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
let execImpl = promisify(require("node:child_process").execFile);
|
||||
function __setExecImpl(fn) {
|
||||
execImpl = fn;
|
||||
@@ -1815,6 +2074,16 @@ async function installPlugin(req, row) {
|
||||
async function install(req, id, opts = {}) {
|
||||
const row = load(req, id);
|
||||
installable(req, row);
|
||||
// Update: an installed skill's copies replaced by the newest snapshot (the
|
||||
// old ones moved aside like any replace). Not another install: no count, no
|
||||
// second row in who-installed.
|
||||
if (opts.update) {
|
||||
if (row.kind !== "skill") throw fail("EBADKIND", "only a skill is updated here");
|
||||
if (!installedCheck(req)(row)) throw fail("ENOTINSTALLED", `${row.source} is not installed`);
|
||||
const result = await installSkill(req, row, { replace: true });
|
||||
if (req?.user && row.owner_id !== req.user.id) audit(req, row, "updated");
|
||||
return result;
|
||||
}
|
||||
const result =
|
||||
row.kind === "skill" ? await installSkill(req, row, opts) : await installPlugin(req, row);
|
||||
sql("UPDATE artifacts SET installs = installs + 1 WHERE id = ?").run(row.id);
|
||||
@@ -1947,6 +2216,7 @@ module.exports = {
|
||||
update,
|
||||
remove,
|
||||
snapshotFile,
|
||||
syncSkillSources,
|
||||
install,
|
||||
uninstall,
|
||||
installsEnabled,
|
||||
|
||||
@@ -75,6 +75,9 @@ router.get(
|
||||
"/",
|
||||
wrap((req, res) => {
|
||||
const q = req.query;
|
||||
// A skill follows its owner's folder: take what changed there first
|
||||
// (throttled), and tell every open Hub page.
|
||||
for (const row of artifacts.syncSkillSources()) changed(row);
|
||||
res.json({
|
||||
items: artifacts.list(req, {
|
||||
scope: q.scope,
|
||||
@@ -358,6 +361,7 @@ router.post(
|
||||
wrap(async (req, res) => {
|
||||
const result = await artifacts.install(req, req.params.id, {
|
||||
replace: req.body?.replace === true,
|
||||
update: req.body?.update === true,
|
||||
});
|
||||
res.json(result);
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user