758609775f
- Files → Trash: ONE sidebar entry. An admin's opens on a folder per user's Trash (My Trash first, item counts from GET /api/homes/trash, an unreadable one showing its error); a folder opens that user's Trash (path bar Trash › user, Up back to the folders). An admin may restore/purge in another user's Trash (audited); purging asks first. The merged "All users" list is gone from the UI. - A home's Trash is its user's whole recycle bin (home store + host store, items tagged `store`). - `agenthub server setup` installs one Linux user per account on Linux by default (`--no-system` opts out); install-system messages drop `sudo`. - Settings → Git: the author email suggests <username>@<AGENTHUB_EMAIL_DOMAIN>. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1263 lines
47 KiB
JavaScript
1263 lines
47 KiB
JavaScript
/**
|
|
* @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 account only (`installTarget`): a member's home under S3
|
|
* (written AS them through `write-as`, `claude` run as them through sudo),
|
|
* the host's Claude home for an admin or in local mode. 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`.
|
|
* @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) {
|
|
if (!row) return null;
|
|
const owner =
|
|
row.owner_id == null
|
|
? null
|
|
: sql("SELECT username, display_name FROM users WHERE id = ?").get(row.owner_id);
|
|
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,
|
|
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.
|
|
*/
|
|
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 = ?");
|
|
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()) {
|
|
where.push("(a.title LIKE ? OR a.description LIKE ? OR a.source LIKE ?)");
|
|
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);
|
|
return rows.map((r) => view(r, req));
|
|
}
|
|
|
|
// ── 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 = ?, 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, " ");
|
|
const quote = (v) => (/^[\w .,()/-]*$/.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));
|
|
lines = has("name")
|
|
? lines.map((l) => (/^name:/.test(l) ? `name: ${name}` : l))
|
|
: [`name: ${name}`, ...lines];
|
|
if (desc) {
|
|
lines = has("description")
|
|
? lines.map((l) => (/^description:/.test(l) ? `description: ${quote(desc)}` : l))
|
|
: [...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}`);
|
|
const text = await res.text();
|
|
if (text.length > MARKET_JSON_MAX) throw fail("ETOOBIG", "marketplace.json is larger than 1 MB");
|
|
return text;
|
|
}
|
|
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 = ?,
|
|
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 = {}) {
|
|
const row = load(req, id);
|
|
if (!canChange(req, row)) throw fail("EFORBIDDEN", "only its owner changes an artifact");
|
|
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);
|
|
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 remove(req, id) {
|
|
const row = load(req, id);
|
|
if (!canChange(req, row)) throw fail("EFORBIDDEN", "only its owner deletes an artifact");
|
|
sql("DELETE FROM artifacts WHERE id = ?").run(row.id);
|
|
dropSnapshot(row.snapshot);
|
|
if (row.shared === 1) audit(req, row, "deleted");
|
|
return row;
|
|
}
|
|
|
|
/** 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");
|
|
}
|
|
|
|
/** Install a skill snapshot into the viewer's own `~/.claude/skills/<name>`. */
|
|
async function installSkill(req, row, { replace = false } = {}) {
|
|
const { acct, claudeHome } = ownAccount(req);
|
|
const runAs = require("./run-as");
|
|
const skills = path.join(claudeHome, "skills");
|
|
const target = path.join(skills, row.source);
|
|
const taken = (() => {
|
|
try {
|
|
fs.lstatSync(target);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
})();
|
|
if (taken && !replace) throw fail("EEXISTS", `a skill named ${row.source} is already installed`);
|
|
if (taken) {
|
|
const aside = path.join(
|
|
claudeHome,
|
|
"agenthub-replaced-skills",
|
|
`${row.source}-${new Date().toISOString().replace(/[:.]/g, "-")}`
|
|
);
|
|
if (acct) {
|
|
if (!runAs.writeAsCanMove()) {
|
|
throw fail("EUPGRADE", "replacing needs the updated helper: run `agenthub server install-system` once");
|
|
}
|
|
runAs.mkdirAs(acct, path.dirname(aside));
|
|
await runAs.writeAsAsync(acct, target, ["move", aside]);
|
|
} else {
|
|
fs.mkdirSync(path.dirname(aside), { recursive: true });
|
|
fs.renameSync(target, aside);
|
|
}
|
|
}
|
|
const files = row.files ? JSON.parse(row.files) : [];
|
|
for (const f of files) {
|
|
const from = path.join(root(), row.snapshot, ...f.path.split("/"));
|
|
const to = path.join(target, ...f.path.split("/"));
|
|
const content = fs.readFileSync(from);
|
|
if (acct) {
|
|
runAs.mkdirAs(acct, path.dirname(to));
|
|
runAs.writeFileAs(to, content);
|
|
} else {
|
|
fs.mkdirSync(path.dirname(to), { recursive: true });
|
|
fs.writeFileSync(to, content);
|
|
}
|
|
}
|
|
return { installed: target, replaced: taken };
|
|
}
|
|
|
|
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);
|
|
if (req?.user && row.owner_id !== req.user.id) audit(req, row, "installed");
|
|
return result;
|
|
}
|
|
|
|
module.exports = {
|
|
KINDS,
|
|
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,
|
|
};
|