b0788eea80
Round 2's escape check rejected a quote whenever a single backslash preceded it, so an escaped backslash before a real closing quote (e.g. path = "path\\" # c) was misread as an escaped quote and the field was dropped. Now counts the run of backslashes and only treats an ODD run as escaping the quote. Folded the old two-branch quote handling into one scan (it already finds the terminator correctly with or without a trailing comment) and decode \\ -> \ in the extracted value, since the scan already knows which backslash run is real.
298 lines
10 KiB
JavaScript
298 lines
10 KiB
JavaScript
/**
|
|
* @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,
|
|
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,
|
|
};
|