Files
AgentHub/server/lib/artifacts.js
T
nntrivi2001 0547488f13 chore: v2.8.13 — Artifacts reinstall/edit/installs, agent steps in chat, pick a local file to attach, Windows paths as links
- Chat: an Agent step shows its task, status and tool calls and opens its conversation in place; Windows paths (E:\…, C:/…) in replies open as files
- Prompt: attach a file on this computer by path (native picker), clearer too-large messages
- Artifacts: edit title/description and a skill's files (new snapshot), Reinstall, install history, Hub-only rows, skills installed into ~/.claude/skills and ~/.agents/skills
- Files: batch upload of small files in one extract
- AI Tools: npm install falls back to --prefix ~/.local when the global prefix is not writable
2026-10-20 16:44:55 +07:00

1601 lines
58 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @file artifacts.js
* @description The Artifacts hub (docs/plans/artifacts/README.md): what agents
* produce, kept per OWNER and — in server mode — shared to every user.
*
* Four kinds:
* - `page` a claude.ai Artifact a session published: only a link, accepted
* only as `https://claude.ai/…`. Captured from the `Artifact`
* tool's PostToolUse hook (`captureFromHook`) or pasted by hand.
* - `file` a file an agent wrote in a workspace, pinned by the user.
* - `skill` a Claude Code skill folder (the user's own, or a workspace's
* `.claude/skills/<name>`).
* - `plugin` a plugin by `name@marketplace` and its marketplace source; no
* files.
*
* Rules:
* - A `file` or `skill` is a SNAPSHOT taken when it is added, written under
* `<data dir>/artifacts/<snapshot>/` and never touched again. Every read a
* viewer makes is of that copy — no route reads another user's home for
* them — and a path from a request only ever picks one of the snapshot's
* own recorded files (`snapshotFile`).
* - Visibility: in local mode every row; in server mode a developer sees
* their own rows plus shared ones, admin and analyst see every row (like
* sessions), and only the owner (or an admin) changes or deletes one.
* - Installing a shared skill or plugin is the viewer's click, into the
* VIEWER's own accounts only (`installAccounts`): a member's home under S3
* (written AS them through `write-as`, `claude` run as them through sudo),
* plus the host's home for an admin (their host-folder workspaces run as
* the host), or the host alone in local mode. A skill goes into BOTH
* `~/.claude/skills/` and the cross-tool `~/.agents/skills/` of each, so
* every AI tool and every session of the viewer finds it. An existing skill
* is never overwritten without `replace`, and the old one is moved aside to
* `~/.claude/agenthub-replaced-skills/`, never deleted. A plugin is
* installed without `-y`, so one whose marketplace declares an install
* command fails rather than runs unseen. An admin can turn installs off
* for everyone (`setInstallsEnabled`). Share, unshare and install are
* written to `audit_log`.
* - Editing a skill's name, description or instructions (`editSkill`) writes
* a NEW snapshot and swaps the row to it; the old snapshot is dropped, and
* copies already installed stay as they were (Reinstall picks it up).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("node:fs");
const path = require("node:path");
const crypto = require("node:crypto");
const { promisify } = require("node:util");
const { getDataDir, getClaudeHome } = require("./claude-home");
const sql = (q) => require("../db").db.prepare(q);
const fail = (code, message, extra = {}) => Object.assign(new Error(message), { code, ...extra });
const isServer = () => require("./auth").isServerMode();
const FILE_MAX_BYTES = 25 * 1024 * 1024;
const SKILL_MAX_BYTES = 5 * 1024 * 1024;
const SKILL_MAX_FILES = 300;
const SKILL_MAX_DEPTH = 6;
const TITLE_MAX = 200;
const DESCRIPTION_MAX = 2000;
const KINDS = ["page", "file", "skill", "plugin"];
// What the file finder offers to pin: documents, not source code.
const DOC_EXT =
/\.(html?|md|markdown|mdx|pdf|png|jpe?g|gif|webp|svg|csv|tsv|json|ya?ml|txt|log|xlsx?|docx?|pptx?|ipynb|mp4|webm|mp3|wav|zip)$/i;
const PAGE_URL_RE = /^https:\/\/claude\.ai\/[A-Za-z0-9._~\-/?#=&%]+$/;
const PAGE_IN_TEXT_RE =
/https:\/\/claude\.ai\/(?:code\/)?artifact\/[A-Za-z0-9_-]+[A-Za-z0-9._~\-/?#=&%]*/;
const SKILL_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
const PLUGIN_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}@[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
const GITHUB_REPO_RE = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,99}\/[A-Za-z0-9][A-Za-z0-9_.-]{0,99}$/;
const SNAPSHOT_RE = /^[A-Za-z0-9_-]{32}$/;
// A skill may not carry a login or a key: refused, never copied.
const SECRET_NAME_RE =
/(^\.env(\..*)?$|credential|secret|password|token|\.pem$|\.key$|\.p12$|\.pfx$|^id_(rsa|ed25519|ecdsa|dsa)$|\.netrc$)/i;
const root = () => path.join(getDataDir(), "artifacts");
const installsOffFlag = () => path.join(root(), "installs-off");
// ── Visibility ────────────────────────────────────────────────────────────
/** `{where, params}` over an `artifacts` alias `a`: the rows this request sees. */
function visibleWhere(req) {
const u = req?.user;
if (!u || u.role === "admin" || u.role === "analyst") return { where: "1", params: [] };
return { where: "(a.owner_id = ? OR a.shared = 1)", params: [u.id] };
}
const canSee = (req, row) => {
const u = req?.user;
if (!row) return false;
if (!u || u.role === "admin" || u.role === "analyst") return true;
return row.owner_id === u.id || row.shared === 1;
};
/** Only the owner changes a row — or an admin, or anyone in local mode. */
const canChange = (req, row) =>
!req?.user || req.user.role === "admin" || row.owner_id === req.user.id;
/** For scope.js's PATH_RULES: may this request name artifact `id`? */
function canSeeId(req, id) {
return canSee(req, sql("SELECT owner_id, shared FROM artifacts WHERE id = ?").get(Number(id)));
}
function load(req, id) {
const row = /^\d+$/.test(String(id))
? sql("SELECT * FROM artifacts WHERE id = ?").get(Number(id))
: null;
if (!canSee(req, row)) throw fail("ENOTFOUND", "artifact not found");
return row;
}
function view(row, req, owners = null) {
if (!row) return null;
let owner = null;
if (row.owner_id != null) {
// A list passes one cache, so each owner is looked up once, not per row.
if (owners?.has(row.owner_id)) owner = owners.get(row.owner_id);
else {
owner =
sql("SELECT username, display_name FROM users WHERE id = ?").get(row.owner_id) || null;
owners?.set(row.owner_id, owner);
}
}
return {
id: row.id,
kind: row.kind,
title: row.title,
description: row.description,
ownerId: row.owner_id,
owner: owner ? owner.display_name || owner.username : null,
mine: !req?.user || row.owner_id === req.user.id,
canChange: canChange(req, row),
laneId: row.lane_id,
sessionId: row.session_id,
source: row.source,
market: row.market,
url: row.url,
files: row.files ? JSON.parse(row.files) : [],
size: row.size,
shared: row.shared === 1,
sharedAt: row.shared_at,
inLibrary: row.in_library !== 0,
installs: row.installs,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
/**
* The rows this request may see. `scope`: `mine` (own rows; every row in
* local mode), `hub` (shared rows, server mode), anything else = all visible.
* Each row also carries `installed` (`installedCheck`).
*/
function list(req, { scope = "all", kind, q, laneId, sessionId, limit = 200 } = {}) {
const v = visibleWhere(req);
const where = [v.where];
const params = [...v.params];
if (scope === "mine" && req?.user) {
where.push("a.owner_id = ? AND a.in_library = 1");
params.push(req.user.id);
} else if (scope === "hub") {
where.push("a.shared = 1");
}
if (kind) {
if (!KINDS.includes(kind)) throw fail("EBADFIELD", `kind: one of ${KINDS.join(", ")}`);
where.push("a.kind = ?");
params.push(kind);
}
if (laneId != null && laneId !== "") {
where.push("a.lane_id = ?");
params.push(Number(laneId));
}
if (sessionId) {
where.push("a.session_id = ?");
params.push(String(sessionId));
}
if (q && String(q).trim()) {
// `\` escapes `%`/`_` (and itself) only with an ESCAPE clause: SQLite
// has no default escape character, so without it `my_skill` found nothing.
where.push(
"(a.title LIKE ? ESCAPE '\\' OR a.description LIKE ? ESCAPE '\\' OR a.source LIKE ? ESCAPE '\\')"
);
const like = `%${String(q)
.trim()
.replace(/[\\%_]/g, (c) => `\\${c}`)}%`;
params.push(like, like, like);
}
const n = Math.min(Math.max(Number(limit) || 200, 1), 500);
const rows = sql(
`SELECT a.* FROM artifacts a WHERE ${where.join(" AND ")} ORDER BY a.updated_at DESC, a.id DESC LIMIT ${n}`
).all(...params);
const owners = new Map();
const installed = installedCheck(req);
return rows.map((r) => ({ ...view(r, req, owners), installed: installed(r) }));
}
// ── Small helpers ─────────────────────────────────────────────────────────
function cleanTitle(v, fallback) {
const t = typeof v === "string" ? v.trim().slice(0, TITLE_MAX) : "";
return t || String(fallback || "Untitled").slice(0, TITLE_MAX);
}
const cleanDescription = (v) => (typeof v === "string" ? v.trim().slice(0, DESCRIPTION_MAX) : "");
function insert(fields) {
const cols = Object.keys(fields);
const info = sql(
`INSERT INTO artifacts (${cols.join(", ")}) VALUES (${cols.map(() => "?").join(", ")})`
).run(...cols.map((c) => fields[c]));
return sql("SELECT * FROM artifacts WHERE id = ?").get(Number(info.lastInsertRowid));
}
function newSnapshotDir() {
const id = crypto.randomBytes(24).toString("base64url");
const dir = path.join(root(), id);
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
return { id, dir };
}
function dropSnapshot(id) {
if (!id || !SNAPSHOT_RE.test(id)) return;
fs.rmSync(path.join(root(), id), { recursive: true, force: true });
}
function laneOf(laneId) {
const lane = require("./lanes").getLane(Number(laneId));
if (!lane) throw fail("ENOLANE", "workspace not found");
return lane;
}
/** The owner of a session's workspace (null: unknown or local mode). */
function sessionOwner(sessionId) {
const owners = [...require("./scope").sessionOwners(sessionId)];
return owners.length ? owners[0] : null;
}
/** A session's workspace, if one owns it. */
function sessionLane(sessionId) {
const row =
sql("SELECT lane_id FROM sessions WHERE id = ?").get(String(sessionId)) ||
sql(
"SELECT lane_id FROM dashboard_runs WHERE session_id = ? AND lane_id IS NOT NULL LIMIT 1"
).get(String(sessionId));
return row?.lane_id ?? null;
}
// ── page ──────────────────────────────────────────────────────────────────
function validPageUrl(url) {
const u = typeof url === "string" ? url.trim() : "";
if (!PAGE_URL_RE.test(u) || u.length > 2000) {
throw fail("EBADURL", "a claude.ai link (https://claude.ai/…) is required");
}
return u;
}
/** Add (or retitle) a claude.ai page by hand. */
function addPage(req, { url, title, description, laneId, sessionId }) {
const u = validPageUrl(url);
const ownerId = req?.user ? req.user.id : null;
const same = sql("SELECT * FROM artifacts WHERE kind = 'page' AND url = ? AND owner_id IS ?").get(
u,
ownerId
);
if (same) {
sql(
"UPDATE artifacts SET title = ?, in_library = 1, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?"
).run(cleanTitle(title, same.title), same.id);
return view(sql("SELECT * FROM artifacts WHERE id = ?").get(same.id), req);
}
return view(
insert({
kind: "page",
title: cleanTitle(title, u.replace(/^https:\/\//, "")),
description: cleanDescription(description),
owner_id: ownerId,
lane_id: laneId != null && laneId !== "" ? Number(laneId) : null,
session_id: sessionId ? String(sessionId) : null,
url: u,
}),
req
);
}
/**
* The `Artifact` tool's PostToolUse hook: a publish answers with the page's
* claude.ai URL somewhere in its response. Recorded once per (owner, url),
* owned by the session's workspace owner. Returns the row or null.
*/
function captureFromHook(sessionId, data) {
const input = data?.tool_input || {};
if (input.action && input.action !== "publish") return null;
const text = JSON.stringify(data?.tool_response ?? "");
const m = PAGE_IN_TEXT_RE.exec(text);
if (!m) return null;
const url = m[0].replace(/[\\"]+$/, "");
if (!PAGE_URL_RE.test(url)) return null;
const ownerId = sessionOwner(sessionId);
const same = sql("SELECT * FROM artifacts WHERE kind = 'page' AND url = ? AND owner_id IS ?").get(
url,
ownerId
);
if (same) return same;
const file = typeof input.file_path === "string" ? input.file_path : "";
return insert({
kind: "page",
title: cleanTitle(input.title, file ? path.basename(file) : "Artifact"),
description: cleanDescription(input.description),
owner_id: ownerId,
lane_id: sessionLane(sessionId),
session_id: String(sessionId),
source: file ? file.slice(0, 1000) : null,
url,
});
}
// ── file ──────────────────────────────────────────────────────────────────
/**
* Files the workspace's sessions wrote (Write/Edit/MultiEdit/NotebookEdit),
* documents only, inside the workspace folder, newest first — what the user
* can pin. `pinned` marks the ones already kept.
*/
function fileCandidates(laneId) {
const lane = laneOf(laneId);
const rows = sql(
`SELECT session_id,
COALESCE(json_extract(data, '$.tool_input.file_path'),
json_extract(data, '$.tool_input.notebook_path')) AS fp,
created_at
FROM events
WHERE event_type = 'PostToolUse'
AND tool_name IN ('Write', 'Edit', 'MultiEdit', 'NotebookEdit')
AND session_id IN (SELECT id FROM sessions WHERE lane_id = ?
UNION SELECT session_id FROM dashboard_runs
WHERE lane_id = ? AND session_id IS NOT NULL)
ORDER BY id DESC LIMIT 5000`
).all(lane.id, lane.id);
const pinned = new Set(
sql("SELECT source FROM artifacts WHERE kind = 'file' AND lane_id = ?")
.all(lane.id)
.map((r) => r.source)
);
const seen = new Set();
const out = [];
for (const r of rows) {
if (typeof r.fp !== "string" || !path.isAbsolute(r.fp) || !DOC_EXT.test(r.fp)) continue;
const rel = path.relative(lane.cwd, r.fp);
if (!rel || rel.startsWith("..") || path.isAbsolute(rel) || seen.has(rel)) continue;
seen.add(rel);
let exists = false;
try {
exists = fs.lstatSync(r.fp).isFile();
} catch {
exists = false;
}
out.push({
path: rel.split(path.sep).join("/"),
sessionId: r.session_id,
at: r.created_at,
exists,
pinned: pinned.has(rel.split(path.sep).join("/")),
});
if (out.length >= 200) break;
}
return out;
}
/** Pin one file of a workspace: a snapshot of it, under the read path's guards. */
async function pinFile(req, { laneId, path: rel, title, description, sessionId }) {
const lane = laneOf(laneId);
const { openDownload } = require("./lane-file-transfer");
const file = await openDownload(lane.cwd, typeof rel === "string" ? rel : "");
if (file.size > FILE_MAX_BYTES) {
file.stream.destroy();
throw fail("ETOOBIG", "a file artifact is at most 25 MB");
}
const snap = newSnapshotDir();
const name = file.name;
try {
await new Promise((resolve, reject) => {
const out = fs.createWriteStream(path.join(snap.dir, name), { mode: 0o600 });
file.stream.on("error", reject);
out.on("error", reject);
out.on("finish", resolve);
file.stream.pipe(out);
});
} catch (err) {
dropSnapshot(snap.id);
throw err;
}
const relPosix = require("./git-diff").lanePath(rel);
return view(
insert({
kind: "file",
title: cleanTitle(title, name),
description: cleanDescription(description),
owner_id: req?.user ? req.user.id : (lane.owner_id ?? null),
lane_id: lane.id,
session_id: sessionId ? String(sessionId) : null,
source: relPosix,
snapshot: snap.id,
files: JSON.stringify([{ path: name, size: file.size }]),
size: file.size,
}),
req
);
}
/**
* A file uploaded from the user's computer: kept as a snapshot like a pinned
* one, under a name made from the upload's own (no folder, no path).
*/
function uploadFile(req, { name, data, title, description }) {
if (!Buffer.isBuffer(data) || !data.length) throw fail("ENOFILE", "the file is empty");
if (data.length > FILE_MAX_BYTES) throw fail("ETOOBIG", "a file artifact is at most 25 MB");
const base = path.posix.basename(String(name || "").replace(/\\/g, "/"));
// eslint-disable-next-line no-control-regex
const clean =
base
.replace(/[\x00-\x1f/]/g, "")
.replace(/^\.+/, "")
.slice(0, 200) || "file";
const snap = newSnapshotDir();
try {
fs.writeFileSync(path.join(snap.dir, clean), data, { mode: 0o600 });
} catch (err) {
dropSnapshot(snap.id);
throw err;
}
return view(
insert({
kind: "file",
title: cleanTitle(title, clean),
description: cleanDescription(description),
owner_id: req?.user ? req.user.id : null,
source: clean,
snapshot: snap.id,
files: JSON.stringify([{ path: clean, size: data.length }]),
size: data.length,
}),
req
);
}
// ── skill ─────────────────────────────────────────────────────────────────
/**
* The account a request's own Claude config lives in: `{acct, claudeHome}`.
* A member under S3 (their home), else the host's Claude home — only in
* local mode or for an admin, since it is the host's own configuration.
*/
function ownAccount(req) {
if (!req?.user) return { acct: null, claudeHome: getClaudeHome() };
const runAs = require("./run-as");
let acct = null;
try {
acct = runAs.accountForUser(req.user.username);
} catch {
acct = null;
}
if (acct) return { acct, claudeHome: path.join(acct.home, ".claude") };
if (req.user.role === "admin") return { acct: null, claudeHome: getClaudeHome() };
throw fail(
"ENOACCOUNT",
"your account has no Claude config of its own on this server (one Linux user per account is not set up)"
);
}
/** Skill folders the request may share: its own and the workspace's. */
function availableSkills(req, laneId) {
const out = [];
const scan = (dir, from) => {
let names = [];
try {
names = fs.readdirSync(dir, { withFileTypes: true });
} catch {
return;
}
for (const d of names) {
if (!d.isDirectory() || !SKILL_NAME_RE.test(d.name)) continue;
try {
if (fs.lstatSync(path.join(dir, d.name, "SKILL.md")).isFile()) {
out.push({ name: d.name, from });
}
} catch {
/* not a skill */
}
}
};
try {
scan(path.join(ownAccount(req).claudeHome, "skills"), "user");
} catch {
/* no own config: only the workspace's */
}
if (laneId != null && laneId !== "") {
scan(path.join(laneOf(laneId).cwd, ".claude", "skills"), "workspace");
}
return out.sort((a, b) => a.name.localeCompare(b.name));
}
/** Every regular file under `dir`, capped; a symlink is refused, never followed. */
function walkSkill(dir) {
const files = [];
let total = 0;
const walk = (abs, rel, depth) => {
if (depth > SKILL_MAX_DEPTH) throw fail("ETOOBIG", "the skill folder is nested too deep");
for (const d of fs.readdirSync(abs, { withFileTypes: true })) {
if (d.name === ".git" || d.name === "node_modules") continue;
const childAbs = path.join(abs, d.name);
const childRel = rel ? `${rel}/${d.name}` : d.name;
const st = fs.lstatSync(childAbs);
if (st.isSymbolicLink()) throw fail("ESYMLINK", `the skill holds a symlink: ${childRel}`);
if (st.isDirectory()) {
walk(childAbs, childRel, depth + 1);
continue;
}
if (!st.isFile()) continue;
if (SECRET_NAME_RE.test(d.name)) {
throw fail("ESECRET", `the skill holds a file that looks like a secret: ${childRel}`);
}
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 });
}
};
walk(dir, "", 0);
return { files, total };
}
const inside = (parent, child) => {
const rel = path.relative(parent, child);
return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel);
};
/** Snapshot one skill folder — the user's own (`laneId` absent) or a workspace's. */
function addSkill(req, { name, laneId, title, description }) {
if (typeof name !== "string" || !SKILL_NAME_RE.test(name)) {
throw fail("EBADNAME", "a skill folder name");
}
let base;
let lane = null;
if (laneId != null && laneId !== "") {
lane = laneOf(laneId);
base = path.join(lane.cwd, ".claude", "skills");
} else {
base = path.join(ownAccount(req).claudeHome, "skills");
}
const dir = path.join(base, name);
let real;
try {
real = fs.realpathSync(dir);
} catch {
throw fail("ENOFILE", "no such skill");
}
// The folder must really be where its name says (no symlinked skill).
const top = lane ? fs.realpathSync(lane.cwd) : fs.realpathSync(path.dirname(base));
if (!inside(top, real) || path.basename(real) !== name) {
throw fail("ESYMLINK", "the skill folder is a link elsewhere");
}
if (!fs.existsSync(path.join(real, "SKILL.md")))
throw fail("ENOFILE", "no SKILL.md in that folder");
const { files } = walkSkill(real);
const runAs = require("./run-as");
return storeSkill(req, {
name,
title,
description,
laneId: lane ? lane.id : null,
files: files.map((f) => ({
path: f.path,
data: () => runAs.readFileSafe(f.abs, null, { max: SKILL_MAX_BYTES }),
})),
});
}
/** `description:` of a SKILL.md's frontmatter, or "". */
function frontmatterField(text, field) {
const fm = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text);
if (!fm) return "";
const m = new RegExp(`^${field}:\\s*(.*)$`, "m").exec(fm[1]);
return (m?.[1] || "").trim().replace(/^(["'])(.*)\1$/, "$2");
}
/**
* Snapshot a skill's files and record the row. `files`: `[{path, data}]`,
* `data` a Buffer or a function returning one (read only while copying).
*/
function storeSkill(req, { name, files, title, description, laneId = null }) {
const snap = newSnapshotDir();
let total = 0;
const recorded = [];
try {
for (const f of files) {
const data = typeof f.data === "function" ? f.data() : f.data;
total += data.length;
if (total > SKILL_MAX_BYTES) throw fail("ETOOBIG", "the skill is larger than 5 MB");
const target = path.join(snap.dir, ...f.path.split("/"));
fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
fs.writeFileSync(target, data, { mode: 0o600 });
recorded.push({ path: f.path, size: data.length });
}
} catch (err) {
dropSnapshot(snap.id);
throw err;
}
let summary = "";
try {
summary = frontmatterField(
fs.readFileSync(path.join(snap.dir, "SKILL.md"), "utf8"),
"description"
);
} 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
);
}
// ── skill: written or uploaded ────────────────────────────────────────────
// Junk an OS or an editor leaves in a folder or a zip: skipped, never an error.
const JUNK_RE = /(^|\/)(__MACOSX|\.DS_Store|Thumbs\.db|desktop\.ini|\.git|node_modules)(\/|$)/;
const ZIP_RE = /\.(zip|skill)$/i;
const TEXT_RE = /\.(md|markdown|txt)$/i;
const ZIP_MAX_ENTRIES = 2000;
/**
* A skill folder name from free text: what the user typed, a frontmatter
* `name:`, a folder or a file name. Spaces and other characters become `-`;
* Vietnamese letters lose their marks. "" when nothing usable is left.
*/
function skillSlug(text) {
return String(text || "")
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/đ/g, "d")
.replace(/Đ/g, "D")
.replace(/[^A-Za-z0-9._-]+/g, "-")
.replace(/^[-._]+|-+$/g, "")
.slice(0, 100);
}
function validSkillName(raw) {
const name = skillSlug(raw);
if (!SKILL_NAME_RE.test(name)) {
throw fail(
"EBADNAME",
"skill name: letters, digits, '.', '_' or '-' (it becomes the folder and the /command)"
);
}
return name;
}
/** One uploaded path, made safe: posix, relative, no `..`, no secret, not too deep. */
function cleanUploadPath(raw) {
const p = String(raw || "")
.replace(/\\/g, "/")
.replace(/^(\.\/)+/, "")
.replace(/^\/+/, "");
// eslint-disable-next-line no-control-regex
if (!p || /[\x00-\x1f]/.test(p)) throw fail("EBADPATH", "an uploaded file has no usable name");
const parts = p.split("/").filter(Boolean);
if (parts.some((x) => x === ".." || x === "."))
throw fail("EBADPATH", `not a path inside the skill: ${p}`);
if (parts.length > SKILL_MAX_DEPTH + 2)
throw fail("ETOOBIG", "the skill folder is nested too deep");
return parts.join("/");
}
/** A zip's (or a .skill's) regular files: sizes checked from its directory before inflating. */
function unzipSkill(buf) {
const AdmZip = require("adm-zip");
let entries;
try {
entries = new AdmZip(buf).getEntries();
} catch {
throw fail("EBADZIP", "not a readable zip file");
}
if (entries.length > ZIP_MAX_ENTRIES) throw fail("ETOOBIG", "the zip has too many entries");
let total = 0;
const out = [];
for (const e of entries) {
if (e.isDirectory || JUNK_RE.test(e.entryName)) continue;
const mode = (e.attr >>> 16) & 0o170000;
if (mode === 0o120000) throw fail("ESYMLINK", `the zip holds a symlink: ${e.entryName}`);
total += e.header.size;
if (total > SKILL_MAX_BYTES) throw fail("ETOOBIG", "the skill is larger than 5 MB");
const data = e.getData();
if (data.length !== e.header.size) throw fail("EBADZIP", `a damaged zip entry: ${e.entryName}`);
out.push({ path: e.entryName, data });
}
return out;
}
/**
* Upload → skills. Every case a user may hand over:
* - one Markdown/text file (any name) → that file is the skill's SKILL.md;
* - a folder or several files with a SKILL.md (at the top or in one folder
* under it) → that folder is the skill;
* - a .zip / .skill archive → unpacked, then the same rules;
* - several skill folders at once (each with its own SKILL.md) → one
* artifact per skill, named by its folder.
* `name` (the user's) names the skill when there is exactly one.
*/
function skillsFromUpload(uploads, { name, description }) {
let files = [];
for (const u of uploads) {
const p = cleanUploadPath(u.path);
if (JUNK_RE.test(p)) continue;
if (ZIP_RE.test(p) && uploads.length === 1) {
files.push(
...unzipSkill(u.data).map((f) => ({ path: cleanUploadPath(f.path), data: f.data }))
);
} else {
files.push({ path: p, data: u.data });
}
}
files = files.filter((f) => !JUNK_RE.test(f.path));
if (!files.length) throw fail("ENOFILE", "nothing to add: the upload is empty");
const seen = new Set();
for (const f of files) {
if (seen.has(f.path)) throw fail("EBADPATH", `the same path twice: ${f.path}`);
seen.add(f.path);
}
const isSkillMd = (p) => path.posix.basename(p).toLowerCase() === "skill.md";
let roots = files.filter((f) => isSkillMd(f.path)).map((f) => path.posix.dirname(f.path));
if (!roots.length) {
if (files.length === 1 && TEXT_RE.test(files[0].path)) {
const only = files[0];
const stem = path.posix.basename(only.path).replace(/\.[^.]+$/, "");
return [
{
fallbackName: frontmatterField(only.data.toString("utf8"), "name") || stem,
files: [{ path: "SKILL.md", data: only.data }],
},
];
}
throw fail(
"ENOSKILLMD",
"no SKILL.md in the upload — add one, or upload a single Markdown file to use as SKILL.md"
);
}
// A SKILL.md inside another skill's folder belongs to that skill.
roots = [...new Set(roots)].sort((a, b) => a.length - b.length);
const under = (dir, p) => dir === "." || p === dir || p.startsWith(`${dir}/`);
roots = roots.filter((r, i) => !roots.slice(0, i).some((o) => under(o, r)));
return roots.map((root) => {
const own = files
.filter((f) => under(root, f.path))
.map((f) => ({
path: root === "." ? f.path : f.path.slice(root.length + 1),
data: f.data,
}))
.map((f) => (f.path.toLowerCase() === "skill.md" ? { ...f, path: "SKILL.md" } : f));
const md = own.find((f) => f.path === "SKILL.md");
const folder = root === "." ? "" : path.posix.basename(root);
return {
fallbackName: frontmatterField(md.data.toString("utf8"), "name") || folder,
files: own,
};
});
}
/** Check one skill's files like a folder on disk is checked. */
function checkSkillFiles(files) {
if (files.length > SKILL_MAX_FILES) throw fail("ETOOBIG", "the skill has too many files");
let total = 0;
for (const f of files) {
if (f.path.split("/").length > SKILL_MAX_DEPTH + 1) {
throw fail("ETOOBIG", "the skill folder is nested too deep");
}
if (SECRET_NAME_RE.test(path.posix.basename(f.path))) {
throw fail("ESECRET", `the skill holds a file that looks like a secret: ${f.path}`);
}
total += f.data.length;
}
if (total > SKILL_MAX_BYTES) throw fail("ETOOBIG", "the skill is larger than 5 MB");
}
/**
* SKILL.md with the chosen `name:` and a `description:` in its frontmatter —
* Claude Code reads both (the description decides when the skill is used).
* A file with no frontmatter gets one; an existing one keeps every other line.
*/
function withFrontmatter(text, name, description) {
const body = String(text).replace(/^\uFEFF/, "");
const fm = /^---\r?\n([\s\S]*?)\r?\n---[^\n]*(\r?\n|$)/.exec(body);
const desc = String(description || "")
.trim()
.replace(/\s+/g, " ");
// Plain only when YAML cannot read it as anything else: starts with a
// letter or digit (never `-`, `?`, `[`… — `- note` is a list item), no
// trailing space, not a bare number. Anything else goes in double quotes.
const quote = (v) =>
/^[A-Za-z0-9][\w .,()/-]*$/.test(v) && !/\s$/.test(v) && !/^[\d.]+$/.test(v)
? v
: JSON.stringify(v);
if (!fm) {
if (!desc) {
throw fail(
"ENODESCRIPTION",
"a skill needs a description — Claude reads it to decide when to use the skill"
);
}
return `---\nname: ${name}\ndescription: ${quote(desc)}\n---\n\n${body.replace(/^\s+/, "")}`;
}
let lines = fm[1].split(/\r?\n/);
const has = (k) => lines.some((l) => new RegExp(`^${k}:`).test(l));
// Replace a top-level key AND its continuation lines (a `>`/`|` block or a
// wrapped value is indented under it) — else the old text stays behind
// and YAML folds it into the new value.
const setKey = (k, value) => {
const out = [];
let skipping = false;
for (const l of lines) {
if (new RegExp(`^${k}:`).test(l)) {
out.push(`${k}: ${value}`);
skipping = true;
continue;
}
if (skipping && (/^\s/.test(l) || l === "")) continue;
skipping = false;
out.push(l);
}
return out;
};
lines = has("name") ? setKey("name", name) : [`name: ${name}`, ...lines];
if (desc) {
lines = has("description")
? setKey("description", quote(desc))
: [...lines, `description: ${quote(desc)}`];
} else if (!frontmatterField(body, "description")) {
throw fail(
"ENODESCRIPTION",
"a skill needs a description — Claude reads it to decide when to use the skill"
);
}
return `---\n${lines.join("\n")}\n---\n${body.slice(fm[0].length)}`;
}
/** Finish one skill: its name, its SKILL.md frontmatter, the checks, the snapshot. */
function saveSkillFiles(req, { name, files, title, 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);
return storeSkill(req, { name, files: all, title, description });
}
/**
* Add skills from uploaded files (multipart: `files` + their relative
* `paths`). Answers every skill it made. Nothing is stored unless every
* skill in the upload passes.
*/
function uploadSkills(req, { uploads, name, title, description }) {
const found = skillsFromUpload(uploads || [], { name, description });
const typed = typeof name === "string" && name.trim();
const prepared = found.map((sk) => ({
name: validSkillName(found.length === 1 && typed ? typed : sk.fallbackName || ""),
files: sk.files,
}));
const dup = prepared.map((p) => p.name).find((n, i, a) => a.indexOf(n) !== i);
if (dup) throw fail("EBADNAME", `two skills in the upload are both named ${dup}`);
for (const p of prepared) {
const md = p.files.find((f) => f.path === "SKILL.md");
// Checked up front so a bad second skill stores nothing.
withFrontmatter(md.data.toString("utf8"), p.name, found.length === 1 ? description : "");
checkSkillFiles(p.files);
}
return prepared.map((p) =>
saveSkillFiles(req, {
name: p.name,
files: p.files,
title: found.length === 1 ? title : "",
description: found.length === 1 ? description : "",
})
);
}
/** A skill written in the dialog: a name, a description and its instructions. */
function writeSkill(req, { name, description, content, title }) {
const n = validSkillName(name);
const text = typeof content === "string" ? content : "";
if (!text.trim()) throw fail("EBADSKILL", "write the skill's instructions");
if (Buffer.byteLength(text) > SKILL_MAX_BYTES)
throw fail("ETOOBIG", "the skill is larger than 5 MB");
return saveSkillFiles(req, {
name: n,
files: [{ path: "SKILL.md", data: Buffer.from(text, "utf8") }],
title,
description,
});
}
// ── plugin ────────────────────────────────────────────────────────────────
/** A marketplace a plugin may be installed from: `owner/repo` or a remote URL. */
function validMarket(market) {
const m = typeof market === "string" ? market.trim() : "";
if (GITHUB_REPO_RE.test(m)) return m;
try {
return require("./git-ops").validateRemoteUrl(m);
} catch {
throw fail("EBADMARKET", "marketplace: owner/repo or an https/ssh git URL");
}
}
const MARKET_JSON_MAX = 1024 * 1024;
const MARKET_TIMEOUT_MS = 45_000;
let inspectImpl = null; // tests: (market) => marketplace.json text
/**
* What a user pasted, made into a marketplace source Claude Code takes:
* `owner/repo`, a git URL (a forge's web link `…/src/branch/main`,
* `…/tree/main` or a trailing slash trimmed off), or an https link to a
* `marketplace.json`.
*/
function normalizeMarket(raw) {
let m = typeof raw === "string" ? raw.trim() : "";
const gh = /^https:\/\/github\.com\/([^/\s]+\/[^/\s]+?)(?:\.git)?\/?$/.exec(m);
if (gh && GITHUB_REPO_RE.test(gh[1])) return gh[1];
if (/^https:\/\//.test(m) && !/\.json$/i.test(m)) {
m = m.replace(/\/(?:src|tree|blob)\/.*$/, "").replace(/\/+$/, "");
}
return validMarket(m);
}
/** An https URL git can clone for `market` (owner/repo = GitHub), or null (ssh). */
function cloneUrl(market) {
if (GITHUB_REPO_RE.test(market)) return `https://github.com/${market}.git`;
return market.startsWith("https://") ? market : null;
}
/** The text of a marketplace's `.claude-plugin/marketplace.json`. */
async function fetchMarketJson(market) {
if (inspectImpl) return inspectImpl(market);
if (/^https:\/\/.*\.json$/i.test(market)) {
let res;
try {
res = await fetch(market, {
signal: AbortSignal.timeout(MARKET_TIMEOUT_MS),
redirect: "follow",
});
} catch (e) {
throw fail("EMARKETFETCH", `could not read ${market}: ${e.message}`);
}
if (!res.url.startsWith("https://"))
throw fail("EMARKETFETCH", "the link redirected off https");
if (!res.ok) throw fail("EMARKETFETCH", `${market} answered ${res.status}`);
// Read at most 1 MB: a link the user names may serve anything, and
// `res.text()` would hold all of it in memory before the size check.
const tooBig = () => fail("ETOOBIG", "marketplace.json is larger than 1 MB");
if (Number(res.headers.get("content-length")) > MARKET_JSON_MAX) {
res.body?.cancel().catch(() => {});
throw tooBig();
}
const chunks = [];
let size = 0;
const reader = res.body?.getReader();
while (reader) {
const { done, value } = await reader.read();
if (done) break;
size += value.length;
if (size > MARKET_JSON_MAX) {
reader.cancel().catch(() => {});
throw tooBig();
}
chunks.push(Buffer.from(value));
}
return Buffer.concat(chunks).toString("utf8");
}
const url = cloneUrl(market);
if (!url) {
throw fail(
"ESSHMARKET",
"an ssh link cannot be read from here — paste the https link to list its plugins, or type name@marketplace by hand"
);
}
const { git } = require("./worktree");
const tmp = fs.mkdtempSync(path.join(require("node:os").tmpdir(), "agenthub-market-"));
// No login is ever sent: the machine's credential helpers are switched off,
// so a URL a browser names never receives a token stored for another use.
const env = { GIT_TERMINAL_PROMPT: "0", GIT_ASKPASS: "", SSH_ASKPASS: "" };
const safe = [
"-c",
"credential.helper=",
"-c",
"protocol.allow=never",
"-c",
"protocol.https.allow=always",
"-c",
"core.hooksPath=/dev/null",
];
try {
try {
await git(
tmp,
[
...safe,
"clone",
"--depth",
"1",
"--filter=blob:none",
"--no-checkout",
"--quiet",
"--",
url,
"repo",
],
{
literal: true,
timeout: MARKET_TIMEOUT_MS,
env,
}
);
} catch (e) {
const why = String(e?.git?.stderr || e?.stderr || e?.message || "");
if (
/terminal prompts disabled|could not read Username|Authentication failed|403|401/i.test(why)
) {
throw fail(
"ENEEDLOGIN",
"the repository does not exist, or it needs a login to read — check the link, or type the plugin as name@marketplace by hand (installing uses each user's own git login)"
);
}
if (/not found|does not exist|404/i.test(why))
throw fail("EMARKETFETCH", "no repository at that link");
throw fail("EMARKETFETCH", `could not read the repository: ${why.trim().split("\n").pop()}`);
}
const repo = path.join(tmp, "repo");
const show = (p) =>
git(repo, ["show", `HEAD:${p}`], {
literal: true,
timeout: MARKET_TIMEOUT_MS,
env,
maxBuffer: MARKET_JSON_MAX,
}).then(
(r) => (typeof r === "string" ? r : r.stdout),
() => null
);
const text = await show(".claude-plugin/marketplace.json");
if (text != null) return text;
if ((await show(".claude-plugin/plugin.json")) != null) {
throw fail(
"ENOTMARKET",
"this repository is one plugin, not a marketplace — Claude Code installs plugins from a marketplace (a repo with .claude-plugin/marketplace.json). Add one to it, or share its skills as Skill artifacts"
);
}
throw fail("ENOTMARKET", "no .claude-plugin/marketplace.json in this repository");
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
}
/**
* Read a marketplace and list its plugins:
* `{ market, name, description, plugins: [{ name, key, description, version, category }] }`.
*/
async function inspectMarket(raw) {
const market = normalizeMarket(raw);
const text = await fetchMarketJson(market);
let json;
try {
json = JSON.parse(text);
} catch {
throw fail("EBADMARKET", "marketplace.json is not valid JSON");
}
const name = typeof json?.name === "string" ? json.name.trim() : "";
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/.test(name)) {
throw fail("EBADMARKET", "marketplace.json has no usable name");
}
const str = (v, n = DESCRIPTION_MAX) => (typeof v === "string" ? v.trim().slice(0, n) : "");
const plugins = (Array.isArray(json.plugins) ? json.plugins : [])
.filter((p) => p && typeof p.name === "string" && PLUGIN_RE.test(`${p.name.trim()}@${name}`))
.slice(0, 500)
.map((p) => ({
name: p.name.trim(),
key: `${p.name.trim()}@${name}`,
description: str(p.description),
version: str(p.version, 50),
category: str(p.category, 50),
}));
return {
market,
name,
description: str(json.description || json.metadata?.description),
plugins,
};
}
function __setInspectImpl(fn) {
inspectImpl = fn;
}
/**
* Add a plugin by `name@marketplace` and its marketplace source. The same
* plugin added again by its owner updates that row instead of a duplicate.
*/
function addPlugin(req, { key, market, title, description }) {
const k = typeof key === "string" ? key.trim() : "";
if (!PLUGIN_RE.test(k)) throw fail("EBADPLUGIN", "plugin: name@marketplace");
const ownerId = req?.user ? req.user.id : null;
const src = normalizeMarket(market);
const same = sql(
"SELECT * FROM artifacts WHERE kind = 'plugin' AND source = ? AND owner_id IS ?"
).get(k, ownerId);
if (same) {
sql(
`UPDATE artifacts SET market = ?, title = ?, description = ?, in_library = 1,
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`
).run(
src,
cleanTitle(title, same.title),
cleanDescription(description) || same.description,
same.id
);
return view(sql("SELECT * FROM artifacts WHERE id = ?").get(same.id), req);
}
return view(
insert({
kind: "plugin",
title: cleanTitle(title, k),
description: cleanDescription(description),
owner_id: req?.user ? req.user.id : null,
source: k,
market: src,
}),
req
);
}
// ── change / delete ───────────────────────────────────────────────────────
function audit(req, row, action) {
if (!req?.user) return;
try {
require("./audit").recordAccountChange(
req.user.id,
row.owner_id ?? null,
"POST",
`artifact ${action}: #${row.id} ${row.kind} "${row.title}"`
);
} catch (err) {
console.warn("[artifacts] could not audit:", err.message);
}
}
function update(req, id, body = {}) {
let row = load(req, id);
if (!canChange(req, row)) throw fail("EFORBIDDEN", "only its owner changes an artifact");
if (body.skill && typeof body.skill === "object") {
editSkill(req, row, body.skill);
row = sql("SELECT * FROM artifacts WHERE id = ?").get(row.id);
if (!("title" in body) && !("shared" in body) && !("description" in body))
return view(row, req);
}
const sets = [];
const params = [];
if ("title" in body) {
sets.push("title = ?");
params.push(cleanTitle(body.title, row.title));
}
if ("description" in body) {
sets.push("description = ?");
params.push(cleanDescription(body.description));
}
if (row.kind === "plugin" && "source" in body) {
const k = typeof body.source === "string" ? body.source.trim() : "";
if (!PLUGIN_RE.test(k)) throw fail("EBADPLUGIN", "plugin: name@marketplace");
sets.push("source = ?");
params.push(k);
}
if (row.kind === "plugin" && "market" in body) {
sets.push("market = ?");
params.push(normalizeMarket(body.market));
}
let shareChange = null;
if ("shared" in body) {
if (typeof body.shared !== "boolean") throw fail("EBADFIELD", "shared: true or false");
if (!isServer()) throw fail("ELOCALMODE", "sharing needs server mode");
if (body.shared !== (row.shared === 1)) {
shareChange = body.shared ? "shared" : "unshared";
sets.push("shared = ?", "shared_at = ?");
params.push(body.shared ? 1 : 0, body.shared ? new Date().toISOString() : null);
}
}
if (!sets.length) return view(row, req);
if (shareChange === "unshared" && row.in_library === 0) {
// Off the Hub and out of its owner's library: nothing keeps it.
deleteRow(row);
audit(req, row, "unshared");
return { ...view(row, req), shared: false, removed: true };
}
sets.push("updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')");
sql(`UPDATE artifacts SET ${sets.join(", ")} WHERE id = ?`).run(...params, row.id);
const next = sql("SELECT * FROM artifacts WHERE id = ?").get(row.id);
if (shareChange) audit(req, next, shareChange);
return view(next, req);
}
function deleteRow(row) {
sql("DELETE FROM artifact_installs WHERE artifact_id = ?").run(row.id);
sql("DELETE FROM artifacts WHERE id = ?").run(row.id);
dropSnapshot(row.snapshot);
}
/**
* Delete from the caller's library. A SHARED row its owner deletes stays in
* the Hub (`in_library = 0`, `{kept: true}`) — only unsharing takes it off
* there. Anything else (unshared, already Hub-only, or an admin removing
* someone else's) is deleted with its snapshot.
*/
function remove(req, id) {
const row = load(req, id);
if (!canChange(req, row)) throw fail("EFORBIDDEN", "only its owner deletes an artifact");
const owner = !req?.user || row.owner_id === req.user.id;
if (owner && row.shared === 1 && row.in_library !== 0) {
sql(
"UPDATE artifacts SET in_library = 0, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?"
).run(row.id);
return { ...row, in_library: 0, kept: true };
}
deleteRow(row);
if (row.shared === 1) audit(req, row, "deleted");
return row;
}
/**
* New contents for a skill row: `name` (the folder and /command),
* `description` (frontmatter — also the card's description) and `content`
* (SKILL.md's body, frontmatter excluded). The other frontmatter keys and
* every other file are kept. A new snapshot replaces the old one.
*/
function editSkill(req, row, { name, description, content }) {
if (row.kind !== "skill") throw fail("EBADKIND", "only a skill's contents are edited");
if (!row.snapshot || !SNAPSHOT_RE.test(row.snapshot)) throw fail("ENOFILE", "no files");
const n = validSkillName(name);
const body = typeof content === "string" ? content : "";
if (!body.trim()) throw fail("EBADSKILL", "write the skill's instructions");
const recorded = row.files ? JSON.parse(row.files) : [];
const files = recorded.map((f) => ({
path: f.path,
data: fs.readFileSync(path.join(root(), row.snapshot, ...f.path.split("/"))),
}));
const md = files.find((f) => f.path === "SKILL.md");
const old = md ? md.data.toString("utf8").replace(/^\uFEFF/, "") : "";
const fm = /^---\r?\n[\s\S]*?\r?\n---[^\n]*(\r?\n|$)/.exec(old);
const text = withFrontmatter(
`${fm ? fm[0] : ""}${fm ? "\n" : ""}${body.replace(/^\s+/, "")}`,
n,
description
);
const all = [
{ path: "SKILL.md", data: Buffer.from(text, "utf8") },
...files.filter((f) => f !== md),
];
checkSkillFiles(all);
const snap = newSnapshotDir();
let total = 0;
const next = [];
try {
for (const f of all) {
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 (err) {
dropSnapshot(snap.id);
throw err;
}
const desc = frontmatterField(text, "description");
sql(
`UPDATE artifacts SET source = ?, snapshot = ?, files = ?, size = ?, description = ?,
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`
).run(n, snap.id, JSON.stringify(next), total, cleanDescription(desc), row.id);
dropSnapshot(row.snapshot);
}
/** The absolute path of one RECORDED file of a row's snapshot (`rel` must match). */
function snapshotFile(req, id, rel) {
const row = load(req, id);
if (!row.snapshot || !SNAPSHOT_RE.test(row.snapshot)) throw fail("ENOFILE", "no files");
const files = row.files ? JSON.parse(row.files) : [];
const want = typeof rel === "string" && rel ? rel : files[0]?.path;
const hit = files.find((f) => f.path === want);
if (!hit) throw fail("ENOFILE", "no such file in this artifact");
return {
row,
abs: path.join(root(), row.snapshot, ...hit.path.split("/")),
name: path.basename(hit.path),
size: hit.size,
};
}
// ── install ───────────────────────────────────────────────────────────────
const installsEnabled = () => !fs.existsSync(installsOffFlag());
function setInstallsEnabled(on) {
fs.mkdirSync(root(), { recursive: true, mode: 0o700 });
if (on) fs.rmSync(installsOffFlag(), { force: true });
else fs.writeFileSync(installsOffFlag(), "", { mode: 0o600 });
return installsEnabled();
}
function installable(req, row) {
if (row.kind !== "skill" && row.kind !== "plugin") {
throw fail("EBADKIND", "only a skill or a plugin is installed");
}
if (!installsEnabled()) throw fail("EINSTALLSOFF", "an admin turned Hub installs off");
}
/**
* Every account a request's sessions run as: `[{acct, home, claudeHome}]`.
* Under S3 a pane runs as its FOLDER's account, so an admin with a member
* account of their own also runs host-folder workspaces as the host — both
* get the skill, or half their sessions never see it.
*/
function installAccounts(req) {
const hostHome = path.dirname(getClaudeHome());
const host = { acct: null, home: hostHome, claudeHome: getClaudeHome() };
if (!req?.user) return [host];
const own = ownAccount(req);
const out = [];
if (own.acct) out.push({ acct: own.acct, home: own.acct.home, claudeHome: own.claudeHome });
if (req.user.role === "admin") out.push(host);
return out;
}
/**
* Where an account's AI tools look for user skills: Claude Code's
* `~/.claude/skills` (also read by OpenCode, Cursor, omp, Grok) and the
* cross-tool `~/.agents/skills` (Codex, Gemini/Antigravity, OpenCode, Cursor).
*/
const skillDirsOf = (a) => [
path.join(a.claudeHome, "skills"),
path.join(a.home, ".agents", "skills"),
];
const exists = (p) => {
try {
fs.lstatSync(p);
return true;
} catch {
return false;
}
};
/**
* `row => boolean`: is this skill/plugin already in every account the
* viewer's sessions run as (`installAccounts`) — a skill as
* `~/.claude/skills/<name>`, a plugin as a key of `installed_plugins.json`.
* The list shows "Installed" instead of Install for those. Pure reads; an
* account that cannot be resolved or read counts as not installed.
*/
function installedCheck(req) {
let accounts = null;
const plugins = new Map();
const pluginKeys = (a) => {
if (!plugins.has(a.claudeHome)) {
const file = path.join(a.claudeHome, "plugins", "installed_plugins.json");
let keys = new Set();
try {
const text = a.acct
? require("./run-as").readFileSafe(file, "utf8", { max: 4 << 20 })
: fs.readFileSync(file, "utf8");
keys = new Set(Object.keys(JSON.parse(text)?.plugins || {}));
} catch {
/* none installed, or unreadable */
}
plugins.set(a.claudeHome, keys);
}
return plugins.get(a.claudeHome);
};
return (row) => {
if ((row.kind !== "skill" && row.kind !== "plugin") || !row.source) return false;
if (accounts === null) {
try {
accounts = installAccounts(req);
} catch {
accounts = [];
}
}
if (!accounts.length) return false;
return accounts.every((a) =>
row.kind === "skill"
? exists(path.join(a.claudeHome, "skills", row.source))
: pluginKeys(a).has(row.source)
);
};
}
/**
* Install a skill snapshot as `<name>` into every skills folder of every
* account the viewer's sessions run as (`installAccounts` × `skillDirsOf`).
*/
async function installSkill(req, row, { replace = false } = {}) {
const runAs = require("./run-as");
const targets = installAccounts(req).flatMap((a) =>
skillDirsOf(a).map((dir) => ({ ...a, target: path.join(dir, row.source) }))
);
const taken = targets.filter((t) => exists(t.target));
if (taken.length && !replace) {
throw fail("EEXISTS", `a skill named ${row.source} is already installed`);
}
if (taken.some((t) => t.acct) && !runAs.writeAsCanMove()) {
throw fail(
"EUPGRADE",
"replacing needs the updated helper: run `agenthub server install-system` once"
);
}
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
for (const t of taken) {
const tag = t.target.startsWith(t.claudeHome + path.sep) ? "" : "-agents";
const aside = path.join(
t.claudeHome,
"agenthub-replaced-skills",
`${row.source}${tag}-${stamp}`
);
if (t.acct) {
runAs.mkdirAs(t.acct, path.dirname(aside));
await runAs.writeAsAsync(t.acct, t.target, ["move", aside]);
} else {
fs.mkdirSync(path.dirname(aside), { recursive: true });
fs.renameSync(t.target, aside);
}
}
const files = row.files ? JSON.parse(row.files) : [];
const contents = files.map((f) => ({
rel: f.path.split("/"),
content: fs.readFileSync(path.join(root(), row.snapshot, ...f.path.split("/"))),
}));
for (const t of targets) {
for (const f of contents) {
const to = path.join(t.target, ...f.rel);
if (t.acct) {
runAs.mkdirAs(t.acct, path.dirname(to));
runAs.writeFileAs(to, f.content);
} else {
fs.mkdirSync(path.dirname(to), { recursive: true });
fs.writeFileSync(to, f.content);
}
}
}
const paths = targets.map((t) => t.target);
return { installed: paths.join(", "), paths, replaced: taken.length > 0 };
}
let execImpl = promisify(require("node:child_process").execFile);
function __setExecImpl(fn) {
execImpl = fn;
}
/** `claude plugin marketplace add` + `claude plugin install --scope user`, as the viewer. */
async function installPlugin(req, row) {
const { acct } = ownAccount(req);
const steps = [
["plugin", "marketplace", "add", validMarket(row.market)],
["plugin", "install", "--scope", "user", row.source],
];
if (!PLUGIN_RE.test(row.source)) throw fail("EBADPLUGIN", "plugin: name@marketplace");
let run;
if (acct) {
const runAs = require("./run-as");
const claude = require("./shared-tools").sharedPath("claude");
if (!claude) throw fail("ENOTSHARED", "claude is not in the shared tools yet");
const env = { HOME: acct.home, DISABLE_AUTOUPDATER: "1" };
run = (args) => {
const [cmd, argv] = runAs.sudoArgv(acct, claude, args, env);
return execImpl(cmd, argv, { cwd: acct.home, timeout: 300_000, encoding: "utf8" });
};
} else {
const env = { ...process.env, DISABLE_AUTOUPDATER: "1" };
if (process.env.CLAUDE_HOME) env.CLAUDE_CONFIG_DIR = getClaudeHome();
run = (args) => execImpl("claude", args, { timeout: 300_000, encoding: "utf8", env });
}
const lastLine = (e) =>
String(e?.stderr || e?.stdout || e?.message || "")
.trim()
.split("\n")
.pop();
try {
await run(steps[0]);
} catch (e) {
// Already added is fine; anything else is the answer.
if (!/already/i.test(String(e?.stderr || "") + String(e?.stdout || ""))) {
throw fail("EPLUGIN", `marketplace: ${lastLine(e)}`);
}
}
try {
await run(steps[1]);
} catch (e) {
throw fail("EPLUGIN", `install: ${lastLine(e)}`);
}
return { installed: row.source };
}
async function install(req, id, opts = {}) {
const row = load(req, id);
installable(req, row);
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);
sql(
"INSERT INTO artifact_installs (artifact_id, user_id, paths, replaced) VALUES (?, ?, ?, ?)"
).run(row.id, req?.user?.id ?? null, JSON.stringify(result.paths || []), result.replaced ? 1 : 0);
if (req?.user && row.owner_id !== req.user.id) audit(req, row, "installed");
return result;
}
/**
* Who installed artifact `id`, newest first: `{items: [{user, mine, at,
* replaced, paths?}], unrecorded}`. The owner, staff and local mode see every
* install; anyone else only their own. `paths` (where a skill was written; a
* plugin has none — it is `claude plugin install --scope user`) goes only to
* the installer and an admin. `unrecorded` = installs counted before this
* list was kept.
*/
function installsOf(req, id) {
const row = load(req, id);
const u = req?.user;
const all = !u || u.role === "admin" || u.role === "analyst" || row.owner_id === u.id;
const rows = sql(
`SELECT i.*, us.username, us.display_name FROM artifact_installs i
LEFT JOIN users us ON us.id = i.user_id
WHERE i.artifact_id = ? ${all ? "" : "AND i.user_id = ?"}
ORDER BY i.id DESC LIMIT 500`
).all(...(all ? [row.id] : [row.id, u.id]));
const items = rows.map((r) => {
const mine = !u || r.user_id === u.id;
let paths = [];
try {
paths = JSON.parse(r.paths) || [];
} catch {
paths = [];
}
return {
user: r.user_id == null ? null : r.display_name || r.username || null,
mine,
at: r.installed_at,
replaced: r.replaced === 1,
...(mine || u?.role === "admin" ? { paths } : {}),
};
});
const total = sql("SELECT COUNT(*) AS n FROM artifact_installs WHERE artifact_id = ?").get(
row.id
).n;
return { items, unrecorded: all ? Math.max(0, (row.installs || 0) - total) : 0 };
}
module.exports = {
KINDS,
installsOf,
DOC_EXT,
list,
load,
view,
canSeeId,
addPage,
captureFromHook,
fileCandidates,
pinFile,
availableSkills,
addSkill,
uploadSkills,
writeSkill,
uploadFile,
skillSlug,
withFrontmatter,
normalizeMarket,
inspectMarket,
addPlugin,
update,
remove,
snapshotFile,
install,
installsEnabled,
setInstallsEnabled,
__setExecImpl,
__setInspectImpl,
};