Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion docs/reference/file-formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,8 @@ Example:
- `url` / `headers` / `allowedEnvVars` (optional, `http` hooks): the POST target URL, request headers (values support `$VAR` interpolation), and the env-var allowlist for that interpolation. Forwarded to Claude Code and Qwen Code http hooks.
- `server` / `tool` / `input` (optional, `mcp_tool` hooks): the configured MCP server name, the tool to call on it, and the (arbitrary JSON) arguments, whose string values support `${path}` substitution from the hook input. Forwarded to Claude Code mcp_tool hooks.
- `model` (optional, `prompt` / `agent` hooks): the model used for evaluation (defaults to a fast model). Forwarded to Claude Code prompt/agent hooks.
- `commandWindows` (optional): a Windows-only override for `command`, so one hook set can be cross-platform. Forwarded to Codex CLI command hooks (`.codex/hooks.json`), which is the only tool that accepts it.
- `statusMessage` (optional): the progress text shown while the hook runs. Forwarded to Qwen Code (command and http hooks) and to Codex CLI command hooks.
- `if` (optional): a single permission rule (same syntax as `settings.json` permission rules, e.g. `"Bash(rm *)"`) that filters a hook by tool arguments in addition to the tool name. Forwarded to Claude Code, where it is evaluated only on tool events (`preToolUse`, `postToolUse`, `postToolUseFailure`, `permissionRequest`, `permissionDenied`); it round-trips as an opaque string.

