diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a1b2c261e8..abfeb89195 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -64,6 +64,189 @@ jobs: - uses: Swatinem/rust-cache@v2 - run: cargo test --all + codex-smoke-windows: + name: codex smoke (windows-latest, codex-cli ${{ matrix.codex_version }}) + needs: clippy + runs-on: windows-latest + strategy: + fail-fast: false + matrix: + include: + - codex_version: 0.119.0 + expect_hooks: "false" + - codex_version: 0.120.0 + expect_hooks: "true" + env: + CODEX_VERSION: ${{ matrix.codex_version }} + EXPECT_HOOKS: ${{ matrix.expect_hooks }} + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + + - name: Prepare Windows smoke-test paths + shell: pwsh + run: | + "CODEX_HOME=$(Join-Path $env:RUNNER_TEMP 'codex-home')" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + "RTK_INSTALL_ROOT=$(Join-Path $env:RUNNER_TEMP 'rtk-root')" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + "CODEX_STUB_DIR=$(Join-Path $env:RUNNER_TEMP 'codex-bin')" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + + - name: Install RTK from workspace + shell: pwsh + run: | + cargo install --path . --root "$env:RTK_INSTALL_ROOT" --force + "$env:RTK_INSTALL_ROOT\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append + + - name: Stub Codex CLI version for Windows hook support + shell: pwsh + run: | + New-Item -ItemType Directory -Force -Path "$env:CODEX_STUB_DIR" | Out-Null + @' + @echo off + echo codex-cli %CODEX_VERSION% + '@ | Set-Content -Path "$env:CODEX_STUB_DIR\codex.cmd" -Encoding ascii + "$env:CODEX_STUB_DIR" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append + New-Item -ItemType Directory -Force -Path "$env:CODEX_HOME" | Out-Null + + - name: Run Codex install flow + shell: pwsh + run: | + rtk --version + codex --version + rtk init -g --codex + rtk init --codex + + - name: Assert installed Codex artifacts + shell: pwsh + run: | + $globalAgents = Join-Path $env:CODEX_HOME 'AGENTS.md' + $globalRtk = Join-Path $env:CODEX_HOME 'RTK.md' + $globalConfig = Join-Path $env:CODEX_HOME 'config.toml' + $globalHooks = Join-Path $env:CODEX_HOME 'hooks.json' + $localAgents = Join-Path $PWD 'AGENTS.md' + $localRtk = Join-Path $PWD 'RTK.md' + $localConfig = Join-Path $PWD '.codex\config.toml' + $localHooks = Join-Path $PWD '.codex\hooks.json' + + foreach ($path in @($globalAgents, $globalRtk, $localAgents, $localRtk)) { + if (-not (Test-Path $path)) { + throw "Missing expected Codex artifact: $path" + } + } + + if (-not (Select-String -Path $globalAgents -Pattern '' -SimpleMatch -Quiet)) { + throw "Global AGENTS.md is missing the RTK inline block" + } + if (-not (Select-String -Path $localAgents -Pattern '' -SimpleMatch -Quiet)) { + throw "Local AGENTS.md is missing the RTK inline block" + } + + if ($env:EXPECT_HOOKS -eq 'true') { + foreach ($path in @($globalConfig, $globalHooks, $localConfig, $localHooks)) { + if (-not (Test-Path $path)) { + throw "Missing expected Codex hook artifact: $path" + } + } + if (-not (Select-String -Path $globalConfig -Pattern 'codex_hooks = true' -SimpleMatch -Quiet)) { + throw "Global config.toml is missing codex_hooks = true" + } + if (-not (Select-String -Path $localConfig -Pattern 'codex_hooks = true' -SimpleMatch -Quiet)) { + throw "Local config.toml is missing codex_hooks = true" + } + if (-not (Select-String -Path $globalHooks -Pattern 'rtk hook codex' -SimpleMatch -Quiet)) { + throw "Global hooks.json is missing the RTK Codex hook" + } + if (-not (Select-String -Path $localHooks -Pattern 'rtk hook codex' -SimpleMatch -Quiet)) { + throw "Local hooks.json is missing the RTK Codex hook" + } + } else { + foreach ($path in @($globalConfig, $globalHooks, $localConfig, $localHooks)) { + if (Test-Path $path) { + throw "Codex hook artifact should not exist for codex-cli $env:CODEX_VERSION: $path" + } + } + } + + - name: Assert show and verify output + shell: pwsh + run: | + $show = (& rtk init --show --codex | Out-String) + $verify = (& rtk verify | Out-String) + + $show | Set-Content -Path codex-show.txt -Encoding utf8 + $verify | Set-Content -Path codex-verify.txt -Encoding utf8 + + foreach ($expected in @( + 'Global AGENTS.md: RTK inline guidance configured', + 'Local AGENTS.md: RTK inline guidance configured' + )) { + if (-not $show.Contains($expected)) { + throw "Missing expected init --show output: $expected" + } + } + + foreach ($expected in @( + 'PASS Codex global AGENTS guidance configured', + 'PASS Codex local AGENTS guidance configured' + )) { + if (-not $verify.Contains($expected)) { + throw "Missing expected verify output: $expected" + } + } + + if ($env:EXPECT_HOOKS -eq 'true') { + foreach ($expected in @( + 'Global .codex/config.toml: codex_hooks enabled', + 'Global .codex/hooks.json: RTK PreToolUse configured', + 'Local .codex/config.toml: codex_hooks enabled', + 'Local .codex/hooks.json: RTK PreToolUse configured', + 'PASS Codex global hooks configured', + 'PASS Codex local hooks configured' + )) { + if (-not ($show.Contains($expected) -or $verify.Contains($expected))) { + throw "Missing expected supported-version output: $expected" + } + } + } else { + if (-not $show.Contains('detected 0.119.0')) { + throw 'Expected init --show output to mention detected codex-cli 0.119.0' + } + foreach ($expected in @( + 'Global .codex/config.toml: not found', + 'Global .codex/hooks.json: not found', + 'Local .codex/config.toml: not found', + 'Local .codex/hooks.json: not found', + 'SKIP Codex global hook verification not applicable', + 'SKIP Codex local hook verification not applicable' + )) { + if (-not ($show.Contains($expected) -or $verify.Contains($expected))) { + throw "Missing expected prompt-only output: $expected" + } + } + } + + - name: Assert Codex hook behavior + shell: pwsh + run: | + $denyInput = @{ tool_name = 'Bash'; tool_input = @{ command = 'git status' } } | ConvertTo-Json -Compress + $denyOutput = ($denyInput | & rtk hook codex | Out-String).Trim() + if ([string]::IsNullOrWhiteSpace($denyOutput)) { + throw 'Expected deny output for supported Codex Bash command' + } + $denyJson = $denyOutput | ConvertFrom-Json + if ($denyJson.hookSpecificOutput.permissionDecision -ne 'deny') { + throw "Expected permissionDecision=deny, got: $($denyJson.hookSpecificOutput.permissionDecision)" + } + if ($denyJson.hookSpecificOutput.permissionDecisionReason -notmatch 'rtk git status') { + throw "Expected deny reason to suggest `rtk git status`, got: $($denyJson.hookSpecificOutput.permissionDecisionReason)" + } + + $allowInput = @{ tool_name = 'Bash'; tool_input = @{ command = 'rtk git status' } } | ConvertTo-Json -Compress + $allowOutput = ($allowInput | & rtk hook codex | Out-String).Trim() + if (-not [string]::IsNullOrWhiteSpace($allowOutput)) { + throw "Expected no output for already-rewritten command, got: $allowOutput" + } + security: name: Security Scan needs: clippy diff --git a/CHANGELOG.md b/CHANGELOG.md index a453097765..964a05b710 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,9 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Features + +* **aws:** expand CLI filters from 8 to 25 subcommands — CloudWatch Logs, CloudFormation events, Lambda, IAM, DynamoDB (with type unwrapping), ECS tasks, EC2 security groups, S3API objects, S3 sync/cp, EKS, SQS, Secrets Manager ([#885](https://github.com/rtk-ai/rtk/pull/885)) +* **aws:** add shared runner `run_aws_filtered()` eliminating per-handler boilerplate +* **tee:** add `force_tee_hint()` — truncated output saves full data to file with recovery hint +* **codex:** add native Codex hook integration with `PreToolUse` deny-and-retry, `CODEX_HOME` support, and Windows `0.120.0+` hooks with prompt-only fallback on older Codex builds + ### Bug Fixes * **git:** remove `-u` short alias from `--ultra-compact` to fix `git push -u` upstream tracking ([#1086](https://github.com/rtk-ai/rtk/issues/1086)) +* **wc:** `wc` filter was never invoked by the hook — removed `"wc "` from `IGNORED_PREFIXES` and added registry entry so `wc` commands are rewritten to `rtk wc` +* **diff:** correct truncation overflow count in condense_unified_diff ([#833](https://github.com/rtk-ai/rtk/pull/833)) ([5399f83](https://github.com/rtk-ai/rtk/commit/5399f83)) +* **git:** replace vague truncation markers with exact counts in log and grep output ([#833](https://github.com/rtk-ai/rtk/pull/833)) ([185fb97](https://github.com/rtk-ai/rtk/commit/185fb97)) +* **init:** honor `CODEX_HOME` for Codex global install paths and cleanup ## [0.35.0](https://github.com/rtk-ai/rtk/compare/v0.34.3...v0.35.0) (2026-04-06) @@ -50,14 +61,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * **security:** default to ask when no permission rule matches ([#886](https://github.com/rtk-ai/rtk/issues/886)) ([41a6c6b](https://github.com/rtk-ai/rtk/commit/41a6c6bf6da78a4754794fdc6a1469df2e327920)) * **tracking:** use std::env::temp_dir() for compatibility (instead of unix tmp) ([e918661](https://github.com/rtk-ai/rtk/commit/e918661440d7b50321f0535032f52c5e87aaf3cb)) -## [Unreleased] - -### Features - -* **aws:** expand CLI filters from 8 to 25 subcommands — CloudWatch Logs, CloudFormation events, Lambda, IAM, DynamoDB (with type unwrapping), ECS tasks, EC2 security groups, S3API objects, S3 sync/cp, EKS, SQS, Secrets Manager ([#885](https://github.com/rtk-ai/rtk/pull/885)) -* **aws:** add shared runner `run_aws_filtered()` eliminating per-handler boilerplate -* **tee:** add `force_tee_hint()` — truncated output saves full data to file with recovery hint - ## [0.34.3](https://github.com/rtk-ai/rtk/compare/v0.34.2...v0.34.3) (2026-04-02) @@ -124,6 +127,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Features + +* **codex:** add native Codex hook integration with `PreToolUse` deny-and-retry, `CODEX_HOME` support, and Windows `0.120.0+` hooks with prompt-only fallback on older Codex builds + ### Bug Fixes * **wc:** `wc` filter was never invoked by the hook — removed `"wc "` from `IGNORED_PREFIXES` and added registry entry so `wc` commands are rewritten to `rtk wc` diff --git a/Cargo.lock b/Cargo.lock index 7f33886e01..d3fb7edb2d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -892,7 +892,7 @@ dependencies = [ [[package]] name = "rtk" -version = "0.35.0" +version = "0.34.3" dependencies = [ "anyhow", "automod", diff --git a/README.md b/README.md index 6228a689d0..40ec07e615 100644 --- a/README.md +++ b/README.md @@ -112,10 +112,10 @@ rtk init --agent kilocode # Kilo Code rtk init --agent antigravity # Google Antigravity # 2. Restart your AI tool, then test -git status # Automatically rewritten to rtk git status +git status # Hook rewrites it or suggests `rtk git status`, depending on the tool ``` -The hook transparently rewrites Bash commands (e.g., `git status` -> `rtk git status`) before execution. Claude never sees the rewrite, it just gets compressed output. +Hooks either transparently rewrite Bash commands (for example `git status` -> `rtk git status`) or, when a harness cannot update the command input yet, block the raw command and tell the model to retry with the exact `rtk ...` replacement. **Important:** the hook only runs on Bash tool calls. Claude Code built-in tools like `Read`, `Grep`, and `Glob` do not pass through the Bash hook, so they are not auto-rewritten. To get RTK's compact output for those workflows, use shell commands (`cat`/`head`/`tail`, `rg`/`grep`, `find`) or call `rtk read`, `rtk grep`, or `rtk find` directly. @@ -358,8 +358,8 @@ RTK supports 12 AI coding tools. Each integration transparently rewrites shell c | **GitHub Copilot (VS Code)** | `rtk init -g --copilot` | PreToolUse hook — transparent rewrite | | **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 | +| **Gemini CLI** | `rtk init -g --gemini` | BeforeTool hook (`rtk hook gemini`) | +| **Codex** | `rtk init -g --codex` | PreToolUse deny-with-suggestion + inline `AGENTS.md` guidance | | **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) | @@ -368,6 +368,135 @@ RTK supports 12 AI coding tools. Each integration transparently rewrites shell c | **Kilo Code** | `rtk init --agent kilocode` | .kilocode/rules/rtk-rules.md (project-scoped) | | **Google Antigravity** | `rtk init --agent antigravity` | .agents/rules/antigravity-rtk-rules.md (project-scoped) | +Codex on Windows now supports lifecycle hooks in Codex CLI `0.120.0+`. Older Windows builds still fall back to prompt-only setup. + +### Claude Code (default) + +```bash +rtk init -g # Install hook + RTK.md +rtk init -g --auto-patch # Non-interactive (CI/CD) +rtk init --show # Verify installation +rtk init -g --uninstall # Remove +``` + +### GitHub Copilot (VS Code + CLI) + +```bash +rtk init -g --copilot # Install hook + instructions +``` + +Creates `.github/hooks/rtk-rewrite.json` (PreToolUse hook) and `.github/copilot-instructions.md` (prompt-level awareness). + +The hook (`rtk hook copilot`) auto-detects the format: +- **VS Code Copilot Chat**: transparent rewrite via `updatedInput` (same as Claude Code) +- **Copilot CLI**: deny-with-suggestion (CLI does not support `updatedInput` yet — see [copilot-cli#2013](https://github.com/github/copilot-cli/issues/2013)) + +### Cursor + +```bash +rtk init -g --agent cursor +``` + +Creates `~/.cursor/hooks/rtk-rewrite.sh` + patches `~/.cursor/hooks.json` with preToolUse matcher. Works with both Cursor editor and `cursor-agent` CLI. + +### Gemini CLI + +```bash +rtk init -g --gemini +rtk init -g --gemini --uninstall +``` + +Creates `~/.gemini/hooks/rtk-hook-gemini.sh` + patches `~/.gemini/settings.json` with BeforeTool hook. + +### Codex (OpenAI) + +```bash +rtk init -g --codex +rtk init --codex +rtk init -g --codex --uninstall +``` + +On macOS and Linux, global install writes `${CODEX_HOME:-~/.codex}/RTK.md`, `${CODEX_HOME:-~/.codex}/AGENTS.md`, `${CODEX_HOME:-~/.codex}/config.toml`, and `${CODEX_HOME:-~/.codex}/hooks.json`. Project-scoped install writes the same files under `./.codex/`. On Windows, native hook files are also installed when Codex CLI is `0.120.0+`. + +RTK writes Codex guidance inline into `AGENTS.md` so Codex does not need to resolve `@RTK.md` includes for either global or project-local installs. + +RTK enables `features.codex_hooks = true` and installs a `PreToolUse` Bash hook that runs `rtk hook codex`. + +Codex does not support transparent `updatedInput` rewrites yet, so supported raw Bash commands are denied with the exact `rtk ...` replacement instead of being silently rewritten. + +Notes: +- If `CODEX_HOME` is set, `rtk init -g --codex` uses that directory instead of `~/.codex`. +- On Windows, Codex CLI `0.120.0+` uses the same native `config.toml` + `hooks.json` setup. Older Codex versions fall back to inline `AGENTS.md` guidance plus `RTK.md`. +- Project-scoped `.codex/` installs only activate when Codex trusts the project. + +### Windsurf + +```bash +rtk init --agent windsurf +``` + +Creates `.windsurfrules` in the current project. Cascade reads rules and prefixes commands with `rtk`. + +### Cline / Roo Code + +```bash +rtk init --agent cline +``` + +Creates `.clinerules` in the current project. Cline reads rules and prefixes commands with `rtk`. + +### OpenCode + +```bash +rtk init -g --opencode +``` + +Creates `~/.config/opencode/plugins/rtk.ts`. Uses `tool.execute.before` hook. + +### OpenClaw + +```bash +openclaw plugins install ./openclaw +``` + +Plugin in `openclaw/` directory. Uses `before_tool_call` hook, delegates to `rtk rewrite`. + +### Mistral Vibe (planned) + +Blocked on upstream BeforeToolCallback support ([mistral-vibe#531](https://github.com/mistralai/mistral-vibe/issues/531), [PR #533](https://github.com/mistralai/mistral-vibe/pull/533)). Tracked in [#800](https://github.com/rtk-ai/rtk/issues/800). + +### Commands Rewritten + +| Raw Command | Rewritten To | +|-------------|-------------| +| `git status/diff/log/add/commit/push/pull` | `rtk git ...` | +| `gh pr/issue/run` | `rtk gh ...` | +| `cargo test/build/clippy` | `rtk cargo ...` | +| `cat/head/tail ` | `rtk read ` | +| `rg/grep ` | `rtk grep ` | +| `ls` | `rtk ls` | +| `vitest/jest` | `rtk vitest run` | +| `tsc` | `rtk tsc` | +| `eslint/biome` | `rtk lint` | +| `prettier` | `rtk prettier` | +| `playwright` | `rtk playwright` | +| `prisma` | `rtk prisma` | +| `ruff check/format` | `rtk ruff ...` | +| `pytest` | `rtk pytest` | +| `pip list/install` | `rtk pip ...` | +| `go test/build/vet` | `rtk go ...` | +| `golangci-lint` | `rtk golangci-lint` | +| `rake test` / `rails test` | `rtk rake test` | +| `rspec` / `bundle exec rspec` | `rtk rspec` | +| `rubocop` / `bundle exec rubocop` | `rtk rubocop` | +| `bundle install/update` | `rtk bundle ...` | +| `aws sts/ec2/lambda/...` | `rtk aws ...` | +| `docker ps/images/logs` | `rtk docker ...` | +| `kubectl get/logs` | `rtk kubectl ...` | +| `curl` | `rtk curl` | +| `pnpm list/outdated` | `rtk pnpm ...` | + +Commands already using `rtk`, heredocs (`<<`), and unrecognized commands pass through unchanged. For per-agent setup details, override controls, and graceful degradation, see the [Supported Agents guide](https://www.rtk-ai.app/guide/getting-started/supported-agents). ## Configuration @@ -395,7 +524,7 @@ For the full config reference (all sections, env vars, per-project filters), see ### Uninstall ```bash -rtk init -g --uninstall # Remove hook, RTK.md, settings.json entry +rtk init -g --uninstall # Remove RTK-managed hook, RTK.md, and harness config entry cargo uninstall rtk # Remove binary brew uninstall rtk # If installed via Homebrew ``` diff --git a/docs/guide/getting-started/supported-agents.md b/docs/guide/getting-started/supported-agents.md index f896822bed..aeab08a105 100644 --- a/docs/guide/getting-started/supported-agents.md +++ b/docs/guide/getting-started/supported-agents.md @@ -37,7 +37,7 @@ Agent runs "cargo test" | OpenClaw | TypeScript plugin (`before_tool_call`) | 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 | Shell hook (`PreToolUse` deny-with-suggestion) + `AGENTS.md` guidance | No (agent retries) | | 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 | @@ -112,6 +112,8 @@ rtk init --windsurf # creates .windsurfrules in current project rtk init --codex # creates AGENTS.md or patches existing one ``` +Codex CLI `0.120.0+` also gets native `.codex/config.toml` and `.codex/hooks.json` hook setup on Windows. Older Windows Codex builds stay in prompt-only mode with inline `AGENTS.md` guidance. + ### Kilo Code ```bash diff --git a/hooks/codex/README.md b/hooks/codex/README.md index 50030e958a..ce60ba87f5 100644 --- a/hooks/codex/README.md +++ b/hooks/codex/README.md @@ -4,6 +4,15 @@ ## Specifics -- 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` +- Awareness document is injected inline into `AGENTS.md` with RTK-managed markers +- `rtk init --codex` also installs `.codex/config.toml` and `.codex/hooks.json` so Codex can run `rtk hook codex` for `PreToolUse` +- Codex currently uses deny-and-retry rather than transparent rewrite because `updatedInput` is not supported yet +- On Windows, native Codex hooks are enabled only when `codex --version` is `0.120.0+`; older builds fall back to prompt-only guidance +- Global install goes to `${CODEX_HOME:-~/.codex}` +- Project installs only activate for trusted projects + +## Test + +```bash +bash hooks/codex/test-rtk-rewrite.sh +``` diff --git a/hooks/codex/rtk-awareness.md b/hooks/codex/rtk-awareness.md index 7ae285e1a8..341bea7f1b 100644 --- a/hooks/codex/rtk-awareness.md +++ b/hooks/codex/rtk-awareness.md @@ -6,6 +6,10 @@ Always prefix shell commands with `rtk`. +If Codex hooks are enabled, raw supported Bash commands may be blocked with a message telling you to rerun the `rtk ...` form. Follow that guidance rather than retrying the raw command. + +On Windows, or when Codex does not load project hooks such as untrusted local `.codex` config, this prompt guidance may be the only active RTK layer. + Examples: ```bash diff --git a/hooks/codex/test-rtk-rewrite.sh b/hooks/codex/test-rtk-rewrite.sh new file mode 100644 index 0000000000..c04c30bd45 --- /dev/null +++ b/hooks/codex/test-rtk-rewrite.sh @@ -0,0 +1,148 @@ +#!/usr/bin/env bash +# Test suite for rtk hook codex. +# Feeds mock Codex PreToolUse JSON through `rtk hook codex` and verifies deny/pass-through behavior. +# +# Usage: bash hooks/codex/test-rtk-rewrite.sh + +RTK="${RTK:-rtk}" +PASS=0 +FAIL=0 +TOTAL=0 + +GREEN='\033[32m' +RED='\033[31m' +DIM='\033[2m' +RESET='\033[0m' + +codex_bash_input() { + local cmd="$1" + jq -cn --arg cmd "$cmd" '{"tool_name":"Bash","tool_input":{"command":$cmd}}' +} + +non_bash_input() { + jq -cn '{"tool_name":"Edit","tool_input":{"command":"git status"}}' +} + +test_deny() { + local description="$1" + local input_cmd="$2" + local expected_rtk="$3" + TOTAL=$((TOTAL + 1)) + + local output + output=$(codex_bash_input "$input_cmd" | "$RTK" hook codex 2>/dev/null) || true + + local decision reason + decision=$(echo "$output" | jq -r '.hookSpecificOutput.permissionDecision // empty' 2>/dev/null) + reason=$(echo "$output" | jq -r '.hookSpecificOutput.permissionDecisionReason // empty' 2>/dev/null) + + if [ "$decision" = "deny" ] && echo "$reason" | grep -qF "$expected_rtk"; then + printf " ${GREEN}DENY${RESET} %s ${DIM}→ %s${RESET}\n" "$description" "$expected_rtk" + PASS=$((PASS + 1)) + else + printf " ${RED}FAIL${RESET} %s\n" "$description" + printf " expected decision: deny, reason containing: %s\n" "$expected_rtk" + printf " actual decision: %s\n" "$decision" + printf " actual reason: %s\n" "$reason" + FAIL=$((FAIL + 1)) + fi +} + +test_allow() { + local description="$1" + local input="$2" + TOTAL=$((TOTAL + 1)) + + local output + output=$(echo "$input" | "$RTK" hook codex 2>/dev/null) || true + + if [ -z "$output" ]; then + printf " ${GREEN}PASS${RESET} %s ${DIM}→ (no output)${RESET}\n" "$description" + PASS=$((PASS + 1)) + else + printf " ${RED}FAIL${RESET} %s\n" "$description" + printf " expected: (no output)\n" + printf " actual: %s\n" "$output" + FAIL=$((FAIL + 1)) + fi +} + +echo "============================================" +echo " RTK Codex Hook Test Suite" +echo "============================================" +echo "" + +echo "--- Deny with RTK suggestion ---" + +test_deny "git status" \ + "git status" \ + "rtk git status" + +test_deny "cargo test" \ + "cargo test" \ + "rtk cargo test" + +test_deny "gh pr list" \ + "gh pr list" \ + "rtk gh" + +echo "" +echo "--- Pass-through ---" + +test_allow "already rtk" \ + "$(codex_bash_input "rtk git status")" + +test_allow "heredoc" \ + "$(codex_bash_input "cat <<'EOF' +hello +EOF")" + +test_allow "unknown command" \ + "$(codex_bash_input "htop")" + +test_allow "non-bash tool" \ + "$(non_bash_input)" + +echo "" +echo "--- Output format ---" + +TOTAL=$((TOTAL + 1)) +raw_output=$(codex_bash_input "git status" | "$RTK" hook codex 2>/dev/null) +if echo "$raw_output" | jq . >/dev/null 2>&1; then + printf " ${GREEN}PASS${RESET} Codex: output is valid JSON\n" + PASS=$((PASS + 1)) +else + printf " ${RED}FAIL${RESET} Codex: output is not valid JSON: %s\n" "$raw_output" + FAIL=$((FAIL + 1)) +fi + +TOTAL=$((TOTAL + 1)) +decision=$(echo "$raw_output" | jq -r '.hookSpecificOutput.permissionDecision') +if [ "$decision" = "deny" ]; then + printf " ${GREEN}PASS${RESET} Codex: hookSpecificOutput.permissionDecision == \"deny\"\n" + PASS=$((PASS + 1)) +else + printf " ${RED}FAIL${RESET} Codex: expected \"deny\", got \"%s\"\n" "$decision" + FAIL=$((FAIL + 1)) +fi + +TOTAL=$((TOTAL + 1)) +reason=$(echo "$raw_output" | jq -r '.hookSpecificOutput.permissionDecisionReason') +if echo "$reason" | grep -qE '`rtk [^`]+`'; then + printf " ${GREEN}PASS${RESET} Codex: reason contains backtick-quoted rtk command ${DIM}→ %s${RESET}\n" "$reason" + PASS=$((PASS + 1)) +else + printf " ${RED}FAIL${RESET} Codex: reason missing backtick-quoted command: %s\n" "$reason" + FAIL=$((FAIL + 1)) +fi + +echo "" +echo "============================================" +if [ $FAIL -eq 0 ]; then + printf " ${GREEN}ALL $TOTAL TESTS PASSED${RESET}\n" +else + printf " ${RED}$FAIL FAILED${RESET} / $TOTAL total ($PASS passed)\n" +fi +echo "============================================" + +exit $FAIL diff --git a/src/discover/registry.rs b/src/discover/registry.rs index ee5f7a7bef..5b7a6eaa50 100644 --- a/src/discover/registry.rs +++ b/src/discover/registry.rs @@ -1418,26 +1418,12 @@ mod tests { #[test] fn test_rewrite_rtk_disabled_subprocess_warns() { - let rtk_bin = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) - .join("target") - .join("debug") - .join("rtk"); - if !rtk_bin.exists() { + // Verify warning via subprocess when Cargo provides the current rtk binary path. + // Unit tests do not reliably have a fresh target/debug/rtk, so skip this leg + // unless Cargo has wired up the executable path for the current test run. + let Ok(rtk_bin) = std::env::var("CARGO_BIN_EXE_rtk") else { return; - } - let rtk_mtime = std::fs::metadata(&rtk_bin) - .ok() - .and_then(|m| m.modified().ok()); - let test_mtime = std::env::current_exe() - .ok() - .and_then(|p| std::fs::metadata(p).ok()) - .and_then(|m| m.modified().ok()); - if let (Some(rtk_t), Some(test_t)) = (rtk_mtime, test_mtime) { - if rtk_t < test_t { - return; - } - } - + }; let output = std::process::Command::new(&rtk_bin) .args(["rewrite", "RTK_DISABLED=1 git status"]) .output() diff --git a/src/hooks/hook_cmd.rs b/src/hooks/hook_cmd.rs index 5f002b7a94..12c6050a3e 100644 --- a/src/hooks/hook_cmd.rs +++ b/src/hooks/hook_cmd.rs @@ -62,6 +62,32 @@ pub fn run_copilot() -> Result<()> { } } +/// Run the Codex CLI PreToolUse hook. +/// Codex currently supports deny/block for Bash commands but fails open on +/// updatedInput and permissionDecision:allow, so this adapter is deny-only. +pub fn run_codex() -> Result<()> { + let mut input = String::new(); + io::stdin() + .read_to_string(&mut input) + .context("Failed to read stdin")?; + + 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(()), + }; + + let Some(command) = detect_codex_command(&v) else { + return Ok(()); + }; + + handle_codex(&command) +} + fn detect_format(v: &Value) -> HookFormat { // VS Code Copilot Chat / Claude Code: snake_case keys if let Some(tool_name) = v.get("tool_name").and_then(|t| t.as_str()) { @@ -120,6 +146,24 @@ fn get_rewritten(cmd: &str) -> Option { Some(rewritten) } +fn deny_reason(rewritten: &str) -> String { + format!( + "Token savings: use `{}` instead (rtk saves 60-90% tokens)", + rewritten + ) +} + +fn detect_codex_command(v: &Value) -> Option { + (v.get("tool_name").and_then(|t| t.as_str()) == Some("Bash")) + .then(|| { + v.pointer("/tool_input/command") + .and_then(|c| c.as_str()) + .filter(|c| !c.is_empty()) + .map(str::to_string) + }) + .flatten() +} + fn handle_vscode(cmd: &str) -> Result<()> { let verdict = permissions::check_command(cmd); if verdict == PermissionVerdict::Deny { @@ -162,10 +206,24 @@ fn handle_copilot_cli(cmd: &str) -> Result<()> { let output = json!({ "permissionDecision": "deny", - "permissionDecisionReason": format!( - "Token savings: use `{}` instead (rtk saves 60-90% tokens)", - rewritten - ) + "permissionDecisionReason": deny_reason(&rewritten) + }); + println!("{output}"); + Ok(()) +} + +fn handle_codex(cmd: &str) -> Result<()> { + let rewritten = match get_rewritten(cmd) { + Some(r) => r, + None => return Ok(()), + }; + + let output = json!({ + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "deny", + "permissionDecisionReason": deny_reason(&rewritten) + } }); let _ = writeln!(io::stdout(), "{output}"); Ok(()) @@ -492,6 +550,13 @@ mod tests { json!({ "toolName": "bash", "toolArgs": args }) } + fn codex_input(cmd: &str) -> Value { + json!({ + "tool_name": "Bash", + "tool_input": { "command": cmd } + }) + } + #[test] fn test_detect_vscode_bash() { assert!(matches!( @@ -527,6 +592,26 @@ mod tests { assert!(matches!(detect_format(&json!({})), HookFormat::PassThrough)); } + #[test] + fn test_detect_codex_bash() { + assert_eq!( + detect_codex_command(&codex_input("git status")), + Some("git status".to_string()) + ); + } + + #[test] + fn test_detect_codex_non_bash_is_passthrough() { + let v = json!({ "tool_name": "Edit", "tool_input": { "command": "git status" } }); + assert_eq!(detect_codex_command(&v), None); + } + + #[test] + fn test_detect_codex_empty_command_is_passthrough() { + let v = json!({ "tool_name": "Bash", "tool_input": { "command": "" } }); + assert_eq!(detect_codex_command(&v), None); + } + #[test] fn test_get_rewritten_supported() { assert!(get_rewritten("git status").is_some()); @@ -547,6 +632,32 @@ mod tests { assert!(get_rewritten("cat <<'EOF'\nhello\nEOF").is_none()); } + #[test] + fn test_deny_reason_format() { + assert_eq!( + deny_reason("rtk git status"), + "Token savings: use `rtk git status` instead (rtk saves 60-90% tokens)" + ); + } + + #[test] + fn test_codex_deny_output_format() { + let output = json!({ + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "deny", + "permissionDecisionReason": deny_reason("rtk git status") + } + }); + let json: Value = serde_json::from_str(&output.to_string()).unwrap(); + assert_eq!(json["hookSpecificOutput"]["hookEventName"], "PreToolUse"); + assert_eq!(json["hookSpecificOutput"]["permissionDecision"], "deny"); + assert_eq!( + json["hookSpecificOutput"]["permissionDecisionReason"], + "Token savings: use `rtk git status` instead (rtk saves 60-90% tokens)" + ); + } + // --- Gemini format --- #[test] diff --git a/src/hooks/init.rs b/src/hooks/init.rs index 24a736e9ae..8167047d16 100644 --- a/src/hooks/init.rs +++ b/src/hooks/init.rs @@ -1,6 +1,8 @@ //! Sets up RTK hooks so AI coding agents automatically route commands through RTK. +use crate::core::utils::resolved_command; use anyhow::{Context, Result}; +use std::fmt; use std::fs; use std::io::Write; use std::path::{Path, PathBuf}; @@ -18,6 +20,177 @@ const OPENCODE_PLUGIN: &str = include_str!("../../hooks/opencode/rtk.ts"); // Embedded slim RTK awareness instructions const RTK_SLIM: &str = include_str!("../../hooks/claude/rtk-awareness.md"); const RTK_SLIM_CODEX: &str = include_str!("../../hooks/codex/rtk-awareness.md"); +const RTK_MD_REF: &str = "@RTK.md"; +const CODEX_HOOK_COMMAND: &str = "rtk hook codex"; +const CODEX_HOOK_TIMEOUT_SEC: u64 = 5; +const CODEX_WINDOWS_HOOKS_MIN_VERSION: CodexCliVersion = CodexCliVersion { + major: 0, + minor: 120, + patch: 0, +}; + +#[derive(Debug, Clone)] +struct CodexPaths { + codex_dir: PathBuf, + agents_md: PathBuf, + rtk_md: PathBuf, + config_toml: PathBuf, + hooks_json: PathBuf, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum CodexVerifyStatus { + HooksConfigured, + PromptOnly, + ConfigTomlMissing, + ConfigTomlInvalid, + HooksDisabled, + HooksJsonMissing, + HooksJsonInvalid, + HookEntryMissing, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub(crate) struct CodexCliVersion { + pub(crate) major: u64, + pub(crate) minor: u64, + pub(crate) patch: u64, +} + +impl fmt::Display for CodexCliVersion { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}.{}.{}", self.major, self.minor, self.patch) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum CodexHooksSupport { + Supported, + WindowsVersionUnsupported { + detected_version: Option, + }, +} + +impl CodexHooksSupport { + fn supported(self) -> bool { + matches!(self, Self::Supported) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct CodexVerifyEntry { + pub(crate) scope: &'static str, + pub(crate) agents_md: PathBuf, + pub(crate) agents_state: CodexAgentsState, + pub(crate) hooks_support: CodexHooksSupport, + pub(crate) status: CodexVerifyStatus, + pub(crate) config_toml: PathBuf, + pub(crate) hooks_json: PathBuf, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum CodexAgentsPatchStatus { + Added, + Updated, + Unchanged, + Malformed, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum CodexAgentsState { + Missing, + InlineCurrent, + InlineStale, + LegacyReference, + MalformedBlock, + NotConfigured, +} + +fn parse_codex_cli_version(output: &str) -> Option { + output.split_whitespace().find_map(|token| { + let start = token.find(|ch: char| ch.is_ascii_digit())?; + let numeric_prefix: String = token[start..] + .chars() + .take_while(|ch| ch.is_ascii_digit() || *ch == '.') + .collect(); + let mut parts = numeric_prefix.split('.'); + let major = parts.next()?.parse().ok()?; + let minor = parts.next()?.parse().ok()?; + let patch = parts.next()?.parse().ok()?; + if parts.next().is_some() { + return None; + } + Some(CodexCliVersion { + major, + minor, + patch, + }) + }) +} + +fn detect_codex_cli_version() -> Option { + let output = resolved_command("codex").arg("--version").output().ok()?; + if !output.status.success() { + return None; + } + + parse_codex_cli_version(&String::from_utf8_lossy(&output.stdout)) + .or_else(|| parse_codex_cli_version(&String::from_utf8_lossy(&output.stderr))) +} + +fn codex_hooks_support_for_platform( + is_windows: bool, + detected_version: Option, +) -> CodexHooksSupport { + if !is_windows { + return CodexHooksSupport::Supported; + } + + match detected_version { + Some(version) if version >= CODEX_WINDOWS_HOOKS_MIN_VERSION => CodexHooksSupport::Supported, + _ => CodexHooksSupport::WindowsVersionUnsupported { detected_version }, + } +} + +fn codex_lifecycle_hooks_support() -> CodexHooksSupport { + codex_hooks_support_for_platform(cfg!(windows), detect_codex_cli_version()) +} + +pub(crate) fn codex_windows_prompt_only_reason(support: CodexHooksSupport) -> Option { + match support { + CodexHooksSupport::Supported => None, + CodexHooksSupport::WindowsVersionUnsupported { + detected_version: Some(version), + } => Some(format!( + "Codex Windows lifecycle hooks require codex-cli {}+; detected {}. RTK is using prompt-only guidance.", + CODEX_WINDOWS_HOOKS_MIN_VERSION, version + )), + CodexHooksSupport::WindowsVersionUnsupported { + detected_version: None, + } => Some(format!( + "Codex Windows lifecycle hooks require codex-cli {}+; RTK could not detect a compatible `codex --version`, so it is using prompt-only guidance.", + CODEX_WINDOWS_HOOKS_MIN_VERSION + )), + } +} + +fn codex_windows_inactive_artifacts_reason(support: CodexHooksSupport) -> Option { + match support { + CodexHooksSupport::Supported => None, + CodexHooksSupport::WindowsVersionUnsupported { + detected_version: Some(version), + } => Some(format!( + "Codex Windows lifecycle hooks require codex-cli {}+; detected {}. `hooks.json` and `features.codex_hooks` stay inactive until Codex is upgraded.", + CODEX_WINDOWS_HOOKS_MIN_VERSION, version + )), + CodexHooksSupport::WindowsVersionUnsupported { + detected_version: None, + } => Some(format!( + "Codex Windows lifecycle hooks require codex-cli {}+; RTK could not detect a compatible `codex --version`, so `hooks.json` and `features.codex_hooks` may stay inactive.", + CODEX_WINDOWS_HOOKS_MIN_VERSION + )), + } +} /// 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. @@ -50,12 +223,6 @@ schema_version = 1 # max_lines = 40 "#; -const RTK_MD: &str = "RTK.md"; -const CLAUDE_MD: &str = "CLAUDE.md"; -const AGENTS_MD: &str = "AGENTS.md"; -const RTK_MD_REF: &str = "@RTK.md"; -const GEMINI_MD: &str = "GEMINI.md"; - /// Control flow for settings.json patching #[derive(Debug, Clone, Copy, PartialEq)] pub enum PatchMode { @@ -356,6 +523,26 @@ fn atomic_write(path: &Path, content: &str) -> Result<()> { Ok(()) } +fn backup_path(path: &Path) -> PathBuf { + match path.extension().and_then(|ext| ext.to_str()) { + Some(ext) => path.with_extension(format!("{ext}.bak")), + None => path.with_extension("bak"), + } +} + +fn backup_file_if_exists(path: &Path, verbose: u8) -> Result> { + if !path.exists() { + return Ok(None); + } + + let backup = backup_path(path); + fs::copy(path, &backup).with_context(|| format!("Failed to backup to {}", backup.display()))?; + if verbose > 0 { + eprintln!("Backup: {}", backup.display()); + } + Ok(Some(backup)) +} + /// Prompt user for consent to patch settings.json /// Prints to stderr (stdout may be piped), reads from stdin /// Default is No (capital N) @@ -458,11 +645,10 @@ fn print_manual_instructions(hook_command: &str, include_opencode: bool) { } } +/// Remove RTK hook entry from settings.json +/// Returns true if hook was found and removed fn remove_hook_from_json(root: &mut serde_json::Value) -> bool { - let hooks = match root - .get_mut("hooks") - .and_then(|h| h.get_mut(PRE_TOOL_USE_KEY)) - { + let hooks = match root.get_mut("hooks").and_then(|h| h.get_mut("PreToolUse")) { Some(pre_tool_use) => pre_tool_use, None => return false, }; @@ -472,6 +658,7 @@ fn remove_hook_from_json(root: &mut serde_json::Value) -> bool { None => return false, }; + // Find and remove RTK entry let original_len = pre_tool_use_array.len(); pre_tool_use_array.retain(|entry| { if let Some(hooks_array) = entry.get("hooks").and_then(|h| h.as_array()) { @@ -484,7 +671,7 @@ fn remove_hook_from_json(root: &mut serde_json::Value) -> bool { } } } - true + true // Keep this entry }); pre_tool_use_array.len() < original_len @@ -494,7 +681,7 @@ fn remove_hook_from_json(root: &mut serde_json::Value) -> bool { /// Backs up before modification, returns true if hook was found and removed fn remove_hook_from_settings(verbose: u8) -> Result { let claude_dir = resolve_claude_dir()?; - let settings_path = claude_dir.join(SETTINGS_JSON); + let settings_path = claude_dir.join("settings.json"); if !settings_path.exists() { if verbose > 0 { @@ -595,7 +782,7 @@ pub fn uninstall(global: bool, gemini: bool, codex: bool, cursor: bool, verbose: } // 2. Remove RTK.md - let rtk_md_path = claude_dir.join(RTK_MD); + let rtk_md_path = claude_dir.join("RTK.md"); if rtk_md_path.exists() { fs::remove_file(&rtk_md_path) .with_context(|| format!("Failed to remove RTK.md: {}", rtk_md_path.display()))?; @@ -603,15 +790,15 @@ pub fn uninstall(global: bool, gemini: bool, codex: bool, cursor: bool, verbose: } // 3. Remove @RTK.md reference from CLAUDE.md - let claude_md_path = claude_dir.join(CLAUDE_MD); + let claude_md_path = claude_dir.join("CLAUDE.md"); if claude_md_path.exists() { let content = fs::read_to_string(&claude_md_path) .with_context(|| format!("Failed to read CLAUDE.md: {}", claude_md_path.display()))?; - if content.contains(RTK_MD_REF) { + if content.contains("@RTK.md") { let new_content = content .lines() - .filter(|line| !line.trim().starts_with(RTK_MD_REF)) + .filter(|line| !line.trim().starts_with("@RTK.md")) .collect::>() .join("\n"); @@ -655,48 +842,68 @@ pub fn uninstall(global: bool, gemini: bool, codex: bool, cursor: bool, verbose: } fn uninstall_codex(global: bool, verbose: u8) -> Result<()> { - 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, verbose)?; + let paths = codex_paths(global)?; + let removed = uninstall_codex_at(&paths, verbose)?; + let hooks_support = codex_lifecycle_hooks_support(); if removed.is_empty() { println!("RTK was not installed for Codex CLI (nothing to remove)"); } else { println!("RTK uninstalled for Codex CLI:"); - for item in removed { + for item in &removed { println!(" - {}", item); } + + match codex_uninstall_warning_with_support(&paths.config_toml, hooks_support) { + Ok(Some(warning)) => { + println!("\n Warning: {warning}"); + } + Ok(None) => {} + Err(err) => { + if verbose > 0 { + eprintln!( + "Warning: failed to inspect Codex config at {}: {err:#}", + paths.config_toml.display() + ); + } + } + } } Ok(()) } -fn uninstall_codex_at(codex_dir: &Path, verbose: u8) -> Result> { +fn uninstall_codex_at(paths: &CodexPaths, verbose: u8) -> 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() { - fs::remove_file(&rtk_md_path) - .with_context(|| format!("Failed to remove RTK.md: {}", rtk_md_path.display()))?; + if paths.rtk_md.exists() { + fs::remove_file(&paths.rtk_md) + .with_context(|| format!("Failed to remove RTK.md: {}", paths.rtk_md.display()))?; if verbose > 0 { - eprintln!("Removed RTK.md: {}", rtk_md_path.display()); + eprintln!("Removed RTK.md: {}", paths.rtk_md.display()); } - removed.push(format!("RTK.md: {}", rtk_md_path.display())); + removed.push(format!("RTK.md: {}", paths.rtk_md.display())); } - let agents_md_path = codex_dir.join(AGENTS_MD); - if remove_rtk_reference_from_agents( - &agents_md_path, - &[RTK_MD_REF, absolute_rtk_md_ref.as_str()], - verbose, - )? { - removed.push("AGENTS.md: removed @RTK.md reference".to_string()); + if remove_codex_guidance_from_agents(&paths.agents_md, verbose)? { + removed.push("AGENTS.md: removed RTK guidance".to_string()); + } + + if paths.hooks_json.exists() { + let content = fs::read_to_string(&paths.hooks_json) + .with_context(|| format!("Failed to read {}", paths.hooks_json.display()))?; + + if !content.trim().is_empty() { + if let Ok(mut root) = serde_json::from_str::(&content) { + if remove_codex_hook_from_json(&mut root) { + backup_file_if_exists(&paths.hooks_json, verbose)?; + let serialized = serde_json::to_string_pretty(&root) + .context("Failed to serialize hooks.json")?; + atomic_write(&paths.hooks_json, &serialized)?; + removed.push("hooks.json: removed RTK PreToolUse hook".to_string()); + } + } + } } Ok(removed) @@ -816,6 +1023,50 @@ fn clean_double_blanks(content: &str) -> String { result.join("\n") } +fn codex_agents_block() -> String { + format!( + "\n{}\n", + RTK_SLIM_CODEX.trim() + ) +} + +fn strip_rtk_reference(content: &str) -> (String, bool) { + if !content + .lines() + .any(|line| line.trim().starts_with(RTK_MD_REF)) + { + return (content.to_string(), false); + } + + let filtered = content + .lines() + .filter(|line| !line.trim().starts_with(RTK_MD_REF)) + .collect::>() + .join("\n"); + + (clean_double_blanks(&filtered).trim().to_string(), true) +} + +fn inspect_codex_agents_content(content: &str) -> CodexAgentsState { + let desired_block = codex_agents_block(); + + match upsert_rtk_block(content, &desired_block).1 { + RtkBlockUpsert::Unchanged => CodexAgentsState::InlineCurrent, + RtkBlockUpsert::Updated => CodexAgentsState::InlineStale, + RtkBlockUpsert::Malformed => CodexAgentsState::MalformedBlock, + RtkBlockUpsert::Added => { + if content + .lines() + .any(|line| line.trim().starts_with(RTK_MD_REF)) + { + CodexAgentsState::LegacyReference + } else { + CodexAgentsState::NotConfigured + } + } + } +} + /// Deep-merge RTK hook entry into settings.json /// Creates hooks.PreToolUse structure if missing, preserves existing hooks fn insert_hook_entry(root: &mut serde_json::Value, hook_command: &str) -> Result<()> { @@ -834,7 +1085,7 @@ fn insert_hook_entry(root: &mut serde_json::Value, hook_command: &str) -> Result .context("hooks value is not an object")?; let pre_tool_use = hooks - .entry(PRE_TOOL_USE_KEY) + .entry("PreToolUse") .or_insert_with(|| serde_json::json!([])) .as_array_mut() .context("PreToolUse value is not an array")?; @@ -854,7 +1105,7 @@ fn insert_hook_entry(root: &mut serde_json::Value, hook_command: &str) -> Result fn hook_already_present(root: &serde_json::Value, hook_command: &str) -> bool { let pre_tool_use_array = match root .get("hooks") - .and_then(|h| h.get(PRE_TOOL_USE_KEY)) + .and_then(|h| h.get("PreToolUse")) .and_then(|p| p.as_array()) { Some(arr) => arr, @@ -900,14 +1151,14 @@ fn run_default_mode( } let claude_dir = resolve_claude_dir()?; - let rtk_md_path = claude_dir.join(RTK_MD); - let claude_md_path = claude_dir.join(CLAUDE_MD); + let rtk_md_path = claude_dir.join("RTK.md"); + let claude_md_path = claude_dir.join("CLAUDE.md"); // 1. Migrate old hook script if present migrate_old_hook_script(verbose); // 2. Write RTK.md - write_if_changed(&rtk_md_path, RTK_SLIM, RTK_MD, verbose)?; + write_if_changed(&rtk_md_path, RTK_SLIM, "RTK.md", verbose)?; let opencode_plugin_path = if install_opencode { let path = prepare_opencode_plugin_path()?; @@ -1024,7 +1275,7 @@ fn generate_project_filters_template(verbose: u8) -> Result<()> { /// Generate ~/.config/rtk/filters.toml template if not present. fn generate_global_filters_template(verbose: u8) -> Result<()> { let config_dir = dirs::config_dir().unwrap_or_else(|| std::path::PathBuf::from(".config")); - let rtk_dir = config_dir.join(crate::core::constants::RTK_DATA_DIR); + let rtk_dir = config_dir.join("rtk"); let path = rtk_dir.join("filters.toml"); if path.exists() { @@ -1120,9 +1371,9 @@ fn run_hook_only_mode( /// Legacy mode: full 137-line injection into CLAUDE.md fn run_claude_md_mode(global: bool, verbose: u8, install_opencode: bool) -> Result<()> { let path = if global { - resolve_claude_dir()?.join(CLAUDE_MD) + resolve_claude_dir()?.join("CLAUDE.md") } else { - PathBuf::from(CLAUDE_MD) + PathBuf::from("CLAUDE.md") }; if global { @@ -1351,277 +1602,516 @@ fn run_antigravity_mode_at(base_dir: &Path, verbose: u8) -> Result<()> { } fn run_codex_mode(global: bool, verbose: u8) -> Result<()> { - let (agents_md_path, rtk_md_path) = if global { - let codex_dir = resolve_codex_dir()?; - (codex_dir.join(AGENTS_MD), codex_dir.join(RTK_MD)) - } else { - (PathBuf::from(AGENTS_MD), PathBuf::from(RTK_MD)) - }; + let paths = codex_paths(global)?; + let hooks_support = codex_lifecycle_hooks_support(); - run_codex_mode_with_paths(agents_md_path, rtk_md_path, global, verbose) -} + if global || hooks_support.supported() { + fs::create_dir_all(&paths.codex_dir).with_context(|| { + format!( + "Failed to create Codex config directory: {}", + paths.codex_dir.display() + ) + })?; + } -fn run_codex_mode_with_paths( - agents_md_path: PathBuf, - rtk_md_path: PathBuf, - global: bool, - verbose: u8, -) -> Result<()> { - if global { - if let Some(parent) = agents_md_path.parent() { - fs::create_dir_all(parent).with_context(|| { - format!( - "Failed to create Codex config directory: {}", - parent.display() - ) - })?; - } + let agents_status = patch_codex_agents_md(&paths.agents_md, global, verbose)?; + if agents_status == CodexAgentsPatchStatus::Malformed { + return Ok(()); } - // 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")?, - ) - } else { - RTK_MD_REF.to_string() + write_if_changed(&paths.rtk_md, RTK_SLIM_CODEX, "RTK.md", verbose)?; + let agents_summary = match agents_status { + CodexAgentsPatchStatus::Added => "RTK inline guidance added", + CodexAgentsPatchStatus::Updated => "RTK inline guidance updated", + CodexAgentsPatchStatus::Unchanged => "RTK inline guidance already current", + CodexAgentsPatchStatus::Malformed => unreachable!("handled above"), }; - write_if_changed(&rtk_md_path, RTK_SLIM_CODEX, RTK_MD, verbose)?; - let added_ref = patch_agents_md(&agents_md_path, &rtk_md_ref, verbose)?; + if !hooks_support.supported() { + println!("\nRTK configured for Codex CLI (prompt-only fallback).\n"); + println!(" RTK.md: {}", paths.rtk_md.display()); + println!(" AGENTS.md: {agents_summary}"); + println!( + " Warning: {}", + codex_windows_prompt_only_reason(hooks_support) + .expect("prompt-only install requires an unsupported hooks state") + ); + println!( + " Installed inline AGENTS.md guidance plus RTK.md; no hook files were patched." + ); + if global { + println!( + "\n Codex global instructions path: {}", + paths.agents_md.display() + ); + } else { + println!( + "\n Codex project instructions path: {}", + paths.agents_md.display() + ); + } + return Ok(()); + } + + let (codex_hooks_changed, codex_config_changed) = patch_codex_hook_files(&paths, verbose)?; 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); - } + println!(" RTK.md: {}", paths.rtk_md.display()); + println!(" AGENTS.md: {agents_summary}"); + println!( + " config.toml: {}", + if codex_config_changed { + "codex_hooks enabled" + } else { + "codex_hooks already enabled" + } + ); + println!( + " hooks.json: {}", + if codex_hooks_changed { + "RTK PreToolUse hook added" + } else { + "RTK PreToolUse hook already present" + } + ); if global { println!( "\n Codex global instructions path: {}", - agents_md_path.display() + paths.agents_md.display() ); } else { println!( "\n Codex project instructions path: {}", - agents_md_path.display() + paths.agents_md.display() ); + println!(" Note: local .codex hooks only load for trusted projects."); } Ok(()) } -// --- upsert_rtk_block: idempotent RTK block management --- - -#[derive(Debug, Clone, Copy, PartialEq)] -enum RtkBlockUpsert { - /// No existing block found — appended new block - Added, - /// Existing block found with different content — replaced - Updated, - /// Existing block found with identical content — no-op - Unchanged, - /// Opening marker found without closing marker — not safe to rewrite - Malformed, +fn codex_hooks_enabled(root: &toml::Value) -> bool { + root.get("features") + .and_then(|features| features.get("codex_hooks")) + .and_then(|value| value.as_bool()) + .unwrap_or(false) } -/// Insert or replace the RTK instructions block in `content`. -/// -/// Returns `(new_content, action)` describing what happened. -/// The caller decides whether to write `new_content` based on `action`. -fn upsert_rtk_block(content: &str, block: &str) -> (String, RtkBlockUpsert) { - let start_marker = ""; - - if let Some(start) = content.find(start_marker) { - if let Some(relative_end) = content[start..].find(end_marker) { - let end = start + relative_end; - let end_pos = end + end_marker.len(); - let current_block = content[start..end_pos].trim(); - let desired_block = block.trim(); - - if current_block == desired_block { - return (content.to_string(), RtkBlockUpsert::Unchanged); - } - - // Replace stale block with desired block - let before = content[..start].trim_end(); - let after = content[end_pos..].trim_start(); +fn patch_codex_hook_files(paths: &CodexPaths, verbose: u8) -> Result<(bool, bool)> { + let codex_hooks_changed = patch_codex_hooks_json(&paths.hooks_json, verbose)?; + let codex_config_changed = patch_codex_config_toml(&paths.config_toml, verbose)?; + Ok((codex_hooks_changed, codex_config_changed)) +} - let result = match (before.is_empty(), after.is_empty()) { - (true, true) => desired_block.to_string(), - (true, false) => format!("{desired_block}\n\n{after}"), - (false, true) => format!("{before}\n\n{desired_block}"), - (false, false) => format!("{before}\n\n{desired_block}\n\n{after}"), - }; +fn codex_uninstall_warning(path: &Path) -> Result> { + codex_uninstall_warning_with_support(path, codex_lifecycle_hooks_support()) +} - return (result, RtkBlockUpsert::Updated); - } +fn codex_uninstall_warning_with_support( + path: &Path, + hooks_support: CodexHooksSupport, +) -> Result> { + if !hooks_support.supported() || !path.exists() { + return Ok(None); + } - // Opening marker without closing marker — malformed - return (content.to_string(), RtkBlockUpsert::Malformed); + let content = + fs::read_to_string(path).with_context(|| format!("Failed to read {}", path.display()))?; + if content.trim().is_empty() { + return Ok(None); } - // No existing block — append - let trimmed = content.trim(); - if trimmed.is_empty() { - (block.to_string(), RtkBlockUpsert::Added) - } else { - ( - format!("{trimmed}\n\n{}", block.trim()), - RtkBlockUpsert::Added, - ) + let root = toml::from_str::(&content) + .with_context(|| format!("Failed to parse {} as TOML", path.display()))?; + if !codex_hooks_enabled(&root) { + return Ok(None); } + + Ok(Some(format!( + "{} still has features.codex_hooks = true. RTK leaves this shared setting unchanged during uninstall; other Codex hooks in this config layer may still run. Disable it manually if you want all Codex hooks off.", + path.display() + ))) } -/// Patch CLAUDE.md: add @RTK.md, migrate if old block exists -fn patch_claude_md(path: &Path, verbose: u8) -> Result { - let mut content = if path.exists() { - fs::read_to_string(path)? +fn patch_codex_config_toml(path: &Path, verbose: u8) -> Result { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("Failed to create directory: {}", parent.display()))?; + } + + let mut root = if path.exists() { + let content = fs::read_to_string(path) + .with_context(|| format!("Failed to read {}", path.display()))?; + if content.trim().is_empty() { + toml::Value::Table(toml::map::Map::new()) + } else { + toml::from_str(&content) + .with_context(|| format!("Failed to parse {} as TOML", path.display()))? + } } else { - String::new() + toml::Value::Table(toml::map::Map::new()) }; - let mut migrated = false; - - // Check for old block and migrate - if content.contains(""; + + if let Some(start) = content.find(start_marker) { + if let Some(relative_end) = content[start..].find(end_marker) { + let end = start + relative_end; + let end_pos = end + end_marker.len(); + let current_block = content[start..end_pos].trim(); + let desired_block = block.trim(); + + if current_block == desired_block { + return (content.to_string(), RtkBlockUpsert::Unchanged); + } + + // Replace stale block with desired block + let before = content[..start].trim_end(); + let after = content[end_pos..].trim_start(); + + let result = match (before.is_empty(), after.is_empty()) { + (true, true) => desired_block.to_string(), + (true, false) => format!("{desired_block}\n\n{after}"), + (false, true) => format!("{before}\n\n{desired_block}"), + (false, false) => format!("{before}\n\n{desired_block}\n\n{after}"), + }; + + return (result, RtkBlockUpsert::Updated); + } + + // Opening marker without closing marker — malformed + return (content.to_string(), RtkBlockUpsert::Malformed); + } + + // No existing block — append + let trimmed = content.trim(); + if trimmed.is_empty() { + (block.to_string(), RtkBlockUpsert::Added) + } else { + ( + format!("{trimmed}\n\n{}", block.trim()), + RtkBlockUpsert::Added, + ) + } } -/// Patch AGENTS.md: add @RTK.md (or absolute path), migrate old inline block if present -fn patch_agents_md(path: &Path, rtk_md_ref: &str, verbose: u8) -> Result { +/// Patch CLAUDE.md: add @RTK.md, migrate if old block exists +fn patch_claude_md(path: &Path, verbose: u8) -> Result { let mut content = if path.exists() { - fs::read_to_string(path) - .with_context(|| format!("Failed to read AGENTS.md: {}", path.display()))? + fs::read_to_string(path)? } else { String::new() }; let mut migrated = false; + + // Check for old block and migrate if content.contains("