feat(codex): read the Codex config surfaces
This commit is contained in:
@@ -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 (<cwd>/.codex) scopes.
|
||||
* @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 { 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);
|
||||
});
|
||||
});
|
||||
@@ -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ĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
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,
|
||||
};
|
||||
Reference in New Issue
Block a user