/** * @file codex-discovery.js * @description Read-only discovery of Codex CLI configuration surfaces * (skills, agents, rules, memory/AGENTS.md, config.toml). Powers the Codex * Config panel. All operations are pure file reads — never writes. MCP * servers and plugins are not covered here — they come from the `codex` CLI * itself, read elsewhere. * * Path containment: every read resolves under getCodexHome(), * getProjectCodexDir(), or getProjectRoot() (for AGENTS.md). Mirrors * cc-discovery.js's scope handling and record shapes so the two panels feel * like one system. * @author Nguyễn Ngọc Trí Vĩ */ const fs = require("node:fs"); const path = require("node:path"); const { getCodexHome, getProjectCodexDir, getProjectRoot } = require("./codex-home"); const { parseFrontmatter, isUnder, isDirLike, isFileLike, MAX_FILE_BYTES, } = require("./cc-discovery"); function safeReadText(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: fs.readFileSync(absPath, "utf8").slice(0, MAX_FILE_BYTES), mtime: stat.mtimeMs, }; } return { truncated: false, size: stat.size, text: fs.readFileSync(absPath, "utf8"), mtime: stat.mtimeMs, }; } catch { return null; } } function listDir(absPath) { try { return fs.readdirSync(absPath, { withFileTypes: true }); } catch { return []; } } // ── Skills ────────────────────────────────────────────────────────────── function readSkillsAt(scope, codexDir) { const dir = path.join(codexDir, "skills"); const skills = []; for (const ent of listDir(dir)) { const skillDir = path.join(dir, ent.name); // isUnder here is defensive only — ent.name comes from readdirSync and // can't carry a traversal segment, so this can never actually trip. if (!isDirLike(ent, skillDir) || !isUnder(codexDir, 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)); } function readSkills(opts = {}) { const out = []; if (opts.scope !== "project") out.push(...readSkillsAt("user", getCodexHome())); if (opts.scope !== "user") out.push(...readSkillsAt("project", getProjectCodexDir(opts.cwd))); return out; } // ── Agents (TOML) ────────────────────────────────────────────────────── /** * Best-effort reader for the handful of top-level scalar fields the UI * shows (`name`, `description`, `model`, `sandbox_mode`). Matches * `key = "value"` / `key = 123` / `key = true` lines appearing before the * first `[section]`, skipping over `"""..."""` blocks so a field name * mentioned inside one (e.g. in `developer_instructions`) is never mistaken * for a real top-level key. Anything richer than these scalars is the * raw-text editor's job, not this reader's — no TOML parser here. */ function readTomlMeta(text) { const meta = {}; const lines = text.split("\n"); let inTripleQuote = false; for (const rawLine of lines) { const line = rawLine.trim(); if (inTripleQuote) { if (line.includes('"""')) inTripleQuote = false; continue; } if (/^\[/.test(line)) break; // first section ends the top-level scalars const m = line.match(/^([A-Za-z0-9_-]+)\s*=\s*(.*)$/); if (!m) continue; let value = m[2]; if (value.startsWith('"""')) { if (!value.slice(3).includes('"""')) inTripleQuote = true; continue; } if (value.startsWith('"') || value.startsWith("'")) { // Find the real closing quote — one not preceded by an ODD run of // backslashes (an even run, including zero, means it isn't escaped) — // so a trailing `# comment` outside the string is stripped without // being fooled by an escaped quote or an escaped backslash inside it. const quote = value[0]; let closeIdx = -1; for (let i = 1; i < value.length; i++) { if (value[i] !== quote) continue; let backslashes = 0; while (value[i - 1 - backslashes] === "\\") backslashes++; if (backslashes % 2 === 0) { closeIdx = i; break; } } if (closeIdx < 0) continue; // \\ decodes to one backslash; other escapes (e.g. \") are left // literal — this is a best-effort reader, not a full TOML unescaper. value = value.slice(1, closeIdx).replace(/\\\\/g, "\\"); } else if (/^(true|false|-?\d+(\.\d+)?)\s*(#.*)?$/.test(value)) { value = value.replace(/\s*#.*$/, ""); value = value === "true" ? true : value === "false" ? false : Number(value); } else { continue; // arrays, inline tables, etc. — not one of the shown fields } meta[m[1]] = value; } return meta; } function readAgentsAt(scope, codexDir) { const dir = path.join(codexDir, "agents"); const agents = []; for (const ent of listDir(dir)) { if (!ent.name.endsWith(".toml")) continue; const file = path.join(dir, ent.name); // isUnder here is defensive only — ent.name comes from readdirSync and // can't carry a traversal segment, so this can never actually trip. if (!isFileLike(ent, file) || !isUnder(codexDir, file)) continue; const read = safeReadText(file); if (!read) continue; agents.push({ scope, name: ent.name.replace(/\.toml$/, ""), file, size: read.size, mtime: read.mtime, meta: readTomlMeta(read.text), preview: read.text.slice(0, 320), }); } return agents.sort((a, b) => a.name.localeCompare(b.name)); } function readAgents(opts = {}) { const out = []; if (opts.scope !== "project") out.push(...readAgentsAt("user", getCodexHome())); if (opts.scope !== "user") out.push(...readAgentsAt("project", getProjectCodexDir(opts.cwd))); return out; } // ── Rules (plain text) ───────────────────────────────────────────────── function readRulesAt(scope, codexDir) { const dir = path.join(codexDir, "rules"); const rules = []; for (const ent of listDir(dir)) { if (!ent.name.endsWith(".rules")) continue; const file = path.join(dir, ent.name); // isUnder here is defensive only — ent.name comes from readdirSync and // can't carry a traversal segment, so this can never actually trip. if (!isFileLike(ent, file) || !isUnder(codexDir, file)) continue; const read = safeReadText(file); if (!read) continue; rules.push({ scope, name: ent.name.replace(/\.rules$/, ""), file, size: read.size, mtime: read.mtime, preview: read.text.slice(0, 320), }); } return rules.sort((a, b) => a.name.localeCompare(b.name)); } function readRules(opts = {}) { const out = []; if (opts.scope !== "project") out.push(...readRulesAt("user", getCodexHome())); if (opts.scope !== "user") out.push(...readRulesAt("project", getProjectCodexDir(opts.cwd))); return out; } // ── Memory (AGENTS.md) ───────────────────────────────────────────────── function readMemory(opts = {}) { const sources = [ { scope: "user", file: path.join(getCodexHome(), "AGENTS.md") }, { scope: "project", file: path.join(getProjectRoot(opts.cwd), "AGENTS.md") }, ]; return sources.map(({ scope, file }) => { const r = safeReadText(file); if (!r) return { scope, file, exists: false }; return { scope, file, exists: true, size: r.size, mtime: r.mtime, preview: r.text.slice(0, 480), }; }); } // ── Config (config.toml) ─────────────────────────────────────────────── function readConfig(opts = {}) { const sources = [ { scope: "user", file: path.join(getCodexHome(), "config.toml") }, { scope: "project", file: path.join(getProjectCodexDir(opts.cwd), "config.toml") }, ]; return sources.map(({ scope, file }) => { const r = safeReadText(file); if (!r) return { scope, file, exists: false }; return { scope, file, exists: true, size: r.size, mtime: r.mtime, content: r.text, truncated: r.truncated, }; }); } // ── Overview (counts + roots) ────────────────────────────────────────── function readOverview(opts = {}) { const skills = readSkills(opts); const agents = readAgents(opts); const rules = readRules(opts); const memory = readMemory(opts); const config = readConfig(opts); const countByScope = (arr) => ({ user: arr.filter((x) => x.scope === "user").length, project: arr.filter((x) => x.scope === "project").length, }); return { roots: { codexHome: getCodexHome(), projectCodexDir: getProjectCodexDir(opts.cwd), projectRoot: getProjectRoot(opts.cwd), }, counts: { skills: countByScope(skills), agents: countByScope(agents), rules: countByScope(rules), memory: countByScope(memory.filter((m) => m.exists)), config: countByScope(config.filter((c) => c.exists)), }, }; } module.exports = { readSkills, readAgents, readRules, readMemory, readConfig, readOverview, readTomlMeta, };