Skip to content

Commit 7a757e1

Browse files
committed
feat: add Codex as a first-class host (0.11.2)
Codex CLI reads a native .codex-plugin/plugin.json that points at a new hooks/hooks-codex.json using the Claude-shaped hook schema (event -> matcher groups -> { "type": "command" }). detectHost recognizes Codex by the un-prefixed PLUGIN_ROOT it sets, since Claude sets only CLAUDE_PLUGIN_ROOT; session state lives under CODEX_HOME (default ~/.codex). Codex shares Claude's hookSpecificOutput contract, so orientation injects on SessionStart and the routing audit on UserPromptSubmit, and the commit-attribution PreToolUse guard applies. The config omits SessionEnd, which Codex has no event for, so stale session state is pruned on the next SessionStart instead. Bumps every host manifest to 0.11.2 and covers the new host with unit and end-to-end hook tests, a README install section, and a CHANGELOG entry.
1 parent 811a3da commit 7a757e1

12 files changed

Lines changed: 144 additions & 10 deletions

File tree

‎.claude-plugin/plugin.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
33
"name": "meridian",
44
"description": "Research-first workflows, ruthless code review, orchestrator-led reasoning, and opaque subagent isolation for the entire development lifecycle.",
5-
"version": "0.11.1",
5+
"version": "0.11.2",
66
"author": {
77
"name": "KodingDev"
88
},

‎.codex-plugin/plugin.json‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"name": "meridian",
3+
"version": "0.11.2",
4+
"description": "Research-first workflows, ruthless code review, orchestrator-led reasoning, and opaque subagent isolation for the entire development lifecycle.",
5+
"hooks": "./hooks/hooks-codex.json"
6+
}

‎.cursor-plugin/plugin.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
33
"name": "meridian",
44
"description": "Research-first workflows, ruthless code review, orchestrator-led reasoning, and opaque subagent isolation for the entire development lifecycle.",
5-
"version": "0.11.1",
5+
"version": "0.11.2",
66
"author": {
77
"name": "KodingDev"
88
},

‎.plugin/plugin.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
22
"name": "meridian",
3-
"version": "0.11.1",
3+
"version": "0.11.2",
44
"hooks": "./hooks/hooks-copilot.json"
55
}

‎CHANGELOG.md‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,19 @@ All notable changes to Meridian are recorded here. The format follows
55
to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). History before
66
0.11.0 lives in the git log.
77

8+
## [0.11.2] - 2026-07-03
9+
10+
### Added
11+
12+
- Codex host support, alongside the existing Claude Code, Cursor, and Copilot hosts. A
13+
`.codex-plugin/plugin.json` manifest points at a new `hooks/hooks-codex.json`, and
14+
`detectHost` recognizes Codex by the un-prefixed `PLUGIN_ROOT` it sets (Claude sets only
15+
`CLAUDE_PLUGIN_ROOT`). Codex shares Claude's `hookSpecificOutput` context-injection
16+
contract, so orientation and the periodic routing audit inject on both `SessionStart` and
17+
`UserPromptSubmit`, and the commit-attribution `PreToolUse` guard applies. Session state
18+
lives under `CODEX_HOME` (default `~/.codex`). The Codex config omits `SessionEnd` — Codex
19+
has no such event — so stale session state is pruned on `SessionStart` instead.
20+
821
## [0.11.1] - 2026-06-29
922

1023
### Changed

‎README.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,10 @@ Copilot CLI installs the same plugin via its `/plugin` commands and loads the sk
6262

