feat: v2.8.0 — Artifacts uploads and plugin links, console tab combos, account avatars
- Artifacts hub: upload a file from the computer; make a skill by uploading a Markdown file, files, a folder or a .zip/.skill, or by writing it in the dialog; add plugins by picking from the list a marketplace link answers (POST /api/artifacts/plugin/inspect); Settings → AI tools → Artifacts Hub. - Console tabs are combos of up to four split panes (ConsoleTabs, PaneGrid). - Accounts: an own avatar (PUT/DELETE /api/me/avatar, raster only, ≤ 1 MB, <data dir>/avatars/), shown with name and role at the sidebar's foot and on the workspace owner groups (GET /api/users/:id/avatar, owner_avatar_at). - Users page: per-user storage (GET /api/users/storage, measured in a worker) and account-pool spend/tokens per account (server/lib/pool-usage.js). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+489
-9
@@ -382,6 +382,38 @@ async function pinFile(req, { laneId, path: rel, title, description, sessionId }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -499,16 +531,45 @@ function addSkill(req, { name, laneId, title, description }) {
|
||||
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, total } = walkSkill(real);
|
||||
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, runAs.readFileSafe(f.abs, null, { max: SKILL_MAX_BYTES }), {
|
||||
mode: 0o600,
|
||||
});
|
||||
fs.writeFileSync(target, data, { mode: 0o600 });
|
||||
recorded.push({ path: f.path, size: data.length });
|
||||
}
|
||||
} catch (err) {
|
||||
dropSnapshot(snap.id);
|
||||
@@ -516,8 +577,7 @@ function addSkill(req, { name, laneId, title, description }) {
|
||||
}
|
||||
let summary = "";
|
||||
try {
|
||||
const head = fs.readFileSync(path.join(snap.dir, "SKILL.md"), "utf8");
|
||||
summary = /^description:\s*(.+)$/m.exec(head)?.[1]?.trim() || "";
|
||||
summary = frontmatterField(fs.readFileSync(path.join(snap.dir, "SKILL.md"), "utf8"), "description");
|
||||
} catch {
|
||||
/* no frontmatter */
|
||||
}
|
||||
@@ -527,16 +587,263 @@ function addSkill(req, { name, laneId, title, description }) {
|
||||
title: cleanTitle(title, name),
|
||||
description: cleanDescription(description) || summary.slice(0, DESCRIPTION_MAX),
|
||||
owner_id: req?.user ? req.user.id : null,
|
||||
lane_id: lane ? lane.id : null,
|
||||
lane_id: laneId,
|
||||
source: name,
|
||||
snapshot: snap.id,
|
||||
files: JSON.stringify(files.map((f) => ({ path: f.path, size: f.size }))),
|
||||
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. */
|
||||
@@ -550,9 +857,164 @@ function validMarket(market) {
|
||||
}
|
||||
}
|
||||
|
||||
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",
|
||||
@@ -560,7 +1022,7 @@ function addPlugin(req, { key, market, title, description }) {
|
||||
description: cleanDescription(description),
|
||||
owner_id: req?.user ? req.user.id : null,
|
||||
source: k,
|
||||
market: validMarket(market),
|
||||
market: src,
|
||||
}),
|
||||
req
|
||||
);
|
||||
@@ -595,6 +1057,16 @@ function update(req, id, 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");
|
||||
@@ -771,6 +1243,13 @@ module.exports = {
|
||||
pinFile,
|
||||
availableSkills,
|
||||
addSkill,
|
||||
uploadSkills,
|
||||
writeSkill,
|
||||
uploadFile,
|
||||
skillSlug,
|
||||
withFrontmatter,
|
||||
normalizeMarket,
|
||||
inspectMarket,
|
||||
addPlugin,
|
||||
update,
|
||||
remove,
|
||||
@@ -779,4 +1258,5 @@ module.exports = {
|
||||
installsEnabled,
|
||||
setInstallsEnabled,
|
||||
__setExecImpl,
|
||||
__setInspectImpl,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user