8546748759
Dependencies
- Security: body-parser and multer DoS advisories fixed (npm audit: 0).
- Server: Express 5 (query parser kept "extended", req.body defaults to {},
SPA catch-all /{*splat}, send-file helper restores Express 4's dot-folder
rule for sendFile/download, alerts ack route without inline regex),
better-sqlite3 13, js-yaml 5 (server/lib/yaml.js keeps js-yaml 4's default
schema), uuid dropped for crypto.randomUUID, node-pty beta.18, multer 2.5.
- Client: React 19, xterm 6 (WebGL renderer replaces the removed canvas one,
DOM fallback on context loss; Alt+arrow word moves re-mapped), lucide-react 1,
TypeScript 7, i18next/react-i18next/codemirror minors.
Workspace rename step 4
- `agenthub lanes` and `--lane` exit 1 with "renamed: use …"; client api.ts,
the CLI and the MCP server call /api/workspaces; /api/lanes stays mounted.
Server mode (S3): members manage their own Claude Code config
- server/lib/cc-target.js resolves whose config a /cc-config request touches
and gates the router by allowlist; /profiles* and /plugins/update-all stay
admin-only. Member writes go through write-as, CLI calls through sudo as the
member (never -y), reads through openSafe; cc_config_changed reaches only
its owner. Not verified live on an S3 install yet.
Other
- Chat: an HTML file is an artifact only when written whole and not a project
page (index.html, or under src/, public/, templates/, …).
- OpenAPI covers every /api route; openapi-coverage.test.js guards it.
- read-pool test keeps its event loop alive (its worker is unref'd).
- Docs: plan statuses, checklists, CLI.md, LANES.md, SERVER-MODE.md, API.md.
1082 lines
38 KiB
JavaScript
1082 lines
38 KiB
JavaScript
/**
|
|
* @file cc-discovery.js
|
|
* @description Read-only discovery of Claude Code configuration surfaces
|
|
* (skills, subagents, slash commands, output styles, plugins, marketplaces,
|
|
* MCP servers, hooks, settings, memory, keybindings, statusline, hook
|
|
* scripts). Powers the Claude Config Explorer page. All operations are pure
|
|
* file reads — never writes.
|
|
*
|
|
* Path containment: every read resolves under the Claude home being read
|
|
* (`opts.home`, else getClaudeHome()), getProjectClaudeDir(), or
|
|
* getProjectRoot() (for CLAUDE.md). Reads outside those roots return null.
|
|
* Settings are redacted of secret-like keys before returning.
|
|
*
|
|
* Whose config: every reader takes `opts.home` (a Claude dir) and
|
|
* `opts.claudeJson` — a member's own (`<home>/.claude`, `<home>/.claude.json`)
|
|
* when the Config Explorer serves a member under S3 — and `opts.noProject`,
|
|
* which reads no project layer at all (a member who named no workspace: never
|
|
* the dashboard's own folder). A path inside a member's home is read only
|
|
* through run-as's guarded reader (a regular file that member owns, size
|
|
* capped) and listed only when it really lies in that home and is theirs
|
|
* (`memberPathOk`), and a plugin's or marketplace's install path that the
|
|
* member's own JSON names is followed only inside their home.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
const fs = require("node:fs");
|
|
const path = require("node:path");
|
|
const os = require("node:os");
|
|
const { getClaudeHome } = require("./claude-home");
|
|
|
|
const MAX_FILE_BYTES = 256 * 1024; // skip reads above this; truncate body in details
|
|
const REDACT_KEY_RE = /token|secret|password|api[_-]?key|auth/i;
|
|
|
|
function getProjectRoot(cwd) {
|
|
return path.resolve(cwd || process.cwd());
|
|
}
|
|
|
|
function getProjectClaudeDir(cwd) {
|
|
return path.join(getProjectRoot(cwd), ".claude");
|
|
}
|
|
|
|
/** The Claude dir a read is about: the caller's `opts.home`, else the host's. */
|
|
const homeOf = (opts) => (opts && opts.home) || getClaudeHome();
|
|
|
|
function getClaudeJsonPath() {
|
|
// NOT path.join(getClaudeHome(), ".claude.json") — verified against the
|
|
// real `claude` binary, the two CLAUDE_HOME branches behave differently:
|
|
// - CLAUDE_HOME set: the CLI's CLAUDE_CONFIG_DIR (pinned to it by
|
|
// cc-plugin-cli.js's runClaude()) becomes a $HOME-substitute for both
|
|
// `.claude.json` AND the rest of the tree (projects/, settings.json,
|
|
// plugins/) — flat inside it, no nested ".claude". Same value
|
|
// getClaudeHome() returns in this branch, so the two formulas agree.
|
|
// - CLAUDE_HOME unset: runClaude() deliberately leaves CLAUDE_CONFIG_DIR
|
|
// unset too, and the CLI's OWN default writes `.claude.json` BESIDE
|
|
// `~/.claude`, not inside it — while getClaudeHome()'s unset branch
|
|
// appends "/.claude". Delegating to getClaudeHome() here would read
|
|
// `~/.claude/.claude.json`, a file the CLI never writes by default.
|
|
return process.env.CLAUDE_HOME
|
|
? path.join(process.env.CLAUDE_HOME, ".claude.json")
|
|
: path.join(os.homedir(), ".claude.json");
|
|
}
|
|
|
|
/**
|
|
* True if `target` is contained within `root` (after symlink-aware resolve).
|
|
* Defends the /file endpoint against `..` traversal and absolute-path tricks.
|
|
*/
|
|
function isUnder(root, target) {
|
|
const r = path.resolve(root);
|
|
const t = path.resolve(target);
|
|
if (t === r) return true;
|
|
return t.startsWith(r + path.sep);
|
|
}
|
|
|
|
/**
|
|
* S3: the member whose home `p` lies in (`run-as.ownerForRead`), or null for a
|
|
* host path / outside S3.
|
|
*/
|
|
function memberOf(p) {
|
|
try {
|
|
return require("./run-as").ownerForRead(p);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Whether a path may be listed or stat'ed for what it holds: always outside a
|
|
* member's home; inside one only when its real path stays in that home and
|
|
* the member owns it. The dashboard reads with CAP_DAC_READ_SEARCH, so a link
|
|
* a member planted (`~/.claude/hooks -> /root`) would otherwise list another
|
|
* account's file names.
|
|
*/
|
|
function memberPathOk(p) {
|
|
const acct = memberOf(p);
|
|
if (!acct) return true;
|
|
try {
|
|
const real = fs.realpathSync(p);
|
|
if (!isUnder(fs.realpathSync(acct.home), real)) return false;
|
|
return fs.statSync(real).uid === acct.uid;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* A path a member's OWN JSON names (a plugin's `installPath`, a marketplace's
|
|
* `installLocation`) is followed only inside that member's home; any path is
|
|
* followed for the host, as before. `claudeDir` is the home being read.
|
|
*/
|
|
function followable(claudeDir, p) {
|
|
const acct = memberOf(claudeDir);
|
|
if (!acct) return true;
|
|
try {
|
|
return isUnder(fs.realpathSync(acct.home), fs.realpathSync(p)) && memberPathOk(p);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function readJson(absPath) {
|
|
try {
|
|
const raw = require("./run-as").readFileSafe(absPath, "utf8");
|
|
return { ok: true, data: JSON.parse(raw), raw };
|
|
} catch (err) {
|
|
if (err && err.code === "ENOENT") return { ok: false, missing: true };
|
|
return { ok: false, error: err.message };
|
|
}
|
|
}
|
|
|
|
function redactSettings(value) {
|
|
if (Array.isArray(value)) return value.map(redactSettings);
|
|
if (value && typeof value === "object") {
|
|
const out = {};
|
|
for (const [k, v] of Object.entries(value)) {
|
|
if (typeof v === "string" && REDACT_KEY_RE.test(k)) {
|
|
out[k] = "<redacted>";
|
|
} else {
|
|
out[k] = redactSettings(v);
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
return value;
|
|
}
|
|
|
|
/**
|
|
* Minimal YAML-frontmatter parser. Handles `---\n<key>: <value>\n---\n<body>`.
|
|
* Quoted strings (single + double) are stripped; multi-line values are
|
|
* preserved as raw strings. Anything we can't parse is returned as null
|
|
* frontmatter — the body is still readable.
|
|
*/
|
|
function parseFrontmatter(text) {
|
|
if (typeof text !== "string") return { frontmatter: null, body: "" };
|
|
if (!text.startsWith("---")) return { frontmatter: null, body: text };
|
|
const end = text.indexOf("\n---", 3);
|
|
if (end < 0) return { frontmatter: null, body: text };
|
|
const head = text.slice(3, end).replace(/^\s*\n/, "");
|
|
const body = text.slice(end + 4).replace(/^\s*\n/, "");
|
|
const fm = {};
|
|
let currentKey = null;
|
|
for (const rawLine of head.split("\n")) {
|
|
const line = rawLine.replace(/\s+$/, "");
|
|
if (!line.trim()) continue;
|
|
// continuation of a multiline value
|
|
if (currentKey && /^\s/.test(rawLine)) {
|
|
fm[currentKey] += "\n" + rawLine.trim();
|
|
continue;
|
|
}
|
|
const m = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
|
|
if (!m) {
|
|
currentKey = null;
|
|
continue;
|
|
}
|
|
currentKey = m[1];
|
|
let v = m[2];
|
|
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
|
|
v = v.slice(1, -1);
|
|
}
|
|
fm[currentKey] = v;
|
|
}
|
|
return { frontmatter: fm, body };
|
|
}
|
|
|
|
function safeReadText(absPath) {
|
|
const runAs = require("./run-as");
|
|
if (runAs.isMemberFile(absPath)) return memberReadText(runAs, absPath);
|
|
try {
|
|
const stat = fs.statSync(absPath);
|
|
if (!stat.isFile()) return null;
|
|
if (stat.size > MAX_FILE_BYTES) {
|
|
return {
|
|
truncated: true,
|
|
size: stat.size,
|
|
text: require("./run-as").readFileSafe(absPath, "utf8").slice(0, MAX_FILE_BYTES),
|
|
mtime: stat.mtimeMs,
|
|
};
|
|
}
|
|
return {
|
|
truncated: false,
|
|
size: stat.size,
|
|
text: require("./run-as").readFileSafe(absPath, "utf8"),
|
|
mtime: stat.mtimeMs,
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* `safeReadText` for a member's file: opened through run-as's guard (a regular
|
|
* file that member owns, never a FIFO or another account's file through a
|
|
* link), and only the first MAX_FILE_BYTES read — never the whole of a file
|
|
* the member made huge.
|
|
*/
|
|
function memberReadText(runAs, absPath) {
|
|
let fd;
|
|
try {
|
|
fd = runAs.openSafe(absPath);
|
|
const st = fs.fstatSync(fd);
|
|
const want = Math.min(st.size, MAX_FILE_BYTES);
|
|
const buf = Buffer.alloc(want);
|
|
let off = 0;
|
|
for (let n; off < want && (n = fs.readSync(fd, buf, off, want - off, off)) > 0;) off += n;
|
|
return {
|
|
truncated: st.size > MAX_FILE_BYTES,
|
|
size: st.size,
|
|
text: buf.toString("utf8", 0, off),
|
|
mtime: st.mtimeMs,
|
|
};
|
|
} catch {
|
|
return null;
|
|
} finally {
|
|
if (fd != null) fs.closeSync(fd);
|
|
}
|
|
}
|
|
|
|
function listDir(absPath) {
|
|
if (!memberPathOk(absPath)) return [];
|
|
try {
|
|
return fs.readdirSync(absPath, { withFileTypes: true });
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* True if `ent` is a directory, OR a symlink that resolves to one.
|
|
* Dirent.isDirectory() returns false for symlinks even when they point at a
|
|
* directory (e.g. a skill installed via `ln -s` so it can live in a git
|
|
* repo) — this follows the link with statSync so those aren't skipped.
|
|
* Broken symlinks are treated as non-directories rather than throwing.
|
|
*/
|
|
function isDirLike(ent, absPath) {
|
|
if (ent.isDirectory()) return true;
|
|
if (!ent.isSymbolicLink()) return false;
|
|
try {
|
|
return fs.statSync(absPath).isDirectory();
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* File counterpart of {@link isDirLike}: true if `ent` is a regular file, OR a
|
|
* symlink that resolves to one. Same Dirent quirk — `ent.isFile()` returns
|
|
* false for a symlink even when it points at a file, so agents/commands/hook
|
|
* scripts installed via `ln -s` were invisible to the Config Explorer while
|
|
* Claude Code itself resolves and uses them. Broken symlinks are treated as
|
|
* non-files rather than throwing.
|
|
*/
|
|
function isFileLike(ent, absPath) {
|
|
if (ent.isFile()) return true;
|
|
if (!ent.isSymbolicLink()) return false;
|
|
try {
|
|
return fs.statSync(absPath).isFile();
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// ── Skills ──────────────────────────────────────────────────────────────
|
|
|
|
function readSkillsAt(scope, claudeDir) {
|
|
const dir = path.join(claudeDir, "skills");
|
|
const entries = listDir(dir);
|
|
const skills = [];
|
|
for (const ent of entries) {
|
|
const skillDir = path.join(dir, ent.name);
|
|
if (!isDirLike(ent, skillDir)) continue;
|
|
const skillFile = path.join(skillDir, "SKILL.md");
|
|
const read = safeReadText(skillFile);
|
|
if (!read) continue;
|
|
const { frontmatter, body } = parseFrontmatter(read.text);
|
|
skills.push({
|
|
scope,
|
|
name: ent.name,
|
|
path: skillDir,
|
|
file: skillFile,
|
|
size: read.size,
|
|
mtime: read.mtime,
|
|
truncated: read.truncated,
|
|
frontmatter: frontmatter || {},
|
|
preview: body.slice(0, 320),
|
|
});
|
|
}
|
|
return skills.sort((a, b) => a.name.localeCompare(b.name));
|
|
}
|
|
|
|
/**
|
|
* @param {{scope?: string, cwd?: string, userClaudeDir?: string}} opts
|
|
* `userClaudeDir` = whose user scope to read (a member's `~/.claude` under
|
|
* S3); the host's Claude home when absent.
|
|
*/
|
|
function readSkills(opts = {}) {
|
|
const out = [];
|
|
if (opts.scope !== "project") {
|
|
out.push(...readSkillsAt("user", opts.userClaudeDir || homeOf(opts)));
|
|
}
|
|
if (opts.scope !== "user" && !opts.noProject) {
|
|
out.push(...readSkillsAt("project", getProjectClaudeDir(opts.cwd)));
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// ── Single-file MD surfaces (agents, commands, output styles) ──────────
|
|
|
|
function readMdFilesAt(scope, claudeDir, subdir) {
|
|
const dir = path.join(claudeDir, subdir);
|
|
const entries = listDir(dir);
|
|
const out = [];
|
|
for (const ent of entries) {
|
|
if (!ent.name.endsWith(".md")) continue;
|
|
const file = path.join(dir, ent.name);
|
|
if (!isFileLike(ent, file)) continue;
|
|
const read = safeReadText(file);
|
|
if (!read) continue;
|
|
const { frontmatter, body } = parseFrontmatter(read.text);
|
|
out.push({
|
|
scope,
|
|
name: ent.name.replace(/\.md$/, ""),
|
|
file,
|
|
size: read.size,
|
|
mtime: read.mtime,
|
|
truncated: read.truncated,
|
|
frontmatter: frontmatter || {},
|
|
preview: body.slice(0, 320),
|
|
});
|
|
}
|
|
return out.sort((a, b) => a.name.localeCompare(b.name));
|
|
}
|
|
|
|
function readSimpleMdSurface(subdir) {
|
|
return (opts = {}) => {
|
|
const out = [];
|
|
if (opts.scope !== "project") {
|
|
out.push(...readMdFilesAt("user", opts.userClaudeDir || homeOf(opts), subdir));
|
|
}
|
|
if (opts.scope !== "user" && !opts.noProject) {
|
|
out.push(...readMdFilesAt("project", getProjectClaudeDir(opts.cwd), subdir));
|
|
}
|
|
return out;
|
|
};
|
|
}
|
|
|
|
const readAgents = readSimpleMdSurface("agents");
|
|
const readCommands = readSimpleMdSurface("commands");
|
|
const readOutputStyles = readSimpleMdSurface("output-styles");
|
|
|
|
// ── Plugins ─────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Absolute `.md` files a plugin contributes for one surface: the conventional
|
|
* `<root>/<subdir>` directory PLUS whatever `plugin.json` points at for that
|
|
* key. Both matter — a manifest entry may be a single file or a directory, and
|
|
* a plugin that declares its commands explicitly (this repo's own `agenthub` lists
|
|
* three files under `./plugins/agenthub/commands/`) keeps NOTHING in `commands/`,
|
|
* so reading only the conventional directory reported zero for it.
|
|
*
|
|
* A manifest is data from a third-party plugin, so a declared path that escapes
|
|
* the plugin's own install directory is dropped rather than read.
|
|
*
|
|
* @param {string} installPath Plugin root.
|
|
* @param {string} subdir Conventional directory name ("agents", "commands", …).
|
|
* @param {unknown} declared The manifest value for that key, any shape.
|
|
* @returns {string[]} Sorted, de-duplicated absolute file paths.
|
|
*/
|
|
function pluginSurfaceFiles(installPath, subdir, declared) {
|
|
const files = new Set();
|
|
const addDir = (dir) => {
|
|
for (const ent of listDir(dir)) {
|
|
const abs = path.join(dir, ent.name);
|
|
if (ent.name.endsWith(".md") && isFileLike(ent, abs)) files.add(abs);
|
|
}
|
|
};
|
|
addDir(path.join(installPath, subdir));
|
|
const entries = Array.isArray(declared)
|
|
? declared
|
|
: typeof declared === "string"
|
|
? [declared]
|
|
: [];
|
|
for (const rel of entries) {
|
|
if (typeof rel !== "string") continue;
|
|
const abs = path.resolve(installPath, rel);
|
|
if (!isUnder(installPath, abs)) continue;
|
|
try {
|
|
const stat = fs.statSync(abs);
|
|
if (stat.isDirectory()) addDir(abs);
|
|
else if (stat.isFile() && abs.endsWith(".md")) files.add(abs);
|
|
} catch {
|
|
/* declared in the manifest but not on disk — reported by its absence */
|
|
}
|
|
}
|
|
return [...files].sort();
|
|
}
|
|
|
|
/**
|
|
* One surface's items with the names the UI shows. `name` prefers the
|
|
* frontmatter's own name (that is what Claude Code addresses the agent/command
|
|
* by) and falls back to the filename.
|
|
*
|
|
* @returns {{name: string, description: string|null, file: string}[]}
|
|
*/
|
|
function readPluginMdSurface(installPath, subdir, declared) {
|
|
const out = [];
|
|
for (const file of pluginSurfaceFiles(installPath, subdir, declared)) {
|
|
const read = safeReadText(file);
|
|
const fm = read ? parseFrontmatter(read.text).frontmatter : null;
|
|
out.push({
|
|
name: fm && typeof fm.name === "string" && fm.name ? fm.name : path.basename(file, ".md"),
|
|
description: fm && typeof fm.description === "string" ? fm.description : null,
|
|
file,
|
|
});
|
|
}
|
|
return out.sort((a, b) => a.name.localeCompare(b.name));
|
|
}
|
|
|
|
/**
|
|
* A plugin's skills: every directory holding a `SKILL.md`, under the
|
|
* conventional `<root>/skills` and under any directory the manifest declares.
|
|
* A declared path that IS a skill directory counts as that one skill.
|
|
*
|
|
* @returns {{name: string, description: string|null, file: string}[]}
|
|
*/
|
|
function readPluginSkills(installPath, declared) {
|
|
const found = new Map();
|
|
const addSkillDir = (dir) => {
|
|
const file = path.join(dir, "SKILL.md");
|
|
const read = safeReadText(file);
|
|
if (!read) return false;
|
|
const fm = parseFrontmatter(read.text).frontmatter;
|
|
found.set(dir, {
|
|
name: fm && typeof fm.name === "string" && fm.name ? fm.name : path.basename(dir),
|
|
description: fm && typeof fm.description === "string" ? fm.description : null,
|
|
file,
|
|
});
|
|
return true;
|
|
};
|
|
const addRoot = (root) => {
|
|
for (const ent of listDir(root)) {
|
|
const dir = path.join(root, ent.name);
|
|
if (isDirLike(ent, dir)) addSkillDir(dir);
|
|
}
|
|
};
|
|
addRoot(path.join(installPath, "skills"));
|
|
const entries = Array.isArray(declared)
|
|
? declared
|
|
: typeof declared === "string"
|
|
? [declared]
|
|
: [];
|
|
for (const rel of entries) {
|
|
if (typeof rel !== "string") continue;
|
|
const abs = path.resolve(installPath, rel);
|
|
if (!isUnder(installPath, abs)) continue;
|
|
// Either the path is one skill, or it is a directory holding several.
|
|
if (!addSkillDir(abs)) addRoot(abs);
|
|
}
|
|
return [...found.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
}
|
|
|
|
/**
|
|
* What one installed plugin contributes. The `skills`/`agents`/`commands`/
|
|
* `outputStyles` counts are unchanged (existing clients read them), and `items`
|
|
* carries the same three surfaces by name so a card can list WHICH ones a
|
|
* plugin ships instead of only how many.
|
|
*/
|
|
function readPluginContributions(installPath) {
|
|
if (!installPath) return null;
|
|
let pluginJson = null;
|
|
try {
|
|
const raw = require("./run-as").readFileSafe(
|
|
path.join(installPath, ".claude-plugin", "plugin.json"),
|
|
"utf8",
|
|
{ max: MAX_FILE_BYTES }
|
|
);
|
|
pluginJson = JSON.parse(raw);
|
|
} catch {
|
|
pluginJson = null;
|
|
}
|
|
const items = {
|
|
skills: readPluginSkills(installPath, pluginJson?.skills),
|
|
agents: readPluginMdSurface(installPath, "agents", pluginJson?.agents),
|
|
commands: readPluginMdSurface(installPath, "commands", pluginJson?.commands),
|
|
outputStyles: readPluginMdSurface(installPath, "output-styles", pluginJson?.outputStyles),
|
|
};
|
|
return {
|
|
// Counts stay derived from the same lists, so a plugin can never show a
|
|
// chip reading "3 commands" next to a list holding two.
|
|
skills: items.skills.length,
|
|
agents: items.agents.length,
|
|
commands: items.commands.length,
|
|
outputStyles: items.outputStyles.length,
|
|
items,
|
|
hooks: (() => {
|
|
return listDir(path.join(installPath, "hooks")).filter((e) =>
|
|
isFileLike(e, path.join(installPath, "hooks", e.name))
|
|
).length;
|
|
})(),
|
|
pluginJson,
|
|
};
|
|
}
|
|
|
|
function readEnabledPluginsMap(home = getClaudeHome()) {
|
|
const userSettings = readJson(path.join(home, "settings.json"));
|
|
if (!userSettings.ok || !userSettings.data) return {};
|
|
const ep = userSettings.data.enabledPlugins;
|
|
return ep && typeof ep === "object" ? ep : {};
|
|
}
|
|
|
|
/** @param {string} [home] whose Claude home (a member's under S3); the host's by default. */
|
|
function readPlugins(home = getClaudeHome()) {
|
|
const manifestPath = path.join(home, "plugins", "installed_plugins.json");
|
|
const manifest = readJson(manifestPath);
|
|
const enabledMap = readEnabledPluginsMap(home);
|
|
const plugins = [];
|
|
if (manifest.ok && manifest.data && manifest.data.plugins) {
|
|
for (const [pluginKey, instances] of Object.entries(manifest.data.plugins)) {
|
|
const arr = Array.isArray(instances) ? instances : [instances];
|
|
for (const inst of arr) {
|
|
const installPath = inst.installPath;
|
|
let exists = false;
|
|
try {
|
|
exists =
|
|
installPath && followable(home, installPath)
|
|
? fs.statSync(installPath).isDirectory()
|
|
: false;
|
|
} catch {
|
|
exists = false;
|
|
}
|
|
const contributes = exists ? readPluginContributions(installPath) : null;
|
|
// enabledPlugins map keys can be just the plugin name OR "<name>@<marketplace>"
|
|
const enabledByKey = enabledMap[pluginKey];
|
|
const enabledByName = enabledMap[pluginKey.split("@")[0]];
|
|
const enabled =
|
|
enabledByKey === true || enabledByName === true
|
|
? true
|
|
: enabledByKey === false || enabledByName === false
|
|
? false
|
|
: null;
|
|
plugins.push({
|
|
key: pluginKey,
|
|
name: pluginKey.split("@")[0],
|
|
marketplace: pluginKey.includes("@") ? pluginKey.split("@")[1] : null,
|
|
scope: inst.scope || "user",
|
|
version: inst.version || null,
|
|
installPath: installPath || null,
|
|
installedAt: inst.installedAt || null,
|
|
lastUpdated: inst.lastUpdated || null,
|
|
gitCommitSha: inst.gitCommitSha || null,
|
|
installPathExists: exists,
|
|
enabled,
|
|
contributes,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
return {
|
|
manifestPath,
|
|
manifestExists: manifest.ok,
|
|
plugins: plugins.sort((a, b) => a.key.localeCompare(b.key)),
|
|
};
|
|
}
|
|
|
|
// ── MCP servers ────────────────────────────────────────────────────────
|
|
|
|
function readMcpServers(opts = {}) {
|
|
const out = { user: [], projectScoped: [] };
|
|
// ~/.claude.json is the primary CLI state file; mcpServers can live at the
|
|
// top level (legacy) or inside projects[<cwd>].mcpServers (per-project).
|
|
const claudeJson = readJson(opts.claudeJson || getClaudeJsonPath());
|
|
if (claudeJson.ok && claudeJson.data) {
|
|
const top = claudeJson.data.mcpServers;
|
|
if (top && typeof top === "object") {
|
|
for (const [name, def] of Object.entries(top)) {
|
|
out.user.push({
|
|
name,
|
|
source: "~/.claude.json (top-level)",
|
|
...summarizeMcpDef(def),
|
|
});
|
|
}
|
|
}
|
|
const projects = claudeJson.data.projects;
|
|
if (projects && typeof projects === "object" && !opts.noProject) {
|
|
const projectRoot = getProjectRoot(opts.cwd);
|
|
const projectEntry = projects[projectRoot];
|
|
if (projectEntry && projectEntry.mcpServers) {
|
|
for (const [name, def] of Object.entries(projectEntry.mcpServers)) {
|
|
out.projectScoped.push({
|
|
name,
|
|
source: `~/.claude.json (projects[${projectRoot}])`,
|
|
...summarizeMcpDef(def),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// Also sniff settings.json for an mcpServers key (rare but supported).
|
|
const userSettings = readJson(path.join(homeOf(opts), "settings.json"));
|
|
if (userSettings.ok && userSettings.data && userSettings.data.mcpServers) {
|
|
for (const [name, def] of Object.entries(userSettings.data.mcpServers)) {
|
|
out.user.push({
|
|
name,
|
|
source: "~/.claude/settings.json",
|
|
// No `claude mcp` scope addresses settings.json, so the dashboard
|
|
// cannot remove these. A flag, not a string comparison on `source`:
|
|
// the UI used to match that display text, so rewording it silently
|
|
// brought back a Remove button that always fails.
|
|
removable: false,
|
|
...summarizeMcpDef(def),
|
|
});
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function summarizeMcpDef(def) {
|
|
if (!def || typeof def !== "object") return { kind: "unknown" };
|
|
if (def.url)
|
|
return { kind: "http", url: def.url, headers: def.headers ? Object.keys(def.headers) : [] };
|
|
if (def.command) {
|
|
return {
|
|
kind: "stdio",
|
|
command: def.command,
|
|
args: Array.isArray(def.args) ? def.args : [],
|
|
envNames: def.env && typeof def.env === "object" ? Object.keys(def.env) : [],
|
|
};
|
|
}
|
|
return { kind: "unknown" };
|
|
}
|
|
|
|
// ── Hooks (read across user + project + project-local) ─────────────────
|
|
|
|
const HOOK_EVENT_TYPES = [
|
|
"SessionStart",
|
|
"SessionEnd",
|
|
"UserPromptSubmit",
|
|
"PreToolUse",
|
|
"PostToolUse",
|
|
"Stop",
|
|
"SubagentStop",
|
|
"Notification",
|
|
"PreCompact",
|
|
];
|
|
|
|
/** The settings layers a read covers: user, plus the project's two unless `noProject`. */
|
|
function settingsSources(opts = {}) {
|
|
const user = [{ scope: "user", file: path.join(homeOf(opts), "settings.json") }];
|
|
if (opts.noProject) return user;
|
|
return [
|
|
...user,
|
|
{
|
|
scope: "project",
|
|
file: path.join(getProjectClaudeDir(opts.cwd), "settings.json"),
|
|
},
|
|
{
|
|
scope: "project-local",
|
|
file: path.join(getProjectClaudeDir(opts.cwd), "settings.local.json"),
|
|
},
|
|
];
|
|
}
|
|
|
|
function readHooks(opts = {}) {
|
|
const sources = settingsSources(opts);
|
|
const result = [];
|
|
for (const { scope, file } of sources) {
|
|
const j = readJson(file);
|
|
const entry = { scope, file, exists: j.ok, hooks: {} };
|
|
if (j.ok && j.data && j.data.hooks && typeof j.data.hooks === "object") {
|
|
for (const event of HOOK_EVENT_TYPES) {
|
|
const matchers = j.data.hooks[event];
|
|
if (!Array.isArray(matchers)) continue;
|
|
const flat = [];
|
|
for (const m of matchers) {
|
|
const matcher = m.matcher || "*";
|
|
const list = Array.isArray(m.hooks) ? m.hooks : [];
|
|
for (const h of list) {
|
|
flat.push({
|
|
matcher,
|
|
type: h.type || "command",
|
|
command: h.command || null,
|
|
timeout: h.timeout || null,
|
|
});
|
|
}
|
|
}
|
|
if (flat.length) entry.hooks[event] = flat;
|
|
}
|
|
// Also surface unknown events the user wrote
|
|
for (const [event, matchers] of Object.entries(j.data.hooks)) {
|
|
if (HOOK_EVENT_TYPES.includes(event)) continue;
|
|
if (!Array.isArray(matchers)) continue;
|
|
entry.hooks[event] = matchers;
|
|
}
|
|
}
|
|
result.push(entry);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
// ── Settings ───────────────────────────────────────────────────────────
|
|
|
|
function readSettings(opts = {}) {
|
|
const sources = settingsSources(opts);
|
|
return sources.map(({ scope, file }) => {
|
|
const j = readJson(file);
|
|
if (!j.ok) return { scope, file, exists: false };
|
|
return {
|
|
scope,
|
|
file,
|
|
exists: true,
|
|
data: redactSettings(j.data),
|
|
raw_size: j.raw.length,
|
|
};
|
|
});
|
|
}
|
|
|
|
// ── Marketplaces ───────────────────────────────────────────────────────
|
|
|
|
function readMarketplaces(opts = {}) {
|
|
const home = homeOf(opts);
|
|
const knownPath = path.join(home, "plugins", "known_marketplaces.json");
|
|
const known = readJson(knownPath);
|
|
const out = [];
|
|
if (known.ok && known.data && typeof known.data === "object") {
|
|
for (const [name, def] of Object.entries(known.data)) {
|
|
const installLocation = def && def.installLocation;
|
|
const sourceDef = def && def.source;
|
|
let pluginCount = null;
|
|
let marketplaceJson = null;
|
|
if (installLocation && followable(home, installLocation)) {
|
|
try {
|
|
const mfPath = path.join(installLocation, ".claude-plugin", "marketplace.json");
|
|
const raw = require("./run-as").readFileSafe(mfPath, "utf8", { max: MAX_FILE_BYTES });
|
|
marketplaceJson = JSON.parse(raw);
|
|
pluginCount = Array.isArray(marketplaceJson.plugins)
|
|
? marketplaceJson.plugins.length
|
|
: null;
|
|
} catch {
|
|
/* not all marketplaces have a manifest */
|
|
}
|
|
}
|
|
out.push({
|
|
name,
|
|
source: sourceDef && typeof sourceDef === "object" ? sourceDef : null,
|
|
installLocation: installLocation || null,
|
|
lastUpdated: def && def.lastUpdated ? def.lastUpdated : null,
|
|
pluginCount,
|
|
marketplaceName: marketplaceJson?.name || null,
|
|
marketplaceDescription: marketplaceJson?.description || null,
|
|
marketplaceOwner: marketplaceJson?.owner || null,
|
|
});
|
|
}
|
|
}
|
|
return {
|
|
knownPath,
|
|
knownExists: known.ok,
|
|
items: out.sort((a, b) => a.name.localeCompare(b.name)),
|
|
};
|
|
}
|
|
|
|
// ── Keybindings ────────────────────────────────────────────────────────
|
|
|
|
function readKeybindings(opts = {}) {
|
|
const file = path.join(homeOf(opts), "keybindings.json");
|
|
const j = readJson(file);
|
|
if (!j.ok) return { file, exists: false };
|
|
const data = j.data && typeof j.data === "object" ? j.data : {};
|
|
const groups = Array.isArray(data.bindings) ? data.bindings : [];
|
|
return {
|
|
file,
|
|
exists: true,
|
|
schema: data.$schema || null,
|
|
docs: data.$docs || null,
|
|
groups: groups.map((g) => ({
|
|
context: g.context || "",
|
|
bindings:
|
|
g.bindings && typeof g.bindings === "object"
|
|
? Object.entries(g.bindings).map(([key, action]) => ({ key, action: String(action) }))
|
|
: [],
|
|
})),
|
|
};
|
|
}
|
|
|
|
// ── Statusline (config + script content) ──────────────────────────────
|
|
|
|
function readStatusline(opts = {}) {
|
|
const userSettingsPath = path.join(homeOf(opts), "settings.json");
|
|
const j = readJson(userSettingsPath);
|
|
const config = j.ok && j.data && j.data.statusLine ? j.data.statusLine : null;
|
|
const candidates = [
|
|
path.join(homeOf(opts), "statusline.py"),
|
|
path.join(homeOf(opts), "statusline-command.sh"),
|
|
];
|
|
const scripts = [];
|
|
for (const file of candidates) {
|
|
const r = safeReadText(file);
|
|
if (r) {
|
|
scripts.push({
|
|
file,
|
|
size: r.size,
|
|
mtime: r.mtime,
|
|
truncated: r.truncated,
|
|
preview: r.text.slice(0, 4000),
|
|
});
|
|
}
|
|
}
|
|
return { config, scripts };
|
|
}
|
|
|
|
// ── Hook handler scripts dir (~/.claude/hooks/) ───────────────────────
|
|
|
|
function readHookScripts(opts = {}) {
|
|
const dir = path.join(homeOf(opts), "hooks");
|
|
const entries = listDir(dir);
|
|
return {
|
|
dir,
|
|
items: entries
|
|
.filter((e) => isFileLike(e, path.join(dir, e.name)))
|
|
.map((e) => {
|
|
const file = path.join(dir, e.name);
|
|
let stat;
|
|
try {
|
|
if (!memberPathOk(file)) return null;
|
|
stat = fs.statSync(file);
|
|
} catch {
|
|
return null;
|
|
}
|
|
return { name: e.name, file, size: stat.size, mtime: stat.mtimeMs };
|
|
})
|
|
.filter(Boolean)
|
|
.sort((a, b) => a.name.localeCompare(b.name)),
|
|
};
|
|
}
|
|
|
|
// ── Memory (CLAUDE.md + per-project file-based memory) ─────────────────
|
|
|
|
// Index/manifest files inside a memory dir (MEMORY.md, INDEX-*.md) sort
|
|
// before the per-fact files so the table-of-contents shows up first.
|
|
const MEMORY_INDEX_RE = /^(MEMORY|INDEX)\b/i;
|
|
|
|
/**
|
|
* Read the two primary CLAUDE.md memory files (user + project) PLUS every
|
|
* markdown file under ~/.claude/projects/<slug>/memory/ — the common
|
|
* community pattern of a file-based agent memory store (a MEMORY.md index
|
|
* plus one file per remembered fact). The latter are emitted with
|
|
* scope "auto-memory" and carry `project` (the projects/<slug> dir name)
|
|
* and `name` (the filename) so the UI can group + label them. They are
|
|
* mutable via cc-mutate's "auto-memory" type (create/edit/delete + backup).
|
|
*/
|
|
function readMemory(opts = {}) {
|
|
const sources = [{ scope: "user", file: path.join(homeOf(opts), "CLAUDE.md") }];
|
|
if (!opts.noProject) {
|
|
sources.push({ scope: "project", file: path.join(getProjectRoot(opts.cwd), "CLAUDE.md") });
|
|
}
|
|
const result = [];
|
|
for (const { scope, file } of sources) {
|
|
const r = safeReadText(file);
|
|
if (!r) continue;
|
|
result.push({
|
|
scope,
|
|
file,
|
|
size: r.size,
|
|
mtime: r.mtime,
|
|
truncated: r.truncated,
|
|
preview: r.text.slice(0, 480),
|
|
});
|
|
}
|
|
|
|
// Per-project file-based memory dirs. Best-effort: a missing projects
|
|
// root, an unreadable memory dir, or a single bad file must never break
|
|
// the memory tab — every layer is wrapped so we degrade to "fewer files".
|
|
try {
|
|
const projectsRoot = path.join(homeOf(opts), "projects");
|
|
for (const { name: proj } of listDir(projectsRoot)) {
|
|
const memDir = path.join(projectsRoot, proj, "memory");
|
|
const files = listDir(memDir)
|
|
.map((e) => e.name)
|
|
.filter((f) => f.endsWith(".md"))
|
|
.sort((a, b) => {
|
|
const rank = (f) => (MEMORY_INDEX_RE.test(f) ? 0 : 1);
|
|
return rank(a) - rank(b) || a.localeCompare(b);
|
|
});
|
|
for (const f of files) {
|
|
const file = path.join(memDir, f);
|
|
const r = safeReadText(file);
|
|
if (!r) continue;
|
|
// Per-fact memory files commonly carry YAML frontmatter (name,
|
|
// description, metadata.type) — parse it like the other MD surfaces
|
|
// so the UI can show a clean title + description instead of raw text.
|
|
const { frontmatter, body } = parseFrontmatter(r.text);
|
|
result.push({
|
|
scope: "auto-memory",
|
|
project: proj,
|
|
name: f,
|
|
isIndex: MEMORY_INDEX_RE.test(f),
|
|
file,
|
|
size: r.size,
|
|
mtime: r.mtime,
|
|
truncated: r.truncated,
|
|
frontmatter: frontmatter || {},
|
|
preview: (body || r.text).slice(0, 480),
|
|
});
|
|
}
|
|
}
|
|
} catch {
|
|
/* best-effort: never break the memory tab */
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
// ── Single-file body endpoint (with strict path containment) ───────────
|
|
|
|
/**
|
|
* A credential is never served by name, to any role: Claude's
|
|
* `.credentials.json`, Codex's `auth.json`, any non-Markdown file named like a
|
|
* secret, and AgentHub's own stores under CLAUDE_HOME (`agenthub-proxies`,
|
|
* `agenthub-git-credentials`, the pool accounts…) — no route returns a key.
|
|
*/
|
|
function isSecretConfigPath(resolved, home) {
|
|
const base = path.basename(resolved);
|
|
if (!base.endsWith(".md") && require("./ai-tools").CONFIG_SECRET_RE.test(base)) return true;
|
|
for (const h of new Set([getClaudeHome(), home].filter(Boolean))) {
|
|
const rel = path.relative(h, resolved);
|
|
if (
|
|
!rel.startsWith("..") &&
|
|
!path.isAbsolute(rel) &&
|
|
rel.split(path.sep)[0].startsWith("agenthub-")
|
|
)
|
|
return true;
|
|
}
|
|
// AgentHub's data dir is no Claude config: it holds the database, the push
|
|
// keys and every gateway pane's settings file WITH its live pane token.
|
|
const { getDataDir, dataDirCandidates } = require("./claude-home");
|
|
const { current, legacy } = dataDirCandidates();
|
|
let real = resolved;
|
|
try {
|
|
real = fs.realpathSync(resolved);
|
|
} catch {
|
|
/* not there: the lexical path decides */
|
|
}
|
|
return [getDataDir(), current, legacy].some((d) => isUnder(d, resolved) || isUnder(d, real));
|
|
}
|
|
|
|
function readFileSafe(absPath, opts = {}) {
|
|
const allowedRoots = [homeOf(opts)];
|
|
if (!opts.noProject) {
|
|
allowedRoots.push(
|
|
getProjectClaudeDir(opts.cwd),
|
|
getProjectRoot(opts.cwd) // for CLAUDE.md only — caller must pass exact name
|
|
);
|
|
}
|
|
const resolved = path.resolve(absPath);
|
|
const inside = allowedRoots.some((root) => isUnder(root, resolved));
|
|
if (!inside) return { error: "path is outside allowed roots" };
|
|
// Extra guard: under project root we only allow CLAUDE.md (avoid leaking
|
|
// arbitrary repo files via this endpoint).
|
|
if (
|
|
!opts.noProject &&
|
|
isUnder(getProjectRoot(opts.cwd), resolved) &&
|
|
!isUnder(getProjectClaudeDir(opts.cwd), resolved) &&
|
|
path.basename(resolved) !== "CLAUDE.md"
|
|
) {
|
|
return { error: "only CLAUDE.md is readable from project root" };
|
|
}
|
|
if (isSecretConfigPath(resolved, homeOf(opts)))
|
|
return { error: "a credential file is never served" };
|
|
const r = safeReadText(resolved);
|
|
if (!r) return { error: "file not readable" };
|
|
return { ok: true, file: resolved, ...r };
|
|
}
|
|
|
|
// ── Overview (counts + roots) ──────────────────────────────────────────
|
|
|
|
function readOverview(opts = {}) {
|
|
const skills = readSkills(opts);
|
|
const agents = readAgents(opts);
|
|
const commands = readCommands(opts);
|
|
const outputStyles = readOutputStyles(opts);
|
|
const plugins = readPlugins(homeOf(opts));
|
|
const mcp = readMcpServers(opts);
|
|
const hooks = readHooks(opts);
|
|
const settings = readSettings(opts);
|
|
const memory = readMemory(opts);
|
|
const marketplaces = readMarketplaces(opts);
|
|
const keybindings = readKeybindings(opts);
|
|
|
|
const countByScope = (arr) => ({
|
|
user: arr.filter((x) => x.scope === "user").length,
|
|
project: arr.filter((x) => x.scope === "project").length,
|
|
});
|
|
|
|
const enabledPlugins = plugins.plugins.filter((p) => p.enabled === true).length;
|
|
const disabledPlugins = plugins.plugins.filter((p) => p.enabled === false).length;
|
|
const keybindingTotal = keybindings.exists
|
|
? keybindings.groups.reduce((n, g) => n + g.bindings.length, 0)
|
|
: 0;
|
|
|
|
return {
|
|
roots: {
|
|
claudeHome: homeOf(opts),
|
|
projectClaudeDir: opts.noProject ? null : getProjectClaudeDir(opts.cwd),
|
|
projectRoot: opts.noProject ? null : getProjectRoot(opts.cwd),
|
|
claudeJson: opts.claudeJson || getClaudeJsonPath(),
|
|
},
|
|
counts: {
|
|
skills: countByScope(skills),
|
|
agents: countByScope(agents),
|
|
commands: countByScope(commands),
|
|
outputStyles: countByScope(outputStyles),
|
|
plugins: plugins.plugins.length,
|
|
pluginsEnabled: enabledPlugins,
|
|
pluginsDisabled: disabledPlugins,
|
|
marketplaces: marketplaces.items.length,
|
|
keybindings: keybindingTotal,
|
|
mcpServers: { user: mcp.user.length, project: mcp.projectScoped.length },
|
|
hooks: hooks.reduce(
|
|
(acc, src) => {
|
|
acc[src.scope] = Object.values(src.hooks).reduce(
|
|
(n, arr) => n + (Array.isArray(arr) ? arr.length : 0),
|
|
0
|
|
);
|
|
return acc;
|
|
},
|
|
{ user: 0, project: 0, "project-local": 0 }
|
|
),
|
|
memory: memory.length,
|
|
settingsFiles: settings.filter((s) => s.exists).length,
|
|
},
|
|
};
|
|
}
|
|
|
|
module.exports = {
|
|
isSecretConfigPath,
|
|
// Exported so a test can pin it against the file the CLI writes — the two
|
|
// drifted apart once and the mismatch was invisible from either side alone.
|
|
getClaudeJsonPath,
|
|
// surface readers
|
|
readSkills,
|
|
readAgents,
|
|
readCommands,
|
|
readOutputStyles,
|
|
readPlugins,
|
|
readMcpServers,
|
|
readHooks,
|
|
readSettings,
|
|
readMemory,
|
|
readMarketplaces,
|
|
readKeybindings,
|
|
readStatusline,
|
|
readHookScripts,
|
|
readOverview,
|
|
readFileSafe,
|
|
// helpers exported for tests
|
|
readPluginContributions,
|
|
parseFrontmatter,
|
|
redactSettings,
|
|
isUnder,
|
|
isDirLike,
|
|
isFileLike,
|
|
memberPathOk,
|
|
MAX_FILE_BYTES,
|
|
HOOK_EVENT_TYPES,
|
|
};
|