6363
Hooks ship in Copilot's own format: Copilot's command schema treats `command` as a shell string and has no `args` array, so the Claude exec-form config would run a bare `node`. Copilot is pointed at `hooks/hooks-copilot.json` via `.plugin/plugin.json` (a manifest slot Copilot reads but Claude and Cursor don't). Session orientation injects via a flat `additionalContext` on `sessionStart`; prompt submission is state-only, because Copilot's `userPromptSubmitted` hook cannot inject context (same ceiling as Cursor).
6464

65+
### Codex
66+
67+
Codex CLI installs the plugin from the same marketplace and reads a native `.codex-plugin/plugin.json` that points at `hooks/hooks-codex.json`. Codex uses the Claude-shaped hook schema (event → matcher groups → `{ "type": "command" }`) and shares Claude's `hookSpecificOutput` context contract — so, unlike Cursor and Copilot, orientation injects on `SessionStart` **and** the routing audit injects on `UserPromptSubmit`, and the commit-attribution `PreToolUse` guard runs. Codex is detected by the un-prefixed `PLUGIN_ROOT` it sets (Claude sets only `CLAUDE_PLUGIN_ROOT`); state lives under `CODEX_HOME` (default `~/.codex`). The config omits `SessionEnd` — Codex has no such event — so stale state is pruned on the next `SessionStart`.
68+
6569
Hook tests: `node --test test/meridian-hooks.test.mjs`
6670

6771
## Credit

‎hooks/hooks-codex.json‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
{
2+
"hooks": {
3+
"SessionStart": [
4+
{
5+
"matcher": "",
6+
"hooks": [
7+
{
8+
"type": "command",
9+
"command": "node -e \"let r=process.env.CLAUDE_PLUGIN_ROOT||process.env.PLUGIN_ROOT;if(!r)process.exit(0);let cp=require('child_process');if(process.platform==='linux'&&/^[a-zA-Z]:/.test(r)){try{r=cp.execFileSync('wslpath',['-u',r],{encoding:'utf8'}).trim()}catch{r='/mnt/'+r[0].toLowerCase()+r.slice(2).split(String.fromCharCode(92)).join('/')}}cp.execFileSync(process.execPath,[r+'/hooks/session-start.mjs'],{stdio:'inherit'})\"",
10+
"async": false
11+
}
12+
]
13+
}
14+
],
15+
"UserPromptSubmit": [
16+
{
17+
"matcher": "",
18+
"hooks": [
19+
{
20+
"type": "command",
21+
"command": "node -e \"let r=process.env.CLAUDE_PLUGIN_ROOT||process.env.PLUGIN_ROOT;if(!r)process.exit(0);let cp=require('child_process');if(process.platform==='linux'&&/^[a-zA-Z]:/.test(r)){try{r=cp.execFileSync('wslpath',['-u',r],{encoding:'utf8'}).trim()}catch{r='/mnt/'+r[0].toLowerCase()+r.slice(2).split(String.fromCharCode(92)).join('/')}}cp.execFileSync(process.execPath,[r+'/hooks/user-prompt-submit.mjs'],{stdio:'inherit'})\"",
22+
"async": false
23+
}
24+
]
25+
}
26+
],
27+
"PreToolUse": [
28+
{
29+
"matcher": "Bash",
30+
"hooks": [
31+
{
32+
"type": "command",
33+
"command": "node -e \"let r=process.env.CLAUDE_PLUGIN_ROOT||process.env.PLUGIN_ROOT;if(!r)process.exit(0);let cp=require('child_process');if(process.platform==='linux'&&/^[a-zA-Z]:/.test(r)){try{r=cp.execFileSync('wslpath',['-u',r],{encoding:'utf8'}).trim()}catch{r='/mnt/'+r[0].toLowerCase()+r.slice(2).split(String.fromCharCode(92)).join('/')}}cp.execFileSync(process.execPath,[r+'/hooks/pre-tool-use.mjs'],{stdio:'inherit'})\"",
34+
"async": false
35+
}
36+
]
37+
}
38+
]
39+
}
40+
}

‎hooks/lib/host.mjs‎

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,14 @@ function resolveStateBase(name) {
2222
const claude = join(homedir(), ".claude");
2323
const cursor = join(homedir(), ".cursor");
2424
const copilot = process.env.COPILOT_HOME || join(homedir(), ".copilot");
25-
// Copilot sets CLAUDE_PLUGIN_ROOT too, so its branch must precede that fallback.
25+
const codex = process.env.CODEX_HOME || join(homedir(), ".codex");
26+
// Copilot and Codex both set CLAUDE_PLUGIN_ROOT (as a compat alias), so their
27+
// branches must precede that fallback or they'd resolve to ~/.claude.
2628
const candidates = [
2729
() => process.env.CLAUDE_CONFIG_DIR,
2830
() => name === "cursor" && cursor,
2931
() => name === "copilot" && copilot,
32+
() => name === "codex" && codex,
3033
() => process.env.CLAUDE_PLUGIN_ROOT && claude,
3134
() => existsSync(claude) && claude,
3235
() => existsSync(cursor) && cursor,
@@ -41,9 +44,14 @@ function resolveStateBase(name) {
4144
/**
4245
* Detect the host once. Cursor sets `CURSOR_PLUGIN_ROOT` and injects context only
4346
* on `SessionStart` (via `additional_context`); Copilot sets `COPILOT_PLUGIN_ROOT`
44-
* and injects on `SessionStart` only (via flat `additionalContext`); Claude injects
45-
* on every event (via `hookSpecificOutput`). Copilot also sets `CLAUDE_PLUGIN_ROOT`,
46-
* so it must be checked before the Claude default.
47+
* and injects on `SessionStart` only (via flat `additionalContext`); Codex sets the
48+
* un-prefixed `PLUGIN_ROOT` and, like Claude, injects on every event via
49+
* `hookSpecificOutput`; Claude injects on every event via `hookSpecificOutput`.
50+
* Copilot and Codex also set `CLAUDE_PLUGIN_ROOT` (compat), so both must be checked
51+
* before the Claude default. Codex ships no unique identity var, so the un-prefixed
52+
* `PLUGIN_ROOT` — which Codex sets but Claude does not — is the documented
53+
* discriminator; a misdetect only picks the wrong state dir, since Codex and Claude
54+
* share the `hookSpecificOutput` emit shape.
4755
* @returns {Host}
4856
*/
4957
export function detectHost() {
@@ -52,7 +60,9 @@ export function detectHost() {
5260
? "cursor"
5361
: process.env.COPILOT_PLUGIN_ROOT
5462
? "copilot"
55-
: "claude";
63+
: process.env.PLUGIN_ROOT
64+
? "codex"
65+
: "claude";
5666
return {
5767
name,
5868
stateBase: resolveStateBase(name),

‎hooks/lib/types.mjs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
* @property {string} [hook_event_name]
1515
*/
1616

17-
/** @typedef {"claude" | "cursor" | "copilot"} HostName */
17+
/** @typedef {"claude" | "cursor" | "copilot" | "codex"} HostName */
1818

1919
/** The hook events that can inject context. @typedef {"SessionStart" | "UserPromptSubmit"} HookEvent */
2020

‎test/meridian-consistency.test.mjs‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,11 @@ test("per-host plugin manifests declare the same version", () => {
5050
assert.match(claude, /^\d+\.\d+\.\d+$/, "Claude manifest has a semver version");
5151
// Compare against the Claude manifest rather than a literal, so a release bump
5252
// touches the manifests but never this test.
53-
for (const rel of [".cursor-plugin/plugin.json", ".plugin/plugin.json"]) {
53+
for (const rel of [
54+
".cursor-plugin/plugin.json",
55+
".plugin/plugin.json",
56+
".codex-plugin/plugin.json",
57+
]) {
5458
const version = readJSON(rel).version;
5559
if (version === undefined) continue; // a manifest may omit version; if present it must agree
5660
assert.equal(version, claude, `${rel} version matches the Claude manifest`);

0 commit comments

Comments
 (0)