From 761876686180e0222f2c8784d92916c70d59f64c Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Tue, 8 Sep 2026 16:32:06 +0700 Subject: [PATCH] feat(codex): read the Codex config surfaces --- server/__tests__/codex-discovery.test.js | 135 +++++++++++ server/lib/codex-discovery.js | 272 +++++++++++++++++++++++ 2 files changed, 407 insertions(+) create mode 100644 server/__tests__/codex-discovery.test.js create mode 100644 server/lib/codex-discovery.js diff --git a/server/__tests__/codex-discovery.test.js b/server/__tests__/codex-discovery.test.js new file mode 100644 index 00000000..d5f606b5 --- /dev/null +++ b/server/__tests__/codex-discovery.test.js @@ -0,0 +1,135 @@ +/** + * @file Tests for codex-discovery.js: read-only discovery of Codex config + * surfaces (skills, agents, rules, memory, config) across the user (CODEX_HOME) + * and project (/.codex) scopes. + * @author Nguyễn Ngọc Trí Vĩ + */ + +const fs = require("node:fs"); +const path = require("node:path"); +const os = require("node:os"); + +const { describe, it, before, after } = require("node:test"); +const assert = require("node:assert/strict"); + +function write(file, content) { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, content); +} + +describe("codex-discovery", () => { + let TMP; + let REPO; + let prevCodexHome; + let cx; + + before(() => { + prevCodexHome = process.env.CODEX_HOME; + TMP = fs.mkdtempSync(path.join(os.tmpdir(), "codex-disc-")); + REPO = fs.mkdtempSync(path.join(os.tmpdir(), "codex-disc-repo-")); + process.env.CODEX_HOME = TMP; + delete require.cache[require.resolve("../lib/codex-home")]; + delete require.cache[require.resolve("../lib/codex-discovery")]; + cx = require("../lib/codex-discovery"); + }); + + after(() => { + if (prevCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = prevCodexHome; + fs.rmSync(TMP, { recursive: true, force: true }); + fs.rmSync(REPO, { recursive: true, force: true }); + }); + + it("reads a user skill and a project skill into one scoped list", () => { + write( + path.join(TMP, "skills", "release-guard", "SKILL.md"), + "---\nname: release-guard\n---\nbody" + ); + write( + path.join(REPO, ".codex", "skills", "onboarding", "SKILL.md"), + "---\nname: onboarding\n---\nb" + ); + + const rows = cx.readSkills({ cwd: REPO }); + assert.deepEqual(rows.map((r) => [r.scope, r.name]).sort(), [ + ["project", "onboarding"], + ["user", "release-guard"], + ]); + assert.equal(rows.find((r) => r.name === "release-guard").frontmatter.name, "release-guard"); + }); + + it("pulls an agent's identity out of its TOML without a TOML parser", () => { + write( + path.join(REPO, ".codex", "agents", "reviewer.toml"), + 'name = "reviewer"\ndescription = "Read-only review agent."\nmodel = "gpt-5.4"\n' + + 'developer_instructions = """\nmulti\nline\n"""\n' + ); + + const [agent] = cx.readAgents({ cwd: REPO, scope: "project" }); + assert.equal(agent.meta.name, "reviewer"); + assert.equal(agent.meta.description, "Read-only review agent."); + assert.equal(agent.meta.model, "gpt-5.4"); + }); + + it("does not mistake a key inside a triple-quoted block for a top-level field", () => { + const meta = cx.readTomlMeta( + 'name = "implementer"\n' + + 'sandbox_mode = "workspace-write"\n' + + 'developer_instructions = """\n' + + 'model = "not-a-real-field"\n' + + '"""\n' + + 'model = "gpt-5.3-codex-spark"\n' + ); + assert.equal(meta.name, "implementer"); + assert.equal(meta.sandbox_mode, "workspace-write"); + assert.equal(meta.model, "gpt-5.3-codex-spark"); + }); + + it("reads a rule file's preview", () => { + write( + path.join(TMP, "rules", "no-force-push.rules"), + "never force-push main\nask before deleting" + ); + const [rule] = cx.readRules({ cwd: REPO, scope: "user" }); + assert.equal(rule.name, "no-force-push"); + assert.match(rule.preview, /never force-push main/); + }); + + it("reports a missing AGENTS.md as a row that does not exist, not a gap", () => { + const rows = cx.readMemory({ cwd: REPO }); + const user = rows.find((r) => r.scope === "user"); + assert.equal(user.exists, false, "the UI offers to create it, so the row must be present"); + }); + + it("reads project AGENTS.md from the repo root, not under .codex", () => { + write(path.join(REPO, "AGENTS.md"), "# Project instructions"); + const rows = cx.readMemory({ cwd: REPO }); + const project = rows.find((r) => r.scope === "project"); + assert.equal(project.exists, true); + assert.equal(project.file, path.join(REPO, "AGENTS.md")); + }); + + it("reads config.toml content for both scopes", () => { + write(path.join(TMP, "config.toml"), 'model = "gpt-5.3-codex-spark"\n'); + const rows = cx.readConfig({ cwd: REPO }); + const user = rows.find((r) => r.scope === "user"); + assert.equal(user.exists, true); + assert.match(user.content, /gpt-5\.3-codex-spark/); + const project = rows.find((r) => r.scope === "project"); + assert.equal(project.exists, false); + }); + + it("never walks outside the codex roots", () => { + write(path.join(TMP, "skills", "esc", "SKILL.md"), "x"); + for (const row of cx.readSkills({ cwd: REPO })) { + assert.ok(row.file.startsWith(TMP) || row.file.startsWith(REPO)); + } + }); + + it("summarizes counts and roots in the overview", () => { + const overview = cx.readOverview({ cwd: REPO }); + assert.equal(overview.roots.projectRoot, REPO); + assert.ok(overview.counts.skills.user >= 1); + assert.ok(overview.counts.agents.project >= 1); + }); +}); diff --git a/server/lib/codex-discovery.js b/server/lib/codex-discovery.js new file mode 100644 index 00000000..7fe21f3c --- /dev/null +++ b/server/lib/codex-discovery.js @@ -0,0 +1,272 @@ +/** + * @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, 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); + if (!ent.isDirectory() || !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.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } else if (value === "true" || value === "false") { + value = value === "true"; + } else if (/^-?\d+(\.\d+)?$/.test(value)) { + value = 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); + if (!ent.isFile() || !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); + if (!ent.isFile() || !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, + isUnder, +};