/** * @file member-claude.js * @description Server mode, one Linux user per account (S3): what AgentHub * keeps in step inside a MEMBER's own Claude Code config. * * - `setTheme`: the `theme` in `~/.claude/settings.json`, following the * dark/light mode the member picked in AgentHub. Read through run-as's * guarded reader (the file must be theirs) and written AS the member * (`write-as`, merged, never replacing their other settings). A file that * is not JSON is left alone. * - `applyPluginBaseline` (P6 fallback, opt-in): when the admin keeps a * `plugin-baseline.json` beside `server.conf` (`/etc/agenthub/`, root's), * runs `claude plugin marketplace add ` for each of its * `marketplaces`, then `claude plugin install ` for each * of its `plugins`, AS the member (`sudo -u`, the shared `claude`), in the * background. No file, nothing runs. An entry that could be read as an * option is skipped, and `-y` is never passed, so a plugin that installs by * running a marketplace-declared command fails rather than runs unseen. * @author Nguyễn Ngọc Trí Vĩ */ const fs = require("node:fs"); const path = require("node:path"); const { promisify } = require("node:util"); const runAs = require("./run-as"); const linux = require("./linux-accounts"); function setTheme(acct, theme) { const dir = path.join(acct.home, ".claude"); const file = path.join(dir, "settings.json"); let settings = {}; try { settings = JSON.parse(runAs.readFileSafe(file, "utf8", { max: 1 << 20 })); } catch (err) { if (err.code !== "ENOENT") return false; // theirs to fix, not ours to overwrite runAs.mkdirAs(acct, dir); } if (!settings || typeof settings !== "object" || Array.isArray(settings)) return false; if (settings.theme === theme) return true; settings.theme = theme; runAs.writeFileAs(file, `${JSON.stringify(settings, null, 2)}\n`); return true; } // owner/repo, a git or https URL, an absolute path — never a leading `-`. const MARKETPLACE_RE = /^[A-Za-z0-9/][^\s]{0,499}$/; const PLUGIN_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}@[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/; const baselinePath = () => path.join(path.dirname(linux.confPath()), "plugin-baseline.json"); // Test seam: async, a marketplace add is a git clone. let execImpl = promisify(require("node:child_process").execFile); function __setExecImpl(fn) { execImpl = fn; } /** * Install the admin's plugin baseline for `username`. Resolves to null when * there is nothing to do (outside S3, no baseline file, empty lists), else to * `[{item, ok, error?}]`, one per entry, in order. Throws on a baseline file * that is not JSON, or ENOTSHARED when `claude` is not in the shared tools. */ async function applyPluginBaseline(username) { const acct = linux.accountFor(username); if (!acct) return null; let raw; try { raw = fs.readFileSync(baselinePath(), "utf8"); } catch { return null; // opt-in: no file, no baseline } const base = JSON.parse(raw); const list = (v) => (Array.isArray(v) ? v.map(String) : []); const steps = [ ...list(base?.marketplaces).map((s) => [ s, MARKETPLACE_RE, ["plugin", "marketplace", "add", s], ]), ...list(base?.plugins).map((p) => [p, PLUGIN_RE, ["plugin", "install", "--scope", "user", p]]), ]; if (!steps.length) return null; const claude = require("./shared-tools").sharedPath("claude"); if (!claude) { throw Object.assign(new Error("claude is not in the shared tools yet"), { code: "ENOTSHARED" }); } const env = { HOME: acct.home, DISABLE_AUTOUPDATER: "1" }; const results = []; for (const [item, re, args] of steps) { if (!re.test(item)) { results.push({ item, ok: false, error: "not a valid entry" }); continue; } const [cmd, argv] = runAs.sudoArgv(acct, claude, args, env); try { await execImpl(cmd, argv, { cwd: acct.home, timeout: 300_000, encoding: "utf8" }); results.push({ item, ok: true }); } catch (e) { const why = String(e.stderr || e.message || "") .trim() .split("\n") .pop(); results.push({ item, ok: false, error: why }); } } for (const r of results) { const line = `[plugin-baseline] ${acct.login}: ${r.item} ${r.ok ? "ok" : `failed: ${r.error}`}`; (r.ok ? console.log : console.warn)(line); } return results; } module.exports = { setTheme, applyPluginBaseline, __setExecImpl };