Top-level `hooks` keys must be canonical event names; unknown event names are rejected at parse time. Tool-specific override blocks (e.g. `kiro-ide.hooks`) additionally accept tool-native event keys, which pass through verbatim.
Expand All @@ -175,7 +177,7 @@ Events present in the shared `hooks` block but unsupported by a given tool are s
| Event | Cursor | Claude Code | OpenCode | Kilo | Copilot | Copilot CLI | Factory Droid | Gemini CLI | Codex CLI | deepagents | Kiro | Antigravity IDE | Antigravity CLI | Devin | AugmentCode | Goose |
| ---------------------- | :----: | :---------: | :------: | :--: | :-----: | :---------: | :-----------: | :--------: | :-------: | :--------: | :--: | :-------------: | :-------------: | :---: | :---------: | :---: |
| `sessionStart` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — | — | — | ✅ | ✅ |
| `sessionEnd` | ✅ | ✅ | — | — | ✅ | ✅ | ✅ | ✅ | | ✅ | ✅ | — | — | — | ✅ | ✅ |
| `sessionEnd` | ✅ | ✅ | — | — | ✅ | ✅ | ✅ | ✅ | | ✅ | ✅ | — | — | — | ✅ | ✅ |
| `beforeSubmitPrompt` | ✅ | ✅ | — | — | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — | — | ✅ | ✅ | ✅ |
| `preToolUse` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — | ✅ | ✅ |
| `postToolUse` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — | ✅ | ✅ |
Expand Down Expand Up @@ -926,10 +928,18 @@ args = ["--from", "git+https://github.com/BeehiveInnovations/pal-mcp-server.git"
env_vars = ["OPENAI_API_KEY", "OPENROUTER_API_KEY", "GEMINI_API_KEY"]
```

An entry may also be an object naming the environment to read the variable from: `{ "name": "REMOTE_TOKEN", "source": "remote" }` reads it from the remote executor environment (and requires remote MCP stdio support), while a bare name and `"source": "local"` read from Codex's own environment. The object form is written to `config.toml` as an inline table, matching Codex's documented shape. Only `name` and `source` are accepted in that object — Codex rejects an unknown key there, and rejecting one server's entry would take the whole `config.toml` down with it, so Rulesync fails on the canonical file instead. For the same reason an entry that a `config.toml` already holds in some other shape is dropped with a warning on import rather than written into a `.rulesync/mcp.jsonc` the next generate would refuse.

- Emitted only into the codex CLI output. Stripped from `RulesyncMcp.getMcpServers()` so it does not appear in other tools' generated configs (Claude Code, Kilo, OpenCode, Gemini CLI, Cursor, Cline, Junie, Factorydroid, Rovodev, etc.).
- Use this for secrets and API keys you do not want literal-encoded into a committed `mcp.json`.
- Precedence: codex CLI resolves these names from the user's runtime shell environment. If a name is also set in `env` (literal value), the codex CLI behavior is upstream-defined; see the [Codex configuration reference](https://developers.openai.com/codex/config-reference#mcp_serversid-env_vars) (last checked 2026-05-13) for the exact resolution rule.

### Codex-specific: run a stdio server remotely (`experimentalEnvironment`)

For stdio servers, `experimentalEnvironment: "remote"` starts the server through a remote executor environment when one is available. It is written as `experimental_environment` in `config.toml`. Like `envVars`, it is stripped before every other tool's MCP config is written, so it cannot leak into a config that would not understand it — and for the same reason, a server config copied straight out of a `config.toml` may spell it `experimental_environment`, which is accepted and normalized on the way to Codex.

See the [Codex MCP reference](https://learn.chatgpt.com/docs/extend/mcp) for both fields.

#### Codex-specific: OAuth client id (`oauth.clientId` → `client_id`)

A server's `oauth` block is preserved in the canonical Claude Code shape (camelCase `clientId`), but Codex CLI reads the OAuth client id from snake_case `oauth.client_id`. Without it, `codex mcp login <server>` falls back to dynamic client registration and fails for providers that do not support it (e.g. Slack). The codex generator therefore **duplicates** `clientId` into a sibling `client_id`, keeping the camelCase key so tools that expect it keep working:
Expand Down
12 changes: 11 additions & 1 deletion skills/rulesync/file-formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,8 @@ Example:
- `url` / `headers` / `allowedEnvVars` (optional, `http` hooks): the POST target URL, request headers (values support `$VAR` interpolation), and the env-var allowlist for that interpolation. Forwarded to Claude Code and Qwen Code http hooks.
- `server` / `tool` / `input` (optional, `mcp_tool` hooks): the configured MCP server name, the tool to call on it, and the (arbitrary JSON) arguments, whose string values support `${path}` substitution from the hook input. Forwarded to Claude Code mcp_tool hooks.
- `model` (optional, `prompt` / `agent` hooks): the model used for evaluation (defaults to a fast model). Forwarded to Claude Code prompt/agent hooks.
- `commandWindows` (optional): a Windows-only override for `command`, so one hook set can be cross-platform. Forwarded to Codex CLI command hooks (`.codex/hooks.json`), which is the only tool that accepts it.
- `statusMessage` (optional): the progress text shown while the hook runs. Forwarded to Qwen Code (command and http hooks) and to Codex CLI command hooks.
- `if` (optional): a single permission rule (same syntax as `settings.json` permission rules, e.g. `"Bash(rm *)"`) that filters a hook by tool arguments in addition to the tool name. Forwarded to Claude Code, where it is evaluated only on tool events (`preToolUse`, `postToolUse`, `postToolUseFailure`, `permissionRequest`, `permissionDenied`); it round-trips as an opaque string.

Top-level `hooks` keys must be canonical event names; unknown event names are rejected at parse time. Tool-specific override blocks (e.g. `kiro-ide.hooks`) additionally accept tool-native event keys, which pass through verbatim.
Expand All @@ -175,7 +177,7 @@ Events present in the shared `hooks` block but unsupported by a given tool are s
| Event | Cursor | Claude Code | OpenCode | Kilo | Copilot | Copilot CLI | Factory Droid | Gemini CLI | Codex CLI | deepagents | Kiro | Antigravity IDE | Antigravity CLI | Devin | AugmentCode | Goose |
| ---------------------- | :----: | :---------: | :------: | :--: | :-----: | :---------: | :-----------: | :--------: | :-------: | :--------: | :--: | :-------------: | :-------------: | :---: | :---------: | :---: |
| `sessionStart` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — | — | — | ✅ | ✅ |
| `sessionEnd` | ✅ | ✅ | — | — | ✅ | ✅ | ✅ | ✅ | | ✅ | ✅ | — | — | — | ✅ | ✅ |
| `sessionEnd` | ✅ | ✅ | — | — | ✅ | ✅ | ✅ | ✅ | | ✅ | ✅ | — | — | — | ✅ | ✅ |
| `beforeSubmitPrompt` | ✅ | ✅ | — | — | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — | — | ✅ | ✅ | ✅ |
| `preToolUse` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — | ✅ | ✅ |
| `postToolUse` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — | ✅ | ✅ |
Expand Down Expand Up @@ -926,10 +928,18 @@ args = ["--from", "git+https://github.com/BeehiveInnovations/pal-mcp-server.git"
env_vars = ["OPENAI_API_KEY", "OPENROUTER_API_KEY", "GEMINI_API_KEY"]
```

An entry may also be an object naming the environment to read the variable from: `{ "name": "REMOTE_TOKEN", "source": "remote" }` reads it from the remote executor environment (and requires remote MCP stdio support), while a bare name and `"source": "local"` read from Codex's own environment. The object form is written to `config.toml` as an inline table, matching Codex's documented shape. Only `name` and `source` are accepted in that object — Codex rejects an unknown key there, and rejecting one server's entry would take the whole `config.toml` down with it, so Rulesync fails on the canonical file instead. For the same reason an entry that a `config.toml` already holds in some other shape is dropped with a warning on import rather than written into a `.rulesync/mcp.jsonc` the next generate would refuse.

- Emitted only into the codex CLI output. Stripped from `RulesyncMcp.getMcpServers()` so it does not appear in other tools' generated configs (Claude Code, Kilo, OpenCode, Gemini CLI, Cursor, Cline, Junie, Factorydroid, Rovodev, etc.).
- Use this for secrets and API keys you do not want literal-encoded into a committed `mcp.json`.
- Precedence: codex CLI resolves these names from the user's runtime shell environment. If a name is also set in `env` (literal value), the codex CLI behavior is upstream-defined; see the [Codex configuration reference](https://developers.openai.com/codex/config-reference#mcp_serversid-env_vars) (last checked 2026-05-13) for the exact resolution rule.

### Codex-specific: run a stdio server remotely (`experimentalEnvironment`)

For stdio servers, `experimentalEnvironment: "remote"` starts the server through a remote executor environment when one is available. It is written as `experimental_environment` in `config.toml`. Like `envVars`, it is stripped before every other tool's MCP config is written, so it cannot leak into a config that would not understand it — and for the same reason, a server config copied straight out of a `config.toml` may spell it `experimental_environment`, which is accepted and normalized on the way to Codex.

See the [Codex MCP reference](https://learn.chatgpt.com/docs/extend/mcp) for both fields.

#### Codex-specific: OAuth client id (`oauth.clientId` → `client_id`)

A server's `oauth` block is preserved in the canonical Claude Code shape (camelCase `clientId`), but Codex CLI reads the OAuth client id from snake_case `oauth.client_id`. Without it, `codex mcp login <server>` falls back to dynamic client registration and fails for providers that do not support it (e.g. Slack). The codex generator therefore **duplicates** `clientId` into a sibling `client_id`, keeping the camelCase key so tools that expect it keep working:
Expand Down
69 changes: 67 additions & 2 deletions src/features/hooks/codexcli-hooks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ describe("CodexcliHooks", () => {
fileContent: JSON.stringify({
hooks: {
sessionStart: [{ command: "echo start" }],
sessionEnd: [{ command: "echo end" }],
preModelInvocation: [{ command: "echo before-model" }],
subagentStop: [{ command: "echo sub" }],
},
}),
Expand All @@ -82,10 +82,43 @@ describe("CodexcliHooks", () => {

const parsed = JSON.parse(codexHooks.getFileContent());
expect(parsed.hooks.SessionStart).toBeDefined();
expect(parsed.hooks.SessionEnd).toBeUndefined();
// Codex CLI has no equivalent of the canonical preModelInvocation event.
expect(parsed.hooks.PreModelInvocation).toBeUndefined();
expect(parsed.hooks.SubagentStop).toBeDefined();
});

it("should emit sessionEnd, commandWindows and statusMessage", async () => {
// SessionEnd landed in Codex CLI 0.145.0; commandWindows (0.131.0) is the
// Windows-only override for `command`, and statusMessage is the TUI label
// shown while the hook runs. https://learn.chatgpt.com/docs/hooks
const rulesyncHooks = new RulesyncHooks(
createMockAiFileParams({
fileContent: JSON.stringify({
hooks: {
sessionEnd: [
{
command: "./scripts/teardown.sh",
commandWindows: "powershell -File ./scripts/teardown.ps1",
statusMessage: "Saving notes",
},
],
},
}),
}),
);

const codexHooks = await CodexcliHooks.fromRulesyncHooks({
outputRoot: testDir,
rulesyncHooks,
validate: true,
});

const hook = JSON.parse(codexHooks.getFileContent()).hooks.SessionEnd[0].hooks[0];
expect(hook.command).toBe("./scripts/teardown.sh");
expect(hook.commandWindows).toBe("powershell -File ./scripts/teardown.ps1");
expect(hook.statusMessage).toBe("Saving notes");
});

it("should convert subagentStart, subagentStop, and preCompact to PascalCase", async () => {
const rulesyncHooks = new RulesyncHooks(
createMockAiFileParams({
Expand Down Expand Up @@ -297,6 +330,38 @@ describe("CodexcliHooks", () => {
});

describe("toRulesyncHooks", () => {
it("should import SessionEnd along with commandWindows and statusMessage", () => {
const codexHooks = new CodexcliHooks(
createMockAiFileParams({
relativeDirPath: ".codex",
relativeFilePath: "hooks.json",
fileContent: JSON.stringify({
hooks: {
SessionEnd: [
{
hooks: [
{
type: "command",
command: "./scripts/teardown.sh",
commandWindows: "powershell -File ./scripts/teardown.ps1",
statusMessage: "Saving notes",
},
],
},
],
},
}),
}),
);

expect(codexHooks.toRulesyncHooks().getJson().hooks.sessionEnd?.[0]).toEqual({
type: "command",
command: "./scripts/teardown.sh",
commandWindows: "powershell -File ./scripts/teardown.ps1",
statusMessage: "Saving notes",
});
});

it("should convert Codex CLI format to canonical format", () => {
const codexHooks = new CodexcliHooks(
createMockAiFileParams({
Expand Down
8 changes: 8 additions & 0 deletions src/features/hooks/codexcli-hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,14 @@ const CODEXCLI_CONVERTER_CONFIG: ToolHooksConverterConfig = {
projectDirVar: "",
supportedHookTypes: new Set(["command"]),
passthroughFields: ["name", "description"],
// `commandWindows` overrides `command` on Windows; `statusMessage` is the
// label the TUI shows while the hook runs. Both are per-handler fields of
// `.codex/hooks.json` and carry the same name on either side.
// https://learn.chatgpt.com/docs/hooks
stringPassthroughFields: [
{ canonical: "commandWindows", tool: "commandWindows" },
{ canonical: "statusMessage", tool: "statusMessage" },
],
};

/**
Expand Down
2 changes: 1 addition & 1 deletion src/features/hooks/tool-hooks-converter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ export type ToolHooksConverterConfig = {
* for tool-specific opaque strings such as Claude Code's `if` condition.
*/
stringPassthroughFields?: ReadonlyArray<{
readonly canonical: "if";
readonly canonical: "if" | "statusMessage" | "commandWindows";
readonly tool: string;
}>;
/**
Expand Down
Loading
Loading