From 65fad0e22fc551525ebf46c0106c178c4e065828 Mon Sep 17 00:00:00 2001 From: Ivan Dergachev Date: Fri, 8 May 2026 23:39:38 +0400 Subject: [PATCH 1/4] Add Codex hook support --- README.md | 13 +- crates/tokf-cli/src/commands.rs | 48 +++- crates/tokf-cli/src/hook/codex.rs | 205 +++++++++++++++++- crates/tokf-cli/src/hook/mod.rs | 170 ++++++++++++--- crates/tokf-cli/src/hook/permission_engine.rs | 3 +- crates/tokf-cli/src/hook/tests.rs | 13 ++ crates/tokf-cli/src/hook/types.rs | 71 ++++++ crates/tokf-cli/src/main.rs | 2 +- crates/tokf-cli/src/setup/detect.rs | 2 +- crates/tokf-cli/tests/cli_hook_handle.rs | 115 +++++++++- crates/tokf-cli/tests/cli_hook_install.rs | 37 +++- crates/tokf-hook-types/src/format.rs | 3 + docs/integrations.md | 13 +- 13 files changed, 641 insertions(+), 54 deletions(-) diff --git a/README.md b/README.md index 9c52a96d..a06e5fa1 100644 --- a/README.md +++ b/README.md @@ -1465,7 +1465,7 @@ If tokf rewrite fails or no filter matches, the command passes through unmodifie ## OpenAI Codex CLI -tokf integrates with [OpenAI Codex CLI](https://github.com/openai/codex) via a skill that instructs the agent to prefix supported commands with `tokf run`. +tokf integrates with [OpenAI Codex CLI](https://github.com/openai/codex) via a Codex `PreToolUse` hook plus skills for guidance and discovery. **Install (project-local):** ```sh @@ -1477,7 +1477,13 @@ tokf hook install --tool codex tokf hook install --tool codex --global ``` -This writes `.agents/skills/tokf-run/SKILL.md` (or `~/.agents/skills/tokf-run/SKILL.md` for `--global`), which Codex auto-discovers. Unlike the Claude Code hook (which intercepts commands at the tool level), the Codex integration is skill-based: it teaches the agent to use `tokf run` as a command prefix. If tokf is not installed, the agent falls back to running commands without the prefix (fail-safe). +This writes a Codex `PreToolUse` hook to `.codex/hooks.json` (or `~/.codex/hooks.json` for `--global`) and installs `.agents/skills/tokf-run/SKILL.md` (or `~/.agents/skills/tokf-run/SKILL.md` for `--global`), which Codex auto-discovers. + +Codex CLI 0.124.0 and newer enable lifecycle hooks by default. tokf's Codex integration uses `PreToolUse`, which first appeared in Codex 0.117.0. + +If the installed hook does not run, upgrade Codex or check that hooks are enabled, then restart Codex. Codex 0.129.0+ prefers `[features].hooks = true`; older builds used the legacy alias `[features].codex_hooks = true`. + +Codex currently does not support hook `updatedInput`, so tokf cannot transparently rewrite the Bash command in-place. Instead, the hook blocks commands that have a tokf rewrite and tells Codex the exact `tokf run ...` command to run next. Commands without a matching tokf filter pass through unchanged. ## Permission engines @@ -1487,12 +1493,13 @@ tokf supports pluggable permission engines that analyse commands and decide whet ### Hook handle command -All hook-based integrations (Claude Code, Gemini CLI, Cursor) call `tokf hook handle` internally. The `--format` flag tells tokf which response protocol to use: +All hook-based integrations (Claude Code, Gemini CLI, Cursor, Codex) call `tokf hook handle` internally. The `--format` flag tells tokf which response protocol to use: ```sh tokf hook handle # default: claude-code tokf hook handle --format gemini # Gemini CLI protocol tokf hook handle --format cursor # Cursor protocol +tokf hook handle --format codex # Codex CLI protocol ``` The hook scripts generated by `tokf hook install` set `--format` automatically — you don't need to pass it manually. The format also determines which JSON field the external permission engine should set (see [hook JSON reference](rewrites-config.md#hook-json-reference-for-engine-developers)). diff --git a/crates/tokf-cli/src/commands.rs b/crates/tokf-cli/src/commands.rs index 687ba5c9..1c46b271 100644 --- a/crates/tokf-cli/src/commands.rs +++ b/crates/tokf-cli/src/commands.rs @@ -47,6 +47,8 @@ pub enum HookFormat { Gemini, #[value(name = "cursor")] Cursor, + #[value(name = "codex")] + Codex, } /// CLI surface for `tokf doctor --sort`. Mirrors `tokf::doctor::SortBy` @@ -595,13 +597,14 @@ pub fn cmd_skill_install(global: bool) -> i32 { } } -pub fn cmd_hook_handle(format: &HookFormat) -> i32 { +pub fn cmd_hook_handle(format: &HookFormat, no_cache: bool) -> i32 { let outcome = match format { - HookFormat::ClaudeCode => hook::handle(), - HookFormat::Gemini => hook::handle_gemini(), - HookFormat::Cursor => hook::handle_cursor(), + HookFormat::ClaudeCode => hook::handle(no_cache), + HookFormat::Gemini => hook::handle_gemini(no_cache), + HookFormat::Cursor => hook::handle_cursor(no_cache), + HookFormat::Codex => hook::handle_codex(no_cache), }; - hook_outcome_exit_code(outcome) + hook_outcome_exit_code(format, outcome) } /// Map a `HookOutcome` to the process exit code expected by the host AI tool. @@ -610,8 +613,11 @@ pub fn cmd_hook_handle(format: &HookFormat) -> i32 { /// from stdout and shows the user the native prompt. Exit 2 would short-circuit /// that path and trigger an unconditional block. `Deny` keeps exit 2 because /// the JSON is already paired with a hard block in every supported host. -const fn hook_outcome_exit_code(outcome: hook::HookOutcome) -> i32 { +const fn hook_outcome_exit_code(format: &HookFormat, outcome: hook::HookOutcome) -> i32 { match outcome { + // Codex parses blocking JSON only from successful hook exits; exit 2 + // requires stderr text and would discard the structured rerun hint. + hook::HookOutcome::Deny if matches!(format, HookFormat::Codex) => 0, hook::HookOutcome::Deny => 2, hook::HookOutcome::Ask | hook::HookOutcome::Allow | hook::HookOutcome::PassThrough => 0, } @@ -627,7 +633,7 @@ pub fn cmd_hook_install( let result = match tool { HookTool::ClaudeCode => hook::install(global, &tokf_bin, install_context), HookTool::OpenCode => hook::opencode::install(global, &tokf_bin), - HookTool::Codex => hook::codex::install(global), + HookTool::Codex => hook::codex::install(global, &tokf_bin, install_context), HookTool::GeminiCli => hook::gemini::install(global, &tokf_bin, install_context), HookTool::Cursor => hook::cursor::install(global, &tokf_bin, install_context), HookTool::Cline => hook::cline::install(global), @@ -652,21 +658,41 @@ mod tests { fn ask_outcome_exits_zero_so_claude_reads_json_decision() { // Regression for #343: exit 2 short-circuits the JSON path and turns // an "ask" verdict into an unconditional block. - assert_eq!(hook_outcome_exit_code(hook::HookOutcome::Ask), 0); + assert_eq!( + hook_outcome_exit_code(&HookFormat::ClaudeCode, hook::HookOutcome::Ask), + 0 + ); } #[test] fn allow_outcome_exits_zero() { - assert_eq!(hook_outcome_exit_code(hook::HookOutcome::Allow), 0); + assert_eq!( + hook_outcome_exit_code(&HookFormat::ClaudeCode, hook::HookOutcome::Allow), + 0 + ); } #[test] fn passthrough_outcome_exits_zero() { - assert_eq!(hook_outcome_exit_code(hook::HookOutcome::PassThrough), 0); + assert_eq!( + hook_outcome_exit_code(&HookFormat::ClaudeCode, hook::HookOutcome::PassThrough), + 0 + ); } #[test] fn deny_outcome_exits_two() { - assert_eq!(hook_outcome_exit_code(hook::HookOutcome::Deny), 2); + assert_eq!( + hook_outcome_exit_code(&HookFormat::ClaudeCode, hook::HookOutcome::Deny), + 2 + ); + } + + #[test] + fn codex_deny_outcome_exits_zero_so_codex_reads_json_block() { + assert_eq!( + hook_outcome_exit_code(&HookFormat::Codex, hook::HookOutcome::Deny), + 0 + ); } } diff --git a/crates/tokf-cli/src/hook/codex.rs b/crates/tokf-cli/src/hook/codex.rs index 0004bbeb..3a0243f2 100644 --- a/crates/tokf-cli/src/hook/codex.rs +++ b/crates/tokf-cli/src/hook/codex.rs @@ -2,6 +2,12 @@ use std::path::{Path, PathBuf}; use anyhow::Context; +use super::{ + patch_json_hook_config_with_command, patch_md_with_reference, resolve_paths, write_context_doc, + write_hook_shim_with_global_args, +}; +use crate::runner; + const SKILL_MD: &str = include_str!("../../skills/codex-run/SKILL.md"); const DISCOVER_SKILL_MD: &str = include_str!("../../skills/codex-discover/SKILL.md"); @@ -21,12 +27,15 @@ const CODEX_SKILLS: &[CodexSkill] = &[ }, ]; -/// Install Codex CLI skills (tokf-run + tokf-discover). +/// Install Codex CLI hook and skills (tokf-run + tokf-discover). /// /// # Errors /// -/// Returns an error if the skill directory cannot be created or the skill file cannot be written. -pub fn install(global: bool) -> anyhow::Result<()> { +/// Returns an error if the hook, hook config, or skill files cannot be written. +pub fn install(global: bool, tokf_bin: &str, install_context: bool) -> anyhow::Result<()> { + let (hook_dir, codex_dir) = resolve_paths(global, ".codex")?; + install_hook_to(&hook_dir, &codex_dir, tokf_bin, install_context)?; + let parent = if global { let home = dirs::home_dir().context("could not determine home directory")?; home.join(".agents/skills") @@ -41,6 +50,113 @@ pub fn install(global: bool) -> anyhow::Result<()> { Ok(()) } +fn install_hook_to( + hook_dir: &Path, + codex_dir: &Path, + tokf_bin: &str, + install_context: bool, +) -> anyhow::Result<()> { + let hooks_json = codex_dir.join("hooks.json"); + let hook_script = write_codex_hook_shim(hook_dir, tokf_bin)?; + let hook_command = codex_hook_command(&hook_script)?; + patch_json_hook_config_with_command(&hooks_json, &hook_command, "PreToolUse", "Bash", None)?; + + eprintln!("[tokf] Codex hook installed"); + eprintln!("[tokf] script: {}", hook_script.display()); + eprintln!("[tokf] hooks: {}", hooks_json.display()); + + if install_context { + let created = write_context_doc(codex_dir)?; + patch_md_with_reference(codex_dir, "AGENTS.md")?; + if created { + eprintln!("[tokf] context: {}", codex_dir.join("TOKF.md").display()); + } else { + eprintln!( + "[tokf] context: {} (already exists, skipped)", + codex_dir.join("TOKF.md").display() + ); + } + } + + Ok(()) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum HookScriptPlatform { + Unix, + Windows, +} + +const fn current_hook_script_platform() -> HookScriptPlatform { + if cfg!(windows) { + HookScriptPlatform::Windows + } else { + HookScriptPlatform::Unix + } +} + +fn write_codex_hook_shim(hook_dir: &Path, tokf_bin: &str) -> anyhow::Result { + write_codex_hook_shim_for_platform(hook_dir, tokf_bin, current_hook_script_platform()) +} + +fn write_codex_hook_shim_for_platform( + hook_dir: &Path, + tokf_bin: &str, + platform: HookScriptPlatform, +) -> anyhow::Result { + let hook_script = hook_dir.join(codex_hook_script_name(platform)); + match platform { + HookScriptPlatform::Unix => write_hook_shim_with_global_args( + hook_dir, + &hook_script, + tokf_bin, + "--no-cache", + "--format codex", + )?, + HookScriptPlatform::Windows => { + std::fs::create_dir_all(hook_dir)?; + let escaped_bin = if tokf_bin == "tokf" { + tokf_bin.to_string() + } else { + cmd_quote(tokf_bin) + }; + let content = format!( + "@echo off\r\n{escaped_bin} --no-cache hook handle --format codex\r\nexit /b %ERRORLEVEL%\r\n" + ); + std::fs::write(&hook_script, content)?; + } + } + Ok(hook_script) +} + +const fn codex_hook_script_name(platform: HookScriptPlatform) -> &'static str { + match platform { + HookScriptPlatform::Unix => "codex-pre-tool-use.sh", + HookScriptPlatform::Windows => "codex-pre-tool-use.cmd", + } +} + +fn codex_hook_command(hook_script: &Path) -> anyhow::Result { + codex_hook_command_for_platform(hook_script, current_hook_script_platform()) +} + +fn codex_hook_command_for_platform( + hook_script: &Path, + platform: HookScriptPlatform, +) -> anyhow::Result { + let script = hook_script + .to_str() + .ok_or_else(|| anyhow::anyhow!("hook script path is not valid UTF-8"))?; + Ok(match platform { + HookScriptPlatform::Unix => runner::shell_escape(script), + HookScriptPlatform::Windows => cmd_quote(script), + }) +} + +fn cmd_quote(arg: &str) -> String { + format!("\"{}\"", arg.replace('"', "\"\"")) +} + #[cfg(test)] fn install_to(skill_dir: &Path) -> anyhow::Result<()> { write_skill_file(skill_dir, SKILL_MD)?; @@ -167,4 +283,87 @@ mod tests { "discover SKILL.md should include name: tokf-discover" ); } + + #[test] + fn install_hook_to_creates_codex_hook_config() { + let dir = TempDir::new().unwrap(); + let hook_dir = dir.path().join(".tokf/hooks"); + let codex_dir = dir.path().join(".codex"); + + install_hook_to(&hook_dir, &codex_dir, "tokf", false).unwrap(); + + let hook_script = hook_dir.join(codex_hook_script_name(current_hook_script_platform())); + assert!(hook_script.exists()); + let script = std::fs::read_to_string(&hook_script).unwrap(); + assert!(script.contains("--no-cache hook handle --format codex")); + + let hooks_json = codex_dir.join("hooks.json"); + let content = std::fs::read_to_string(hooks_json).unwrap(); + let value: serde_json::Value = serde_json::from_str(&content).unwrap(); + assert_eq!(value["hooks"]["PreToolUse"][0]["matcher"], "Bash"); + } + + #[test] + fn unix_codex_shim_uses_no_cache_shell_script() { + let dir = TempDir::new().unwrap(); + let hook_dir = dir.path().join(".tokf/hooks"); + + let hook_script = + write_codex_hook_shim_for_platform(&hook_dir, "tokf", HookScriptPlatform::Unix) + .unwrap(); + + assert_eq!(hook_script.file_name().unwrap(), "codex-pre-tool-use.sh"); + let script = std::fs::read_to_string(&hook_script).unwrap(); + assert!(script.starts_with("#!/bin/sh\n")); + assert!(script.contains("exec tokf --no-cache hook handle --format codex")); + + let command = + codex_hook_command_for_platform(&hook_script, HookScriptPlatform::Unix).unwrap(); + assert!(command.starts_with('\'')); + assert!(command.ends_with('\'')); + } + + #[test] + fn windows_codex_shim_uses_cmd_script_and_cmd_quoting() { + let dir = TempDir::new().unwrap(); + let hook_dir = dir.path().join("tokf hooks"); + + let hook_script = write_codex_hook_shim_for_platform( + &hook_dir, + r"C:\Program Files\tokf\tokf.exe", + HookScriptPlatform::Windows, + ) + .unwrap(); + + assert_eq!(hook_script.file_name().unwrap(), "codex-pre-tool-use.cmd"); + let script = std::fs::read_to_string(&hook_script).unwrap(); + assert!(script.starts_with("@echo off\r\n")); + assert!( + script.contains( + r#""C:\Program Files\tokf\tokf.exe" --no-cache hook handle --format codex"# + ) + ); + assert!(script.ends_with("exit /b %ERRORLEVEL%\r\n")); + + let command = + codex_hook_command_for_platform(&hook_script, HookScriptPlatform::Windows).unwrap(); + assert!(command.starts_with('"')); + assert!(command.ends_with('"')); + assert!(command.contains("tokf hooks")); + } + + #[test] + fn install_hook_to_is_idempotent() { + let dir = TempDir::new().unwrap(); + let hook_dir = dir.path().join(".tokf/hooks"); + let codex_dir = dir.path().join(".codex"); + + install_hook_to(&hook_dir, &codex_dir, "tokf", false).unwrap(); + install_hook_to(&hook_dir, &codex_dir, "tokf", false).unwrap(); + + let hooks_json = codex_dir.join("hooks.json"); + let content = std::fs::read_to_string(hooks_json).unwrap(); + let value: serde_json::Value = serde_json::from_str(&content).unwrap(); + assert_eq!(value["hooks"]["PreToolUse"].as_array().unwrap().len(), 1); + } } diff --git a/crates/tokf-cli/src/hook/mod.rs b/crates/tokf-cli/src/hook/mod.rs index 6067f951..5dc17b30 100644 --- a/crates/tokf-cli/src/hook/mod.rs +++ b/crates/tokf-cli/src/hook/mod.rs @@ -19,7 +19,8 @@ use permission_engine::ErrorFallback; use permissions::PermissionVerdict; use tokf_hook_types::PermissionDecision; use types::{ - CursorHookResponse, CursorInput, GeminiHookResponse, HookFormat, HookInput, HookResponse, + CodexHookResponse, CursorHookResponse, CursorInput, GeminiHookResponse, HookFormat, HookInput, + HookResponse, }; use crate::rewrite; @@ -46,26 +47,31 @@ pub enum HookOutcome { /// /// Returns the outcome of the hook (allow/ask/deny/pass-through). /// Errors are intentionally swallowed to never block commands. -pub fn handle() -> HookOutcome { - handle_from_reader(&mut std::io::stdin()) +pub fn handle(no_cache: bool) -> HookOutcome { + handle_from_reader_with_cache(&mut std::io::stdin(), no_cache) } -/// Testable version that reads from any `Read` source. -pub(crate) fn handle_from_reader(reader: &mut R) -> HookOutcome { +fn handle_from_reader_with_cache(reader: &mut R, no_cache: bool) -> HookOutcome { let mut input = String::new(); if reader.read_to_string(&mut input).is_err() { return HookOutcome::PassThrough; } - handle_json(&input) + handle_json_with_cache(&input, no_cache) } /// Core handle logic operating on a JSON string. +#[cfg(test)] pub(crate) fn handle_json(json: &str) -> HookOutcome { + handle_json_with_cache(json, false) +} + +fn handle_json_with_cache(json: &str, no_cache: bool) -> HookOutcome { handle_with_autodiscovery( json, "Bash", HookFormat::ClaudeCode, + no_cache, HookResponse::rewrite, HookResponse::rewrite_ask, HookResponse::deny, @@ -85,6 +91,7 @@ pub(crate) fn handle_json_with_rules( HookFormat::ClaudeCode, user_config, search_dirs, + false, HookResponse::rewrite, HookResponse::rewrite_ask, HookResponse::deny, @@ -92,20 +99,26 @@ pub(crate) fn handle_json_with_rules( } /// Process a Gemini CLI `BeforeTool` hook invocation. -pub fn handle_gemini() -> HookOutcome { +pub fn handle_gemini(no_cache: bool) -> HookOutcome { let mut input = String::new(); if std::io::stdin().read_to_string(&mut input).is_err() { return HookOutcome::PassThrough; } - handle_gemini_json(&input) + handle_gemini_json_with_cache(&input, no_cache) } /// Core Gemini handle logic operating on a JSON string. +#[cfg(test)] pub(crate) fn handle_gemini_json(json: &str) -> HookOutcome { + handle_gemini_json_with_cache(json, false) +} + +fn handle_gemini_json_with_cache(json: &str, no_cache: bool) -> HookOutcome { handle_with_autodiscovery( json, "run_shell_command", HookFormat::Gemini, + no_cache, GeminiHookResponse::rewrite, GeminiHookResponse::rewrite_ask, GeminiHookResponse::deny, @@ -125,6 +138,7 @@ pub(crate) fn handle_gemini_json_with_rules( HookFormat::Gemini, user_config, search_dirs, + false, GeminiHookResponse::rewrite, GeminiHookResponse::rewrite_ask, GeminiHookResponse::deny, @@ -138,6 +152,7 @@ fn handle_with_autodiscovery( json: &str, expected_tool: &str, format: HookFormat, + no_cache: bool, build_allow: impl FnOnce(String, Option) -> R, build_ask: impl FnOnce(String, Option) -> R, build_deny: impl FnOnce(String, Option) -> R, @@ -150,6 +165,7 @@ fn handle_with_autodiscovery( format, &user_config, &search_dirs, + no_cache, build_allow, build_ask, build_deny, @@ -157,22 +173,27 @@ fn handle_with_autodiscovery( } /// Process a Cursor `preToolUse` hook invocation. -pub fn handle_cursor() -> HookOutcome { +pub fn handle_cursor(no_cache: bool) -> HookOutcome { let mut input = String::new(); if std::io::stdin().read_to_string(&mut input).is_err() { return HookOutcome::PassThrough; } - handle_cursor_json(&input) + handle_cursor_json_with_cache(&input, no_cache) } /// Core Cursor handle logic operating on a JSON string. /// /// Cursor's `beforeShellExecution` sends `command` at the top level /// (not nested under `tool_input` like Claude Code / Gemini). +#[cfg(test)] pub(crate) fn handle_cursor_json(json: &str) -> HookOutcome { + handle_cursor_json_with_cache(json, false) +} + +fn handle_cursor_json_with_cache(json: &str, no_cache: bool) -> HookOutcome { let user_config = rewrite::load_user_config().unwrap_or_default(); let search_dirs = crate::config::default_search_dirs(); - handle_cursor_json_inner(json, &user_config, &search_dirs) + handle_cursor_json_inner(json, &user_config, &search_dirs, no_cache) } /// Fully injectable Cursor handle logic with explicit config (hermetic). @@ -182,7 +203,34 @@ pub(crate) fn handle_cursor_json_with_rules( user_config: &RewriteConfig, search_dirs: &[PathBuf], ) -> HookOutcome { - handle_cursor_json_inner(json, user_config, search_dirs) + handle_cursor_json_inner(json, user_config, search_dirs, false) +} + +/// Process an `OpenAI` Codex CLI `PreToolUse` hook invocation. +pub fn handle_codex(no_cache: bool) -> HookOutcome { + let mut input = String::new(); + if std::io::stdin().read_to_string(&mut input).is_err() { + return HookOutcome::PassThrough; + } + handle_codex_json_with_cache(&input, no_cache) +} + +/// Core Codex handle logic operating on a JSON string. +#[cfg(test)] +pub(crate) fn handle_codex_json(json: &str) -> HookOutcome { + handle_codex_json_with_cache(json, false) +} + +fn handle_codex_json_with_cache(json: &str, no_cache: bool) -> HookOutcome { + handle_with_autodiscovery( + json, + "Bash", + HookFormat::Codex, + no_cache, + CodexHookResponse::rewrite, + CodexHookResponse::rewrite_ask, + CodexHookResponse::deny, + ) } /// Cursor handle logic with explicit permission rules. @@ -190,6 +238,7 @@ fn handle_cursor_json_inner( json: &str, user_config: &RewriteConfig, search_dirs: &[PathBuf], + no_cache: bool, ) -> HookOutcome { let Ok(input) = serde_json::from_str::(json) else { return HookOutcome::PassThrough; @@ -206,6 +255,7 @@ fn handle_cursor_json_inner( HookFormat::Cursor, user_config, search_dirs, + no_cache, CursorHookResponse::rewrite, CursorHookResponse::rewrite_ask, CursorHookResponse::deny, @@ -258,6 +308,7 @@ fn handle_generic( format: HookFormat, user_config: &RewriteConfig, search_dirs: &[PathBuf], + no_cache: bool, build_allow: impl FnOnce(String, Option) -> R, build_ask: impl FnOnce(String, Option) -> R, build_deny: impl FnOnce(String, Option) -> R, @@ -281,6 +332,7 @@ fn handle_generic( format, user_config, search_dirs, + no_cache, build_allow, build_ask, build_deny, @@ -290,8 +342,9 @@ fn handle_generic( /// Shared post-deserialization logic for all hook formats. /// /// Handles both the external-engine path (verdict on every command) and -/// the no-engine path (rewrite-only, auto-allow). When `TOKF_HOOK_LOG` -/// is set in the env, every invocation appends one diagnostic record. +/// the no-engine path (rewrite-only, host-specific response). When +/// `TOKF_HOOK_LOG` is set in the env, every invocation appends one +/// diagnostic record. #[allow(clippy::too_many_arguments)] fn process_command( command: &str, @@ -300,6 +353,7 @@ fn process_command( format: HookFormat, user_config: &RewriteConfig, search_dirs: &[PathBuf], + no_cache: bool, build_allow: impl FnOnce(String, Option) -> R, build_ask: impl FnOnce(String, Option) -> R, build_deny: impl FnOnce(String, Option) -> R, @@ -310,6 +364,7 @@ fn process_command( format, user_config, search_dirs, + no_cache, build_allow, build_ask, build_deny, @@ -328,6 +383,7 @@ fn decide( format: HookFormat, user_config: &RewriteConfig, search_dirs: &[PathBuf], + no_cache: bool, build_allow: impl FnOnce(String, Option) -> R, build_ask: impl FnOnce(String, Option) -> R, build_deny: impl FnOnce(String, Option) -> R, @@ -342,7 +398,7 @@ fn decide( } return (HookOutcome::PassThrough, None); } - let rewritten = rewrite::rewrite_with_config(command, user_config, search_dirs, false); + let rewritten = rewrite::rewrite_with_config(command, user_config, search_dirs, no_cache); let rewrite_changed = rewritten != command; let output_cmd = if rewrite_changed { rewritten @@ -351,8 +407,21 @@ fn decide( }; let logged_after = rewrite_changed.then(|| output_cmd.clone()); let (response, outcome) = match verdict.decision { - PermissionDecision::Ask => (build_ask(output_cmd, verdict.reason), HookOutcome::Ask), - _ => (build_allow(output_cmd, verdict.reason), HookOutcome::Allow), + PermissionDecision::Ask if format == HookFormat::Codex && !rewrite_changed => ( + build_deny(command.to_string(), verdict.reason), + HookOutcome::Deny, + ), + PermissionDecision::Ask => ( + build_ask(output_cmd, verdict.reason), + host_rewrite_ask_outcome(format), + ), + _ if format == HookFormat::Codex && !rewrite_changed => { + return (HookOutcome::PassThrough, None); + } + _ => ( + build_allow(output_cmd, verdict.reason), + host_rewrite_allow_outcome(format), + ), }; if emit_response(&response) { return (outcome, logged_after); @@ -361,19 +430,36 @@ fn decide( } // No external engine — only act when tokf has a matching filter. - let rewritten = rewrite::rewrite_with_config(command, user_config, search_dirs, false); + let rewritten = rewrite::rewrite_with_config(command, user_config, search_dirs, no_cache); if rewritten == command { return (HookOutcome::PassThrough, None); } let logged_after = Some(rewritten.clone()); if emit_response(&build_allow(rewritten, None)) { - (HookOutcome::Allow, logged_after) + (host_rewrite_allow_outcome(format), logged_after) } else { (HookOutcome::PassThrough, logged_after) } } +const fn host_rewrite_allow_outcome(format: HookFormat) -> HookOutcome { + match format { + // Codex cannot transparently apply updatedInput; a successful tokf + // rewrite is represented as a structured block plus rerun hint. + HookFormat::Codex => HookOutcome::Deny, + HookFormat::ClaudeCode | HookFormat::Gemini | HookFormat::Cursor => HookOutcome::Allow, + } +} + +const fn host_rewrite_ask_outcome(format: HookFormat) -> HookOutcome { + match format { + // Codex has no supported ask decision, so ask maps to block. + HookFormat::Codex => HookOutcome::Deny, + HookFormat::ClaudeCode | HookFormat::Gemini | HookFormat::Cursor => HookOutcome::Ask, + } +} + /// Serialize and print a hook response. Returns true on success. fn emit_response(response: &R) -> bool { if let Ok(json) = serde_json::to_string(response) { @@ -513,6 +599,18 @@ pub(crate) fn write_hook_shim( hook_script: &Path, tokf_bin: &str, extra_args: &str, +) -> anyhow::Result<()> { + write_hook_shim_with_global_args(hook_dir, hook_script, tokf_bin, "", extra_args) +} + +/// Write the hook shim script with optional global tokf args inserted before +/// `hook handle`. `extra_args` is appended after `hook handle`. +pub(crate) fn write_hook_shim_with_global_args( + hook_dir: &Path, + hook_script: &Path, + tokf_bin: &str, + global_args: &str, + extra_args: &str, ) -> anyhow::Result<()> { std::fs::create_dir_all(hook_dir)?; @@ -521,12 +619,17 @@ pub(crate) fn write_hook_shim( } else { runner::shell_escape(tokf_bin) }; + let global_suffix = if global_args.is_empty() { + String::new() + } else { + format!(" {}", global_args.trim()) + }; let suffix = if extra_args.is_empty() { String::new() } else { format!(" {}", extra_args.trim()) }; - let content = format!("#!/bin/sh\nexec {escaped_bin} hook handle{suffix}\n"); + let content = format!("#!/bin/sh\nexec {escaped_bin}{global_suffix} hook handle{suffix}\n"); std::fs::write(hook_script, content)?; // Make executable on Unix @@ -554,6 +657,27 @@ pub(crate) fn patch_json_hook_config( hook_event_key: &str, matcher: &str, initial_value: Option, +) -> anyhow::Result<()> { + let hook_command = runner::shell_escape( + hook_script + .to_str() + .ok_or_else(|| anyhow::anyhow!("hook script path is not valid UTF-8"))?, + ); + patch_json_hook_config_with_command( + settings_path, + &hook_command, + hook_event_key, + matcher, + initial_value, + ) +} + +pub(crate) fn patch_json_hook_config_with_command( + settings_path: &Path, + hook_command: &str, + hook_event_key: &str, + matcher: &str, + initial_value: Option, ) -> anyhow::Result<()> { let mut settings: serde_json::Value = if settings_path.exists() { let content = std::fs::read_to_string(settings_path)?; @@ -564,12 +688,6 @@ pub(crate) fn patch_json_hook_config( initial_value.unwrap_or_else(|| serde_json::json!({})) }; - let hook_command = runner::shell_escape( - hook_script - .to_str() - .ok_or_else(|| anyhow::anyhow!("hook script path is not valid UTF-8"))?, - ); - let tokf_hook_entry = serde_json::json!({ "matcher": matcher, "hooks": [{ "type": "command", "command": hook_command }] diff --git a/crates/tokf-cli/src/hook/permission_engine.rs b/crates/tokf-cli/src/hook/permission_engine.rs index f4098dae..eda3dceb 100644 --- a/crates/tokf-cli/src/hook/permission_engine.rs +++ b/crates/tokf-cli/src/hook/permission_engine.rs @@ -139,11 +139,12 @@ fn parse_engine_output( /// Returns `None` if the response doesn't contain a recognisable verdict. /// Also extracts the reason string (per-format field names): /// - Claude: `hookSpecificOutput.permissionDecisionReason` +/// - Codex: `hookSpecificOutput.permissionDecisionReason` /// - Gemini: `reason` /// - Cursor: `userMessage` pub fn extract_verdict(json: &Value, format: HookFormat) -> Option { let (decision_str, reason_str) = match format { - HookFormat::ClaudeCode => { + HookFormat::ClaudeCode | HookFormat::Codex => { let hso = json.get("hookSpecificOutput")?; let decision = hso.get("permissionDecision").and_then(Value::as_str); let reason = hso.get("permissionDecisionReason").and_then(Value::as_str); diff --git a/crates/tokf-cli/src/hook/tests.rs b/crates/tokf-cli/src/hook/tests.rs index 5c70e38a..6c58c3ca 100644 --- a/crates/tokf-cli/src/hook/tests.rs +++ b/crates/tokf-cli/src/hook/tests.rs @@ -516,6 +516,19 @@ fn handle_gemini_invalid_json_passes_through() { assert_eq!(handle_gemini_json("not json"), HookOutcome::PassThrough); } +// --- handle_codex_json --- + +#[test] +fn handle_codex_non_bash_tool_passes_through() { + let json = r#"{"tool_name":"Read","tool_input":{"file_path":"/tmp/foo"}}"#; + assert_eq!(handle_codex_json(json), HookOutcome::PassThrough); +} + +#[test] +fn handle_codex_invalid_json_passes_through() { + assert_eq!(handle_codex_json("not json"), HookOutcome::PassThrough); +} + // --- handle_cursor_json_with_config --- #[test] diff --git a/crates/tokf-cli/src/hook/types.rs b/crates/tokf-cli/src/hook/types.rs index b785ab12..401ded01 100644 --- a/crates/tokf-cli/src/hook/types.rs +++ b/crates/tokf-cli/src/hook/types.rs @@ -191,6 +191,60 @@ impl CursorHookResponse { } } +/// Response to send back to Codex `PreToolUse`. +/// +/// Codex currently parses but does not support `updatedInput`, so tokf cannot +/// transparently rewrite the command. Instead, the hook blocks the original +/// command and tells Codex the exact `tokf run ...` command to execute next. +#[derive(Debug, Clone, Serialize)] +pub struct CodexHookResponse { + #[serde(rename = "hookSpecificOutput")] + pub hook_specific_output: CodexHookSpecificOutput, +} + +/// Codex-specific output that blocks the original command. +#[derive(Debug, Clone, Serialize)] +pub struct CodexHookSpecificOutput { + #[serde(rename = "hookEventName")] + pub hook_event_name: &'static str, + #[serde(rename = "permissionDecision")] + pub permission_decision: &'static str, + #[serde(rename = "permissionDecisionReason")] + pub permission_decision_reason: String, +} + +#[allow(clippy::needless_pass_by_value)] +impl CodexHookResponse { + /// Create a Codex response that blocks and asks Codex to rerun the rewritten command. + pub fn rewrite(command: String, _reason: Option) -> Self { + Self::deny_with_reason(format!("Run with tokf: {command}")) + } + + /// Codex `PreToolUse` has no supported ask decision, so ask maps to a block. + pub fn rewrite_ask(command: String, reason: Option) -> Self { + let reason = reason.map_or_else( + || format!("Requires confirmation. Run with tokf: {command}"), + |reason| format!("{reason}. Run with tokf: {command}"), + ); + Self::deny_with_reason(reason) + } + + /// Create a Codex deny response with an optional reason. + pub fn deny(command: String, reason: Option) -> Self { + Self::deny_with_reason(reason.unwrap_or_else(|| format!("Blocked by tokf: {command}"))) + } + + const fn deny_with_reason(reason: String) -> Self { + Self { + hook_specific_output: CodexHookSpecificOutput { + hook_event_name: "PreToolUse", + permission_decision: "deny", + permission_decision_reason: reason, + }, + } + } +} + #[cfg(test)] #[allow(clippy::unwrap_used, clippy::expect_used)] mod tests { @@ -288,6 +342,23 @@ mod tests { ); } + #[test] + fn serialize_codex_response_blocks_with_rerun_hint() { + let response = CodexHookResponse::rewrite("tokf run git status".to_string(), None); + let json = serde_json::to_string(&response).unwrap(); + let value: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert_eq!(value["hookSpecificOutput"]["hookEventName"], "PreToolUse"); + assert_eq!(value["hookSpecificOutput"]["permissionDecision"], "deny"); + assert_eq!( + value["hookSpecificOutput"]["permissionDecisionReason"], + "Run with tokf: tokf run git status" + ); + assert!( + value["hookSpecificOutput"].get("updatedInput").is_none(), + "Codex must not receive unsupported updatedInput" + ); + } + #[test] fn response_round_trip() { let response = HookResponse::rewrite("tokf run cargo test".to_string(), None); diff --git a/crates/tokf-cli/src/main.rs b/crates/tokf-cli/src/main.rs index fcca7366..455b7213 100644 --- a/crates/tokf-cli/src/main.rs +++ b/crates/tokf-cli/src/main.rs @@ -498,7 +498,7 @@ fn main() { Commands::Show { filter, hash } => show_cmd::cmd_show(filter, *hash), Commands::Eject { filter, global } => eject_cmd::cmd_eject(filter, *global, cli.no_cache), Commands::Hook { action } => match action { - HookAction::Handle { format } => cmd_hook_handle(format), + HookAction::Handle { format } => cmd_hook_handle(format, cli.no_cache), HookAction::Install { global, tool, diff --git a/crates/tokf-cli/src/setup/detect.rs b/crates/tokf-cli/src/setup/detect.rs index ee24271e..8bf2e4a4 100644 --- a/crates/tokf-cli/src/setup/detect.rs +++ b/crates/tokf-cli/src/setup/detect.rs @@ -126,7 +126,7 @@ fn detect_binary_tools(found: &mut Vec) { found, Tool::Codex, "OpenAI Codex CLI", - false, + true, has_binary("codex").then(|| "binary in PATH".into()), ); } diff --git a/crates/tokf-cli/tests/cli_hook_handle.rs b/crates/tokf-cli/tests/cli_hook_handle.rs index bb1bfd7d..bafad557 100644 --- a/crates/tokf-cli/tests/cli_hook_handle.rs +++ b/crates/tokf-cli/tests/cli_hook_handle.rs @@ -13,10 +13,14 @@ fn manifest_dir() -> &'static str { /// Helper: pipe JSON to `tokf hook handle` from a fresh tempdir. /// Embedded stdlib is always available, so no filters need to be copied. fn hook_handle_with_stdlib(json: &str) -> (String, bool) { + hook_handle_format_with_stdlib(json, "claude-code") +} + +fn hook_handle_format_with_stdlib(json: &str, format: &str) -> (String, bool) { let dir = tempfile::TempDir::new().unwrap(); let mut child = tokf() - .args(["hook", "handle"]) + .args(["hook", "handle", "--format", format]) .current_dir(dir.path()) .stdin(Stdio::piped()) .stdout(Stdio::piped()) @@ -35,6 +39,34 @@ fn hook_handle_with_stdlib(json: &str) -> (String, bool) { (stdout, output.status.success()) } +fn hook_handle_format_with_args( + dir: &std::path::Path, + json: &str, + args: &[&str], +) -> (String, String, bool) { + let mut child = tokf() + .args(args) + .current_dir(dir) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + + { + use std::io::Write; + let stdin = child.stdin.as_mut().unwrap(); + stdin.write_all(json.as_bytes()).unwrap(); + } + + let output = child.wait_with_output().unwrap(); + ( + String::from_utf8_lossy(&output.stdout).to_string(), + String::from_utf8_lossy(&output.stderr).to_string(), + output.status.success(), + ) +} + /// Helper: pipe JSON to `tokf hook handle` with a single custom filter. fn hook_handle_with_filter(json: &str, filter_name: &str, filter_content: &str) -> String { let dir = tempfile::TempDir::new().unwrap(); @@ -106,6 +138,62 @@ fn hook_handle_rewrites_bash_with_args() { ); } +#[test] +fn hook_handle_codex_blocks_with_rerun_hint() { + let json = r#"{"tool_name":"Bash","tool_input":{"command":"git status"}}"#; + let (stdout, success) = hook_handle_format_with_stdlib(json, "codex"); + assert!(success); + + let response: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap(); + assert_eq!( + response["hookSpecificOutput"]["hookEventName"], + "PreToolUse" + ); + assert_eq!(response["hookSpecificOutput"]["permissionDecision"], "deny"); + assert_eq!( + response["hookSpecificOutput"]["permissionDecisionReason"], + "Run with tokf: tokf run git status" + ); + assert!( + response["hookSpecificOutput"].get("updatedInput").is_none(), + "Codex hook output must not use unsupported updatedInput" + ); +} + +#[test] +fn hook_handle_codex_unmatched_command_silent() { + let json = r#"{"tool_name":"Bash","tool_input":{"command":"unknown-xyz-cmd-99"}}"#; + let (stdout, success) = hook_handle_format_with_stdlib(json, "codex"); + assert!(success); + assert!( + stdout.trim().is_empty(), + "expected no output for unmatched command, got: {stdout}" + ); +} + +#[test] +fn hook_handle_no_cache_skips_project_cache_for_matching_command() { + let dir = tempfile::TempDir::new().unwrap(); + std::fs::create_dir_all(dir.path().join(".tokf/hooks")).unwrap(); + let json = r#"{"tool_name":"Bash","tool_input":{"command":"git status"}}"#; + + let (stdout, stderr, success) = hook_handle_format_with_args( + dir.path(), + json, + &["--no-cache", "hook", "handle", "--format", "codex"], + ); + + assert!(success); + assert!( + stderr.trim().is_empty(), + "--no-cache hook handling should stay silent on stderr, got: {stderr}" + ); + assert!( + stdout.contains("Run with tokf: tokf run git status"), + "expected Codex rerun hint, got: {stdout}" + ); +} + #[test] fn hook_handle_non_bash_tool_silent() { let json = r#"{"tool_name":"Read","tool_input":{"file_path":"/tmp/foo"}}"#; @@ -446,11 +534,15 @@ fn hook_handle_ask_verdict_exits_zero_and_emits_ask_json() { /// the file's contents. The hook is invoked from a temp cwd so embedded /// stdlib filters apply with no project config. fn hook_handle_with_log(json: &str) -> (bool, String) { + hook_handle_format_with_log(json, "claude-code") +} + +fn hook_handle_format_with_log(json: &str, format: &str) -> (bool, String) { let dir = tempfile::TempDir::new().unwrap(); let log_path = dir.path().join("hook.log"); let mut child = tokf() - .args(["hook", "handle"]) + .args(["hook", "handle", "--format", format]) .env("TOKF_HOOK_LOG", &log_path) .current_dir(dir.path()) .stdin(Stdio::piped()) @@ -512,6 +604,25 @@ fn hook_log_records_passthrough_with_null_after() { assert!(log.contains("after: ~\n"), "expected null after: {log:?}"); } +#[test] +fn hook_log_records_codex_rewrite_as_deny() { + let json = r#"{"tool_name":"Bash","tool_input":{"command":"git status"}}"#; + let (success, log) = hook_handle_format_with_log(json, "codex"); + assert!(success); + assert!( + log.contains("format: codex\n"), + "missing Codex format field: {log:?}" + ); + assert!( + log.contains("outcome: Deny\n"), + "Codex block-and-rerun should log Deny outcome: {log:?}" + ); + assert!( + log.contains("after: |-\n tokf run git status\n"), + "missing/malformed after block: {log:?}" + ); +} + #[test] fn hook_log_preserves_multiline_command_355() { // Regression for #355: the BEFORE block must show the original diff --git a/crates/tokf-cli/tests/cli_hook_install.rs b/crates/tokf-cli/tests/cli_hook_install.rs index df69852e..c27ccac8 100644 --- a/crates/tokf-cli/tests/cli_hook_install.rs +++ b/crates/tokf-cli/tests/cli_hook_install.rs @@ -281,6 +281,37 @@ fn hook_install_codex_creates_skill_file() { ); } +#[test] +fn hook_install_codex_creates_hook_config() { + let dir = tempfile::TempDir::new().unwrap(); + + let output = tokf() + .args(["hook", "install", "--tool", "codex"]) + .current_dir(dir.path()) + .output() + .unwrap(); + assert!( + output.status.success(), + "hook install --tool codex failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + + #[cfg(windows)] + let hook_script = dir.path().join(".tokf/hooks/codex-pre-tool-use.cmd"); + #[cfg(not(windows))] + let hook_script = dir.path().join(".tokf/hooks/codex-pre-tool-use.sh"); + let hooks_json = dir.path().join(".codex/hooks.json"); + assert!(hook_script.exists(), "Codex hook shim should exist"); + assert!(hooks_json.exists(), "Codex hooks.json should exist"); + + let script = std::fs::read_to_string(hook_script).unwrap(); + assert!(script.contains("--no-cache hook handle --format codex")); + + let content = std::fs::read_to_string(hooks_json).unwrap(); + let value: serde_json::Value = serde_json::from_str(&content).unwrap(); + assert_eq!(value["hooks"]["PreToolUse"][0]["matcher"], "Bash"); +} + #[test] fn hook_install_codex_is_idempotent() { let dir = tempfile::TempDir::new().unwrap(); @@ -343,11 +374,11 @@ fn hook_install_codex_shows_info_on_stderr() { let stderr = String::from_utf8_lossy(&output.stderr); assert!( - stderr.contains("Codex skill") && stderr.contains("installed"), + stderr.contains("Codex hook") && stderr.contains("installed"), "expected install confirmation, got: {stderr}" ); assert!( - stderr.contains("SKILL.md"), - "expected skill file path in output, got: {stderr}" + stderr.contains("hooks.json"), + "expected hooks.json path in output, got: {stderr}" ); } diff --git a/crates/tokf-hook-types/src/format.rs b/crates/tokf-hook-types/src/format.rs index 940b4d3b..a63227d7 100644 --- a/crates/tokf-hook-types/src/format.rs +++ b/crates/tokf-hook-types/src/format.rs @@ -8,6 +8,8 @@ pub enum HookFormat { Gemini, /// Cursor: `permission` Cursor, + /// `OpenAI` Codex CLI: `hookSpecificOutput.permissionDecision = "deny"`. + Codex, } impl HookFormat { @@ -19,6 +21,7 @@ impl HookFormat { Self::ClaudeCode => "claude-code", Self::Gemini => "gemini", Self::Cursor => "cursor", + Self::Codex => "codex", } } } diff --git a/docs/integrations.md b/docs/integrations.md index 8940c8fd..82e3c141 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -118,7 +118,7 @@ If tokf rewrite fails or no filter matches, the command passes through unmodifie ## OpenAI Codex CLI -tokf integrates with [OpenAI Codex CLI](https://github.com/openai/codex) via a skill that instructs the agent to prefix supported commands with `tokf run`. +tokf integrates with [OpenAI Codex CLI](https://github.com/openai/codex) via a Codex `PreToolUse` hook plus skills for guidance and discovery. **Install (project-local):** ```sh @@ -130,7 +130,13 @@ tokf hook install --tool codex tokf hook install --tool codex --global ``` -This writes `.agents/skills/tokf-run/SKILL.md` (or `~/.agents/skills/tokf-run/SKILL.md` for `--global`), which Codex auto-discovers. Unlike the Claude Code hook (which intercepts commands at the tool level), the Codex integration is skill-based: it teaches the agent to use `tokf run` as a command prefix. If tokf is not installed, the agent falls back to running commands without the prefix (fail-safe). +This writes a Codex `PreToolUse` hook to `.codex/hooks.json` (or `~/.codex/hooks.json` for `--global`) and installs `.agents/skills/tokf-run/SKILL.md` (or `~/.agents/skills/tokf-run/SKILL.md` for `--global`), which Codex auto-discovers. + +Codex CLI 0.124.0 and newer enable lifecycle hooks by default. tokf's Codex integration uses `PreToolUse`, which first appeared in Codex 0.117.0. + +If the installed hook does not run, upgrade Codex or check that hooks are enabled, then restart Codex. Codex 0.129.0+ prefers `[features].hooks = true`; older builds used the legacy alias `[features].codex_hooks = true`. + +Codex currently does not support hook `updatedInput`, so tokf cannot transparently rewrite the Bash command in-place. Instead, the hook blocks commands that have a tokf rewrite and tells Codex the exact `tokf run ...` command to run next. Commands without a matching tokf filter pass through unchanged. ## Permission engines @@ -140,12 +146,13 @@ tokf supports pluggable permission engines that analyse commands and decide whet ### Hook handle command -All hook-based integrations (Claude Code, Gemini CLI, Cursor) call `tokf hook handle` internally. The `--format` flag tells tokf which response protocol to use: +All hook-based integrations (Claude Code, Gemini CLI, Cursor, Codex) call `tokf hook handle` internally. The `--format` flag tells tokf which response protocol to use: ```sh tokf hook handle # default: claude-code tokf hook handle --format gemini # Gemini CLI protocol tokf hook handle --format cursor # Cursor protocol +tokf hook handle --format codex # Codex CLI protocol ``` The hook scripts generated by `tokf hook install` set `--format` automatically — you don't need to pass it manually. The format also determines which JSON field the external permission engine should set (see [hook JSON reference](rewrites-config.md#hook-json-reference-for-engine-developers)). From 951988a8ca4a5ecf5bf4e333500c1c37a500e7a6 Mon Sep 17 00:00:00 2001 From: Ivan Dergachev Date: Mon, 11 May 2026 19:16:40 +0400 Subject: [PATCH 2/4] Extract hook install helpers --- crates/tokf-cli/src/hook/install.rs | 318 ++++++++++++++++++++++++++ crates/tokf-cli/src/hook/mod.rs | 342 ++-------------------------- 2 files changed, 336 insertions(+), 324 deletions(-) create mode 100644 crates/tokf-cli/src/hook/install.rs diff --git a/crates/tokf-cli/src/hook/install.rs b/crates/tokf-cli/src/hook/install.rs new file mode 100644 index 00000000..d25a158c --- /dev/null +++ b/crates/tokf-cli/src/hook/install.rs @@ -0,0 +1,318 @@ +use std::path::{Path, PathBuf}; + +use crate::runner; + +/// Install the hook shim and register it in Claude Code settings. +/// +/// # Errors +/// +/// Returns an error if file I/O fails. +pub(super) fn install(global: bool, tokf_bin: &str, install_context: bool) -> anyhow::Result<()> { + let (hook_dir, settings_path) = if global { + let user = crate::paths::user_dir() + .ok_or_else(|| anyhow::anyhow!("could not determine config directory"))?; + let hook_dir = user.join("hooks"); + let home = dirs::home_dir() + .ok_or_else(|| anyhow::anyhow!("could not determine home directory"))?; + let settings_path = home.join(".claude/settings.json"); + (hook_dir, settings_path) + } else { + let cwd = std::env::current_dir()?; + let hook_dir = cwd.join(".tokf/hooks"); + let settings_path = cwd.join(".claude/settings.json"); + (hook_dir, settings_path) + }; + + install_to(&hook_dir, &settings_path, tokf_bin, install_context) +} + +/// Core install logic with explicit paths (testable). +pub(super) fn install_to( + hook_dir: &Path, + settings_path: &Path, + tokf_bin: &str, + install_context: bool, +) -> anyhow::Result<()> { + let hook_script = hook_dir.join("pre-tool-use.sh"); + write_hook_shim(hook_dir, &hook_script, tokf_bin, "")?; + patch_json_hook_config(settings_path, &hook_script, "PreToolUse", "Bash", None)?; + + eprintln!("[tokf] hook installed"); + eprintln!("[tokf] script: {}", hook_script.display()); + eprintln!("[tokf] settings: {}", settings_path.display()); + + if install_context && let Some(claude_dir) = settings_path.parent() { + let created = write_context_doc(claude_dir)?; + patch_md_with_reference(claude_dir, "CLAUDE.md")?; + if created { + eprintln!("[tokf] context: {}", claude_dir.join("TOKF.md").display()); + } else { + eprintln!( + "[tokf] context: {} (already exists, skipped)", + claude_dir.join("TOKF.md").display() + ); + } + } + + Ok(()) +} + +/// Resolve hook dir and tool-specific paths for global or project-local installation. +/// +/// Returns `(hook_dir, tool_config_dir)` where: +/// - `hook_dir`: where the shim script goes (e.g. `~/.tokf/hooks` or `.tokf/hooks`) +/// - `tool_config_dir`: tool-specific directory (e.g. `~/.gemini` or `.gemini`) +pub(super) fn resolve_paths( + global: bool, + tool_dir_name: &str, +) -> anyhow::Result<(PathBuf, PathBuf)> { + if global { + let user = crate::paths::user_dir() + .ok_or_else(|| anyhow::anyhow!("could not determine config directory"))?; + let hook_dir = user.join("hooks"); + let home = dirs::home_dir() + .ok_or_else(|| anyhow::anyhow!("could not determine home directory"))?; + let tool_dir = home.join(tool_dir_name); + Ok((hook_dir, tool_dir)) + } else { + let cwd = std::env::current_dir()?; + let hook_dir = cwd.join(".tokf/hooks"); + let tool_dir = cwd.join(tool_dir_name); + Ok((hook_dir, tool_dir)) + } +} + +/// Write the TOKF.md context file that explains the compression indicator. +/// Skips writing if the file already exists (preserves user edits). +/// Returns `true` if the file was created, `false` if it already existed. +pub(super) fn write_context_doc(dir: &Path) -> anyhow::Result { + std::fs::create_dir_all(dir)?; + let tokf_md = dir.join("TOKF.md"); + if tokf_md.exists() { + return Ok(false); + } + let content = "\ +🗜️ means this output was compressed by tokf. +Run `tokf raw last` to see the full uncompressed output of the last command. +"; + std::fs::write(&tokf_md, content)?; + Ok(true) +} + +/// Add an `@TOKF.md` reference to an md file (creates the file if needed). +/// +/// Used for `CLAUDE.md`, `GEMINI.md`, etc. +pub(super) fn patch_md_with_reference(dir: &Path, filename: &str) -> anyhow::Result<()> { + let md_path = dir.join(filename); + let marker = "@TOKF.md"; + match std::fs::read_to_string(&md_path) { + Ok(content) if content.contains(marker) => Ok(()), + Ok(content) => { + let separator = if content.is_empty() || content.ends_with('\n') { + "" + } else { + "\n" + }; + let updated = format!("{content}{separator}{marker}\n"); + std::fs::write(&md_path, updated)?; + Ok(()) + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + std::fs::write(&md_path, format!("{marker}\n"))?; + Ok(()) + } + Err(e) => Err(e.into()), + } +} + +/// Write the hook shim script. `extra_args` is appended after `hook handle` +/// (e.g. `"--format gemini"`). A space is inserted automatically if non-empty. +pub(super) fn write_hook_shim( + hook_dir: &Path, + hook_script: &Path, + tokf_bin: &str, + extra_args: &str, +) -> anyhow::Result<()> { + write_hook_shim_with_global_args(hook_dir, hook_script, tokf_bin, "", extra_args) +} + +/// Write the hook shim script with optional global tokf args inserted before +/// `hook handle`. `extra_args` is appended after `hook handle`. +pub(super) fn write_hook_shim_with_global_args( + hook_dir: &Path, + hook_script: &Path, + tokf_bin: &str, + global_args: &str, + extra_args: &str, +) -> anyhow::Result<()> { + std::fs::create_dir_all(hook_dir)?; + + let escaped_bin = if tokf_bin == "tokf" { + tokf_bin.to_string() + } else { + runner::shell_escape(tokf_bin) + }; + let global_suffix = if global_args.is_empty() { + String::new() + } else { + format!(" {}", global_args.trim()) + }; + let suffix = if extra_args.is_empty() { + String::new() + } else { + format!(" {}", extra_args.trim()) + }; + let content = format!("#!/bin/sh\nexec {escaped_bin}{global_suffix} hook handle{suffix}\n"); + std::fs::write(hook_script, content)?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let perms = std::fs::Permissions::from_mode(0o755); + std::fs::set_permissions(hook_script, perms)?; + } + + Ok(()) +} + +/// Patch a JSON settings/config file to register a tokf hook entry. +/// +/// Works for both Claude Code `settings.json` and Gemini `settings.json`. +/// For Cursor, which uses a different structure, see `cursor::patch_hooks_json`. +/// +/// - `hook_event_key`: e.g. `"PreToolUse"` or `"BeforeTool"` +/// - `matcher`: e.g. `"Bash"` or `"run_shell_command"` +/// - `initial_value`: optional initial JSON object (e.g. for Cursor's `"version": 1`) +pub(super) fn patch_json_hook_config( + settings_path: &Path, + hook_script: &Path, + hook_event_key: &str, + matcher: &str, + initial_value: Option, +) -> anyhow::Result<()> { + let hook_command = runner::shell_escape( + hook_script + .to_str() + .ok_or_else(|| anyhow::anyhow!("hook script path is not valid UTF-8"))?, + ); + patch_json_hook_config_with_command( + settings_path, + &hook_command, + hook_event_key, + matcher, + initial_value, + ) +} + +pub(super) fn patch_json_hook_config_with_command( + settings_path: &Path, + hook_command: &str, + hook_event_key: &str, + matcher: &str, + initial_value: Option, +) -> anyhow::Result<()> { + let mut settings: serde_json::Value = if settings_path.exists() { + let content = std::fs::read_to_string(settings_path)?; + serde_json::from_str(&content).map_err(|e| { + anyhow::anyhow!("corrupt settings.json at {}: {e}", settings_path.display()) + })? + } else { + initial_value.unwrap_or_else(|| serde_json::json!({})) + }; + + let tokf_hook_entry = serde_json::json!({ + "matcher": matcher, + "hooks": [{ "type": "command", "command": hook_command }] + }); + + let hooks = settings + .as_object_mut() + .ok_or_else(|| anyhow::anyhow!("settings.json is not an object"))? + .entry("hooks") + .or_insert_with(|| serde_json::json!({})); + + let hook_array = hooks + .as_object_mut() + .ok_or_else(|| anyhow::anyhow!("settings.json hooks is not an object"))? + .entry(hook_event_key) + .or_insert_with(|| serde_json::json!([])); + + let arr = hook_array + .as_array_mut() + .ok_or_else(|| anyhow::anyhow!("hooks.{hook_event_key} is not an array"))?; + + arr.retain(|entry| { + let is_tokf = entry + .get("hooks") + .and_then(|h| h.as_array()) + .is_some_and(|hooks| { + hooks.iter().any(|h| { + h.get("command") + .and_then(serde_json::Value::as_str) + .is_some_and(|cmd| cmd.contains("tokf") && cmd.contains("hook")) + }) + }); + !is_tokf + }); + + arr.push(tokf_hook_entry); + + if let Some(parent) = settings_path.parent() { + std::fs::create_dir_all(parent)?; + } + let json = serde_json::to_string_pretty(&settings)?; + let tmp_path = settings_path.with_extension("json.tmp"); + std::fs::write(&tmp_path, &json)?; + std::fs::rename(&tmp_path, settings_path)?; + + Ok(()) +} + +/// Append or replace a tokf section in a markdown file, idempotent via markers. +pub(super) fn append_or_replace_section( + path: &Path, + content_fn: impl FnOnce() -> String, +) -> anyhow::Result<()> { + let start_marker = ""; + let end_marker = ""; + + let existing = match std::fs::read_to_string(path) { + Ok(content) => content, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(), + Err(e) => return Err(e.into()), + }; + + let start_pos = existing.find(start_marker); + let end_pos = existing.find(end_marker); + + if let (Some(s), Some(e)) = (start_pos, end_pos) + && s < e + { + let before = &existing[..s]; + let after = &existing[e + end_marker.len()..]; + let section = content_fn(); + let updated = format!("{before}{section}{after}"); + std::fs::write(path, updated)?; + return Ok(()); + } + + let separator = if existing.is_empty() || existing.ends_with('\n') { + "" + } else { + "\n" + }; + let section = content_fn(); + let updated = format!("{existing}{separator}\n{section}"); + std::fs::write(path, updated)?; + + Ok(()) +} + +/// Write an instruction/convention file (creates parent dirs, overwrites). +pub(super) fn write_instruction_file(path: &Path, content: &str) -> anyhow::Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(path, content)?; + Ok(()) +} diff --git a/crates/tokf-cli/src/hook/mod.rs b/crates/tokf-cli/src/hook/mod.rs index 5dc17b30..4de4d02d 100644 --- a/crates/tokf-cli/src/hook/mod.rs +++ b/crates/tokf-cli/src/hook/mod.rs @@ -5,6 +5,7 @@ pub mod copilot; pub mod cursor; mod debug_log; pub mod gemini; +mod install; pub mod instructions; pub mod opencode; pub mod permission_engine; @@ -13,8 +14,15 @@ pub mod types; pub mod windsurf; use std::io::Read; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; +#[cfg(test)] +use install::install_to; +use install::{ + append_or_replace_section, patch_json_hook_config, patch_json_hook_config_with_command, + patch_md_with_reference, resolve_paths, write_context_doc, write_hook_shim, + write_hook_shim_with_global_args, write_instruction_file, +}; use permission_engine::ErrorFallback; use permissions::PermissionVerdict; use tokf_hook_types::PermissionDecision; @@ -25,7 +33,15 @@ use types::{ use crate::rewrite; use crate::rewrite::types::{PermissionEngineType, RewriteConfig}; -use crate::runner; + +/// Install the hook shim and register it in Claude Code settings. +/// +/// # Errors +/// +/// Returns an error if file I/O fails. +pub fn install(global: bool, tokf_bin: &str, install_context: bool) -> anyhow::Result<()> { + install::install(global, tokf_bin, install_context) +} /// Outcome of a hook handle invocation. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -469,328 +485,6 @@ fn emit_response(response: &R) -> bool { false } -/// Install the hook shim and register it in Claude Code settings. -/// -/// # Errors -/// -/// Returns an error if file I/O fails. -pub fn install(global: bool, tokf_bin: &str, install_context: bool) -> anyhow::Result<()> { - let (hook_dir, settings_path) = if global { - let user = crate::paths::user_dir() - .ok_or_else(|| anyhow::anyhow!("could not determine config directory"))?; - let hook_dir = user.join("hooks"); - let home = dirs::home_dir() - .ok_or_else(|| anyhow::anyhow!("could not determine home directory"))?; - let settings_path = home.join(".claude/settings.json"); - (hook_dir, settings_path) - } else { - let cwd = std::env::current_dir()?; - let hook_dir = cwd.join(".tokf/hooks"); - let settings_path = cwd.join(".claude/settings.json"); - (hook_dir, settings_path) - }; - - install_to(&hook_dir, &settings_path, tokf_bin, install_context) -} - -/// Core install logic with explicit paths (testable). -pub(crate) fn install_to( - hook_dir: &Path, - settings_path: &Path, - tokf_bin: &str, - install_context: bool, -) -> anyhow::Result<()> { - let hook_script = hook_dir.join("pre-tool-use.sh"); - write_hook_shim(hook_dir, &hook_script, tokf_bin, "")?; - patch_json_hook_config(settings_path, &hook_script, "PreToolUse", "Bash", None)?; - - eprintln!("[tokf] hook installed"); - eprintln!("[tokf] script: {}", hook_script.display()); - eprintln!("[tokf] settings: {}", settings_path.display()); - - if install_context && let Some(claude_dir) = settings_path.parent() { - let created = write_context_doc(claude_dir)?; - patch_md_with_reference(claude_dir, "CLAUDE.md")?; - if created { - eprintln!("[tokf] context: {}", claude_dir.join("TOKF.md").display()); - } else { - eprintln!( - "[tokf] context: {} (already exists, skipped)", - claude_dir.join("TOKF.md").display() - ); - } - } - - Ok(()) -} - -/// Resolve hook dir and tool-specific paths for global or project-local installation. -/// -/// Returns `(hook_dir, tool_config_dir)` where: -/// - `hook_dir`: where the shim script goes (e.g. `~/.tokf/hooks` or `.tokf/hooks`) -/// - `tool_config_dir`: tool-specific directory (e.g. `~/.gemini` or `.gemini`) -pub(crate) fn resolve_paths( - global: bool, - tool_dir_name: &str, -) -> anyhow::Result<(PathBuf, PathBuf)> { - if global { - let user = crate::paths::user_dir() - .ok_or_else(|| anyhow::anyhow!("could not determine config directory"))?; - let hook_dir = user.join("hooks"); - let home = dirs::home_dir() - .ok_or_else(|| anyhow::anyhow!("could not determine home directory"))?; - let tool_dir = home.join(tool_dir_name); - Ok((hook_dir, tool_dir)) - } else { - let cwd = std::env::current_dir()?; - let hook_dir = cwd.join(".tokf/hooks"); - let tool_dir = cwd.join(tool_dir_name); - Ok((hook_dir, tool_dir)) - } -} - -/// Write the TOKF.md context file that explains the compression indicator. -/// Skips writing if the file already exists (preserves user edits). -/// Returns `true` if the file was created, `false` if it already existed. -pub(crate) fn write_context_doc(dir: &Path) -> anyhow::Result { - std::fs::create_dir_all(dir)?; - let tokf_md = dir.join("TOKF.md"); - if tokf_md.exists() { - return Ok(false); - } - let content = "\ -🗜️ means this output was compressed by tokf. -Run `tokf raw last` to see the full uncompressed output of the last command. -"; - std::fs::write(&tokf_md, content)?; - Ok(true) -} - -/// Add an `@TOKF.md` reference to an md file (creates the file if needed). -/// -/// Used for `CLAUDE.md`, `GEMINI.md`, etc. -pub(crate) fn patch_md_with_reference(dir: &Path, filename: &str) -> anyhow::Result<()> { - let md_path = dir.join(filename); - let marker = "@TOKF.md"; - match std::fs::read_to_string(&md_path) { - Ok(content) if content.contains(marker) => Ok(()), - Ok(content) => { - let separator = if content.is_empty() || content.ends_with('\n') { - "" - } else { - "\n" - }; - let updated = format!("{content}{separator}{marker}\n"); - std::fs::write(&md_path, updated)?; - Ok(()) - } - Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - std::fs::write(&md_path, format!("{marker}\n"))?; - Ok(()) - } - Err(e) => Err(e.into()), - } -} - -/// Write the hook shim script. `extra_args` is appended after `hook handle` -/// (e.g. `"--format gemini"`). A space is inserted automatically if non-empty. -pub(crate) fn write_hook_shim( - hook_dir: &Path, - hook_script: &Path, - tokf_bin: &str, - extra_args: &str, -) -> anyhow::Result<()> { - write_hook_shim_with_global_args(hook_dir, hook_script, tokf_bin, "", extra_args) -} - -/// Write the hook shim script with optional global tokf args inserted before -/// `hook handle`. `extra_args` is appended after `hook handle`. -pub(crate) fn write_hook_shim_with_global_args( - hook_dir: &Path, - hook_script: &Path, - tokf_bin: &str, - global_args: &str, - extra_args: &str, -) -> anyhow::Result<()> { - std::fs::create_dir_all(hook_dir)?; - - let escaped_bin = if tokf_bin == "tokf" { - tokf_bin.to_string() - } else { - runner::shell_escape(tokf_bin) - }; - let global_suffix = if global_args.is_empty() { - String::new() - } else { - format!(" {}", global_args.trim()) - }; - let suffix = if extra_args.is_empty() { - String::new() - } else { - format!(" {}", extra_args.trim()) - }; - let content = format!("#!/bin/sh\nexec {escaped_bin}{global_suffix} hook handle{suffix}\n"); - std::fs::write(hook_script, content)?; - - // Make executable on Unix - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let perms = std::fs::Permissions::from_mode(0o755); - std::fs::set_permissions(hook_script, perms)?; - } - - Ok(()) -} - -/// Patch a JSON settings/config file to register a tokf hook entry. -/// -/// Works for both Claude Code `settings.json` and Gemini `settings.json`. -/// For Cursor, which uses a different structure, see `cursor::patch_hooks_json`. -/// -/// - `hook_event_key`: e.g. `"PreToolUse"` or `"BeforeTool"` -/// - `matcher`: e.g. `"Bash"` or `"run_shell_command"` -/// - `initial_value`: optional initial JSON object (e.g. for Cursor's `"version": 1`) -pub(crate) fn patch_json_hook_config( - settings_path: &Path, - hook_script: &Path, - hook_event_key: &str, - matcher: &str, - initial_value: Option, -) -> anyhow::Result<()> { - let hook_command = runner::shell_escape( - hook_script - .to_str() - .ok_or_else(|| anyhow::anyhow!("hook script path is not valid UTF-8"))?, - ); - patch_json_hook_config_with_command( - settings_path, - &hook_command, - hook_event_key, - matcher, - initial_value, - ) -} - -pub(crate) fn patch_json_hook_config_with_command( - settings_path: &Path, - hook_command: &str, - hook_event_key: &str, - matcher: &str, - initial_value: Option, -) -> anyhow::Result<()> { - let mut settings: serde_json::Value = if settings_path.exists() { - let content = std::fs::read_to_string(settings_path)?; - serde_json::from_str(&content).map_err(|e| { - anyhow::anyhow!("corrupt settings.json at {}: {e}", settings_path.display()) - })? - } else { - initial_value.unwrap_or_else(|| serde_json::json!({})) - }; - - let tokf_hook_entry = serde_json::json!({ - "matcher": matcher, - "hooks": [{ "type": "command", "command": hook_command }] - }); - - let hooks = settings - .as_object_mut() - .ok_or_else(|| anyhow::anyhow!("settings.json is not an object"))? - .entry("hooks") - .or_insert_with(|| serde_json::json!({})); - - let hook_array = hooks - .as_object_mut() - .ok_or_else(|| anyhow::anyhow!("settings.json hooks is not an object"))? - .entry(hook_event_key) - .or_insert_with(|| serde_json::json!([])); - - let arr = hook_array - .as_array_mut() - .ok_or_else(|| anyhow::anyhow!("hooks.{hook_event_key} is not an array"))?; - - // Remove any existing tokf hook entries (idempotent install) - arr.retain(|entry| { - let is_tokf = entry - .get("hooks") - .and_then(|h| h.as_array()) - .is_some_and(|hooks| { - hooks.iter().any(|h| { - h.get("command") - .and_then(serde_json::Value::as_str) - .is_some_and(|cmd| cmd.contains("tokf") && cmd.contains("hook")) - }) - }); - !is_tokf - }); - - arr.push(tokf_hook_entry); - - // Write atomically: write to temp file then rename - if let Some(parent) = settings_path.parent() { - std::fs::create_dir_all(parent)?; - } - let json = serde_json::to_string_pretty(&settings)?; - let tmp_path = settings_path.with_extension("json.tmp"); - std::fs::write(&tmp_path, &json)?; - std::fs::rename(&tmp_path, settings_path)?; - - Ok(()) -} - -/// Append or replace a tokf section in a markdown file, idempotent via markers. -pub(crate) fn append_or_replace_section( - path: &Path, - content_fn: impl FnOnce() -> String, -) -> anyhow::Result<()> { - let start_marker = ""; - let end_marker = ""; - - let existing = match std::fs::read_to_string(path) { - Ok(content) => content, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(), - Err(e) => return Err(e.into()), - }; - - let start_pos = existing.find(start_marker); - let end_pos = existing.find(end_marker); - - // Only replace when both markers are present and in correct order. - // If only the start marker exists (missing end), fall through to append - // to avoid truncating user content after the start marker. - if let (Some(s), Some(e)) = (start_pos, end_pos) - && s < e - { - let before = &existing[..s]; - let after = &existing[e + end_marker.len()..]; - let section = content_fn(); - let updated = format!("{before}{section}{after}"); - std::fs::write(path, updated)?; - return Ok(()); - } - - // No valid marker pair found — append the section. - let separator = if existing.is_empty() || existing.ends_with('\n') { - "" - } else { - "\n" - }; - let section = content_fn(); - let updated = format!("{existing}{separator}\n{section}"); - std::fs::write(path, updated)?; - - Ok(()) -} - -/// Write an instruction/convention file (creates parent dirs, overwrites). -pub(crate) fn write_instruction_file(path: &Path, content: &str) -> anyhow::Result<()> { - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent)?; - } - std::fs::write(path, content)?; - Ok(()) -} - #[cfg(test)] #[allow(clippy::unwrap_used, clippy::expect_used)] mod tests; From c065e85f31555e6305d29696bb76ba162f7038f1 Mon Sep 17 00:00:00 2001 From: Ivan Dergachev Date: Tue, 12 May 2026 13:33:06 +0400 Subject: [PATCH 3/4] Address Codex hook review feedback --- crates/tokf-cli/src/hook/codex.rs | 29 ++++++++++------------- crates/tokf-cli/src/hook/mod.rs | 2 +- crates/tokf-cli/src/hook/types.rs | 1 + crates/tokf-cli/src/setup/detect.rs | 5 ++-- crates/tokf-cli/tests/cli_hook_install.rs | 3 ++- 5 files changed, 19 insertions(+), 21 deletions(-) diff --git a/crates/tokf-cli/src/hook/codex.rs b/crates/tokf-cli/src/hook/codex.rs index 3a0243f2..de2a57fe 100644 --- a/crates/tokf-cli/src/hook/codex.rs +++ b/crates/tokf-cli/src/hook/codex.rs @@ -4,7 +4,7 @@ use anyhow::Context; use super::{ patch_json_hook_config_with_command, patch_md_with_reference, resolve_paths, write_context_doc, - write_hook_shim_with_global_args, + write_hook_shim, }; use crate::runner; @@ -106,13 +106,9 @@ fn write_codex_hook_shim_for_platform( ) -> anyhow::Result { let hook_script = hook_dir.join(codex_hook_script_name(platform)); match platform { - HookScriptPlatform::Unix => write_hook_shim_with_global_args( - hook_dir, - &hook_script, - tokf_bin, - "--no-cache", - "--format codex", - )?, + HookScriptPlatform::Unix => { + write_hook_shim(hook_dir, &hook_script, tokf_bin, "--format codex")?; + } HookScriptPlatform::Windows => { std::fs::create_dir_all(hook_dir)?; let escaped_bin = if tokf_bin == "tokf" { @@ -121,7 +117,7 @@ fn write_codex_hook_shim_for_platform( cmd_quote(tokf_bin) }; let content = format!( - "@echo off\r\n{escaped_bin} --no-cache hook handle --format codex\r\nexit /b %ERRORLEVEL%\r\n" + "@echo off\r\n{escaped_bin} hook handle --format codex\r\nexit /b %ERRORLEVEL%\r\n" ); std::fs::write(&hook_script, content)?; } @@ -295,7 +291,8 @@ mod tests { let hook_script = hook_dir.join(codex_hook_script_name(current_hook_script_platform())); assert!(hook_script.exists()); let script = std::fs::read_to_string(&hook_script).unwrap(); - assert!(script.contains("--no-cache hook handle --format codex")); + assert!(!script.contains("--no-cache")); + assert!(script.contains("hook handle --format codex")); let hooks_json = codex_dir.join("hooks.json"); let content = std::fs::read_to_string(hooks_json).unwrap(); @@ -304,7 +301,7 @@ mod tests { } #[test] - fn unix_codex_shim_uses_no_cache_shell_script() { + fn unix_codex_shim_uses_shell_script() { let dir = TempDir::new().unwrap(); let hook_dir = dir.path().join(".tokf/hooks"); @@ -315,7 +312,8 @@ mod tests { assert_eq!(hook_script.file_name().unwrap(), "codex-pre-tool-use.sh"); let script = std::fs::read_to_string(&hook_script).unwrap(); assert!(script.starts_with("#!/bin/sh\n")); - assert!(script.contains("exec tokf --no-cache hook handle --format codex")); + assert!(!script.contains("--no-cache")); + assert!(script.contains("exec tokf hook handle --format codex")); let command = codex_hook_command_for_platform(&hook_script, HookScriptPlatform::Unix).unwrap(); @@ -338,11 +336,8 @@ mod tests { assert_eq!(hook_script.file_name().unwrap(), "codex-pre-tool-use.cmd"); let script = std::fs::read_to_string(&hook_script).unwrap(); assert!(script.starts_with("@echo off\r\n")); - assert!( - script.contains( - r#""C:\Program Files\tokf\tokf.exe" --no-cache hook handle --format codex"# - ) - ); + assert!(!script.contains("--no-cache")); + assert!(script.contains(r#""C:\Program Files\tokf\tokf.exe" hook handle --format codex"#)); assert!(script.ends_with("exit /b %ERRORLEVEL%\r\n")); let command = diff --git a/crates/tokf-cli/src/hook/mod.rs b/crates/tokf-cli/src/hook/mod.rs index 4de4d02d..bdb1c35f 100644 --- a/crates/tokf-cli/src/hook/mod.rs +++ b/crates/tokf-cli/src/hook/mod.rs @@ -21,7 +21,7 @@ use install::install_to; use install::{ append_or_replace_section, patch_json_hook_config, patch_json_hook_config_with_command, patch_md_with_reference, resolve_paths, write_context_doc, write_hook_shim, - write_hook_shim_with_global_args, write_instruction_file, + write_instruction_file, }; use permission_engine::ErrorFallback; use permissions::PermissionVerdict; diff --git a/crates/tokf-cli/src/hook/types.rs b/crates/tokf-cli/src/hook/types.rs index 401ded01..ce6261d8 100644 --- a/crates/tokf-cli/src/hook/types.rs +++ b/crates/tokf-cli/src/hook/types.rs @@ -217,6 +217,7 @@ pub struct CodexHookSpecificOutput { impl CodexHookResponse { /// Create a Codex response that blocks and asks Codex to rerun the rewritten command. pub fn rewrite(command: String, _reason: Option) -> Self { + // For rewrites, Codex needs the exact rerun command more than the engine reason. Self::deny_with_reason(format!("Run with tokf: {command}")) } diff --git a/crates/tokf-cli/src/setup/detect.rs b/crates/tokf-cli/src/setup/detect.rs index 8bf2e4a4..eed85e7f 100644 --- a/crates/tokf-cli/src/setup/detect.rs +++ b/crates/tokf-cli/src/setup/detect.rs @@ -36,7 +36,8 @@ pub struct DetectedTool { pub display_name: &'static str, /// What evidence was found (e.g. "binary in PATH", "~/.claude/ exists"). pub evidence: String, - /// Whether this tool supports the skill install step. + /// Whether setup should offer the separate `tokf skill install` step. + /// Tool-specific hook installers may still install their own skills. pub supports_skill: bool, } @@ -126,7 +127,7 @@ fn detect_binary_tools(found: &mut Vec) { found, Tool::Codex, "OpenAI Codex CLI", - true, + false, has_binary("codex").then(|| "binary in PATH".into()), ); } diff --git a/crates/tokf-cli/tests/cli_hook_install.rs b/crates/tokf-cli/tests/cli_hook_install.rs index c27ccac8..c26e3d3f 100644 --- a/crates/tokf-cli/tests/cli_hook_install.rs +++ b/crates/tokf-cli/tests/cli_hook_install.rs @@ -305,7 +305,8 @@ fn hook_install_codex_creates_hook_config() { assert!(hooks_json.exists(), "Codex hooks.json should exist"); let script = std::fs::read_to_string(hook_script).unwrap(); - assert!(script.contains("--no-cache hook handle --format codex")); + assert!(!script.contains("--no-cache")); + assert!(script.contains("hook handle --format codex")); let content = std::fs::read_to_string(hooks_json).unwrap(); let value: serde_json::Value = serde_json::from_str(&content).unwrap(); From 29efdf1a694ebea6ed4c0e30a7f4c7c1313a970a Mon Sep 17 00:00:00 2001 From: Ivan Dergachev Date: Mon, 18 May 2026 23:35:22 +0400 Subject: [PATCH 4/4] Use updatedInput for supported Codex hooks --- README.md | 2 +- crates/tokf-cli/skills/codex-run/SKILL.md | 8 +- crates/tokf-cli/src/commands.rs | 2 +- crates/tokf-cli/src/hook/codex.rs | 173 ++++++++++++++++++++-- crates/tokf-cli/src/hook/mod.rs | 115 +++++++++----- crates/tokf-cli/src/hook/types.rs | 71 +++++++-- crates/tokf-cli/tests/cli_hook_handle.rs | 60 ++++++-- docs/integrations.md | 2 +- 8 files changed, 353 insertions(+), 80 deletions(-) diff --git a/README.md b/README.md index a06e5fa1..936d03cd 100644 --- a/README.md +++ b/README.md @@ -1483,7 +1483,7 @@ Codex CLI 0.124.0 and newer enable lifecycle hooks by default. tokf's Codex inte If the installed hook does not run, upgrade Codex or check that hooks are enabled, then restart Codex. Codex 0.129.0+ prefers `[features].hooks = true`; older builds used the legacy alias `[features].codex_hooks = true`. -Codex currently does not support hook `updatedInput`, so tokf cannot transparently rewrite the Bash command in-place. Instead, the hook blocks commands that have a tokf rewrite and tells Codex the exact `tokf run ...` command to run next. Commands without a matching tokf filter pass through unchanged. +Codex CLI 0.131.0 and newer support `PreToolUse` `updatedInput`, so tokf transparently rewrites matching Bash commands in-place. During installation, tokf checks the local `codex --version` output and installs a conservative deny-and-rerun fallback for older or unknown Codex versions so the original command does not fail open. After upgrading Codex, rerun `tokf hook install --tool codex` so tokf can refresh the generated shim mode. Commands without a matching tokf filter pass through unchanged. ## Permission engines diff --git a/crates/tokf-cli/skills/codex-run/SKILL.md b/crates/tokf-cli/skills/codex-run/SKILL.md index bcb6e3b7..898c65ae 100644 --- a/crates/tokf-cli/skills/codex-run/SKILL.md +++ b/crates/tokf-cli/skills/codex-run/SKILL.md @@ -9,7 +9,9 @@ tokf compresses verbose command output so you receive concise, structured result ## Rule -When running a supported command, prefix it with `tokf run`: +When the tokf Codex hook is installed, run supported commands normally. The hook rewrites matching commands to `tokf run ` before execution. + +If the hook is unavailable, blocked, or not rewriting a supported command, use the manual fallback: ``` tokf run @@ -26,7 +28,7 @@ tokf run docker ps ## Supported commands -The following commands have built-in filters. Prefix these with `tokf run`: +The following commands have built-in filters. The hook can rewrite them automatically; use `tokf run` manually only as fallback: - `git status`, `git diff`, `git log`, `git push`, `git add`, `git commit`, `git show` - `cargo build`, `cargo test`, `cargo check`, `cargo clippy`, `cargo install` @@ -48,7 +50,7 @@ Commands not in this list pass through unchanged when prefixed with `tokf run`. ## Important rules 1. **Never double-prefix.** If a command already starts with `tokf run`, do not add it again. -2. **Arguments pass through.** Include all flags and arguments after the base command: `tokf run cargo test --release -- my_test`. +2. **Arguments pass through.** In manual fallback mode, include all flags and arguments after the base command: `tokf run cargo test --release -- my_test`. 3. **Fail-safe.** If `tokf` is not installed or not on PATH, run the command without the prefix. 4. **Environment variables.** Place env vars before `tokf run`: `RUST_LOG=debug tokf run cargo test`. 5. **Pipes.** Do not add redundant filtering pipes (e.g. `| grep`, `| tail`, `| head`) after `tokf run` commands — tokf already compresses the output. Piping tokf's output for other purposes (e.g. `tokf run cargo test | wc -l`) is fine. diff --git a/crates/tokf-cli/src/commands.rs b/crates/tokf-cli/src/commands.rs index 1c46b271..5526eca7 100644 --- a/crates/tokf-cli/src/commands.rs +++ b/crates/tokf-cli/src/commands.rs @@ -616,7 +616,7 @@ pub fn cmd_hook_handle(format: &HookFormat, no_cache: bool) -> i32 { const fn hook_outcome_exit_code(format: &HookFormat, outcome: hook::HookOutcome) -> i32 { match outcome { // Codex parses blocking JSON only from successful hook exits; exit 2 - // requires stderr text and would discard the structured rerun hint. + // requires stderr text and would discard the structured deny reason. hook::HookOutcome::Deny if matches!(format, HookFormat::Codex) => 0, hook::HookOutcome::Deny => 2, hook::HookOutcome::Ask | hook::HookOutcome::Allow | hook::HookOutcome::PassThrough => 0, diff --git a/crates/tokf-cli/src/hook/codex.rs b/crates/tokf-cli/src/hook/codex.rs index de2a57fe..be95e1b6 100644 --- a/crates/tokf-cli/src/hook/codex.rs +++ b/crates/tokf-cli/src/hook/codex.rs @@ -1,10 +1,11 @@ use std::path::{Path, PathBuf}; +use std::process::Command; use anyhow::Context; use super::{ - patch_json_hook_config_with_command, patch_md_with_reference, resolve_paths, write_context_doc, - write_hook_shim, + CodexRewriteMode, patch_json_hook_config_with_command, patch_md_with_reference, resolve_paths, + write_context_doc, }; use crate::runner; @@ -34,7 +35,8 @@ const CODEX_SKILLS: &[CodexSkill] = &[ /// Returns an error if the hook, hook config, or skill files cannot be written. pub fn install(global: bool, tokf_bin: &str, install_context: bool) -> anyhow::Result<()> { let (hook_dir, codex_dir) = resolve_paths(global, ".codex")?; - install_hook_to(&hook_dir, &codex_dir, tokf_bin, install_context)?; + let mode = detect_codex_rewrite_mode(); + install_hook_to(&hook_dir, &codex_dir, tokf_bin, install_context, mode)?; let parent = if global { let home = dirs::home_dir().context("could not determine home directory")?; @@ -55,13 +57,15 @@ fn install_hook_to( codex_dir: &Path, tokf_bin: &str, install_context: bool, + mode: CodexRewriteMode, ) -> anyhow::Result<()> { let hooks_json = codex_dir.join("hooks.json"); - let hook_script = write_codex_hook_shim(hook_dir, tokf_bin)?; + let hook_script = write_codex_hook_shim(hook_dir, tokf_bin, mode)?; let hook_command = codex_hook_command(&hook_script)?; patch_json_hook_config_with_command(&hooks_json, &hook_command, "PreToolUse", "Bash", None)?; eprintln!("[tokf] Codex hook installed"); + eprintln!("[tokf] rewrite mode: {}", mode.env_value()); eprintln!("[tokf] script: {}", hook_script.display()); eprintln!("[tokf] hooks: {}", hooks_json.display()); @@ -95,19 +99,40 @@ const fn current_hook_script_platform() -> HookScriptPlatform { } } -fn write_codex_hook_shim(hook_dir: &Path, tokf_bin: &str) -> anyhow::Result { - write_codex_hook_shim_for_platform(hook_dir, tokf_bin, current_hook_script_platform()) +fn write_codex_hook_shim( + hook_dir: &Path, + tokf_bin: &str, + mode: CodexRewriteMode, +) -> anyhow::Result { + write_codex_hook_shim_for_platform(hook_dir, tokf_bin, current_hook_script_platform(), mode) } fn write_codex_hook_shim_for_platform( hook_dir: &Path, tokf_bin: &str, platform: HookScriptPlatform, + mode: CodexRewriteMode, ) -> anyhow::Result { let hook_script = hook_dir.join(codex_hook_script_name(platform)); match platform { HookScriptPlatform::Unix => { - write_hook_shim(hook_dir, &hook_script, tokf_bin, "--format codex")?; + std::fs::create_dir_all(hook_dir)?; + let escaped_bin = if tokf_bin == "tokf" { + tokf_bin.to_string() + } else { + runner::shell_escape(tokf_bin) + }; + let mode = mode.env_value(); + let content = format!( + "#!/bin/sh\nTOKF_CODEX_REWRITE_MODE={mode} exec {escaped_bin} hook handle --format codex\n" + ); + std::fs::write(&hook_script, content)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let perms = std::fs::Permissions::from_mode(0o755); + std::fs::set_permissions(&hook_script, perms)?; + } } HookScriptPlatform::Windows => { std::fs::create_dir_all(hook_dir)?; @@ -116,8 +141,9 @@ fn write_codex_hook_shim_for_platform( } else { cmd_quote(tokf_bin) }; + let mode = mode.env_value(); let content = format!( - "@echo off\r\n{escaped_bin} hook handle --format codex\r\nexit /b %ERRORLEVEL%\r\n" + "@echo off\r\nset \"TOKF_CODEX_REWRITE_MODE={mode}\"\r\n{escaped_bin} hook handle --format codex\r\nexit /b %ERRORLEVEL%\r\n" ); std::fs::write(&hook_script, content)?; } @@ -125,6 +151,72 @@ fn write_codex_hook_shim_for_platform( Ok(hook_script) } +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +struct CodexVersion { + major: u64, + minor: u64, + patch: u64, +} + +const CODEX_UPDATED_INPUT_MIN_VERSION: CodexVersion = CodexVersion { + major: 0, + minor: 131, + patch: 0, +}; + +fn detect_codex_rewrite_mode() -> CodexRewriteMode { + let Some(version) = installed_codex_version() else { + eprintln!( + "[tokf] warning: could not detect Codex version; installing conservative deny-rerun fallback" + ); + eprintln!("[tokf] after upgrading Codex, rerun `tokf hook install --tool codex`."); + return CodexRewriteMode::DenyRerun; + }; + if version >= CODEX_UPDATED_INPUT_MIN_VERSION { + eprintln!( + "[tokf] detected Codex {}.{}.{} with updatedInput support", + version.major, version.minor, version.patch + ); + CodexRewriteMode::UpdatedInput + } else { + eprintln!( + "[tokf] warning: Codex {}.{}.{} does not support updatedInput; installing deny-rerun fallback", + version.major, version.minor, version.patch + ); + eprintln!( + "[tokf] upgrade Codex to 0.131.0+ and rerun `tokf hook install --tool codex` for transparent rewrites." + ); + CodexRewriteMode::DenyRerun + } +} + +fn installed_codex_version() -> Option { + let output = Command::new("codex").arg("--version").output().ok()?; + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + parse_codex_version(&stdout).or_else(|| parse_codex_version(&stderr)) +} + +fn parse_codex_version(output: &str) -> Option { + output.split_whitespace().find_map(parse_codex_version_word) +} + +fn parse_codex_version_word(word: &str) -> Option { + let word = word + .trim_start_matches("codex-cli") + .trim_start_matches("rust-v") + .trim_start_matches('v'); + let mut parts = word.split(['.', '-']); + let major = parts.next()?.parse().ok()?; + let minor = parts.next()?.parse().ok()?; + let patch = parts.next()?.parse().ok()?; + Some(CodexVersion { + major, + minor, + patch, + }) +} + const fn codex_hook_script_name(platform: HookScriptPlatform) -> &'static str { match platform { HookScriptPlatform::Unix => "codex-pre-tool-use.sh", @@ -280,18 +372,47 @@ mod tests { ); } + #[test] + fn parse_codex_version_from_cli_output() { + assert_eq!( + parse_codex_version("codex-cli 0.131.0"), + Some(CodexVersion { + major: 0, + minor: 131, + patch: 0, + }) + ); + assert_eq!( + parse_codex_version("codex-cli rust-v0.132.1-alpha.1"), + Some(CodexVersion { + major: 0, + minor: 132, + patch: 1, + }) + ); + assert_eq!(parse_codex_version("not a version"), None); + } + #[test] fn install_hook_to_creates_codex_hook_config() { let dir = TempDir::new().unwrap(); let hook_dir = dir.path().join(".tokf/hooks"); let codex_dir = dir.path().join(".codex"); - install_hook_to(&hook_dir, &codex_dir, "tokf", false).unwrap(); + install_hook_to( + &hook_dir, + &codex_dir, + "tokf", + false, + CodexRewriteMode::UpdatedInput, + ) + .unwrap(); let hook_script = hook_dir.join(codex_hook_script_name(current_hook_script_platform())); assert!(hook_script.exists()); let script = std::fs::read_to_string(&hook_script).unwrap(); assert!(!script.contains("--no-cache")); + assert!(script.contains("TOKF_CODEX_REWRITE_MODE=updated-input")); assert!(script.contains("hook handle --format codex")); let hooks_json = codex_dir.join("hooks.json"); @@ -305,15 +426,21 @@ mod tests { let dir = TempDir::new().unwrap(); let hook_dir = dir.path().join(".tokf/hooks"); - let hook_script = - write_codex_hook_shim_for_platform(&hook_dir, "tokf", HookScriptPlatform::Unix) - .unwrap(); + let hook_script = write_codex_hook_shim_for_platform( + &hook_dir, + "tokf", + HookScriptPlatform::Unix, + CodexRewriteMode::UpdatedInput, + ) + .unwrap(); assert_eq!(hook_script.file_name().unwrap(), "codex-pre-tool-use.sh"); let script = std::fs::read_to_string(&hook_script).unwrap(); assert!(script.starts_with("#!/bin/sh\n")); assert!(!script.contains("--no-cache")); - assert!(script.contains("exec tokf hook handle --format codex")); + assert!(script.contains( + "TOKF_CODEX_REWRITE_MODE=updated-input exec tokf hook handle --format codex" + )); let command = codex_hook_command_for_platform(&hook_script, HookScriptPlatform::Unix).unwrap(); @@ -330,6 +457,7 @@ mod tests { &hook_dir, r"C:\Program Files\tokf\tokf.exe", HookScriptPlatform::Windows, + CodexRewriteMode::DenyRerun, ) .unwrap(); @@ -337,6 +465,7 @@ mod tests { let script = std::fs::read_to_string(&hook_script).unwrap(); assert!(script.starts_with("@echo off\r\n")); assert!(!script.contains("--no-cache")); + assert!(script.contains(r#"set "TOKF_CODEX_REWRITE_MODE=deny-rerun""#)); assert!(script.contains(r#""C:\Program Files\tokf\tokf.exe" hook handle --format codex"#)); assert!(script.ends_with("exit /b %ERRORLEVEL%\r\n")); @@ -353,8 +482,22 @@ mod tests { let hook_dir = dir.path().join(".tokf/hooks"); let codex_dir = dir.path().join(".codex"); - install_hook_to(&hook_dir, &codex_dir, "tokf", false).unwrap(); - install_hook_to(&hook_dir, &codex_dir, "tokf", false).unwrap(); + install_hook_to( + &hook_dir, + &codex_dir, + "tokf", + false, + CodexRewriteMode::UpdatedInput, + ) + .unwrap(); + install_hook_to( + &hook_dir, + &codex_dir, + "tokf", + false, + CodexRewriteMode::UpdatedInput, + ) + .unwrap(); let hooks_json = codex_dir.join("hooks.json"); let content = std::fs::read_to_string(hooks_json).unwrap(); diff --git a/crates/tokf-cli/src/hook/mod.rs b/crates/tokf-cli/src/hook/mod.rs index bdb1c35f..ad501664 100644 --- a/crates/tokf-cli/src/hook/mod.rs +++ b/crates/tokf-cli/src/hook/mod.rs @@ -56,6 +56,31 @@ pub enum HookOutcome { PassThrough, } +/// Codex rewrite protocol selected for the installed hook shim. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CodexRewriteMode { + /// Codex 0.131.0+ supports transparent `updatedInput` rewrites. + UpdatedInput, + /// Older Codex builds parse but ignore `updatedInput`, so deny with a rerun hint. + DenyRerun, +} + +impl CodexRewriteMode { + pub const fn env_value(self) -> &'static str { + match self { + Self::UpdatedInput => "updated-input", + Self::DenyRerun => "deny-rerun", + } + } + + fn from_env() -> Self { + match std::env::var("TOKF_CODEX_REWRITE_MODE") { + Ok(value) if value == Self::UpdatedInput.env_value() => Self::UpdatedInput, + _ => Self::DenyRerun, + } + } +} + /// Process a `PreToolUse` hook invocation. /// /// Reads JSON from stdin, checks if it's a Bash tool call, rewrites the command @@ -91,6 +116,8 @@ fn handle_json_with_cache(json: &str, no_cache: bool) -> HookOutcome { HookResponse::rewrite, HookResponse::rewrite_ask, HookResponse::deny, + HookOutcome::Allow, + HookOutcome::Ask, ) } @@ -111,6 +138,8 @@ pub(crate) fn handle_json_with_rules( HookResponse::rewrite, HookResponse::rewrite_ask, HookResponse::deny, + HookOutcome::Allow, + HookOutcome::Ask, ) } @@ -138,6 +167,8 @@ fn handle_gemini_json_with_cache(json: &str, no_cache: bool) -> HookOutcome { GeminiHookResponse::rewrite, GeminiHookResponse::rewrite_ask, GeminiHookResponse::deny, + HookOutcome::Allow, + HookOutcome::Ask, ) } @@ -158,6 +189,8 @@ pub(crate) fn handle_gemini_json_with_rules( GeminiHookResponse::rewrite, GeminiHookResponse::rewrite_ask, GeminiHookResponse::deny, + HookOutcome::Allow, + HookOutcome::Ask, ) } @@ -172,6 +205,8 @@ fn handle_with_autodiscovery( build_allow: impl FnOnce(String, Option) -> R, build_ask: impl FnOnce(String, Option) -> R, build_deny: impl FnOnce(String, Option) -> R, + allow_outcome: HookOutcome, + ask_outcome: HookOutcome, ) -> HookOutcome { let user_config = rewrite::load_user_config().unwrap_or_default(); let search_dirs = crate::config::default_search_dirs(); @@ -185,6 +220,8 @@ fn handle_with_autodiscovery( build_allow, build_ask, build_deny, + allow_outcome, + ask_outcome, ) } @@ -238,15 +275,34 @@ pub(crate) fn handle_codex_json(json: &str) -> HookOutcome { } fn handle_codex_json_with_cache(json: &str, no_cache: bool) -> HookOutcome { - handle_with_autodiscovery( - json, - "Bash", - HookFormat::Codex, - no_cache, - CodexHookResponse::rewrite, - CodexHookResponse::rewrite_ask, - CodexHookResponse::deny, - ) + handle_codex_json_with_mode(json, no_cache, CodexRewriteMode::from_env()) +} + +fn handle_codex_json_with_mode(json: &str, no_cache: bool, mode: CodexRewriteMode) -> HookOutcome { + match mode { + CodexRewriteMode::UpdatedInput => handle_with_autodiscovery( + json, + "Bash", + HookFormat::Codex, + no_cache, + CodexHookResponse::rewrite, + CodexHookResponse::rewrite_ask, + CodexHookResponse::deny, + HookOutcome::Allow, + HookOutcome::Deny, + ), + CodexRewriteMode::DenyRerun => handle_with_autodiscovery( + json, + "Bash", + HookFormat::Codex, + no_cache, + CodexHookResponse::rewrite_deny_rerun, + CodexHookResponse::rewrite_ask, + CodexHookResponse::deny, + HookOutcome::Deny, + HookOutcome::Deny, + ), + } } /// Cursor handle logic with explicit permission rules. @@ -275,6 +331,8 @@ fn handle_cursor_json_inner( CursorHookResponse::rewrite, CursorHookResponse::rewrite_ask, CursorHookResponse::deny, + HookOutcome::Allow, + HookOutcome::Ask, ) } @@ -328,6 +386,8 @@ fn handle_generic( build_allow: impl FnOnce(String, Option) -> R, build_ask: impl FnOnce(String, Option) -> R, build_deny: impl FnOnce(String, Option) -> R, + allow_outcome: HookOutcome, + ask_outcome: HookOutcome, ) -> HookOutcome { let Ok(hook_input) = serde_json::from_str::(json) else { return HookOutcome::PassThrough; @@ -352,6 +412,8 @@ fn handle_generic( build_allow, build_ask, build_deny, + allow_outcome, + ask_outcome, ) } @@ -373,6 +435,8 @@ fn process_command( build_allow: impl FnOnce(String, Option) -> R, build_ask: impl FnOnce(String, Option) -> R, build_deny: impl FnOnce(String, Option) -> R, + allow_outcome: HookOutcome, + ask_outcome: HookOutcome, ) -> HookOutcome { let (outcome, after) = decide( command, @@ -384,6 +448,8 @@ fn process_command( build_allow, build_ask, build_deny, + allow_outcome, + ask_outcome, ); debug_log::log_event(tool_name, format, command, after.as_deref(), outcome); outcome @@ -403,6 +469,8 @@ fn decide( build_allow: impl FnOnce(String, Option) -> R, build_ask: impl FnOnce(String, Option) -> R, build_deny: impl FnOnce(String, Option) -> R, + allow_outcome: HookOutcome, + ask_outcome: HookOutcome, ) -> (HookOutcome, Option) { // When an external permission engine is configured, consult it on every // command — even ones tokf has no filter for. @@ -427,17 +495,11 @@ fn decide( build_deny(command.to_string(), verdict.reason), HookOutcome::Deny, ), - PermissionDecision::Ask => ( - build_ask(output_cmd, verdict.reason), - host_rewrite_ask_outcome(format), - ), + PermissionDecision::Ask => (build_ask(output_cmd, verdict.reason), ask_outcome), _ if format == HookFormat::Codex && !rewrite_changed => { return (HookOutcome::PassThrough, None); } - _ => ( - build_allow(output_cmd, verdict.reason), - host_rewrite_allow_outcome(format), - ), + _ => (build_allow(output_cmd, verdict.reason), allow_outcome), }; if emit_response(&response) { return (outcome, logged_after); @@ -453,29 +515,12 @@ fn decide( let logged_after = Some(rewritten.clone()); if emit_response(&build_allow(rewritten, None)) { - (host_rewrite_allow_outcome(format), logged_after) + (allow_outcome, logged_after) } else { (HookOutcome::PassThrough, logged_after) } } -const fn host_rewrite_allow_outcome(format: HookFormat) -> HookOutcome { - match format { - // Codex cannot transparently apply updatedInput; a successful tokf - // rewrite is represented as a structured block plus rerun hint. - HookFormat::Codex => HookOutcome::Deny, - HookFormat::ClaudeCode | HookFormat::Gemini | HookFormat::Cursor => HookOutcome::Allow, - } -} - -const fn host_rewrite_ask_outcome(format: HookFormat) -> HookOutcome { - match format { - // Codex has no supported ask decision, so ask maps to block. - HookFormat::Codex => HookOutcome::Deny, - HookFormat::ClaudeCode | HookFormat::Gemini | HookFormat::Cursor => HookOutcome::Ask, - } -} - /// Serialize and print a hook response. Returns true on success. fn emit_response(response: &R) -> bool { if let Ok(json) = serde_json::to_string(response) { diff --git a/crates/tokf-cli/src/hook/types.rs b/crates/tokf-cli/src/hook/types.rs index ce6261d8..1d3968d3 100644 --- a/crates/tokf-cli/src/hook/types.rs +++ b/crates/tokf-cli/src/hook/types.rs @@ -192,40 +192,45 @@ impl CursorHookResponse { } /// Response to send back to Codex `PreToolUse`. -/// -/// Codex currently parses but does not support `updatedInput`, so tokf cannot -/// transparently rewrite the command. Instead, the hook blocks the original -/// command and tells Codex the exact `tokf run ...` command to execute next. #[derive(Debug, Clone, Serialize)] pub struct CodexHookResponse { #[serde(rename = "hookSpecificOutput")] pub hook_specific_output: CodexHookSpecificOutput, } -/// Codex-specific output that blocks the original command. +/// Codex-specific output for `PreToolUse`. #[derive(Debug, Clone, Serialize)] pub struct CodexHookSpecificOutput { #[serde(rename = "hookEventName")] pub hook_event_name: &'static str, #[serde(rename = "permissionDecision")] pub permission_decision: &'static str, - #[serde(rename = "permissionDecisionReason")] - pub permission_decision_reason: String, + #[serde( + rename = "permissionDecisionReason", + skip_serializing_if = "Option::is_none" + )] + pub permission_decision_reason: Option, + #[serde(rename = "updatedInput", skip_serializing_if = "Option::is_none")] + pub updated_input: Option, } #[allow(clippy::needless_pass_by_value)] impl CodexHookResponse { - /// Create a Codex response that blocks and asks Codex to rerun the rewritten command. - pub fn rewrite(command: String, _reason: Option) -> Self { - // For rewrites, Codex needs the exact rerun command more than the engine reason. + /// Create a Codex response that rewrites and auto-allows the command. + pub const fn rewrite(command: String, reason: Option) -> Self { + Self::with_decision("allow", command, reason) + } + + /// Create a Codex response for versions that do not support `updatedInput`. + pub fn rewrite_deny_rerun(command: String, _reason: Option) -> Self { Self::deny_with_reason(format!("Run with tokf: {command}")) } - /// Codex `PreToolUse` has no supported ask decision, so ask maps to a block. + /// Codex `PreToolUse` has no supported ask decision, so ask maps to deny. pub fn rewrite_ask(command: String, reason: Option) -> Self { let reason = reason.map_or_else( - || format!("Requires confirmation. Run with tokf: {command}"), - |reason| format!("{reason}. Run with tokf: {command}"), + || format!("Requires confirmation before running: {command}"), + |reason| format!("{reason}. Rewritten command: {command}"), ); Self::deny_with_reason(reason) } @@ -240,7 +245,23 @@ impl CodexHookResponse { hook_specific_output: CodexHookSpecificOutput { hook_event_name: "PreToolUse", permission_decision: "deny", + permission_decision_reason: Some(reason), + updated_input: None, + }, + } + } + + const fn with_decision( + decision: &'static str, + command: String, + reason: Option, + ) -> Self { + Self { + hook_specific_output: CodexHookSpecificOutput { + hook_event_name: "PreToolUse", + permission_decision: decision, permission_decision_reason: reason, + updated_input: Some(UpdatedInput { command }), }, } } @@ -344,11 +365,31 @@ mod tests { } #[test] - fn serialize_codex_response_blocks_with_rerun_hint() { + fn serialize_codex_response_allows_with_updated_input() { let response = CodexHookResponse::rewrite("tokf run git status".to_string(), None); let json = serde_json::to_string(&response).unwrap(); let value: serde_json::Value = serde_json::from_str(&json).unwrap(); assert_eq!(value["hookSpecificOutput"]["hookEventName"], "PreToolUse"); + assert_eq!(value["hookSpecificOutput"]["permissionDecision"], "allow"); + assert!( + value["hookSpecificOutput"] + .get("permissionDecisionReason") + .is_none(), + "allow rewrites do not need a reason" + ); + assert_eq!( + value["hookSpecificOutput"]["updatedInput"]["command"], + "tokf run git status" + ); + } + + #[test] + fn serialize_codex_legacy_response_blocks_with_rerun_hint() { + let response = + CodexHookResponse::rewrite_deny_rerun("tokf run git status".to_string(), None); + let json = serde_json::to_string(&response).unwrap(); + let value: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert_eq!(value["hookSpecificOutput"]["hookEventName"], "PreToolUse"); assert_eq!(value["hookSpecificOutput"]["permissionDecision"], "deny"); assert_eq!( value["hookSpecificOutput"]["permissionDecisionReason"], @@ -356,7 +397,7 @@ mod tests { ); assert!( value["hookSpecificOutput"].get("updatedInput").is_none(), - "Codex must not receive unsupported updatedInput" + "legacy Codex mode must not receive ignored updatedInput" ); } diff --git a/crates/tokf-cli/tests/cli_hook_handle.rs b/crates/tokf-cli/tests/cli_hook_handle.rs index bafad557..bb2b9762 100644 --- a/crates/tokf-cli/tests/cli_hook_handle.rs +++ b/crates/tokf-cli/tests/cli_hook_handle.rs @@ -17,16 +17,27 @@ fn hook_handle_with_stdlib(json: &str) -> (String, bool) { } fn hook_handle_format_with_stdlib(json: &str, format: &str) -> (String, bool) { + hook_handle_format_with_env(json, format, None) +} + +fn hook_handle_format_with_env( + json: &str, + format: &str, + env: Option<(&str, &str)>, +) -> (String, bool) { let dir = tempfile::TempDir::new().unwrap(); - let mut child = tokf() + let mut command = tokf(); + command .args(["hook", "handle", "--format", format]) .current_dir(dir.path()) .stdin(Stdio::piped()) .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .unwrap(); + .stderr(Stdio::piped()); + if let Some((key, value)) = env { + command.env(key, value); + } + let mut child = command.spawn().unwrap(); { use std::io::Write; @@ -139,7 +150,38 @@ fn hook_handle_rewrites_bash_with_args() { } #[test] -fn hook_handle_codex_blocks_with_rerun_hint() { +fn hook_handle_codex_rewrites_with_updated_input() { + let json = r#"{"tool_name":"Bash","tool_input":{"command":"git status"}}"#; + let (stdout, success) = hook_handle_format_with_env( + json, + "codex", + Some(("TOKF_CODEX_REWRITE_MODE", "updated-input")), + ); + assert!(success); + + let response: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap(); + assert_eq!( + response["hookSpecificOutput"]["hookEventName"], + "PreToolUse" + ); + assert_eq!( + response["hookSpecificOutput"]["permissionDecision"], + "allow" + ); + assert_eq!( + response["hookSpecificOutput"]["updatedInput"]["command"], + "tokf run git status" + ); + assert!( + response["hookSpecificOutput"] + .get("permissionDecisionReason") + .is_none(), + "Codex allow rewrite should not include a reason" + ); +} + +#[test] +fn hook_handle_codex_legacy_mode_blocks_with_rerun_hint() { let json = r#"{"tool_name":"Bash","tool_input":{"command":"git status"}}"#; let (stdout, success) = hook_handle_format_with_stdlib(json, "codex"); assert!(success); @@ -156,7 +198,7 @@ fn hook_handle_codex_blocks_with_rerun_hint() { ); assert!( response["hookSpecificOutput"].get("updatedInput").is_none(), - "Codex hook output must not use unsupported updatedInput" + "legacy Codex mode should not emit ignored updatedInput" ); } @@ -190,7 +232,7 @@ fn hook_handle_no_cache_skips_project_cache_for_matching_command() { ); assert!( stdout.contains("Run with tokf: tokf run git status"), - "expected Codex rerun hint, got: {stdout}" + "expected conservative Codex rerun hint, got: {stdout}" ); } @@ -605,7 +647,7 @@ fn hook_log_records_passthrough_with_null_after() { } #[test] -fn hook_log_records_codex_rewrite_as_deny() { +fn hook_log_records_codex_default_rewrite_as_deny() { let json = r#"{"tool_name":"Bash","tool_input":{"command":"git status"}}"#; let (success, log) = hook_handle_format_with_log(json, "codex"); assert!(success); @@ -615,7 +657,7 @@ fn hook_log_records_codex_rewrite_as_deny() { ); assert!( log.contains("outcome: Deny\n"), - "Codex block-and-rerun should log Deny outcome: {log:?}" + "Codex default deny-rerun rewrite should log Deny outcome: {log:?}" ); assert!( log.contains("after: |-\n tokf run git status\n"), diff --git a/docs/integrations.md b/docs/integrations.md index 82e3c141..38f51ddd 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -136,7 +136,7 @@ Codex CLI 0.124.0 and newer enable lifecycle hooks by default. tokf's Codex inte If the installed hook does not run, upgrade Codex or check that hooks are enabled, then restart Codex. Codex 0.129.0+ prefers `[features].hooks = true`; older builds used the legacy alias `[features].codex_hooks = true`. -Codex currently does not support hook `updatedInput`, so tokf cannot transparently rewrite the Bash command in-place. Instead, the hook blocks commands that have a tokf rewrite and tells Codex the exact `tokf run ...` command to run next. Commands without a matching tokf filter pass through unchanged. +Codex CLI 0.131.0 and newer support `PreToolUse` `updatedInput`, so tokf transparently rewrites matching Bash commands in-place. During installation, tokf checks the local `codex --version` output and installs a conservative deny-and-rerun fallback for older or unknown Codex versions so the original command does not fail open. After upgrading Codex, rerun `tokf hook install --tool codex` so tokf can refresh the generated shim mode. Commands without a matching tokf filter pass through unchanged. ## Permission engines