From dde3273222dfd6f9e736de5bc373c359054cb37f Mon Sep 17 00:00:00 2001 From: AZ-LL Date: Sat, 23 May 2026 17:50:26 -0700 Subject: [PATCH 1/3] feat(codex): route shell commands through plugin hooks Add an RTK Codex plugin package with bundled skill guidance, hook lifecycle configuration, and a thin launcher that delegates Codex PreToolUse payloads to the native RTK hook processor. Teach rtk init --codex to install local and global plugin packages, register marketplace entries, report plugin and feature state, and uninstall only RTK-managed plugin or legacy guidance state. Add native rtk hook codex handling for Bash rewrites with fail-open behavior, supported allow-with-updatedInput responses, and tests for plugin packaging, lifecycle migration, unsupported inputs, heredocs, and missing RTK executables. --- README.md | 4 +- docs/contributing/TECHNICAL.md | 4 +- .../guide/getting-started/supported-agents.md | 9 +- hooks/README.md | 33 +- hooks/codex/README.md | 38 +- .../codex/rtk-codex/.codex-plugin/plugin.json | 42 + hooks/codex/rtk-codex/hooks/hooks.json | 17 + .../rtk-codex/hooks/run-rtk-codex-hook.sh | 45 + hooks/codex/rtk-codex/skills/rtk/SKILL.md | 27 + src/hooks/README.md | 6 +- src/hooks/hook_cmd.rs | 162 +++ src/hooks/init.rs | 1078 ++++++++++++++--- src/main.rs | 19 +- 13 files changed, 1283 insertions(+), 201 deletions(-) create mode 100644 hooks/codex/rtk-codex/.codex-plugin/plugin.json create mode 100644 hooks/codex/rtk-codex/hooks/hooks.json create mode 100644 hooks/codex/rtk-codex/hooks/run-rtk-codex-hook.sh create mode 100644 hooks/codex/rtk-codex/skills/rtk/SKILL.md diff --git a/README.md b/README.md index f8d65efe5..c35692725 100644 --- a/README.md +++ b/README.md @@ -104,7 +104,7 @@ rtk gain # Should show token savings stats # 1. Install for your AI tool rtk init -g # Claude Code / Copilot (default) rtk init -g --gemini # Gemini CLI -rtk init -g --codex # Codex (OpenAI) +rtk init -g --codex # Codex plugin (OpenAI) rtk init -g --agent cursor # Cursor rtk init --agent windsurf # Windsurf rtk init --agent cline # Cline / Roo Code @@ -360,7 +360,7 @@ RTK supports 13 AI coding tools. Each integration rewrites shell commands to `rt | **GitHub Copilot CLI** | `rtk init -g --copilot` | PreToolUse deny-with-suggestion (CLI limitation) | | **Cursor** | `rtk init -g --agent cursor` | preToolUse hook (hooks.json) | | **Gemini CLI** | `rtk init -g --gemini` | BeforeTool hook | -| **Codex** | `rtk init -g --codex` | AGENTS.md + RTK.md instructions | +| **Codex** | `rtk init -g --codex` | Codex plugin with RTK skill + PreToolUse hook | | **Windsurf** | `rtk init --agent windsurf` | .windsurfrules (project-scoped) | | **Cline / Roo Code** | `rtk init --agent cline` | .clinerules (project-scoped) | | **OpenCode** | `rtk init -g --opencode` | Plugin TS (tool.execute.before) | diff --git a/docs/contributing/TECHNICAL.md b/docs/contributing/TECHNICAL.md index ddadf8d73..9fcc764f0 100644 --- a/docs/contributing/TECHNICAL.md +++ b/docs/contributing/TECHNICAL.md @@ -315,7 +315,7 @@ Start here, then drill down into each README for file-level details. | [`cursor/`](../hooks/cursor/README.md) | Cursor IDE | Shell hook, empty JSON response requirement | | [`cline/`](../hooks/cline/README.md) | Cline / Roo Code | Rules file (prompt-level, no programmatic hook) | | [`windsurf/`](../hooks/windsurf/README.md) | Windsurf / Cascade | Rules file (workspace-scoped) | -| [`codex/`](../hooks/codex/README.md) | OpenAI Codex CLI | Awareness document, AGENTS.md integration | +| [`codex/`](../hooks/codex/README.md) | OpenAI Codex CLI | Codex plugin package, RTK skill, PreToolUse hook | | [`opencode/`](../hooks/opencode/README.md) | OpenCode | TypeScript plugin, zx library, in-place mutation | --- @@ -333,7 +333,7 @@ RTK supports the following LLM agents through hook integrations: | Gemini CLI | Rust binary | `rtk hook gemini` reads JSON | Yes (`hookSpecificOutput`) | | Cline/Roo Code | Rules file | Prompt-level guidance | N/A (prompt) | | Windsurf | Rules file | Prompt-level guidance | N/A (prompt) | -| Codex CLI | Awareness doc | AGENTS.md integration | N/A (prompt) | +| Codex CLI | Codex plugin | `rtk hook codex` PreToolUse processor | Yes (`updatedInput`) | | OpenCode | TS plugin | `tool.execute.before` event | Yes (in-place mutation) | > **Details**: [`hooks/README.md`](../hooks/README.md) has the full JSON schemas for each agent. [`src/hooks/README.md`](../src/hooks/README.md) covers installation, integrity verification, and the rewrite command. diff --git a/docs/guide/getting-started/supported-agents.md b/docs/guide/getting-started/supported-agents.md index b7fb920de..663f0942c 100644 --- a/docs/guide/getting-started/supported-agents.md +++ b/docs/guide/getting-started/supported-agents.md @@ -39,7 +39,7 @@ Agent runs "cargo test" | Hermes | Python plugin (`terminal` command mutation) | Yes | | Cline / Roo Code | Rules file (prompt-level) | N/A | | Windsurf | Rules file (prompt-level) | N/A | -| Codex CLI | AGENTS.md instructions | N/A | +| Codex CLI | Codex plugin (`PreToolUse`) | Yes | | Kilo Code | Rules file (prompt-level) | N/A | | Google Antigravity | Rules file (prompt-level) | N/A | | Mistral Vibe | Planned ([#800](https://github.com/rtk-ai/rtk/issues/800)) | Pending upstream | @@ -142,9 +142,12 @@ rtk init --windsurf # creates .windsurfrules in current project ### Codex CLI ```bash -rtk init --codex # creates AGENTS.md or patches existing one +rtk init --codex # registers local RTK Codex plugin marketplace +rtk init -g --codex # registers personal RTK Codex plugin marketplace ``` +The Codex plugin bundles an RTK skill and a Bash `PreToolUse` hook. After installation, restart Codex, enable or install the RTK plugin if Codex prompts for it, and review/trust the plugin hook in `/hooks`. Codex currently supports rewritten input only with `permissionDecision: "allow"`, so RTK does not emit unsupported `ask` rewrites. + ### Kilo Code ```bash @@ -173,7 +176,7 @@ Support is blocked on upstream `BeforeToolCallback` ([mistral-vibe#531](https:// | **Plugin** | TypeScript, JavaScript, or Python in agent's plugin system | Transparent, in-place mutation when the agent allows it | | **Rules file** | Prompt-level instructions | Guidance only — agent is told to prefer `rtk ` | -Rules file integrations (Cline, Windsurf, Codex, Kilo Code, Antigravity) rely on the model following instructions. Full hook integrations (Claude Code, Cursor, Gemini) are guaranteed — the command is rewritten before the agent sees it. Plugin integrations (OpenCode, Pi) use in-place mutation via the agent's TypeScript extension API. +Rules file integrations (Cline, Windsurf, Kilo Code, Antigravity) rely on the model following instructions. Full hook integrations (Claude Code, Cursor, Gemini, Codex) are guaranteed after their hooks are enabled and trusted -- the command is rewritten before the agent sees it. Plugin integrations (OpenCode, Pi, Hermes) use the agent's plugin API to mutate commands before execution. ## Windows support diff --git a/hooks/README.md b/hooks/README.md index 55b2149dd..babc9c00b 100644 --- a/hooks/README.md +++ b/hooks/README.md @@ -2,7 +2,7 @@ ## Scope -**Deployed hook artifacts** — the actual files installed on user machines by `rtk init`. These are shell scripts, TypeScript plugins, and rules files that run outside the Rust binary. They are **thin delegates**: parse agent-specific JSON, call `rtk rewrite` as a subprocess, format agent-specific response. Zero filtering logic lives here. +**Deployed hook artifacts** — the actual files installed on user machines by `rtk init`. These are shell scripts, Codex plugin files, TypeScript plugins, and rules files that run outside the Rust binary. They are **thin delegates**: parse agent-specific JSON, call the native RTK processor or `rtk rewrite`, and format agent-specific responses. Zero filtering logic lives here. Owns: per-agent hook scripts and configuration files for 9 supported agents (Claude Code, Copilot, Cursor, Cline, Windsurf, Codex, OpenCode, Hermes, Pi). @@ -38,7 +38,7 @@ Each agent subdirectory has its own README with hook-specific details: - **[`cursor/`](cursor/README.md)** — Shell hook, Cursor JSON format, empty `{}` response requirement - **[`cline/`](cline/README.md)** — Rules file (prompt-level), `.clinerules` project-local installation - **[`windsurf/`](windsurf/README.md)** — Rules file (prompt-level), `.windsurfrules` workspace-scoped -- **[`codex/`](codex/README.md)** — Awareness document, `AGENTS.md` integration, `$CODEX_HOME` or `~/.codex/` location +- **[`codex/`](codex/README.md)** — Codex plugin package, bundled RTK skill, `PreToolUse` hook launcher, local marketplace registration - **[`opencode/`](opencode/README.md)** — TypeScript plugin, `zx` library, `tool.execute.before` event, in-place mutation - **[`pi/`](pi/README.md)** — TypeScript extension, `tool_call` event, `isToolCallEventType` guard, in-place mutation, `~/.pi/agent/extensions/` - **[`hermes/`](hermes/README.md)** — Python plugin, `pre_tool_call` hook, in-place terminal command mutation @@ -54,7 +54,7 @@ Each agent subdirectory has its own README with hook-specific details: | Gemini CLI | Rust binary (`rtk hook gemini`) | Transparent rewrite | Yes (`hookSpecificOutput`) | | Cline / Roo Code | Custom instructions (rules file) | Prompt-level guidance | N/A | | Windsurf | Custom instructions (rules file) | Prompt-level guidance | N/A | -| Codex CLI | AGENTS.md / instructions | Prompt-level guidance | N/A | +| Codex CLI | Codex plugin (`PreToolUse`) | Transparent rewrite | Yes (`updatedInput`) | | OpenCode | TypeScript plugin (`tool.execute.before`) | In-place mutation | Yes | | Pi | TypeScript extension (`tool_call` event) | In-place mutation | Yes | | Hermes | Python plugin (`pre_tool_call`) | In-place mutation | Yes | @@ -131,6 +131,33 @@ Returns `{}` when no rewrite (Cursor requires JSON for all paths). } ``` +### Codex CLI (Plugin Hook) + +**Input** (stdin): + +```json +{ + "hook_event_name": "PreToolUse", + "tool_name": "Bash", + "tool_input": { "command": "git status" } +} +``` + +**Output** (stdout, when rewritten): + +```json +{ + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "allow", + "permissionDecisionReason": "RTK auto-rewrite", + "updatedInput": { "command": "rtk git status" } + } +} +``` + +Codex currently requires `permissionDecision: "allow"` when returning `updatedInput`; `permissionDecision: "ask"` with rewritten input is not supported. + **Output**: Same as Claude Code format (with `updatedInput`). ### Gemini CLI (Rust Binary) diff --git a/hooks/codex/README.md b/hooks/codex/README.md index 50030e958..1e109b0bc 100644 --- a/hooks/codex/README.md +++ b/hooks/codex/README.md @@ -1,9 +1,35 @@ -# Codex CLI Hooks +# Codex CLI Plugin -> Part of [`hooks/`](../README.md) — see also [`src/hooks/`](../../src/hooks/README.md) for installation code +> Part of [`hooks/`](../README.md) -- see also [`src/hooks/`](../../src/hooks/README.md) for installation code -## Specifics +## Package Layout -- Prompt-level guidance via awareness document -- no programmatic hook -- `rtk-awareness.md` is injected into `AGENTS.md` with an `@RTK.md` reference -- Installed to `$CODEX_HOME` when set, otherwise `~/.codex/`, by `rtk init --codex` +`rtk init --codex` registers the RTK Codex plugin through a local Codex marketplace instead of creating new loose `RTK.md` or `AGENTS.md` guidance files. + +The source package lives at [`rtk-codex/`](rtk-codex/): + +- `.codex-plugin/plugin.json` -- Codex plugin metadata, skill path, and hook path +- `skills/rtk/SKILL.md` -- RTK usage, rewrite, opt-out, and validation guidance for Codex +- `hooks/hooks.json` -- `PreToolUse` hook for Bash tool calls +- `hooks/run-rtk-codex-hook.sh` -- thin launcher that delegates stdin to `rtk hook codex` + +## Install Paths + +- Local: `plugins/rtk-codex/` plus `.agents/plugins/marketplace.json` +- Global: `$CODEX_HOME/plugins/rtk-codex/` or `~/.codex/plugins/rtk-codex/`, plus `~/.agents/plugins/marketplace.json` + +Uninstall removes only the RTK plugin package, its marketplace entry, and legacy RTK-managed `RTK.md` / `AGENTS.md` references. Unrelated plugins and marketplace entries are preserved. + +## Hook Behavior + +The hook launcher resolves `RTK_EXE` first, falls back to `rtk` on `PATH`, forwards the original Codex hook payload to `rtk hook codex`, and fails open when RTK is unavailable. + +`rtk hook codex` rewrites only `PreToolUse` payloads for `tool_name = "Bash"` with a string `tool_input.command`. Empty input, malformed JSON, unsupported tools, missing commands, unsupported commands, already-RTK commands, and heredocs exit successfully without hook output. + +Codex currently supports rewritten input only with `permissionDecision: "allow"` and `updatedInput.command`. RTK does not emit the unsupported `permissionDecision: "ask"` rewrite shape. + +## Activation and Trust + +Codex plugin hooks require the Codex `hooks` and `plugin_hooks` features to be active. After installation, restart Codex and use `/hooks` to review and trust the RTK plugin hook when Codex asks for hook trust. + +The legacy `rtk-awareness.md` file is retained only as compatibility context for previous instruction-only installs. diff --git a/hooks/codex/rtk-codex/.codex-plugin/plugin.json b/hooks/codex/rtk-codex/.codex-plugin/plugin.json new file mode 100644 index 000000000..47cc37a15 --- /dev/null +++ b/hooks/codex/rtk-codex/.codex-plugin/plugin.json @@ -0,0 +1,42 @@ +{ + "name": "rtk-codex", + "version": "0.1.0", + "description": "RTK command rewriting and token-optimized command output for Codex developer workflows.", + "author": { + "name": "RTK AI Labs", + "email": "contact@rtk-ai.app", + "url": "https://www.rtk-ai.app" + }, + "homepage": "https://github.com/rtk-ai/rtk", + "repository": "https://github.com/rtk-ai/rtk", + "license": "MIT", + "keywords": [ + "rtk", + "codex", + "hooks", + "token-optimization", + "developer-tools", + "command-rewrite" + ], + "skills": "./skills/", + "hooks": "./hooks/hooks.json", + "interface": { + "displayName": "RTK", + "shortDescription": "Rewrite Codex Bash commands through RTK for compact output.", + "longDescription": "Adds RTK skills and a Codex PreToolUse hook that rewrites supported Bash commands to token-optimized RTK equivalents.", + "developerName": "RTK AI Labs", + "category": "Developer Tools", + "capabilities": [ + "Read", + "Write" + ], + "websiteURL": "https://github.com/rtk-ai/rtk", + "privacyPolicyURL": "https://github.com/rtk-ai/rtk/blob/develop/docs/TELEMETRY.md", + "termsOfServiceURL": "https://github.com/rtk-ai/rtk/blob/develop/LICENSE", + "defaultPrompt": [ + "Use RTK when running shell commands so Codex receives compact command output.", + "Use $rtk to review RTK command rewriting and opt-out behavior." + ], + "brandColor": "#10A37F" + } +} diff --git a/hooks/codex/rtk-codex/hooks/hooks.json b/hooks/codex/rtk-codex/hooks/hooks.json new file mode 100644 index 000000000..96b843383 --- /dev/null +++ b/hooks/codex/rtk-codex/hooks/hooks.json @@ -0,0 +1,17 @@ +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "bash \"$PLUGIN_ROOT/hooks/run-rtk-codex-hook.sh\"", + "statusMessage": "RTK is rewriting a Bash command for token-optimized output", + "timeout": 30 + } + ] + } + ] + } +} diff --git a/hooks/codex/rtk-codex/hooks/run-rtk-codex-hook.sh b/hooks/codex/rtk-codex/hooks/run-rtk-codex-hook.sh new file mode 100644 index 000000000..8f1163625 --- /dev/null +++ b/hooks/codex/rtk-codex/hooks/run-rtk-codex-hook.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash + +set -u + +payload="$(cat)" + +json_escape() { + local value="$1" + value="${value//\\/\\\\}" + value="${value//\"/\\\"}" + value="${value//$'\n'/\\n}" + value="${value//$'\r'/\\r}" + value="${value//$'\t'/\\t}" + printf '%s' "$value" +} + +write_advisory() { + local message + message="$(json_escape "$1")" + printf '{"systemMessage":"%s"}\n' "$message" +} + +resolve_rtk() { + if [[ -n "${RTK_EXE:-}" ]]; then + if [[ "${RTK_EXE}" == */* ]]; then + if [[ -x "${RTK_EXE}" ]]; then + printf '%s\n' "${RTK_EXE}" + return 0 + fi + return 1 + fi + + command -v -- "${RTK_EXE}" + return $? + fi + + command -v rtk +} + +if ! rtk_exe="$(resolve_rtk)"; then + write_advisory "RTK Codex plugin hook could not find the rtk executable. The original Bash command will run unchanged. Set RTK_EXE or add rtk to PATH." + exit 0 +fi + +printf '%s' "$payload" | "$rtk_exe" hook codex || exit 0 diff --git a/hooks/codex/rtk-codex/skills/rtk/SKILL.md b/hooks/codex/rtk-codex/skills/rtk/SKILL.md new file mode 100644 index 000000000..49c908cb6 --- /dev/null +++ b/hooks/codex/rtk-codex/skills/rtk/SKILL.md @@ -0,0 +1,27 @@ +--- +name: rtk +description: Use RTK from Codex to rewrite supported shell commands for token-optimized command output and validate token savings. +--- + +# RTK + +Use RTK when running shell commands whose raw output can be large. RTK wraps common developer commands, preserves command behavior, and returns compact output for Codex. + +## When to use + +- Prefer `rtk ` for supported build, test, Git, GitHub, package-manager, file-search, log, JSON, and infrastructure commands. +- The RTK Codex plugin can rewrite supported `Bash` tool calls before execution. For example, `git status` can become `rtk git status` automatically after plugin hooks are enabled and trusted. +- Use `rtk gain` to inspect token savings and hook adoption. +- Use `rtk hook check ` to preview how RTK would rewrite a command. + +## Opt out + +- Prefix a command with `RTK_DISABLED=1` to run it without RTK rewriting. +- Configure `[hooks].exclude_commands` in the RTK config for commands that should never be rewritten. +- Use `rtk proxy ` when you need raw command behavior while still invoking RTK explicitly. + +## Validation + +- After installing or updating the plugin, restart Codex and open `/hooks` to review and trust the RTK plugin hook if Codex requests trust. +- Run a simple command such as `git status`; when the hook is active, Codex should execute the RTK-prefixed command. +- Run `rtk gain` after several commands to confirm savings are being tracked. diff --git a/src/hooks/README.md b/src/hooks/README.md index a0c76b76d..82fc5fef9 100644 --- a/src/hooks/README.md +++ b/src/hooks/README.md @@ -28,7 +28,7 @@ LLM agent integration layer that installs, validates, and executes command-rewri | Claude-MD (legacy) | `rtk init --claude-md` | 134-line RTK block | CLAUDE.md | | Windsurf | `rtk init -g --agent windsurf` | `.windsurfrules` | -- | | Cline | `rtk init --agent cline` | `.clinerules` | -- | -| Codex | `rtk init --codex` | RTK.md in `$CODEX_HOME` or `~/.codex` | AGENTS.md | +| Codex | `rtk init --codex` | Codex plugin package | `.agents/plugins/marketplace.json` | | Cursor | `rtk init -g --agent cursor` | Cursor hook | hooks.json | | Pi | `rtk init --agent pi` | `.pi/extensions/rtk.ts` | -- | | Hermes | `rtk init --agent hermes` | Python plugin in `~/.hermes/plugins/rtk-rewrite/` | `config.yaml` `plugins.enabled` | @@ -88,7 +88,7 @@ Rules are loaded from all Claude Code `settings.json` files (project + global, i | Copilot VS Code (rtk hook copilot) | Yes | `permissionDecision: "ask"` — user prompted | | Gemini CLI (rtk hook gemini) | No (allow/deny only) | allow (limitation — no ask mode in Gemini) | | Copilot CLI (rtk hook copilot) | No updatedInput | deny-with-suggestion (unchanged) | -| Codex | ask parsed but no-op | allow (limitation — fails open) | +| Codex (rtk hook codex) | No rewritten ask support | allow (Codex requires allow with updatedInput) | ### Implementation @@ -101,4 +101,4 @@ Rules are loaded from all Claude Code `settings.json` files (project + global, i Hook processors in `hook_cmd.rs` must return `Ok(())` on every path — success, no-match, parse error, and unexpected input. Returning `Err` propagates to `main()` and exits non-zero, which blocks the agent's command from executing. This violates the non-blocking guarantee documented in `hooks/README.md`. ## Adding New Functionality -To add support for a new AI coding agent: (1) add the hook installation logic to `init.rs` following the existing agent patterns, (2) if the agent requires a custom hook protocol (like Gemini's `BeforeTool`), add a processor function in `hook_cmd.rs`, (3) add the agent's hook file path to `hook_check.rs` for validation, and (4) update `integrity.rs` with the expected hash for the new hook file. Test by running `rtk init` in a fresh environment and verifying the hook rewrites commands correctly in the target agent. +To add support for a new AI coding agent: (1) add the hook installation logic to `init.rs` following the existing agent patterns, (2) if the agent requires a custom hook protocol (like Gemini's `BeforeTool` or Codex `PreToolUse`), add a processor function in `hook_cmd.rs`, (3) add the agent's hook file path to `hook_check.rs` for validation when it installs standalone hook files, and (4) update `integrity.rs` when the install path uses integrity-managed hook files. Test by running `rtk init` in a fresh environment and verifying the hook rewrites commands correctly in the target agent. diff --git a/src/hooks/hook_cmd.rs b/src/hooks/hook_cmd.rs index 36825e3c5..ba70a5121 100644 --- a/src/hooks/hook_cmd.rs +++ b/src/hooks/hook_cmd.rs @@ -397,6 +397,101 @@ fn run_claude_inner(input: &str) -> Option { } } +// ── Codex native hook ───────────────────────────────────────── + +fn process_codex_payload(v: &Value) -> PayloadAction { + if v.get("hook_event_name") + .and_then(|event| event.as_str()) + .is_some_and(|event| event != PRE_TOOL_USE_KEY) + { + return PayloadAction::Ignore; + } + + if v.get("tool_name").and_then(|tool| tool.as_str()) != Some("Bash") { + return PayloadAction::Ignore; + } + + let cmd = match v + .pointer("/tool_input/command") + .and_then(|c| c.as_str()) + .filter(|c| !c.is_empty()) + { + Some(c) => c, + None => return PayloadAction::Ignore, + }; + + if permissions::check_command(cmd) == PermissionVerdict::Deny { + return PayloadAction::Skip { + reason: "skip:deny_rule", + cmd: cmd.to_string(), + }; + } + + let rewritten = match get_rewritten(cmd) { + Some(r) => r, + None => { + return PayloadAction::Skip { + reason: "skip:no_match", + cmd: cmd.to_string(), + } + } + }; + + PayloadAction::Rewrite { + cmd: cmd.to_string(), + rewritten: rewritten.clone(), + output: json!({ + "hookSpecificOutput": { + "hookEventName": PRE_TOOL_USE_KEY, + "permissionDecision": "allow", + "permissionDecisionReason": "RTK auto-rewrite", + "updatedInput": { "command": rewritten } + } + }), + } +} + +/// Run the Codex PreToolUse hook natively. +pub fn run_codex() -> Result<()> { + let input = read_stdin_limited()?; + + let input = input.trim(); + if input.is_empty() { + return Ok(()); + } + + let v: Value = match serde_json::from_str(input) { + Ok(v) => v, + Err(_) => return Ok(()), + }; + + match process_codex_payload(&v) { + PayloadAction::Rewrite { + cmd, + rewritten, + output, + } => { + audit_log("rewrite", &cmd, &rewritten); + let _ = writeln!(io::stdout(), "{output}"); + } + PayloadAction::Skip { reason, cmd } => { + audit_log(reason, &cmd, ""); + } + PayloadAction::Ignore => {} + } + + Ok(()) +} + +#[cfg(test)] +fn run_codex_inner(input: &str) -> Option { + let v: Value = serde_json::from_str(input).ok()?; + match process_codex_payload(&v) { + PayloadAction::Rewrite { output, .. } => Some(output.to_string()), + _ => None, + } +} + // ── Cursor native hook ───────────────────────────────────────── /// Cursor on Windows ships hook payloads with one or more leading @@ -780,6 +875,73 @@ mod tests { assert!(run_claude_inner(&input).is_none()); } + // --- Codex handler --- + + fn codex_input(tool: &str, cmd: &str) -> String { + json!({ + "hook_event_name": PRE_TOOL_USE_KEY, + "tool_name": tool, + "tool_input": { "command": cmd } + }) + .to_string() + } + + #[test] + fn test_codex_rewrite_git_status() { + let result = run_codex_inner(&codex_input("Bash", "git status")).unwrap(); + let v: Value = serde_json::from_str(&result).unwrap(); + let hook = &v["hookSpecificOutput"]; + + assert_eq!(hook["hookEventName"], PRE_TOOL_USE_KEY); + assert_eq!(hook["permissionDecision"], "allow"); + assert_eq!(hook["permissionDecisionReason"], "RTK auto-rewrite"); + assert_eq!(hook["updatedInput"]["command"], "rtk git status"); + } + + #[test] + fn test_codex_never_emits_ask_for_rewrite() { + let result = run_codex_inner(&codex_input("Bash", "cargo test")).unwrap(); + let v: Value = serde_json::from_str(&result).unwrap(); + assert_eq!(v["hookSpecificOutput"]["permissionDecision"], "allow"); + assert_ne!(v["hookSpecificOutput"]["permissionDecision"], "ask"); + } + + #[test] + fn test_codex_non_bash_passthrough() { + assert!(run_codex_inner(&codex_input("Read", "git status")).is_none()); + } + + #[test] + fn test_codex_missing_command_passthrough() { + let input = json!({ + "hook_event_name": PRE_TOOL_USE_KEY, + "tool_name": "Bash", + "tool_input": {} + }) + .to_string(); + assert!(run_codex_inner(&input).is_none()); + } + + #[test] + fn test_codex_unsupported_command_passthrough() { + assert!(run_codex_inner(&codex_input("Bash", "htop")).is_none()); + } + + #[test] + fn test_codex_already_rtk_passthrough() { + assert!(run_codex_inner(&codex_input("Bash", "rtk git status")).is_none()); + } + + #[test] + fn test_codex_heredoc_passthrough() { + assert!(run_codex_inner(&codex_input("Bash", "cat < String { diff --git a/src/hooks/init.rs b/src/hooks/init.rs index 189f5de55..3a4c09050 100644 --- a/src/hooks/init.rs +++ b/src/hooks/init.rs @@ -30,6 +30,19 @@ const PI_PLUGIN: &str = include_str!("../../hooks/pi/rtk.ts"); const RTK_SLIM: &str = include_str!("../../hooks/claude/rtk-awareness.md"); const RTK_SLIM_CODEX: &str = include_str!("../../hooks/codex/rtk-awareness.md"); +// Embedded Codex plugin package +const CODEX_PLUGIN_NAME: &str = "rtk-codex"; +const CODEX_PLUGIN_MARKETPLACE_NAME: &str = "rtk-local"; +const CODEX_PLUGIN_MARKETPLACE_DISPLAY_NAME: &str = "RTK Local Plugins"; +const CODEX_PERSONAL_MARKETPLACE_NAME: &str = "personal"; +const CODEX_PERSONAL_MARKETPLACE_DISPLAY_NAME: &str = "Personal"; +const CODEX_PLUGIN_MANIFEST: &str = + include_str!("../../hooks/codex/rtk-codex/.codex-plugin/plugin.json"); +const CODEX_PLUGIN_SKILL: &str = include_str!("../../hooks/codex/rtk-codex/skills/rtk/SKILL.md"); +const CODEX_PLUGIN_HOOKS_JSON: &str = include_str!("../../hooks/codex/rtk-codex/hooks/hooks.json"); +const CODEX_PLUGIN_LAUNCHER: &str = + include_str!("../../hooks/codex/rtk-codex/hooks/run-rtk-codex-hook.sh"); + /// Template written by `rtk init` when no filters.toml exists yet. const FILTERS_TEMPLATE: &str = r#"# Project-local RTK filters — commit this file with your repo. # Filters here override user-global and built-in filters. @@ -836,22 +849,16 @@ pub fn uninstall( fn uninstall_codex(global: bool, ctx: InitContext) -> Result<()> { let InitContext { dry_run, .. } = ctx; - if !global { - anyhow::bail!( - "Uninstall only works with --global flag. For local projects, manually remove RTK from AGENTS.md" - ); - } - - let codex_dir = resolve_codex_dir()?; - let removed = uninstall_codex_at(&codex_dir, ctx)?; + let paths = codex_plugin_paths(global)?; + let removed = uninstall_codex_at(&paths, global, ctx)?; if removed.is_empty() { - println!("RTK was not installed for Codex CLI (nothing to remove)"); + println!("RTK Codex plugin was not installed (nothing to remove)"); } else { let header = if dry_run { - "[dry-run] would uninstall RTK for Codex CLI:" + "[dry-run] would uninstall RTK Codex plugin:" } else { - "RTK uninstalled for Codex CLI:" + "RTK Codex plugin uninstalled:" }; println!("{}", header); for item in removed { @@ -862,56 +869,30 @@ fn uninstall_codex(global: bool, ctx: InitContext) -> Result<()> { Ok(()) } -fn uninstall_codex_at(codex_dir: &Path, ctx: InitContext) -> Result> { - let InitContext { verbose, dry_run } = ctx; +fn uninstall_codex_at( + paths: &CodexPluginPaths, + global: bool, + ctx: InitContext, +) -> Result> { let mut removed = Vec::new(); - let absolute_rtk_md_ref = codex_rtk_md_ref(codex_dir); - let rtk_md_path = codex_dir.join(RTK_MD); - if rtk_md_path.exists() { - if dry_run { - println!("[dry-run] would remove RTK.md: {}", rtk_md_path.display()); - } else { - fs::remove_file(&rtk_md_path) - .with_context(|| format!("Failed to remove RTK.md: {}", rtk_md_path.display()))?; - if verbose > 0 { - eprintln!("Removed RTK.md: {}", rtk_md_path.display()); - } - } - removed.push(format!("RTK.md: {}", rtk_md_path.display())); + if remove_codex_plugin_dir(paths, ctx)? { + removed.push(format!("Plugin package: {}", paths.plugin_dir.display())); } - let agents_md_path = codex_dir.join(AGENTS_MD); - if agents_md_path.exists() { - let content = fs::read_to_string(&agents_md_path) - .with_context(|| format!("Failed to read AGENTS.md: {}", agents_md_path.display()))?; - - let mut working_content = content.clone(); - let mut agents_changed = false; - - if working_content.contains(RTK_BLOCK_START) { - let (cleaned, did_remove) = remove_rtk_block(&working_content); - if did_remove { - working_content = cleaned; - agents_changed = true; - removed.push("AGENTS.md: removed rtk-instructions block".to_string()); - } - } - - if agents_changed { - atomic_write(&agents_md_path, &working_content).with_context(|| { - format!("Failed to write AGENTS.md: {}", agents_md_path.display()) - })?; - } + if remove_codex_plugin_marketplace_entry(paths, ctx)? { + removed.push(format!( + "Marketplace entry: {}", + paths.marketplace_path.display() + )); } - if remove_rtk_reference_from_agents( - &agents_md_path, - &[RTK_MD_REF, absolute_rtk_md_ref.as_str()], + removed.extend(remove_codex_legacy_at( + &paths.legacy_agents_md_path, + &paths.legacy_rtk_md_path, + global, ctx, - )? { - removed.push("AGENTS.md: removed @RTK.md reference".to_string()); - } + )?); Ok(removed) } @@ -2244,73 +2225,473 @@ fn normalized_yaml_scalar(value: &str) -> Option { (!trimmed.is_empty()).then(|| trimmed.to_string()) } +#[derive(Debug, Clone)] +struct CodexPluginPaths { + plugin_dir: PathBuf, + marketplace_path: PathBuf, + marketplace_source_path: String, + marketplace_name: &'static str, + marketplace_display_name: &'static str, + legacy_agents_md_path: PathBuf, + legacy_rtk_md_path: PathBuf, +} + fn run_codex_mode(global: bool, ctx: InitContext) -> Result<()> { - let (agents_md_path, rtk_md_path) = if global { + let paths = codex_plugin_paths(global)?; + run_codex_mode_with_paths(paths, global, ctx) +} + +fn codex_plugin_paths(global: bool) -> Result { + if global { let codex_dir = resolve_codex_dir()?; - (codex_dir.join(AGENTS_MD), codex_dir.join(RTK_MD)) + let home_dir = dirs::home_dir().context("Cannot determine home directory for Codex")?; + Ok(CodexPluginPaths { + plugin_dir: codex_dir.join(PLUGIN_SUBDIR).join(CODEX_PLUGIN_NAME), + marketplace_path: home_dir + .join(".agents") + .join(PLUGIN_SUBDIR) + .join("marketplace.json"), + marketplace_source_path: format!("./{CODEX_DIR}/{PLUGIN_SUBDIR}/{CODEX_PLUGIN_NAME}"), + marketplace_name: CODEX_PERSONAL_MARKETPLACE_NAME, + marketplace_display_name: CODEX_PERSONAL_MARKETPLACE_DISPLAY_NAME, + legacy_agents_md_path: codex_dir.join(AGENTS_MD), + legacy_rtk_md_path: codex_dir.join(RTK_MD), + }) } else { - (PathBuf::from(AGENTS_MD), PathBuf::from(RTK_MD)) - }; - - run_codex_mode_with_paths(agents_md_path, rtk_md_path, global, ctx) + Ok(CodexPluginPaths { + plugin_dir: PathBuf::from(PLUGIN_SUBDIR).join(CODEX_PLUGIN_NAME), + marketplace_path: PathBuf::from(".agents") + .join(PLUGIN_SUBDIR) + .join("marketplace.json"), + marketplace_source_path: format!("./{PLUGIN_SUBDIR}/{CODEX_PLUGIN_NAME}"), + marketplace_name: CODEX_PLUGIN_MARKETPLACE_NAME, + marketplace_display_name: CODEX_PLUGIN_MARKETPLACE_DISPLAY_NAME, + legacy_agents_md_path: PathBuf::from(AGENTS_MD), + legacy_rtk_md_path: PathBuf::from(RTK_MD), + }) + } } fn run_codex_mode_with_paths( - agents_md_path: PathBuf, - rtk_md_path: PathBuf, + paths: CodexPluginPaths, global: bool, ctx: InitContext, ) -> Result<()> { let InitContext { dry_run, .. } = ctx; - if global && !dry_run { - if let Some(parent) = agents_md_path.parent() { - fs::create_dir_all(parent).with_context(|| { + install_codex_plugin_package(&paths, ctx)?; + let legacy_removed = remove_codex_legacy_at( + &paths.legacy_agents_md_path, + &paths.legacy_rtk_md_path, + global, + ctx, + )?; + + if !dry_run { + println!("\nRTK Codex plugin registered.\n"); + println!(" Plugin: {}", paths.plugin_dir.display()); + println!(" Marketplace: {}", paths.marketplace_path.display()); + println!( + " Hook config: {}/hooks/hooks.json", + paths.plugin_dir.display() + ); + if !legacy_removed.is_empty() { + println!(" Legacy cleanup:"); + for item in legacy_removed { + println!(" - {}", item); + } + } + println!("\n Restart Codex, enable/install the RTK plugin if prompted, then review and trust the RTK plugin hook in /hooks."); + println!(" Codex plugin hooks require the hooks and plugin_hooks features to be enabled."); + } + + Ok(()) +} + +fn install_codex_plugin_package(paths: &CodexPluginPaths, ctx: InitContext) -> Result<()> { + write_codex_plugin_file( + &paths.plugin_dir.join(".codex-plugin").join("plugin.json"), + CODEX_PLUGIN_MANIFEST, + "Codex plugin manifest", + ctx, + )?; + write_codex_plugin_file( + &paths.plugin_dir.join("skills").join("rtk").join("SKILL.md"), + CODEX_PLUGIN_SKILL, + "Codex plugin skill", + ctx, + )?; + write_codex_plugin_file( + &paths.plugin_dir.join(HOOKS_SUBDIR).join(HOOKS_JSON), + CODEX_PLUGIN_HOOKS_JSON, + "Codex plugin hooks", + ctx, + )?; + let launcher_path = paths + .plugin_dir + .join(HOOKS_SUBDIR) + .join("run-rtk-codex-hook.sh"); + write_codex_plugin_file( + &launcher_path, + CODEX_PLUGIN_LAUNCHER, + "Codex plugin hook launcher", + ctx, + )?; + + #[cfg(unix)] + if !ctx.dry_run && launcher_path.exists() { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&launcher_path, fs::Permissions::from_mode(0o755)) + .with_context(|| format!("Failed to chmod {}", launcher_path.display()))?; + } + + upsert_codex_plugin_marketplace_entry(paths, ctx)?; + Ok(()) +} + +fn write_codex_plugin_file( + path: &Path, + content: &str, + name: &str, + ctx: InitContext, +) -> Result { + if !ctx.dry_run { + let parent = path + .parent() + .with_context(|| format!("{} path has no parent: {}", name, path.display()))?; + fs::create_dir_all(parent) + .with_context(|| format!("Failed to create {}", parent.display()))?; + } + + write_if_changed(path, content, name, ctx) +} + +fn codex_plugin_marketplace_entry(source_path: &str) -> serde_json::Value { + serde_json::json!({ + "name": CODEX_PLUGIN_NAME, + "source": { + "source": "local", + "path": source_path + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "Developer Tools" + }) +} + +fn read_or_create_codex_marketplace(paths: &CodexPluginPaths) -> Result { + if paths.marketplace_path.exists() { + let content = fs::read_to_string(&paths.marketplace_path).with_context(|| { + format!( + "Failed to read Codex marketplace: {}", + paths.marketplace_path.display() + ) + })?; + if !content.trim().is_empty() { + return serde_json::from_str(&content).with_context(|| { format!( - "Failed to create Codex config directory: {}", - parent.display() + "Failed to parse Codex marketplace as JSON: {}", + paths.marketplace_path.display() ) - })?; + }); } } - // ISSUE #892: In global mode, use absolute path so @RTK.md resolves - // from any CWD (worktrees, nested projects). Codex resolves @ references - // relative to CWD, not the AGENTS.md file location. - let rtk_md_ref = if global { - codex_rtk_md_ref( - rtk_md_path - .parent() - .context("RTK.md path missing parent directory")?, - ) + Ok(serde_json::json!({ + "name": paths.marketplace_name, + "interface": { + "displayName": paths.marketplace_display_name + }, + "plugins": [] + })) +} + +fn upsert_codex_plugin_marketplace_entry( + paths: &CodexPluginPaths, + ctx: InitContext, +) -> Result { + let InitContext { verbose, dry_run } = ctx; + let mut root = read_or_create_codex_marketplace(paths)?; + let mut changed = !paths.marketplace_path.exists(); + + let root_obj = root + .as_object_mut() + .context("Codex marketplace root must be a JSON object")?; + if !root_obj.contains_key("name") { + root_obj.insert("name".into(), serde_json::json!(paths.marketplace_name)); + changed = true; + } + if !root_obj.contains_key("interface") { + root_obj.insert( + "interface".into(), + serde_json::json!({ "displayName": paths.marketplace_display_name }), + ); + changed = true; + } + + let plugins = root_obj + .entry("plugins") + .or_insert_with(|| serde_json::json!([])) + .as_array_mut() + .context("Codex marketplace plugins must be an array")?; + let entry = codex_plugin_marketplace_entry(&paths.marketplace_source_path); + + if let Some(existing) = plugins + .iter_mut() + .find(|plugin| plugin.get("name").and_then(|name| name.as_str()) == Some(CODEX_PLUGIN_NAME)) + { + if *existing != entry { + *existing = entry; + changed = true; + } } else { - RTK_MD_REF.to_string() + plugins.push(entry); + changed = true; + } + + if !changed { + if verbose > 0 { + eprintln!( + "Codex marketplace entry already up to date: {}", + paths.marketplace_path.display() + ); + } + return Ok(false); + } + + let serialized = + serde_json::to_string_pretty(&root).context("Failed to serialize Codex marketplace")?; + + if dry_run { + println!( + "[dry-run] would update Codex marketplace: {}", + paths.marketplace_path.display() + ); + if verbose > 0 { + println!("[dry-run] content:\n{}", serialized); + } + return Ok(true); + } + + if let Some(parent) = paths.marketplace_path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("Failed to create {}", parent.display()))?; + } + atomic_write(&paths.marketplace_path, &serialized).with_context(|| { + format!( + "Failed to write Codex marketplace: {}", + paths.marketplace_path.display() + ) + })?; + Ok(true) +} + +fn marketplace_has_codex_plugin(path: &Path) -> bool { + let Ok(content) = fs::read_to_string(path) else { + return false; + }; + let Ok(root) = serde_json::from_str::(&content) else { + return false; }; + root.get("plugins") + .and_then(|plugins| plugins.as_array()) + .is_some_and(|plugins| { + plugins.iter().any(|plugin| { + plugin.get("name").and_then(|name| name.as_str()) == Some(CODEX_PLUGIN_NAME) + }) + }) +} - write_if_changed(&rtk_md_path, RTK_SLIM_CODEX, RTK_MD, ctx)?; - let added_ref = patch_agents_md(&agents_md_path, &rtk_md_ref, ctx)?; +fn remove_codex_plugin_marketplace_entry( + paths: &CodexPluginPaths, + ctx: InitContext, +) -> Result { + let InitContext { verbose, dry_run } = ctx; + if !paths.marketplace_path.exists() { + return Ok(false); + } - if !dry_run { - println!("\nRTK configured for Codex CLI.\n"); - println!(" RTK.md: {}", rtk_md_path.display()); - if added_ref { - println!(" AGENTS.md: {} reference added", rtk_md_ref); - } else { - println!(" AGENTS.md: {} reference already present", rtk_md_ref); + let content = fs::read_to_string(&paths.marketplace_path).with_context(|| { + format!( + "Failed to read Codex marketplace: {}", + paths.marketplace_path.display() + ) + })?; + if content.trim().is_empty() { + return Ok(false); + } + + let mut root: serde_json::Value = serde_json::from_str(&content).with_context(|| { + format!( + "Failed to parse Codex marketplace as JSON: {}", + paths.marketplace_path.display() + ) + })?; + let Some(plugins) = root + .get_mut("plugins") + .and_then(|plugins| plugins.as_array_mut()) + else { + return Ok(false); + }; + + let original_len = plugins.len(); + plugins.retain(|plugin| { + plugin.get("name").and_then(|name| name.as_str()) != Some(CODEX_PLUGIN_NAME) + }); + if plugins.len() == original_len { + return Ok(false); + } + + let serialized = + serde_json::to_string_pretty(&root).context("Failed to serialize Codex marketplace")?; + if dry_run { + println!( + "[dry-run] would remove RTK Codex plugin marketplace entry from {}", + paths.marketplace_path.display() + ); + if verbose > 0 { + println!("[dry-run] content:\n{}", serialized); } - if global { - println!( - "\n Codex global instructions path: {}", - agents_md_path.display() - ); - } else { - println!( - "\n Codex project instructions path: {}", - agents_md_path.display() + return Ok(true); + } + + atomic_write(&paths.marketplace_path, &serialized).with_context(|| { + format!( + "Failed to write Codex marketplace: {}", + paths.marketplace_path.display() + ) + })?; + Ok(true) +} + +fn remove_codex_plugin_dir(paths: &CodexPluginPaths, ctx: InitContext) -> Result { + let InitContext { dry_run, .. } = ctx; + if !paths.plugin_dir.exists() { + return Ok(false); + } + + if dry_run { + println!( + "[dry-run] would remove RTK Codex plugin package: {}", + paths.plugin_dir.display() + ); + return Ok(true); + } + + // nosemgrep: filesystem-deletion + fs::remove_dir_all(&paths.plugin_dir).with_context(|| { + format!( + "Failed to remove RTK Codex plugin package: {}", + paths.plugin_dir.display() + ) + })?; + Ok(true) +} + +fn remove_codex_legacy_at( + agents_md_path: &Path, + rtk_md_path: &Path, + global: bool, + ctx: InitContext, +) -> Result> { + let InitContext { verbose, dry_run } = ctx; + let mut removed = Vec::new(); + + let rtk_md_existed = rtk_md_path.exists(); + let removed_rtk_md = remove_codex_legacy_rtk_md(rtk_md_path, ctx)?; + if removed_rtk_md { + removed.push(format!("RTK.md: {}", rtk_md_path.display())); + } + + let mut removed_inline_block = false; + if agents_md_path.exists() { + let content = fs::read_to_string(agents_md_path) + .with_context(|| format!("Failed to read AGENTS.md: {}", agents_md_path.display()))?; + if content.contains(RTK_BLOCK_START) { + let (cleaned, did_remove) = remove_rtk_block(&content); + if did_remove { + removed_inline_block = true; + if dry_run { + println!( + "[dry-run] would remove legacy RTK block from AGENTS.md: {}", + agents_md_path.display() + ); + if verbose > 0 { + println!("[dry-run] content:\n{}", cleaned); + } + } else { + atomic_write(agents_md_path, &cleaned).with_context(|| { + format!("Failed to write AGENTS.md: {}", agents_md_path.display()) + })?; + } + removed.push("AGENTS.md: removed rtk-instructions block".to_string()); + } + } + } + + let mut refs = vec![RTK_MD_REF.to_string()]; + if global { + if let Some(parent) = rtk_md_path.parent() { + refs.push(codex_rtk_md_ref(parent)); + } + } + let ref_slices = refs.iter().map(String::as_str).collect::>(); + let preserve_existing_unmanaged_rtk_md = rtk_md_existed && !removed_rtk_md; + if !preserve_existing_unmanaged_rtk_md + && remove_rtk_reference_from_agents(agents_md_path, &ref_slices, ctx)? + { + removed.push("AGENTS.md: removed @RTK.md reference".to_string()); + } else if removed_inline_block && verbose > 0 && preserve_existing_unmanaged_rtk_md { + eprintln!( + "Preserved AGENTS.md RTK.md reference because RTK.md is not RTK-managed: {}", + rtk_md_path.display() + ); + } + + Ok(removed) +} + +fn remove_codex_legacy_rtk_md(rtk_md_path: &Path, ctx: InitContext) -> Result { + let InitContext { verbose, dry_run } = ctx; + if !rtk_md_path.exists() { + return Ok(false); + } + + let content = fs::read_to_string(rtk_md_path).with_context(|| { + format!( + "Failed to read legacy Codex RTK.md: {}", + rtk_md_path.display() + ) + })?; + if content != RTK_SLIM_CODEX { + if verbose > 0 { + eprintln!( + "Preserved RTK.md because it is not RTK-managed: {}", + rtk_md_path.display() ); } + return Ok(false); } - Ok(()) + if dry_run { + println!( + "[dry-run] would remove legacy RTK.md: {}", + rtk_md_path.display() + ); + return Ok(true); + } + + fs::remove_file(rtk_md_path).with_context(|| { + format!( + "Failed to remove legacy Codex RTK.md: {}", + rtk_md_path.display() + ) + })?; + if verbose > 0 { + eprintln!("Removed legacy RTK.md: {}", rtk_md_path.display()); + } + Ok(true) } // --- upsert_rtk_block: idempotent RTK block management --- @@ -2523,6 +2904,7 @@ fn patch_claude_md(path: &Path, ctx: InitContext) -> Result { } /// Patch AGENTS.md: add @RTK.md (or absolute path), migrate old inline block if present +#[cfg(test)] fn patch_agents_md(path: &Path, rtk_md_ref: &str, ctx: InitContext) -> Result { let InitContext { verbose, dry_run } = ctx; let mut content = if path.exists() { @@ -3496,8 +3878,8 @@ fn show_claude_config() -> Result<()> { println!(" rtk init -g --uninstall # Remove all RTK artifacts"); println!(" rtk init -g --claude-md # Legacy: full injection into ~/.claude/CLAUDE.md"); println!(" rtk init -g --hook-only # Hook only, no RTK.md"); - println!(" rtk init --codex # Configure local AGENTS.md + RTK.md"); - println!(" rtk init -g --codex # Configure $CODEX_HOME/AGENTS.md + $CODEX_HOME/RTK.md (or ~/.codex/)"); + println!(" rtk init --codex # Register local RTK Codex plugin marketplace"); + println!(" rtk init -g --codex # Register personal RTK Codex plugin marketplace"); println!(" rtk init -g --opencode # OpenCode plugin only"); println!(" rtk init -g --agent cursor # Install Cursor Agent hooks"); @@ -3506,57 +3888,138 @@ fn show_claude_config() -> Result<()> { fn show_codex_config() -> Result<()> { let codex_dir = resolve_codex_dir()?; - let global_agents_md = codex_dir.join(AGENTS_MD); - let global_rtk_md = codex_dir.join(RTK_MD); - let global_rtk_md_ref = codex_rtk_md_ref(&codex_dir); - let local_agents_md = PathBuf::from(AGENTS_MD); - let local_rtk_md = PathBuf::from(RTK_MD); + let global_paths = codex_plugin_paths(true)?; + let local_paths = codex_plugin_paths(false)?; println!("rtk Configuration (Codex CLI):\n"); - if global_rtk_md.exists() { - println!("[ok] Global RTK.md: {}", global_rtk_md.display()); + print_codex_plugin_scope("Global", &global_paths); + print_codex_plugin_scope("Local", &local_paths); + print_codex_feature_status(&codex_dir)?; + + print_codex_legacy_scope("Global", &global_paths, true)?; + print_codex_legacy_scope("Local", &local_paths, false)?; + + println!("\nUsage:"); + println!(" rtk init --codex # Register local RTK Codex plugin marketplace"); + println!( + " rtk init -g --codex # Register personal RTK Codex plugin marketplace" + ); + println!(" rtk init --codex --uninstall # Remove local RTK Codex plugin state"); + println!(" rtk init -g --codex --uninstall # Remove personal RTK Codex plugin state"); + + Ok(()) +} + +fn print_codex_plugin_scope(label: &str, paths: &CodexPluginPaths) { + let manifest_path = paths.plugin_dir.join(".codex-plugin").join("plugin.json"); + let hooks_path = paths.plugin_dir.join(HOOKS_SUBDIR).join(HOOKS_JSON); + + if manifest_path.exists() { + println!( + "[ok] {label} plugin package: {}", + paths.plugin_dir.display() + ); } else { - println!("[--] Global RTK.md: not found"); + println!( + "[--] {label} plugin package: not found ({})", + paths.plugin_dir.display() + ); } - if global_agents_md.exists() { - let content = fs::read_to_string(&global_agents_md)?; - if has_rtk_reference(&content, &[RTK_MD_REF, global_rtk_md_ref.as_str()]) { - println!("[ok] Global AGENTS.md: RTK.md reference"); - } else if content.contains(RTK_BLOCK_START) { - println!("[!!] Global AGENTS.md: old inline RTK block"); - } else { - println!("[--] Global AGENTS.md: exists but rtk not configured"); - } + if marketplace_has_codex_plugin(&paths.marketplace_path) { + println!( + "[ok] {label} marketplace: RTK entry in {}", + paths.marketplace_path.display() + ); + } else if paths.marketplace_path.exists() { + println!( + "[--] {label} marketplace: exists without RTK entry ({})", + paths.marketplace_path.display() + ); } else { - println!("[--] Global AGENTS.md: not found"); + println!( + "[--] {label} marketplace: not found ({})", + paths.marketplace_path.display() + ); } - if local_rtk_md.exists() { - println!("[ok] Local RTK.md: {}", local_rtk_md.display()); + if hooks_path.exists() { + println!("[ok] {label} plugin hook config: {}", hooks_path.display()); } else { - println!("[--] Local RTK.md: not found"); + println!( + "[--] {label} plugin hook config: not found ({})", + hooks_path.display() + ); + } +} + +fn print_codex_feature_status(codex_dir: &Path) -> Result<()> { + let config_path = codex_dir.join("config.toml"); + if !config_path.exists() { + println!( + "[--] Codex config.toml: not found; hook feature defaults apply ({})", + config_path.display() + ); + return Ok(()); } - if local_agents_md.exists() { - let content = fs::read_to_string(&local_agents_md)?; - if has_rtk_reference(&content, &[RTK_MD_REF]) { - println!("[ok] Local AGENTS.md: @RTK.md reference"); + let content = fs::read_to_string(&config_path) + .with_context(|| format!("Failed to read Codex config: {}", config_path.display()))?; + let parsed: toml::Value = toml::from_str(&content) + .with_context(|| format!("Failed to parse Codex config: {}", config_path.display()))?; + + print_codex_feature_line("hooks", codex_feature_value(&parsed, "hooks")); + print_codex_feature_line("plugin_hooks", codex_feature_value(&parsed, "plugin_hooks")); + Ok(()) +} + +fn codex_feature_value(config: &toml::Value, name: &str) -> Option { + config + .get("features") + .and_then(|features| features.get(name)) + .and_then(|value| value.as_bool()) +} + +fn print_codex_feature_line(name: &str, state: Option) { + match state { + Some(true) => println!("[ok] Codex feature {name}: enabled"), + Some(false) => println!("[warn] Codex feature {name}: disabled"), + None => println!("[--] Codex feature {name}: not set; Codex default applies"), + } +} + +fn print_codex_legacy_scope(label: &str, paths: &CodexPluginPaths, global: bool) -> Result<()> { + let mut refs = vec![RTK_MD_REF.to_string()]; + if global { + if let Some(parent) = paths.legacy_rtk_md_path.parent() { + refs.push(codex_rtk_md_ref(parent)); + } + } + let ref_slices = refs.iter().map(String::as_str).collect::>(); + + if paths.legacy_rtk_md_path.exists() { + println!( + "[!!] {label} legacy RTK.md: {}", + paths.legacy_rtk_md_path.display() + ); + } else { + println!("[ok] {label} legacy RTK.md: not present"); + } + + if paths.legacy_agents_md_path.exists() { + let content = fs::read_to_string(&paths.legacy_agents_md_path)?; + if has_rtk_reference(&content, &ref_slices) { + println!("[!!] {label} legacy AGENTS.md: RTK.md reference"); } else if content.contains(RTK_BLOCK_START) { - println!("[!!] Local AGENTS.md: old inline RTK block"); + println!("[!!] {label} legacy AGENTS.md: old inline RTK block"); } else { - println!("[--] Local AGENTS.md: exists but rtk not configured"); + println!("[ok] {label} legacy AGENTS.md: no RTK-managed reference"); } } else { - println!("[--] Local AGENTS.md: not found"); + println!("[ok] {label} legacy AGENTS.md: not present"); } - println!("\nUsage:"); - println!(" rtk init --codex # Configure local AGENTS.md + RTK.md"); - println!(" rtk init -g --codex # Configure $CODEX_HOME/AGENTS.md + $CODEX_HOME/RTK.md (or ~/.codex/)"); - println!(" rtk init -g --codex --uninstall # Remove global Codex RTK artifacts"); - Ok(()) } @@ -3944,6 +4407,22 @@ mod tests { use super::*; use tempfile::TempDir; + fn codex_test_paths(temp: &TempDir) -> CodexPluginPaths { + CodexPluginPaths { + plugin_dir: temp.path().join(PLUGIN_SUBDIR).join(CODEX_PLUGIN_NAME), + marketplace_path: temp + .path() + .join(".agents") + .join(PLUGIN_SUBDIR) + .join("marketplace.json"), + marketplace_source_path: format!("./{PLUGIN_SUBDIR}/{CODEX_PLUGIN_NAME}"), + marketplace_name: CODEX_PLUGIN_MARKETPLACE_NAME, + marketplace_display_name: CODEX_PLUGIN_MARKETPLACE_DISPLAY_NAME, + legacy_agents_md_path: temp.path().join(AGENTS_MD), + legacy_rtk_md_path: temp.path().join(RTK_MD), + } + } + #[test] fn test_init_mentions_all_top_level_commands() { for cmd in [ @@ -4746,25 +5225,143 @@ mod tests { } #[test] - fn test_run_codex_mode_global_writes_absolute_reference_to_codex_dir() { + fn test_run_codex_mode_registers_plugin_package_and_marketplace() { let temp = TempDir::new().unwrap(); - let agents_md = temp.path().join("AGENTS.md"); - let rtk_md = temp.path().join("RTK.md"); + let paths = codex_test_paths(&temp); - run_codex_mode_with_paths( - agents_md.clone(), - rtk_md.clone(), - true, - InitContext::default(), - ) - .unwrap(); + run_codex_mode_with_paths(paths.clone(), false, InitContext::default()).unwrap(); - assert!(rtk_md.exists()); - assert_eq!(fs::read_to_string(&rtk_md).unwrap(), RTK_SLIM_CODEX); + assert!(paths + .plugin_dir + .join(".codex-plugin") + .join("plugin.json") + .exists()); + assert!(paths + .plugin_dir + .join(HOOKS_SUBDIR) + .join("run-rtk-codex-hook.sh") + .exists()); + assert!(marketplace_has_codex_plugin(&paths.marketplace_path)); + assert!( + !paths.legacy_rtk_md_path.exists(), + "Codex plugin setup must not create loose RTK.md" + ); + assert!( + !paths.legacy_agents_md_path.exists(), + "Codex plugin setup must not create AGENTS.md" + ); + } + + #[test] + fn test_codex_plugin_manifest_points_to_existing_paths() { + let manifest: serde_json::Value = serde_json::from_str(CODEX_PLUGIN_MANIFEST).unwrap(); + assert_eq!(manifest["name"], CODEX_PLUGIN_NAME); + assert_eq!(manifest["skills"], "./skills/"); + assert_eq!(manifest["hooks"], "./hooks/hooks.json"); + + let plugin_root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("hooks") + .join("codex") + .join(CODEX_PLUGIN_NAME); + assert!(plugin_root + .join("skills") + .join("rtk") + .join("SKILL.md") + .exists()); + assert!(plugin_root.join(HOOKS_SUBDIR).join(HOOKS_JSON).exists()); + assert!(plugin_root + .join(HOOKS_SUBDIR) + .join("run-rtk-codex-hook.sh") + .exists()); + } + + #[test] + fn test_codex_plugin_hooks_json_pretooluse_bash() { + let hooks: serde_json::Value = serde_json::from_str(CODEX_PLUGIN_HOOKS_JSON).unwrap(); + let pre_tool_use = hooks["hooks"][PRE_TOOL_USE_KEY].as_array().unwrap(); + let hook = &pre_tool_use[0]; + assert_eq!(hook["matcher"], "Bash"); assert_eq!( - fs::read_to_string(&agents_md).unwrap(), - format!("{}\n", codex_rtk_md_ref(temp.path())) + hook["hooks"][0]["statusMessage"], + "RTK is rewriting a Bash command for token-optimized output" ); + assert!(hook["hooks"][0]["command"] + .as_str() + .unwrap() + .contains("run-rtk-codex-hook.sh")); + } + + #[test] + fn test_codex_plugin_launcher_delegates_to_native_processor() { + assert!(CODEX_PLUGIN_LAUNCHER.contains("RTK_EXE")); + assert!(CODEX_PLUGIN_LAUNCHER.contains("command -v rtk")); + assert!(CODEX_PLUGIN_LAUNCHER.contains("hook codex")); + assert!(CODEX_PLUGIN_LAUNCHER.contains("exit 0")); + } + + #[test] + fn test_codex_marketplace_upsert_preserves_unrelated_plugins() { + let temp = TempDir::new().unwrap(); + let paths = codex_test_paths(&temp); + fs::create_dir_all(paths.marketplace_path.parent().unwrap()).unwrap(); + fs::write( + &paths.marketplace_path, + r#"{ + "name": "local", + "plugins": [ + { + "name": "other", + "source": { "source": "local", "path": "./plugins/other" }, + "policy": { "installation": "AVAILABLE", "authentication": "ON_INSTALL" }, + "category": "Developer Tools" + } + ] +}"#, + ) + .unwrap(); + + upsert_codex_plugin_marketplace_entry(&paths, InitContext::default()).unwrap(); + + let content = fs::read_to_string(&paths.marketplace_path).unwrap(); + let root: serde_json::Value = serde_json::from_str(&content).unwrap(); + let plugins = root["plugins"].as_array().unwrap(); + assert!(plugins + .iter() + .any(|plugin| plugin["name"].as_str() == Some("other"))); + assert!(plugins + .iter() + .any(|plugin| plugin["name"].as_str() == Some(CODEX_PLUGIN_NAME))); + } + + #[test] + fn test_codex_uninstall_preserves_unrelated_marketplace_plugins() { + let temp = TempDir::new().unwrap(); + let paths = codex_test_paths(&temp); + fs::create_dir_all(paths.marketplace_path.parent().unwrap()).unwrap(); + fs::write( + &paths.marketplace_path, + r#"{ + "name": "local", + "plugins": [ + { + "name": "other", + "source": { "source": "local", "path": "./plugins/other" }, + "policy": { "installation": "AVAILABLE", "authentication": "ON_INSTALL" }, + "category": "Developer Tools" + } + ] +}"#, + ) + .unwrap(); + upsert_codex_plugin_marketplace_entry(&paths, InitContext::default()).unwrap(); + + remove_codex_plugin_marketplace_entry(&paths, InitContext::default()).unwrap(); + + let content = fs::read_to_string(&paths.marketplace_path).unwrap(); + let root: serde_json::Value = serde_json::from_str(&content).unwrap(); + let plugins = root["plugins"].as_array().unwrap(); + assert_eq!(plugins.len(), 1); + assert_eq!(plugins[0]["name"], "other"); } #[test] @@ -4810,21 +5407,24 @@ mod tests { #[test] fn test_uninstall_codex_at_is_idempotent() { let temp = TempDir::new().unwrap(); - let codex_dir = temp.path(); - let agents_md = codex_dir.join("AGENTS.md"); - let rtk_md = codex_dir.join("RTK.md"); + let paths = codex_test_paths(&temp); + let agents_md = &paths.legacy_agents_md_path; + let rtk_md = &paths.legacy_rtk_md_path; - fs::write(&agents_md, "# Team rules\n\n@RTK.md\n").unwrap(); - fs::write(&rtk_md, "codex config").unwrap(); + install_codex_plugin_package(&paths, InitContext::default()).unwrap(); + fs::write(agents_md, "# Team rules\n\n@RTK.md\n").unwrap(); + fs::write(rtk_md, RTK_SLIM_CODEX).unwrap(); - let removed_first = uninstall_codex_at(codex_dir, InitContext::default()).unwrap(); - let removed_second = uninstall_codex_at(codex_dir, InitContext::default()).unwrap(); + let removed_first = uninstall_codex_at(&paths, false, InitContext::default()).unwrap(); + let removed_second = uninstall_codex_at(&paths, false, InitContext::default()).unwrap(); - assert_eq!(removed_first.len(), 2); + assert_eq!(removed_first.len(), 4); assert!(removed_second.is_empty()); assert!(!rtk_md.exists()); + assert!(!paths.plugin_dir.exists()); + assert!(!marketplace_has_codex_plugin(&paths.marketplace_path)); - let content = fs::read_to_string(&agents_md).unwrap(); + let content = fs::read_to_string(agents_md).unwrap(); assert!(!content.contains("@RTK.md")); assert!(content.contains("# Team rules")); } @@ -4832,22 +5432,50 @@ mod tests { #[test] fn test_uninstall_codex_at_removes_absolute_reference() { let temp = TempDir::new().unwrap(); - let codex_dir = temp.path(); - let agents_md = codex_dir.join("AGENTS.md"); - let rtk_md = codex_dir.join("RTK.md"); - let absolute_ref = codex_rtk_md_ref(codex_dir); + let mut paths = codex_test_paths(&temp); + paths.legacy_agents_md_path = temp.path().join(CODEX_DIR).join("AGENTS.md"); + paths.legacy_rtk_md_path = temp.path().join(CODEX_DIR).join("RTK.md"); + let absolute_ref = codex_rtk_md_ref( + paths + .legacy_rtk_md_path + .parent() + .expect("RTK.md test path has parent"), + ); + fs::create_dir_all(paths.legacy_rtk_md_path.parent().unwrap()).unwrap(); - fs::write(&agents_md, format!("# Team rules\n\n{}\n", absolute_ref)).unwrap(); - fs::write(&rtk_md, "codex config").unwrap(); + fs::write( + &paths.legacy_agents_md_path, + format!("# Team rules\n\n{}\n", absolute_ref), + ) + .unwrap(); + fs::write(&paths.legacy_rtk_md_path, RTK_SLIM_CODEX).unwrap(); - let removed = uninstall_codex_at(codex_dir, InitContext::default()).unwrap(); + let removed = uninstall_codex_at(&paths, true, InitContext::default()).unwrap(); assert_eq!(removed.len(), 2); - let content = fs::read_to_string(&agents_md).unwrap(); + let content = fs::read_to_string(&paths.legacy_agents_md_path).unwrap(); assert!(!content.contains(&absolute_ref)); assert!(content.contains("# Team rules")); } + #[test] + fn test_uninstall_codex_at_preserves_unmanaged_rtk_md_and_reference() { + let temp = TempDir::new().unwrap(); + let paths = codex_test_paths(&temp); + let agents_md = &paths.legacy_agents_md_path; + let rtk_md = &paths.legacy_rtk_md_path; + let custom_content = "# Team-owned RTK notes\n"; + + fs::write(agents_md, "# Team rules\n\n@RTK.md\n").unwrap(); + fs::write(rtk_md, custom_content).unwrap(); + + let removed = uninstall_codex_at(&paths, false, InitContext::default()).unwrap(); + + assert!(removed.is_empty()); + assert_eq!(fs::read_to_string(rtk_md).unwrap(), custom_content); + assert!(fs::read_to_string(agents_md).unwrap().contains("@RTK.md")); + } + #[test] fn test_write_if_changed_dry_run_does_not_create_file() { let temp = TempDir::new().unwrap(); @@ -4903,13 +5531,11 @@ mod tests { #[test] fn test_run_codex_mode_dry_run_writes_nothing() { let temp = TempDir::new().unwrap(); - let agents_md = temp.path().join("AGENTS.md"); - let rtk_md = temp.path().join("RTK.md"); + let paths = codex_test_paths(&temp); run_codex_mode_with_paths( - agents_md.clone(), - rtk_md.clone(), - true, + paths.clone(), + false, InitContext { dry_run: true, ..Default::default() @@ -4918,37 +5544,37 @@ mod tests { .unwrap(); assert!( - !rtk_md.exists(), - "dry-run must not create RTK.md: {}", - rtk_md.display() + !paths.plugin_dir.exists(), + "dry-run must not create plugin package: {}", + paths.plugin_dir.display() ); assert!( - !agents_md.exists(), - "dry-run must not create AGENTS.md: {}", - agents_md.display() + !paths.marketplace_path.exists(), + "dry-run must not create marketplace: {}", + paths.marketplace_path.display() ); } #[test] fn test_uninstall_codex_at_removes_rtk_instructions_block() { let temp = TempDir::new().unwrap(); - let codex_dir = temp.path(); - let agents_md = codex_dir.join("AGENTS.md"); - let rtk_md = codex_dir.join("RTK.md"); + let paths = codex_test_paths(&temp); + let agents_md = &paths.legacy_agents_md_path; + let rtk_md = &paths.legacy_rtk_md_path; fs::write( - &agents_md, + agents_md, format!( "# Team rules\n\n{} v2 -->\nOLD RTK STUFF\n{}\n\nMore content", RTK_BLOCK_START, RTK_BLOCK_END ), ) .unwrap(); - fs::write(&rtk_md, "codex config").unwrap(); + fs::write(rtk_md, RTK_SLIM_CODEX).unwrap(); - let removed = uninstall_codex_at(codex_dir, InitContext::default()).unwrap(); + let removed = uninstall_codex_at(&paths, false, InitContext::default()).unwrap(); - let content = fs::read_to_string(&agents_md).unwrap(); + let content = fs::read_to_string(agents_md).unwrap(); assert!(!content.contains("OLD RTK STUFF")); assert!(content.contains("# Team rules")); assert!(content.contains("More content")); @@ -5544,6 +6170,7 @@ mod tests { use std::sync::Mutex; static CLAUDE_DIR_LOCK: Mutex<()> = Mutex::new(()); + static CODEX_ENV_LOCK: Mutex<()> = Mutex::new(()); static PI_DIR_LOCK: Mutex<()> = Mutex::new(()); /// Serialises all tests that mutate the process-wide working directory. static CWD_LOCK: Mutex<()> = Mutex::new(()); @@ -5562,6 +6189,28 @@ mod tests { } } + fn with_codex_env_override(tmp: &TempDir, f: F) { + let _guard = CODEX_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let home_dir = tmp.path().join("home"); + let codex_dir = tmp.path().join("codex"); + fs::create_dir_all(&home_dir).unwrap(); + fs::create_dir_all(&codex_dir).unwrap(); + + let orig_home = std::env::var_os("HOME"); + let orig_codex_home = std::env::var_os("CODEX_HOME"); + std::env::set_var("HOME", &home_dir); + std::env::set_var("CODEX_HOME", &codex_dir); + f(&home_dir, &codex_dir); + match orig_codex_home { + Some(v) => std::env::set_var("CODEX_HOME", v), + None => std::env::remove_var("CODEX_HOME"), + } + match orig_home { + Some(v) => std::env::set_var("HOME", v), + None => std::env::remove_var("HOME"), + } + } + fn with_pi_dir_override(tmp: &TempDir, f: F) { let _guard = PI_DIR_LOCK.lock().unwrap_or_else(|e| e.into_inner()); let pi_dir = tmp.path().join("pi_agent"); @@ -5576,6 +6225,73 @@ mod tests { } } + #[test] + fn test_run_codex_mode_global_registers_plugin_package_and_marketplace() { + let tmp = TempDir::new().unwrap(); + with_codex_env_override(&tmp, |home_dir, codex_dir| { + run_codex_mode(true, InitContext::default()).unwrap(); + + let paths = codex_plugin_paths(true).unwrap(); + assert_eq!( + paths.plugin_dir, + codex_dir.join(PLUGIN_SUBDIR).join(CODEX_PLUGIN_NAME) + ); + assert_eq!( + paths.marketplace_path, + home_dir + .join(".agents") + .join(PLUGIN_SUBDIR) + .join("marketplace.json") + ); + assert!(paths + .plugin_dir + .join(".codex-plugin") + .join("plugin.json") + .exists()); + assert!(paths + .plugin_dir + .join(HOOKS_SUBDIR) + .join("run-rtk-codex-hook.sh") + .exists()); + assert!(marketplace_has_codex_plugin(&paths.marketplace_path)); + assert!(!paths.legacy_rtk_md_path.exists()); + assert!(!paths.legacy_agents_md_path.exists()); + }); + } + + #[test] + fn test_show_codex_config_handles_installed_plugin_and_feature_flags() { + let tmp = TempDir::new().unwrap(); + with_codex_env_override(&tmp, |_home_dir, codex_dir| { + fs::write( + codex_dir.join("config.toml"), + "[features]\nhooks = true\nplugin_hooks = false\n", + ) + .unwrap(); + + let project_dir = tmp.path().join("project"); + fs::create_dir_all(&project_dir).unwrap(); + let _cwd_guard = CWD_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let cwd = std::env::current_dir().unwrap(); + std::env::set_current_dir(&project_dir).unwrap(); + + let result = (|| -> Result<()> { + run_codex_mode(false, InitContext::default())?; + run_codex_mode(true, InitContext::default())?; + show_codex_config() + })(); + std::env::set_current_dir(&cwd).unwrap(); + + result.unwrap(); + assert!(project_dir + .join(PLUGIN_SUBDIR) + .join(CODEX_PLUGIN_NAME) + .join(".codex-plugin") + .join("plugin.json") + .exists()); + }); + } + #[test] fn test_global_default_mode_creates_artifacts() { let tmp = TempDir::new().unwrap(); diff --git a/src/main.rs b/src/main.rs index 22e6cbca8..e8a3f42ab 100644 --- a/src/main.rs +++ b/src/main.rs @@ -369,7 +369,7 @@ enum Commands { #[arg(long)] uninstall: bool, - /// Target Codex CLI (uses AGENTS.md + RTK.md, no Claude hook patching) + /// Target Codex CLI (registers the RTK Codex plugin package) #[arg(long)] codex: bool, @@ -775,6 +775,8 @@ enum HookCommands { Gemini, /// Process Copilot preToolUse hook (VS Code + Copilot CLI, reads JSON from stdin) Copilot, + /// Process Codex PreToolUse hook (reads JSON from stdin) + Codex, /// Check how a command would be rewritten by the hook engine (dry-run) Check { /// Target agent @@ -2191,6 +2193,10 @@ fn run_cli() -> Result { hooks::hook_cmd::run_copilot()?; 0 } + HookCommands::Codex => { + hooks::hook_cmd::run_codex()?; + 0 + } HookCommands::Check { agent: _, command } => { use crate::discover::registry::rewrite_command; let raw = command.join(" "); @@ -2840,6 +2846,17 @@ mod tests { )); } + #[test] + fn test_hook_codex_parses() { + let cli = Cli::try_parse_from(["rtk", "hook", "codex"]).unwrap(); + assert!(matches!( + cli.command, + Commands::Hook { + command: HookCommands::Codex + } + )); + } + #[test] fn test_hook_check_parses() { let cli = Cli::try_parse_from(["rtk", "hook", "check", "git", "status"]).unwrap(); From 9f753e5a8370d93253291da4ea37bba6cf423b7d Mon Sep 17 00:00:00 2001 From: AZ-LL Date: Sat, 23 May 2026 21:28:05 -0700 Subject: [PATCH 2/3] fix(codex): use native hook commands --- .../guide/getting-started/supported-agents.md | 2 +- hooks/README.md | 4 +- hooks/codex/README.md | 7 +- hooks/codex/rtk-codex/hooks/hooks.json | 3 +- .../rtk-codex/hooks/run-rtk-codex-hook.sh | 45 ------ src/hooks/init.rs | 141 ++++++++++++++---- 6 files changed, 120 insertions(+), 82 deletions(-) delete mode 100644 hooks/codex/rtk-codex/hooks/run-rtk-codex-hook.sh diff --git a/docs/guide/getting-started/supported-agents.md b/docs/guide/getting-started/supported-agents.md index 663f0942c..8da59600f 100644 --- a/docs/guide/getting-started/supported-agents.md +++ b/docs/guide/getting-started/supported-agents.md @@ -146,7 +146,7 @@ rtk init --codex # registers local RTK Codex plugin marketplace rtk init -g --codex # registers personal RTK Codex plugin marketplace ``` -The Codex plugin bundles an RTK skill and a Bash `PreToolUse` hook. After installation, restart Codex, enable or install the RTK plugin if Codex prompts for it, and review/trust the plugin hook in `/hooks`. Codex currently supports rewritten input only with `permissionDecision: "allow"`, so RTK does not emit unsupported `ask` rewrites. +The Codex plugin bundles an RTK skill and a Bash `PreToolUse` hook. The hook matches Codex's Bash tool payloads, but the installed command delegates directly to the native RTK binary with `rtk hook codex` on Linux/macOS and `rtk.exe hook codex` on Windows. After installation, restart Codex, enable or install the RTK plugin if Codex prompts for it, and review/trust the plugin hook in `/hooks`. Codex currently supports rewritten input only with `permissionDecision: "allow"`, so RTK does not emit unsupported `ask` rewrites. ### Kilo Code diff --git a/hooks/README.md b/hooks/README.md index babc9c00b..8b75d5a77 100644 --- a/hooks/README.md +++ b/hooks/README.md @@ -38,7 +38,7 @@ Each agent subdirectory has its own README with hook-specific details: - **[`cursor/`](cursor/README.md)** — Shell hook, Cursor JSON format, empty `{}` response requirement - **[`cline/`](cline/README.md)** — Rules file (prompt-level), `.clinerules` project-local installation - **[`windsurf/`](windsurf/README.md)** — Rules file (prompt-level), `.windsurfrules` workspace-scoped -- **[`codex/`](codex/README.md)** — Codex plugin package, bundled RTK skill, `PreToolUse` hook launcher, local marketplace registration +- **[`codex/`](codex/README.md)** — Codex plugin package, bundled RTK skill, native `PreToolUse` hook command, local marketplace registration - **[`opencode/`](opencode/README.md)** — TypeScript plugin, `zx` library, `tool.execute.before` event, in-place mutation - **[`pi/`](pi/README.md)** — TypeScript extension, `tool_call` event, `isToolCallEventType` guard, in-place mutation, `~/.pi/agent/extensions/` - **[`hermes/`](hermes/README.md)** — Python plugin, `pre_tool_call` hook, in-place terminal command mutation @@ -133,6 +133,8 @@ Returns `{}` when no rewrite (Cursor requires JSON for all paths). ### Codex CLI (Plugin Hook) +The plugin hook matcher is `Bash`, because Codex exposes shell tool calls under that tool name. The hook command itself is native: `rtk hook codex` on Linux/macOS and `rtk.exe hook codex` through `commandWindows` on native Windows. + **Input** (stdin): ```json diff --git a/hooks/codex/README.md b/hooks/codex/README.md index 1e109b0bc..99b7c3853 100644 --- a/hooks/codex/README.md +++ b/hooks/codex/README.md @@ -10,8 +10,7 @@ The source package lives at [`rtk-codex/`](rtk-codex/): - `.codex-plugin/plugin.json` -- Codex plugin metadata, skill path, and hook path - `skills/rtk/SKILL.md` -- RTK usage, rewrite, opt-out, and validation guidance for Codex -- `hooks/hooks.json` -- `PreToolUse` hook for Bash tool calls -- `hooks/run-rtk-codex-hook.sh` -- thin launcher that delegates stdin to `rtk hook codex` +- `hooks/hooks.json` -- `PreToolUse` hook for Bash tool calls that invokes `rtk hook codex` ## Install Paths @@ -22,7 +21,9 @@ Uninstall removes only the RTK plugin package, its marketplace entry, and legacy ## Hook Behavior -The hook launcher resolves `RTK_EXE` first, falls back to `rtk` on `PATH`, forwards the original Codex hook payload to `rtk hook codex`, and fails open when RTK is unavailable. +The hook config invokes the native RTK hook processor directly. On Linux and macOS it uses `rtk hook codex`; on native Windows it uses `commandWindows` with `rtk.exe hook codex`, so Windows does not need Bash, a `.sh` launcher, POSIX executable bits, or POSIX environment expansion. + +If `RTK_EXE` is set when `rtk init --codex` runs, RTK writes that executable into the installed hook command. Otherwise, the installed hook resolves `rtk` or `rtk.exe` from `PATH`. Without the old shell launcher, RTK cannot emit a pre-launch advisory if the configured executable is missing, so users should rerun `rtk init --codex` after moving the RTK binary or changing `RTK_EXE`. `rtk hook codex` rewrites only `PreToolUse` payloads for `tool_name = "Bash"` with a string `tool_input.command`. Empty input, malformed JSON, unsupported tools, missing commands, unsupported commands, already-RTK commands, and heredocs exit successfully without hook output. diff --git a/hooks/codex/rtk-codex/hooks/hooks.json b/hooks/codex/rtk-codex/hooks/hooks.json index 96b843383..8d86c2e91 100644 --- a/hooks/codex/rtk-codex/hooks/hooks.json +++ b/hooks/codex/rtk-codex/hooks/hooks.json @@ -6,7 +6,8 @@ "hooks": [ { "type": "command", - "command": "bash \"$PLUGIN_ROOT/hooks/run-rtk-codex-hook.sh\"", + "command": "rtk hook codex", + "commandWindows": "rtk.exe hook codex", "statusMessage": "RTK is rewriting a Bash command for token-optimized output", "timeout": 30 } diff --git a/hooks/codex/rtk-codex/hooks/run-rtk-codex-hook.sh b/hooks/codex/rtk-codex/hooks/run-rtk-codex-hook.sh deleted file mode 100644 index 8f1163625..000000000 --- a/hooks/codex/rtk-codex/hooks/run-rtk-codex-hook.sh +++ /dev/null @@ -1,45 +0,0 @@ -#!/usr/bin/env bash - -set -u - -payload="$(cat)" - -json_escape() { - local value="$1" - value="${value//\\/\\\\}" - value="${value//\"/\\\"}" - value="${value//$'\n'/\\n}" - value="${value//$'\r'/\\r}" - value="${value//$'\t'/\\t}" - printf '%s' "$value" -} - -write_advisory() { - local message - message="$(json_escape "$1")" - printf '{"systemMessage":"%s"}\n' "$message" -} - -resolve_rtk() { - if [[ -n "${RTK_EXE:-}" ]]; then - if [[ "${RTK_EXE}" == */* ]]; then - if [[ -x "${RTK_EXE}" ]]; then - printf '%s\n' "${RTK_EXE}" - return 0 - fi - return 1 - fi - - command -v -- "${RTK_EXE}" - return $? - fi - - command -v rtk -} - -if ! rtk_exe="$(resolve_rtk)"; then - write_advisory "RTK Codex plugin hook could not find the rtk executable. The original Bash command will run unchanged. Set RTK_EXE or add rtk to PATH." - exit 0 -fi - -printf '%s' "$payload" | "$rtk_exe" hook codex || exit 0 diff --git a/src/hooks/init.rs b/src/hooks/init.rs index 3a4c09050..a14dd7116 100644 --- a/src/hooks/init.rs +++ b/src/hooks/init.rs @@ -40,8 +40,9 @@ const CODEX_PLUGIN_MANIFEST: &str = include_str!("../../hooks/codex/rtk-codex/.codex-plugin/plugin.json"); const CODEX_PLUGIN_SKILL: &str = include_str!("../../hooks/codex/rtk-codex/skills/rtk/SKILL.md"); const CODEX_PLUGIN_HOOKS_JSON: &str = include_str!("../../hooks/codex/rtk-codex/hooks/hooks.json"); -const CODEX_PLUGIN_LAUNCHER: &str = - include_str!("../../hooks/codex/rtk-codex/hooks/run-rtk-codex-hook.sh"); +const CODEX_HOOK_COMMAND: &str = "rtk hook codex"; +const CODEX_HOOK_COMMAND_WINDOWS: &str = "rtk.exe hook codex"; +const RTK_EXE_ENV: &str = "RTK_EXE"; /// Template written by `rtk init` when no filters.toml exists yet. const FILTERS_TEMPLATE: &str = r#"# Project-local RTK filters — commit this file with your repo. @@ -2308,6 +2309,7 @@ fn run_codex_mode_with_paths( } fn install_codex_plugin_package(paths: &CodexPluginPaths, ctx: InitContext) -> Result<()> { + let hooks_json = codex_plugin_hooks_json(&codex_hook_commands())?; write_codex_plugin_file( &paths.plugin_dir.join(".codex-plugin").join("plugin.json"), CODEX_PLUGIN_MANIFEST, @@ -2322,32 +2324,70 @@ fn install_codex_plugin_package(paths: &CodexPluginPaths, ctx: InitContext) -> R )?; write_codex_plugin_file( &paths.plugin_dir.join(HOOKS_SUBDIR).join(HOOKS_JSON), - CODEX_PLUGIN_HOOKS_JSON, + &hooks_json, "Codex plugin hooks", ctx, )?; - let launcher_path = paths - .plugin_dir - .join(HOOKS_SUBDIR) - .join("run-rtk-codex-hook.sh"); - write_codex_plugin_file( - &launcher_path, - CODEX_PLUGIN_LAUNCHER, - "Codex plugin hook launcher", - ctx, - )?; - - #[cfg(unix)] - if !ctx.dry_run && launcher_path.exists() { - use std::os::unix::fs::PermissionsExt; - fs::set_permissions(&launcher_path, fs::Permissions::from_mode(0o755)) - .with_context(|| format!("Failed to chmod {}", launcher_path.display()))?; - } upsert_codex_plugin_marketplace_entry(paths, ctx)?; Ok(()) } +#[derive(Debug, Clone, PartialEq, Eq)] +struct CodexHookCommands { + command: String, + command_windows: String, +} + +fn codex_hook_commands() -> CodexHookCommands { + let override_exe = std::env::var(RTK_EXE_ENV) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()); + codex_hook_commands_from_override(override_exe.as_deref()) +} + +fn codex_hook_commands_from_override(override_exe: Option<&str>) -> CodexHookCommands { + match override_exe { + Some(exe) => CodexHookCommands { + command: format!("{} hook codex", quote_posix_command_program(exe)), + command_windows: format!("{} hook codex", quote_windows_command_program(exe)), + }, + None => CodexHookCommands { + command: CODEX_HOOK_COMMAND.to_string(), + command_windows: CODEX_HOOK_COMMAND_WINDOWS.to_string(), + }, + } +} + +fn quote_posix_command_program(program: &str) -> String { + format!("'{}'", program.replace('\'', "'\\''")) +} + +fn quote_windows_command_program(program: &str) -> String { + format!("\"{}\"", program.replace('"', "\\\"")) +} + +fn codex_plugin_hooks_json(commands: &CodexHookCommands) -> Result { + let mut root: serde_json::Value = + serde_json::from_str(CODEX_PLUGIN_HOOKS_JSON).context("Invalid Codex plugin hooks JSON")?; + let pointer = format!("/hooks/{PRE_TOOL_USE_KEY}/0/hooks/0"); + let hook = root + .pointer_mut(&pointer) + .context("Codex plugin hooks JSON command entry is missing")? + .as_object_mut() + .context("Codex plugin hooks JSON command entry is not an object")?; + hook.insert( + "command".into(), + serde_json::Value::String(commands.command.clone()), + ); + hook.insert( + "commandWindows".into(), + serde_json::Value::String(commands.command_windows.clone()), + ); + serde_json::to_string_pretty(&root).context("Failed to serialize Codex plugin hooks JSON") +} + fn write_codex_plugin_file( path: &Path, content: &str, @@ -5239,8 +5279,16 @@ mod tests { assert!(paths .plugin_dir .join(HOOKS_SUBDIR) - .join("run-rtk-codex-hook.sh") + .join(HOOKS_JSON) .exists()); + assert!( + !paths + .plugin_dir + .join(HOOKS_SUBDIR) + .join("run-rtk-codex-hook.sh") + .exists(), + "Codex plugin must not install a Unix-only shell launcher" + ); assert!(marketplace_has_codex_plugin(&paths.marketplace_path)); assert!( !paths.legacy_rtk_md_path.exists(), @@ -5269,7 +5317,7 @@ mod tests { .join("SKILL.md") .exists()); assert!(plugin_root.join(HOOKS_SUBDIR).join(HOOKS_JSON).exists()); - assert!(plugin_root + assert!(!plugin_root .join(HOOKS_SUBDIR) .join("run-rtk-codex-hook.sh") .exists()); @@ -5285,18 +5333,44 @@ mod tests { hook["hooks"][0]["statusMessage"], "RTK is rewriting a Bash command for token-optimized output" ); - assert!(hook["hooks"][0]["command"] - .as_str() - .unwrap() - .contains("run-rtk-codex-hook.sh")); + assert_eq!(hook["hooks"][0]["command"], CODEX_HOOK_COMMAND); + assert_eq!( + hook["hooks"][0]["commandWindows"], + CODEX_HOOK_COMMAND_WINDOWS + ); + let command = hook["hooks"][0]["command"].as_str().unwrap(); + assert!(!command.contains("bash")); + assert!(!command.contains("PLUGIN_ROOT")); + assert!(!command.contains(".sh")); } #[test] - fn test_codex_plugin_launcher_delegates_to_native_processor() { - assert!(CODEX_PLUGIN_LAUNCHER.contains("RTK_EXE")); - assert!(CODEX_PLUGIN_LAUNCHER.contains("command -v rtk")); - assert!(CODEX_PLUGIN_LAUNCHER.contains("hook codex")); - assert!(CODEX_PLUGIN_LAUNCHER.contains("exit 0")); + fn test_codex_hook_commands_support_executable_override() { + let commands = codex_hook_commands_from_override(Some("/opt/RTK Builds/rtk")); + + assert_eq!(commands.command, "'/opt/RTK Builds/rtk' hook codex"); + assert_eq!( + commands.command_windows, + "\"/opt/RTK Builds/rtk\" hook codex" + ); + } + + #[test] + fn test_codex_plugin_hooks_json_accepts_generated_commands() { + let commands = codex_hook_commands_from_override(Some("/opt/rtk/bin/rtk")); + let hooks_json = codex_plugin_hooks_json(&commands).unwrap(); + let hooks: serde_json::Value = serde_json::from_str(&hooks_json).unwrap(); + let command_hook = &hooks["hooks"][PRE_TOOL_USE_KEY][0]["hooks"][0]; + + assert_eq!(command_hook["command"], "'/opt/rtk/bin/rtk' hook codex"); + assert_eq!( + command_hook["commandWindows"], + "\"/opt/rtk/bin/rtk\" hook codex" + ); + assert_eq!( + command_hook["statusMessage"], + "RTK is rewriting a Bash command for token-optimized output" + ); } #[test] @@ -6249,6 +6323,11 @@ mod tests { .join("plugin.json") .exists()); assert!(paths + .plugin_dir + .join(HOOKS_SUBDIR) + .join(HOOKS_JSON) + .exists()); + assert!(!paths .plugin_dir .join(HOOKS_SUBDIR) .join("run-rtk-codex-hook.sh") From f4b506d592b22fc638e24cf7e905cfac488245c4 Mon Sep 17 00:00:00 2001 From: Aurel Hasanah <54573223+xuegaoge@users.noreply.github.com> Date: Sun, 24 May 2026 19:31:44 +0800 Subject: [PATCH 3/3] docs: document Codex PreToolUse plugin setup --- .../guide/getting-started/supported-agents.md | 2 +- docs/usage/CODEX_PRETOOLUSE_ADAPTER.md | 107 ++++++++++++++++++ hooks/README.md | 8 +- hooks/codex/README.md | 2 + src/hooks/README.md | 2 +- src/hooks/hook_cmd.rs | 40 ++++++- 6 files changed, 153 insertions(+), 8 deletions(-) create mode 100644 docs/usage/CODEX_PRETOOLUSE_ADAPTER.md diff --git a/docs/guide/getting-started/supported-agents.md b/docs/guide/getting-started/supported-agents.md index 8da59600f..7c478dcd2 100644 --- a/docs/guide/getting-started/supported-agents.md +++ b/docs/guide/getting-started/supported-agents.md @@ -146,7 +146,7 @@ rtk init --codex # registers local RTK Codex plugin marketplace rtk init -g --codex # registers personal RTK Codex plugin marketplace ``` -The Codex plugin bundles an RTK skill and a Bash `PreToolUse` hook. The hook matches Codex's Bash tool payloads, but the installed command delegates directly to the native RTK binary with `rtk hook codex` on Linux/macOS and `rtk.exe hook codex` on Windows. After installation, restart Codex, enable or install the RTK plugin if Codex prompts for it, and review/trust the plugin hook in `/hooks`. Codex currently supports rewritten input only with `permissionDecision: "allow"`, so RTK does not emit unsupported `ask` rewrites. +The Codex plugin bundles an RTK skill and a Bash `PreToolUse` hook. The hook matches Codex's Bash tool payloads, but the installed command delegates directly to the native RTK binary with `rtk hook codex` on Linux/macOS and `rtk.exe hook codex` on Windows. After installation, restart Codex, enable or install the RTK plugin if Codex prompts for it, and review/trust the plugin hook in `/hooks`. Codex currently supports rewritten input only with `permissionDecision: "allow"`, so RTK does not emit unsupported `ask` rewrites. RTK preserves the original Bash `tool_input` and replaces only `command`. ### Kilo Code diff --git a/docs/usage/CODEX_PRETOOLUSE_ADAPTER.md b/docs/usage/CODEX_PRETOOLUSE_ADAPTER.md new file mode 100644 index 000000000..8ec519752 --- /dev/null +++ b/docs/usage/CODEX_PRETOOLUSE_ADAPTER.md @@ -0,0 +1,107 @@ +# Codex PreToolUse Adapter + +This guide installs RTK for Codex Desktop or Codex CLI with a native +`PreToolUse` hook. The hook rewrites supported Bash commands through RTK before +Codex executes them. + +## Quick setup + +Use this on machines that may or may not already have RTK installed. The +commands reuse a working RTK installation when `rtk gain` succeeds, and install +RTK only when it is missing or the wrong `rtk` binary is on `PATH`. + +```bash +set -eu + +if ! command -v rtk >/dev/null 2>&1 || ! rtk gain >/dev/null 2>&1; then + curl -fsSL https://raw.githubusercontent.com/rtk-ai/rtk/master/install.sh | sh +fi + +export PATH="$HOME/.local/bin:$PATH" + +rtk init -g --codex +``` + +Restart Codex, enable or install the RTK plugin if Codex prompts for it, then +review and trust the RTK hook from `/hooks` or the Codex settings panel. + +## What gets installed + +`rtk init -g --codex` registers the RTK Codex plugin in the user-level Codex +environment: + +- `$CODEX_HOME/plugins/rtk-codex/` or `~/.codex/plugins/rtk-codex/` +- `~/.agents/plugins/marketplace.json` +- A plugin-owned `PreToolUse` hook that runs `rtk hook codex` +- A bundled `$rtk` skill that explains RTK behavior and validation + +For project-local setup, run `rtk init --codex` inside the project. That writes +the local plugin package under the project plugin directory and registers it in +the local marketplace. + +## Behavior + +- Handles Codex `Bash` tool calls only. +- Uses RTK's native `rtk hook codex` processor, so new rewrite rules are picked + up by the installed RTK binary. +- Rewrites supported commands such as `git status` to `rtk git status`. +- Preserves the rest of Codex's original `tool_input`; only `command` is + replaced. +- Produces no hook output when RTK has no rewrite, the payload is malformed, the + tool is not Bash, the command is already RTK-prefixed, or the command contains + a heredoc. +- Returns rewritten input only with `permissionDecision: "allow"`, matching the + Codex hook contract. + +## Verify + +Check the RTK binary: + +```bash +rtk --version +rtk gain +``` + +Check the Codex installation: + +```bash +rtk init --show --codex +``` + +Preview a rewrite: + +```bash +rtk hook check git status +``` + +After Codex has restarted and the hook is trusted, run a simple Bash command +such as: + +```bash +git status +``` + +When the hook is active, Codex should execute the RTK-prefixed form and RTK +usage should appear in: + +```bash +rtk gain +``` + +## Uninstall + +For global setup: + +```bash +rtk init -g --codex --uninstall +``` + +For project-local setup: + +```bash +rtk init --codex --uninstall +``` + +Uninstall removes only RTK-managed plugin state, marketplace entries, and legacy +RTK-managed Codex guidance. Unrelated Codex plugins and marketplace entries are +preserved. diff --git a/hooks/README.md b/hooks/README.md index 8b75d5a77..78ca4a21e 100644 --- a/hooks/README.md +++ b/hooks/README.md @@ -141,7 +141,7 @@ The plugin hook matcher is `Bash`, because Codex exposes shell tool calls under { "hook_event_name": "PreToolUse", "tool_name": "Bash", - "tool_input": { "command": "git status" } + "tool_input": { "command": "git status", "timeout": 30000 } } ``` @@ -153,14 +153,12 @@ The plugin hook matcher is `Bash`, because Codex exposes shell tool calls under "hookEventName": "PreToolUse", "permissionDecision": "allow", "permissionDecisionReason": "RTK auto-rewrite", - "updatedInput": { "command": "rtk git status" } + "updatedInput": { "command": "rtk git status", "timeout": 30000 } } } ``` -Codex currently requires `permissionDecision: "allow"` when returning `updatedInput`; `permissionDecision: "ask"` with rewritten input is not supported. - -**Output**: Same as Claude Code format (with `updatedInput`). +Codex currently requires `permissionDecision: "allow"` when returning `updatedInput`; `permissionDecision: "ask"` with rewritten input is not supported. RTK preserves the rest of Codex's original `tool_input` and replaces only `command`. ### Gemini CLI (Rust Binary) diff --git a/hooks/codex/README.md b/hooks/codex/README.md index 99b7c3853..883ec441d 100644 --- a/hooks/codex/README.md +++ b/hooks/codex/README.md @@ -34,3 +34,5 @@ Codex currently supports rewritten input only with `permissionDecision: "allow"` Codex plugin hooks require the Codex `hooks` and `plugin_hooks` features to be active. After installation, restart Codex and use `/hooks` to review and trust the RTK plugin hook when Codex asks for hook trust. The legacy `rtk-awareness.md` file is retained only as compatibility context for previous instruction-only installs. + +For a user-facing setup and verification walkthrough, see [`../../docs/usage/CODEX_PRETOOLUSE_ADAPTER.md`](../../docs/usage/CODEX_PRETOOLUSE_ADAPTER.md). diff --git a/src/hooks/README.md b/src/hooks/README.md index 82fc5fef9..3c3d4f348 100644 --- a/src/hooks/README.md +++ b/src/hooks/README.md @@ -94,7 +94,7 @@ Rules are loaded from all Claude Code `settings.json` files (project + global, i - `permissions.rs` — loads deny/ask/allow rules, evaluates precedence, returns `PermissionVerdict` - `rewrite_cmd.rs` — maps verdict to exit code (consumed by shell hook) -- `hook_cmd.rs` — maps verdict to JSON `permissionDecision` field (Copilot/Gemini) +- `hook_cmd.rs` — maps verdict to JSON `permissionDecision` field (Copilot/Gemini/Codex) ## Exit Code Contract diff --git a/src/hooks/hook_cmd.rs b/src/hooks/hook_cmd.rs index ba70a5121..88374f390 100644 --- a/src/hooks/hook_cmd.rs +++ b/src/hooks/hook_cmd.rs @@ -437,6 +437,14 @@ fn process_codex_payload(v: &Value) -> PayloadAction { } }; + let updated_input = { + let mut tool_input = v.get("tool_input").cloned().unwrap_or_else(|| json!({})); + if let Some(obj) = tool_input.as_object_mut() { + obj.insert("command".into(), Value::String(rewritten.clone())); + } + tool_input + }; + PayloadAction::Rewrite { cmd: cmd.to_string(), rewritten: rewritten.clone(), @@ -445,7 +453,7 @@ fn process_codex_payload(v: &Value) -> PayloadAction { "hookEventName": PRE_TOOL_USE_KEY, "permissionDecision": "allow", "permissionDecisionReason": "RTK auto-rewrite", - "updatedInput": { "command": rewritten } + "updatedInput": updated_input } }), } @@ -886,6 +894,19 @@ mod tests { .to_string() } + fn codex_input_with_fields(tool: &str, cmd: &str, timeout: u64, description: &str) -> String { + json!({ + "hook_event_name": PRE_TOOL_USE_KEY, + "tool_name": tool, + "tool_input": { + "command": cmd, + "timeout": timeout, + "description": description + } + }) + .to_string() + } + #[test] fn test_codex_rewrite_git_status() { let result = run_codex_inner(&codex_input("Bash", "git status")).unwrap(); @@ -898,6 +919,23 @@ mod tests { assert_eq!(hook["updatedInput"]["command"], "rtk git status"); } + #[test] + fn test_codex_rewrite_preserves_tool_input_fields() { + let result = run_codex_inner(&codex_input_with_fields( + "Bash", + "git status", + 30000, + "Check repo", + )) + .unwrap(); + let v: Value = serde_json::from_str(&result).unwrap(); + let updated = &v["hookSpecificOutput"]["updatedInput"]; + + assert_eq!(updated["command"], "rtk git status"); + assert_eq!(updated["timeout"], 30000); + assert_eq!(updated["description"], "Check repo"); + } + #[test] fn test_codex_never_emits_ask_for_rewrite() { let result = run_codex_inner(&codex_input("Bash", "cargo test")).unwrap();