diff --git a/README.md b/README.md index 9c52a96d..936d03cd 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 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 @@ -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/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 687ba5c9..5526eca7 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 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, } @@ -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..be95e1b6 100644 --- a/crates/tokf-cli/src/hook/codex.rs +++ b/crates/tokf-cli/src/hook/codex.rs @@ -1,7 +1,14 @@ use std::path::{Path, PathBuf}; +use std::process::Command; use anyhow::Context; +use super::{ + CodexRewriteMode, patch_json_hook_config_with_command, patch_md_with_reference, resolve_paths, + write_context_doc, +}; +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 +28,16 @@ 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")?; + 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")?; home.join(".agents/skills") @@ -41,6 +52,199 @@ pub fn install(global: bool) -> anyhow::Result<()> { Ok(()) } +fn install_hook_to( + hook_dir: &Path, + 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, 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()); + + 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, + 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 => { + 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)?; + let escaped_bin = if tokf_bin == "tokf" { + tokf_bin.to_string() + } else { + cmd_quote(tokf_bin) + }; + let mode = mode.env_value(); + let content = format!( + "@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)?; + } + } + 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", + 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 +371,137 @@ mod tests { "discover SKILL.md should include name: tokf-discover" ); } + + #[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, + 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"); + 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_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, + 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( + "TOKF_CODEX_REWRITE_MODE=updated-input exec tokf 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, + CodexRewriteMode::DenyRerun, + ) + .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("--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")); + + 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, + 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(); + 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/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 6067f951..ad501664 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,18 +14,34 @@ 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_instruction_file, +}; 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; 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)] @@ -39,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 @@ -46,29 +88,36 @@ 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, + HookOutcome::Allow, + HookOutcome::Ask, ) } @@ -85,30 +134,41 @@ pub(crate) fn handle_json_with_rules( HookFormat::ClaudeCode, user_config, search_dirs, + false, HookResponse::rewrite, HookResponse::rewrite_ask, HookResponse::deny, + HookOutcome::Allow, + HookOutcome::Ask, ) } /// 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, + HookOutcome::Allow, + HookOutcome::Ask, ) } @@ -125,9 +185,12 @@ pub(crate) fn handle_gemini_json_with_rules( HookFormat::Gemini, user_config, search_dirs, + false, GeminiHookResponse::rewrite, GeminiHookResponse::rewrite_ask, GeminiHookResponse::deny, + HookOutcome::Allow, + HookOutcome::Ask, ) } @@ -138,9 +201,12 @@ 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, + 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(); @@ -150,29 +216,37 @@ fn handle_with_autodiscovery( format, &user_config, &search_dirs, + no_cache, build_allow, build_ask, build_deny, + allow_outcome, + ask_outcome, ) } /// 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 +256,53 @@ 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_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. @@ -190,6 +310,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,9 +327,12 @@ fn handle_cursor_json_inner( HookFormat::Cursor, user_config, search_dirs, + no_cache, CursorHookResponse::rewrite, CursorHookResponse::rewrite_ask, CursorHookResponse::deny, + HookOutcome::Allow, + HookOutcome::Ask, ) } @@ -258,9 +382,12 @@ 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, + allow_outcome: HookOutcome, + ask_outcome: HookOutcome, ) -> HookOutcome { let Ok(hook_input) = serde_json::from_str::(json) else { return HookOutcome::PassThrough; @@ -281,17 +408,21 @@ fn handle_generic( format, user_config, search_dirs, + no_cache, build_allow, build_ask, build_deny, + allow_outcome, + ask_outcome, ) } /// 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,9 +431,12 @@ 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, + allow_outcome: HookOutcome, + ask_outcome: HookOutcome, ) -> HookOutcome { let (outcome, after) = decide( command, @@ -310,9 +444,12 @@ fn process_command( format, user_config, search_dirs, + no_cache, build_allow, build_ask, build_deny, + allow_outcome, + ask_outcome, ); debug_log::log_event(tool_name, format, command, after.as_deref(), outcome); outcome @@ -328,9 +465,12 @@ 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, + 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. @@ -342,7 +482,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 +491,15 @@ 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), ask_outcome), + _ if format == HookFormat::Codex && !rewrite_changed => { + return (HookOutcome::PassThrough, None); + } + _ => (build_allow(output_cmd, verdict.reason), allow_outcome), }; if emit_response(&response) { return (outcome, logged_after); @@ -361,14 +508,14 @@ 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) + (allow_outcome, logged_after) } else { (HookOutcome::PassThrough, logged_after) } @@ -383,296 +530,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<()> { - 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 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"); - 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 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 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 }] - }); - - 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; 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..1d3968d3 100644 --- a/crates/tokf-cli/src/hook/types.rs +++ b/crates/tokf-cli/src/hook/types.rs @@ -191,6 +191,82 @@ impl CursorHookResponse { } } +/// Response to send back to Codex `PreToolUse`. +#[derive(Debug, Clone, Serialize)] +pub struct CodexHookResponse { + #[serde(rename = "hookSpecificOutput")] + pub hook_specific_output: CodexHookSpecificOutput, +} + +/// 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", + 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 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 deny. + pub fn rewrite_ask(command: String, reason: Option) -> Self { + let reason = reason.map_or_else( + || format!("Requires confirmation before running: {command}"), + |reason| format!("{reason}. Rewritten command: {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: 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 }), + }, + } + } +} + #[cfg(test)] #[allow(clippy::unwrap_used, clippy::expect_used)] mod tests { @@ -288,6 +364,43 @@ mod tests { ); } + #[test] + 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"], + "Run with tokf: tokf run git status" + ); + assert!( + value["hookSpecificOutput"].get("updatedInput").is_none(), + "legacy Codex mode must not receive ignored 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..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, } diff --git a/crates/tokf-cli/tests/cli_hook_handle.rs b/crates/tokf-cli/tests/cli_hook_handle.rs index bb1bfd7d..bb2b9762 100644 --- a/crates/tokf-cli/tests/cli_hook_handle.rs +++ b/crates/tokf-cli/tests/cli_hook_handle.rs @@ -13,13 +13,53 @@ 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) { + 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() - .args(["hook", "handle"]) + let mut command = tokf(); + command + .args(["hook", "handle", "--format", format]) .current_dir(dir.path()) .stdin(Stdio::piped()) .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + if let Some((key, value)) = env { + command.env(key, value); + } + let mut child = command.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(); + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + (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(); @@ -31,8 +71,11 @@ fn hook_handle_with_stdlib(json: &str) -> (String, bool) { } let output = child.wait_with_output().unwrap(); - let stdout = String::from_utf8_lossy(&output.stdout).to_string(); - (stdout, output.status.success()) + ( + 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. @@ -106,6 +149,93 @@ fn hook_handle_rewrites_bash_with_args() { ); } +#[test] +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); + + 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(), + "legacy Codex mode should not emit ignored 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 conservative 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 +576,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 +646,25 @@ fn hook_log_records_passthrough_with_null_after() { assert!(log.contains("after: ~\n"), "expected null after: {log:?}"); } +#[test] +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); + assert!( + log.contains("format: codex\n"), + "missing Codex format field: {log:?}" + ); + assert!( + log.contains("outcome: Deny\n"), + "Codex default deny-rerun rewrite 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..c26e3d3f 100644 --- a/crates/tokf-cli/tests/cli_hook_install.rs +++ b/crates/tokf-cli/tests/cli_hook_install.rs @@ -281,6 +281,38 @@ 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")); + 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(); + assert_eq!(value["hooks"]["PreToolUse"][0]["matcher"], "Bash"); +} + #[test] fn hook_install_codex_is_idempotent() { let dir = tempfile::TempDir::new().unwrap(); @@ -343,11 +375,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..38f51ddd 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 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 @@ -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)).