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
This commit is contained in:
2026-10-20 16:44:55 +07:00
parent 49f87c4da3
commit 0547488f13
59 changed files with 4285 additions and 690 deletions
+271 -50
View File
@@ -23,15 +23,21 @@
* 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
* VIEWER's own accounts only (`installAccounts`): 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
* 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>
*/
@@ -133,6 +139,7 @@ function view(row, req, owners = null) {
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,
@@ -142,13 +149,14 @@ function view(row, req, owners = null) {
/**
* 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 = ?");
where.push("a.owner_id = ? AND a.in_library = 1");
params.push(req.user.id);
} else if (scope === "hub") {
where.push("a.shared = 1");
@@ -182,7 +190,8 @@ function list(req, { scope = "all", kind, q, laneId, sessionId, limit = 200 } =
`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();
return rows.map((r) => view(r, req, owners));
const installed = installedCheck(req);
return rows.map((r) => ({ ...view(r, req, owners), installed: installed(r) }));
}
// ── Small helpers ─────────────────────────────────────────────────────────
@@ -255,7 +264,7 @@ function addPage(req, { url, title, description, laneId, sessionId }) {
);
if (same) {
sql(
"UPDATE artifacts SET title = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?"
"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);
}
@@ -1114,7 +1123,7 @@ function addPlugin(req, { key, market, title, description }) {
).get(k, ownerId);
if (same) {
sql(
`UPDATE artifacts SET market = ?, title = ?, description = ?,
`UPDATE artifacts SET market = ?, title = ?, description = ?, in_library = 1,
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`
).run(
src,
@@ -1154,8 +1163,14 @@ function audit(req, row, action) {
}
function update(req, id, body = {}) {
const row = load(req, id);
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) {
@@ -1187,6 +1202,12 @@ function update(req, id, body = {}) {
}
}
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);
@@ -1194,15 +1215,86 @@ function update(req, id, body = {}) {
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");
sql("DELETE FROM artifacts WHERE id = ?").run(row.id);
dropSnapshot(row.snapshot);
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);
@@ -1237,55 +1329,140 @@ function installable(req, row) {
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"
);
/**
* 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 */
}
runAs.mkdirAs(acct, path.dirname(aside));
await runAs.writeAsAsync(acct, target, ["move", aside]);
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(target, aside);
fs.renameSync(t.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);
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);
}
}
}
return { installed: target, replaced: taken };
const paths = targets.map((t) => t.target);
return { installed: paths.join(", "), paths, replaced: taken.length > 0 };
}
let execImpl = promisify(require("node:child_process").execFile);
@@ -1343,12 +1520,56 @@ async function install(req, id, opts = {}) {
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,