The Codex agent had no name
The sync ran clean. Exit code 0. The skill file I'd authored on the Claude side showed up at the matching path on the Codex side, byte-for-byte where I expected it. I opened the Codex agent picker and the entry was there, but its name was the empty string. Just a blank row.
I assumed I'd mis-named something on disk. I hadn't. The file had name: code-review at the top, in plain YAML frontmatter, exactly the way Claude writes it. The string was right there. The Codex parser was just refusing to see it.
The culprit was one line three rows down: globs: **/*.{js,ts}. Claude's YAML loader is lenient. It reads the value as a string and moves on. Codex uses a strict YAML 1.2 parser, which sees the leading * as an alias anchor, fails to parse the scalar, and silently drops the entire frontmatter block. Every field, including name, goes empty.
The fix is one substitution. The file had an inline helper that decided whether to quote a frontmatter scalar:
function serializeFrontmatterScalar(value) {
const text = String(value);
if (/[:#"\n]/.test(text)) return JSON.stringify(text);
return text;
}
Four characters. That regex covers four of the nineteen YAML 1.2 c-indicators. The other fifteen, including the * from my glob, sail through unquoted and the strict parser breaks. The replacement is a single shared module at bin/util/yaml-scalar.mjs and a one-line call site. The relevant rule in CLAUDE.md now reads, in parentheses, (this has actually happened). I added that parenthetical when I wrote the rule, because by then it had.
This isn't a YAML quirk story. It's what bidirectional sync between two parsers actually costs, and the cost shows up in the most boring place possible.
What "bidirectional" actually means
I keep having to explain this to people who hear "config sync" and picture a one-shot migration wizard. That isn't the shape of the problem.
Both tools are running. I edit a Claude skill on Monday, I edit a Codex MCP server config on Tuesday, and by Wednesday the two surfaces have drifted in independent directions. Neither side is the source of truth. Each side needs to keep being valid on its own parser. The job isn't "export from A, import to B." The job is "keep A and B in agreement, in both directions, on a config surface that overlaps but doesn't match."
That surface is wider than people think. Instructions (CLAUDE.md and AGENTS.md). Skills with frontmatter. MCP servers. Permissions. Hooks. Same concepts, different file paths, different vocabularies (Read and Bash on one side, spawn_agent and codex exec on the other). The CLI I built treats this as a diff problem first and a translation problem second:
ai-config-sync status
ai-config-sync sync --dry-run
ai-config-sync sync --apply
No wizard. No magic. You look at the diff, you decide.
Two parsers, one frontmatter
Here is the rule I wrote into CLAUDE.md after the bug, copied verbatim from the project doc:
Forbidden to write your own quote/escape logic. Forbidden to judge indicators directly with regex.
Reason: guarantee Claude (lenient YAML) ↔ Codex (strict YAML 1.2) round-trip. If even one site uses its own quoting, the strict parser fails to parse the entire frontmatter and fields likenamego missing (this has actually happened).
That rule reads strict because it has to. The whole reason the bug existed was that the file had grown a one-off serializeFrontmatterScalar function tucked next to a frontmatter writer, and nobody (including me) noticed it didn't cover the full c-indicator set. The next time someone, possibly an AI assistant editing the file, reaches for the same convenience, the rule needs to stop them.
The shared utility lives at bin/util/yaml-scalar.mjs. Forty-two lines. Two exported functions. Here are the three regexes that do the work:
const RESERVED_INDICATOR_PREFIX = /^[-?:,[\]{}#&*!|>'"%@`]/;
const YAML_BOOL_NULL = /^(?:null|Null|NULL|~|true|True|TRUE|false|False|FALSE|yes|Yes|YES|no|No|NO|on|On|ON|off|Off|OFF|y|Y|n|N)$/;
const YAML_TIMESTAMP = /^\d{4}-\d{2}-\d{2}(?:[Tt ]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}(?::?\d{2})?)?)?$/;
The first one is the c-indicator set: nineteen characters, every one of them a structural sentinel in YAML 1.2. That's the regex the old four-character version was a draft of. The second covers YAML 1.1 boolean and null coercion, including the single-letter forms y, Y, n, N that strict parsers will quietly turn into true/false if you leave them bare. The third catches ISO timestamps, because 2014-12-31 as an unquoted scalar becomes a Date object in some parsers and a string in others, and that disagreement is its own class of bug.
The trigger frontmatter for the original bug was this:
---
name: code-review
globs: **/*.{js,ts}
---
Claude reads it. Codex sees **, classifies * as an alias anchor, and discards the block. After the fix, the writer wraps **/*.{js,ts} in double quotes and both parsers agree the value is a string.
The split between the two exported function names matters to me. yamlScalarRequiresQuoting answers a yes/no question. serializeYamlScalar does the wrap. Callers can query without serializing, which is how tests/yaml-scalar.test.mjs gets to assert directly on the predicate. One of the test cases there is the https://x case: a colon followed by a slash (not whitespace) stays plain-safe per YAML 1.2, and the utility correctly does not over-quote it. That's the kind of edge the four-character regex never knew it was missing.
One 8,803-line file
Yes, 8,803 lines in one file is a smell by every standard rubric I'd apply to someone else's code. If a junior eng handed me this in review I would ask, with feeling, why.
Here's what's in it: a diff engine, a plan/apply loop, the paraphrase engine that rewrites Claude-strict tokens into Codex-strict tokens and back, a TOML patcher for ~/.codex/config.toml, a connect installer that wires the host-launcher into either side's plugin path, a reference generator, an agent mapper, a rule loader. All cross-cutting. All sharing state. All built on top of zero external runtime dependencies, which is the constraint that holds the rest of the structure.
That constraint is load-bearing. The full import surface of the main file is node:fs, node:child_process, node:crypto, node:os, node:path, node:readline, node:url, plus the one internal util. There's no bundler in the build. npm run build:dist copies files and injects a thin launcher; it doesn't transpile or bundle anything. Splitting the monolith into ten files means either (a) shipping ten files in the npm package and managing the import graph by hand across them, or (b) adding a bundler, which adds a devDependency and a build step that has to keep producing byte-identical output. The single file makes install trivially flat, the audit surface trivially small, and the diff against any prior version trivially readable.
That's the frame. Now back to why two parsers disagreeing is the part that actually bites.
bin/util/yaml-scalar.mjs is the first extraction. Forty-two lines, two functions, the smallest possible first step out of the monolith. CLAUDE.md says it directly: Splitting bin/ai-config-sync.mjs is on hold. That's not "we'll get to it later." That's "the next extraction has to earn its own file, the way the YAML one did, by being a real shared contract."
The honest cost: parts of the TOML and rule parser are regex-based and hand-rolled. The architecture notes in .claude/docs/repo-analysis/ already flag this. Regex parsing of mostly-structured input works until it doesn t, and when it doesn t, the failure mode looks exactly like the **/*.{js,ts} story above. That's the price of "zero deps" applied to parsing, and I'm paying it knowingly. If I had to guess where the next yaml-scalar.mjs-shaped extraction comes from, it's the TOML side, and it'll be because some real config in the wild produces a parse mismatch I didn't anticipate. The trigger for the YAML extraction was a real bug, not a refactoring mood. I expect the next one to arrive the same way.
I might be wrong about all of this. There's a version of the project where the monolith gets split into a dozen files now, before any more cross-cutting state piles up, and that version is probably easier to onboard contributors to. But it would buy that ease by adding a bundler or a multi-file ship, and neither of those changes makes the YAML bug go away. The bug was in the contract between two parsers, not in the file layout.
Where this lands
The project is v0.1.0. Two known debts are named explicitly in the repo's own architecture notes: the monolith hasn't been split, and the Codex plugin installer has been corrected several times because the plugin spec on that side is still moving. Neither one is solved. Both are the kind of debt you accept when you're trying to ship a usable CLI against two tools that are themselves changing under you.
What the YAML fix actually showed me is that the hard part of bidirectional sync is not the diffing. The diffing is the easy part. The hard part is making serialized output survive both parsers without either one quietly eating a field. bin/util/yaml-scalar.mjs isn't a refactor of old code. It's the first shared contract between the two parse environments, written down as forty-two lines that both sides have to agree on. Every future extraction from the monolith will probably look like that one: small, ugly, named for the bug that made it necessary.
The first extraction took 42 lines. The next one will probably take longer.
Top comments (0)