/** * @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 (`/.claude`, `/.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ĩ */ 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] = ""; } else { out[k] = redactSettings(v); } } return out; } return value; } /** * Minimal YAML-frontmatter parser. Handles `---\n: \n---\n`. * 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 * `/` 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 `/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 "@" 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[].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//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/ 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, };