From 69bf9a4717429ac4e7f00fc8e6d9415b0b4627e5 Mon Sep 17 00:00:00 2001 From: lok1s Date: Mon, 25 May 2026 18:30:08 +0300 Subject: [PATCH 01/17] feat: add Google Antigravity CLI (agy) hook integration (resolves #1975) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add programmatic PreToolUse hook support for the agy CLI (successor to Gemini CLI) while keeping the existing Antigravity IDE rules-file integration intact. Changes: - hook_cmd.rs: process_antigravity_payload + run_antigravity() handle both run_command (CommandLine field) and Bash (command field) tool payloads; extra args (e.g. Cwd) are preserved in the rewritten response - init.rs: run_agy_cli_mode(), patch_agy_hooks_json(), agy_hook_already_present(), insert_agy_hook_entry(), uninstall_agy_cli() — install/uninstall hooks.json at ~/.agents/hooks.json (global) or .agents/hooks.json (project-local) - main.rs: AgentTarget::Agy, HookCommands::Antigravity (alias: agy), routing for rtk init --agent agy and rtk hook antigravity; uninstall dispatch updated - constants.rs: ANTIGRAVITY_DIR, ANTIGRAVITY_HOOK_COMMAND, ANTIGRAVITY_TOOL_MATCHER - hooks/antigravity/README.md: document agy CLI vs Antigravity IDE vs Gemini (legacy) JSON protocol: Input: {"toolCall": {"name": "run_command", "args": {"CommandLine": "git status"}}} Output: {"decision": "allow", "toolCall": {"args": {"CommandLine": "rtk git status"}}} No output for non-shell tools or commands with no RTK rewrite (pass-through). Co-Authored-By: Claude Sonnet 4.6 --- hooks/antigravity/README.md | 90 ++++++++++++- src/hooks/constants.rs | 8 ++ src/hooks/hook_cmd.rs | 207 +++++++++++++++++++++++++++++ src/hooks/init.rs | 253 +++++++++++++++++++++++++++++++++++- src/main.rs | 23 +++- 5 files changed, 566 insertions(+), 15 deletions(-) diff --git a/hooks/antigravity/README.md b/hooks/antigravity/README.md index 0b279254b6..e3b8684486 100644 --- a/hooks/antigravity/README.md +++ b/hooks/antigravity/README.md @@ -2,8 +2,90 @@ > Part of [`hooks/`](../README.md) — see also [`src/hooks/`](../../src/hooks/README.md) for installation code -## Specifics +## Supported Modes -- Prompt-level guidance only (no programmatic hook) -- relies on Antigravity reading custom instructions -- `rules.md` contains the instruction to prefix all shell commands with `rtk`, usage examples, and meta commands -- Installed to `.agents/rules/antigravity-rtk-rules.md` (project-local) by `rtk init --agent antigravity` +RTK supports two separate Google Antigravity integrations: + +### 1. Antigravity CLI (`agy`) — Programmatic Hook + +The `agy` CLI (successor to Gemini CLI) supports a JSON hook protocol. RTK registers as a `PreToolUse` hook that rewrites shell commands before execution. + +**Install (global):** +```bash +rtk init -g --agent agy +``` + +**Install (project-local):** +```bash +rtk init --agent agy +``` + +**Hook location:** +- Global: `~/.agents/hooks.json` +- Project: `.agents/hooks.json` + +**Hook entry written:** +```json +{ + "hooks": { + "PreToolUse": [ + { + "type": "command", + "command": "rtk hook antigravity", + "toolNameMatcher": "^(run_command|Bash)$" + } + ] + } +} +``` + +**JSON protocol:** + +Input (`run_command` tool): +```json +{"toolCall": {"name": "run_command", "args": {"CommandLine": "git status"}}} +``` + +Output (rewritten): +```json +{"decision": "allow", "toolCall": {"args": {"CommandLine": "rtk git status"}}} +``` + +Input (`Bash` tool): +```json +{"toolCall": {"name": "Bash", "args": {"command": "cargo test"}}} +``` + +Output (rewritten): +```json +{"decision": "allow", "toolCall": {"args": {"command": "rtk cargo test"}}} +``` + +No output is produced for non-shell tools or commands that don't match any RTK filter — the tool runs unchanged. + +**Uninstall:** +```bash +rtk init --uninstall --agent agy # project-local +rtk init -g --uninstall --agent agy # global +``` + +### 2. Antigravity IDE — Prompt-Level Guidance + +The Antigravity IDE (formerly "Antigravity") reads project rules files. RTK installs its awareness instructions as a rules file. + +**Install (project-local only):** +```bash +rtk init --agent antigravity +``` + +- Installs `.agents/rules/antigravity-rtk-rules.md` in the project root +- Instructs Antigravity to prefix shell commands with `rtk` +- No programmatic hook — relies on the model following the rules + +## Gemini CLI (Legacy) + +The Gemini CLI is still supported but is now considered **legacy**. New users should use `agy`. + +```bash +rtk init -g --gemini # Gemini CLI (legacy) +``` diff --git a/src/hooks/constants.rs b/src/hooks/constants.rs index 85340510c8..8e273750dc 100644 --- a/src/hooks/constants.rs +++ b/src/hooks/constants.rs @@ -21,6 +21,14 @@ pub const OPENCODE_PLUGIN_FILE: &str = "rtk.ts"; pub const CURSOR_DIR: &str = ".cursor"; pub const CODEX_DIR: &str = ".codex"; pub const GEMINI_DIR: &str = ".gemini"; + +pub const ANTIGRAVITY_DIR: &str = ".agents"; + +/// Native Rust hook command for Google Antigravity CLI (agy). +pub const ANTIGRAVITY_HOOK_COMMAND: &str = "rtk hook antigravity"; +/// Regex matcher covering both Antigravity shell-execution tool names. +pub const ANTIGRAVITY_TOOL_MATCHER: &str = "^(run_command|Bash)$"; + pub const HERMES_DIR: &str = ".hermes"; pub const HERMES_PLUGINS_SUBDIR: &str = "plugins"; pub const HERMES_PLUGIN_NAME: &str = "rtk-rewrite"; diff --git a/src/hooks/hook_cmd.rs b/src/hooks/hook_cmd.rs index 36825e3c5e..e23cf1b12d 100644 --- a/src/hooks/hook_cmd.rs +++ b/src/hooks/hook_cmd.rs @@ -523,6 +523,130 @@ fn run_cursor_inner_with_rules( } } +// ── Google Antigravity (agy) hook ───────────────────────────── + +fn process_antigravity_payload(v: &Value) -> PayloadAction { + let tool_name = v + .pointer("/toolCall/name") + .and_then(|t| t.as_str()) + .unwrap_or(""); + + // Map tool name → command field name. Non-shell tools are ignored. + let (cmd, field) = match tool_name { + "run_command" => { + let c = v + .pointer("/toolCall/args/CommandLine") + .and_then(|c| c.as_str()) + .unwrap_or(""); + (c, "CommandLine") + } + "Bash" | "bash" => { + let c = v + .pointer("/toolCall/args/command") + .and_then(|c| c.as_str()) + .unwrap_or(""); + (c, "command") + } + _ => return PayloadAction::Ignore, + }; + + if cmd.is_empty() { + return PayloadAction::Ignore; + } + + let verdict = permissions::check_command(cmd); + if verdict == PermissionVerdict::Deny { + return PayloadAction::Skip { + reason: "skip:deny_rule", + cmd: cmd.to_string(), + }; + } + + let rewritten = match get_rewritten(cmd) { + Some(r) => r, + None => { + return PayloadAction::Skip { + reason: "skip:no_match", + cmd: cmd.to_string(), + } + } + }; + + // Mirror the full args object from the request, updating only the + // command field so extra fields like `Cwd` are preserved unchanged. + let updated_args = { + let mut args = v + .pointer("/toolCall/args") + .cloned() + .unwrap_or_else(|| json!({})); + if let Some(obj) = args.as_object_mut() { + obj.insert(field.to_string(), Value::String(rewritten.clone())); + } + args + }; + + let output = json!({ + "decision": "allow", + "toolCall": { "args": updated_args } + }); + + PayloadAction::Rewrite { + cmd: cmd.to_string(), + rewritten, + output, + } +} + +/// Run the Google Antigravity (agy) CLI PreToolUse hook. +/// +/// Reads a JSON payload from stdin, rewrites shell commands through RTK, +/// and outputs the modified tool call. Non-shell tools and commands that +/// don't match any RTK filter produce no output so the tool runs unchanged. +pub fn run_antigravity() -> Result<()> { + let input = read_stdin_limited()?; + let input = input.trim(); + if input.is_empty() { + return Ok(()); + } + + let v: Value = match serde_json::from_str(input) { + Ok(v) => v, + Err(e) => { + let _ = writeln!(io::stderr(), "[rtk hook] Failed to parse JSON input: {e}"); + return Ok(()); + } + }; + + match process_antigravity_payload(&v) { + PayloadAction::Rewrite { + cmd, + rewritten, + output, + } => { + audit_log("rewrite", &cmd, &rewritten); + let _ = writeln!(io::stdout(), "{output}"); + } + PayloadAction::Skip { reason, cmd } => { + audit_log(reason, &cmd, ""); + // No stdout output — the Antigravity CLI runs the tool unchanged. + } + PayloadAction::Ignore => { + // Non-shell tool — no output, let it pass through natively. + } + } + + Ok(()) +} + +#[cfg(test)] +fn run_antigravity_inner(input: &str) -> Option { + let v: Value = serde_json::from_str(input).ok()?; + match process_antigravity_payload(&v) { + PayloadAction::Rewrite { output, .. } => Some(output.to_string()), + _ => None, + } +} + #[cfg(test)] mod tests { use super::*; @@ -988,4 +1112,87 @@ mod tests { "cargo test should be rewritable when not denied" ); } + + // --- Antigravity (agy) hook --- + + fn agy_input_run_command(cmd: &str) -> String { + json!({ + "toolCall": { + "name": "run_command", + "args": { "CommandLine": cmd } + } + }) + .to_string() + } + + fn agy_input_bash(cmd: &str) -> String { + json!({ + "toolCall": { + "name": "Bash", + "args": { "command": cmd } + } + }) + .to_string() + } + + #[test] + fn test_agy_run_command_rewrite() { + let out = run_antigravity_inner(&agy_input_run_command("git status")).unwrap(); + let v: Value = serde_json::from_str(&out).unwrap(); + assert_eq!(v["decision"], "allow"); + assert_eq!(v["toolCall"]["args"]["CommandLine"], "rtk git status"); + } + + #[test] + fn test_agy_bash_rewrite() { + let out = run_antigravity_inner(&agy_input_bash("cargo test")).unwrap(); + let v: Value = serde_json::from_str(&out).unwrap(); + assert_eq!(v["decision"], "allow"); + assert_eq!(v["toolCall"]["args"]["command"], "rtk cargo test"); + } + + #[test] + fn test_agy_non_shell_tool_returns_none() { + let input = json!({ + "toolCall": { "name": "ListPermissions", "args": {} } + }) + .to_string(); + assert!(run_antigravity_inner(&input).is_none()); + } + + #[test] + fn test_agy_already_rtk_returns_none() { + assert!(run_antigravity_inner(&agy_input_run_command("rtk git status")).is_none()); + } + + #[test] + fn test_agy_unsupported_command_returns_none() { + assert!(run_antigravity_inner(&agy_input_run_command("htop")).is_none()); + } + + #[test] + fn test_agy_extra_args_preserved() { + // Extra fields in toolCall.args (e.g. Cwd) must survive the rewrite + let input = json!({ + "toolCall": { + "name": "run_command", + "args": { "CommandLine": "git status", "Cwd": "/tmp" } + } + }) + .to_string(); + let out = run_antigravity_inner(&input).unwrap(); + let v: Value = serde_json::from_str(&out).unwrap(); + assert_eq!(v["toolCall"]["args"]["Cwd"], "/tmp"); + assert_eq!(v["toolCall"]["args"]["CommandLine"], "rtk git status"); + } + + #[test] + fn test_agy_empty_input_returns_none() { + assert!(run_antigravity_inner("").is_none()); + } + + #[test] + fn test_agy_invalid_json_returns_none() { + assert!(run_antigravity_inner("not json").is_none()); + } } diff --git a/src/hooks/init.rs b/src/hooks/init.rs index 9af21dc575..c319302d0f 100644 --- a/src/hooks/init.rs +++ b/src/hooks/init.rs @@ -12,10 +12,11 @@ use crate::hooks::constants::{ }; use super::constants::{ - BEFORE_TOOL_KEY, CLAUDE_DIR, CLAUDE_HOOK_COMMAND, CODEX_DIR, CURSOR_HOOK_COMMAND, - GEMINI_HOOK_FILE, HERMES_DIR, HERMES_PLUGINS_SUBDIR, HERMES_PLUGIN_INIT_FILE, - HERMES_PLUGIN_MANIFEST_FILE, HERMES_PLUGIN_NAME, HOOKS_JSON, HOOKS_SUBDIR, PRE_TOOL_USE_KEY, - REWRITE_HOOK_FILE, SETTINGS_JSON, + ANTIGRAVITY_DIR, ANTIGRAVITY_HOOK_COMMAND, ANTIGRAVITY_TOOL_MATCHER, BEFORE_TOOL_KEY, + CLAUDE_DIR, CLAUDE_HOOK_COMMAND, CODEX_DIR, CURSOR_HOOK_COMMAND, GEMINI_HOOK_FILE, HERMES_DIR, + HERMES_PLUGINS_SUBDIR, HERMES_PLUGIN_INIT_FILE, HERMES_PLUGIN_MANIFEST_FILE, + HERMES_PLUGIN_NAME, HOOKS_JSON, HOOKS_SUBDIR, PRE_TOOL_USE_KEY, REWRITE_HOOK_FILE, + SETTINGS_JSON, }; use super::integrity; @@ -606,6 +607,7 @@ pub fn uninstall( gemini: bool, codex: bool, cursor: bool, + antigravity_cli: bool, ctx: InitContext, ) -> Result<()> { let InitContext { verbose, dry_run } = ctx; @@ -644,6 +646,11 @@ pub fn uninstall( return Ok(()); } + if antigravity_cli { + uninstall_agy_cli(global, ctx)?; + return Ok(()); + } + if !global { anyhow::bail!("Uninstall only works with --global flag. For local projects, manually remove RTK from CLAUDE.md"); } @@ -1735,6 +1742,215 @@ fn run_antigravity_mode_at(base_dir: &Path, ctx: InitContext) -> Result<()> { Ok(()) } +// ─── Google Antigravity CLI (agy) hook support ───────────────── + +/// Entry point for `rtk init --agent agy`. +/// +/// global=true → `~/.agents/hooks.json` +/// global=false → `.agents/hooks.json` (project-local) +pub fn run_agy_cli_mode(global: bool, ctx: InitContext) -> Result<()> { + let InitContext { dry_run, .. } = ctx; + + let hooks_json_path = if global { + let agents_dir = resolve_home_subdir(ANTIGRAVITY_DIR)?; + agents_dir.join(HOOKS_JSON) + } else { + PathBuf::from(ANTIGRAVITY_DIR).join(HOOKS_JSON) + }; + + if !dry_run { + if let Some(parent) = hooks_json_path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("Failed to create directory: {}", parent.display()))?; + } + } + + let patched = patch_agy_hooks_json(&hooks_json_path, ctx)?; + + if dry_run { + print_dry_run_footer(); + } else { + let scope = if global { "global" } else { "project-local" }; + if patched { + println!("\nGoogle Antigravity CLI (agy) hook installed ({scope}).\n"); + } else { + println!("\nGoogle Antigravity CLI (agy) hook already present ({scope}).\n"); + } + println!(" hooks.json: {}", hooks_json_path.display()); + println!(" Restart agy. Test with: git status\n"); + } + + Ok(()) +} + +/// Patch `hooks.json` with the RTK PreToolUse hook for agy. +/// Returns true if the file was modified. +fn patch_agy_hooks_json(path: &Path, ctx: InitContext) -> Result { + let InitContext { verbose, dry_run } = ctx; + + let mut root = if path.exists() { + let content = fs::read_to_string(path) + .with_context(|| format!("Failed to read {}", path.display()))?; + if content.trim().is_empty() { + serde_json::json!({}) + } else { + serde_json::from_str(&content) + .with_context(|| format!("Failed to parse {} as JSON", path.display()))? + } + } else { + serde_json::json!({}) + }; + + if agy_hook_already_present(&root) { + if verbose > 0 { + eprintln!("agy hooks.json: RTK hook already present"); + } + return Ok(false); + } + + insert_agy_hook_entry(&mut root)?; + + let serialized = + serde_json::to_string_pretty(&root).context("Failed to serialize hooks.json")?; + + if dry_run { + println!("[dry-run] would patch agy hooks.json: {}", path.display()); + if verbose > 0 { + println!("[dry-run] content:\n{}", serialized); + } + return Ok(true); + } + + if path.exists() { + let backup = path.with_extension("json.bak"); + fs::copy(path, &backup) + .with_context(|| format!("Failed to backup to {}", backup.display()))?; + if verbose > 0 { + eprintln!("Backup: {}", backup.display()); + } + } + + atomic_write(path, &serialized)?; + Ok(true) +} + +/// Returns true if an RTK hook is already in the agy hooks.json PreToolUse array. +fn agy_hook_already_present(root: &serde_json::Value) -> bool { + root.get("hooks") + .and_then(|h| h.get(PRE_TOOL_USE_KEY)) + .and_then(|v| v.as_array()) + .is_some_and(|arr| { + arr.iter().any(|entry| { + entry + .get("command") + .and_then(|c| c.as_str()) + .is_some_and(|cmd| cmd.contains("rtk")) + }) + }) +} + +/// Insert the RTK PreToolUse hook entry into the agy hooks.json root. +fn insert_agy_hook_entry(root: &mut serde_json::Value) -> Result<()> { + let root_obj = root + .as_object_mut() + .context("agy hooks.json root is not an object")?; + + let hooks = root_obj + .entry("hooks") + .or_insert_with(|| serde_json::json!({})) + .as_object_mut() + .context("hooks value is not an object")?; + + let pre_tool_use = hooks + .entry(PRE_TOOL_USE_KEY) + .or_insert_with(|| serde_json::json!([])) + .as_array_mut() + .context("PreToolUse value is not an array")?; + + pre_tool_use.push(serde_json::json!({ + "type": "command", + "command": ANTIGRAVITY_HOOK_COMMAND, + "toolNameMatcher": ANTIGRAVITY_TOOL_MATCHER + })); + + Ok(()) +} + +/// Remove RTK agy artifacts during uninstall. +fn uninstall_agy_cli(global: bool, ctx: InitContext) -> Result<()> { + let InitContext { verbose, dry_run } = ctx; + + let hooks_json_path = if global { + let agents_dir = match resolve_home_subdir(ANTIGRAVITY_DIR) { + Ok(d) => d, + Err(_) => { + println!("RTK agy hook was not installed (nothing to remove)"); + return Ok(()); + } + }; + agents_dir.join(HOOKS_JSON) + } else { + PathBuf::from(ANTIGRAVITY_DIR).join(HOOKS_JSON) + }; + + let mut removed = Vec::new(); + + if hooks_json_path.exists() { + let content = fs::read_to_string(&hooks_json_path) + .with_context(|| format!("Failed to read {}", hooks_json_path.display()))?; + + if let Ok(mut root) = serde_json::from_str::(&content) { + if let Some(arr) = root + .pointer_mut(&format!("/hooks/{}", PRE_TOOL_USE_KEY)) + .and_then(|v| v.as_array_mut()) + { + let before = arr.len(); + arr.retain(|entry| { + !entry + .get("command") + .and_then(|c| c.as_str()) + .is_some_and(|cmd| cmd.contains("rtk")) + }); + if arr.len() < before { + if dry_run { + println!( + "[dry-run] would remove RTK hook from agy hooks.json: {}", + hooks_json_path.display() + ); + } else { + let serialized = serde_json::to_string_pretty(&root)?; + atomic_write(&hooks_json_path, &serialized)?; + if verbose > 0 { + eprintln!( + "Removed RTK hook from agy hooks.json: {}", + hooks_json_path.display() + ); + } + } + removed.push(format!( + "agy hooks.json: removed RTK hook ({})", + hooks_json_path.display() + )); + } + } + } + } + + if dry_run { + print_dry_run_footer(); + } else if !removed.is_empty() { + println!("RTK uninstalled (agy):"); + for item in &removed { + println!(" - {}", item); + } + println!("\nRestart agy to apply changes."); + } else { + println!("RTK agy hook was not installed (nothing to remove)"); + } + + Ok(()) +} + // ─── Hermes support ──────────────────────────────────────────── const HERMES_PLUGIN_INIT: &str = include_str!("../../hooks/hermes/rtk-rewrite/__init__.py"); @@ -5438,7 +5654,7 @@ mod tests { let tmp = TempDir::new().unwrap(); with_claude_dir_override(&tmp, |claude_dir| { run_default_mode(true, PatchMode::Auto, false, InitContext::default()).unwrap(); - uninstall(true, false, false, false, InitContext::default()).unwrap(); + uninstall(true, false, false, false, false, InitContext::default()).unwrap(); assert!(!claude_dir.join(RTK_MD).exists(), "RTK.md must be removed"); let settings_content = @@ -5565,7 +5781,7 @@ mod tests { dry_run: true, ..Default::default() }; - uninstall(true, false, false, false, dry).unwrap(); + uninstall(true, false, false, false, false, dry).unwrap(); // Files must still exist with identical content assert!( @@ -5679,6 +5895,31 @@ mod tests { ); } + #[test] + fn test_claude_md_mode_refuses_malformed_block() { + let tmp = TempDir::new().unwrap(); + with_claude_dir_override(&tmp, |claude_dir| { + let claude_md = claude_dir.join(CLAUDE_MD); + let malformed = format!( + "# Existing notes\n\n{}\nincomplete RTK block\n", + RTK_BLOCK_START + ); + fs::write(&claude_md, &malformed).unwrap(); + + let result = run_claude_md_mode(true, false, InitContext::default()); + + assert!( + result.is_err(), + "Malformed CLAUDE.md must cause a hard error, not silent skip" + ); + + let after = fs::read_to_string(&claude_md).unwrap(); + assert_eq!(after, malformed, "File must not be modified when malformed"); + }); + } + + // ─── Copilot tests ─────────────────────────────────────────────── + #[test] fn test_copilot_init_preserves_existing_instructions() { let temp = TempDir::new().unwrap(); diff --git a/src/main.rs b/src/main.rs index c1a8971908..f01c26e54a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -43,8 +43,10 @@ pub enum AgentTarget { Cline, /// Kilo Code Kilocode, - /// Google Antigravity + /// Google Antigravity IDE (formerly Antigravity) Antigravity, + /// Google Antigravity CLI (agy) - successor to Gemini CLI + Agy, /// Hermes CLI Hermes, } @@ -774,6 +776,9 @@ enum HookCommands { Gemini, /// Process Copilot preToolUse hook (VS Code + Copilot CLI, reads JSON from stdin) Copilot, + /// Process Google Antigravity (agy) CLI PreToolUse hook (reads JSON from stdin) + #[command(alias = "agy")] + Antigravity, /// Check how a command would be rewritten by the hook engine (dry-run) Check { /// Target agent @@ -1376,13 +1381,15 @@ fn uninstall_init_dispatch( ) -> Result<()> where UninstallHermes: FnOnce(hooks::init::InitContext) -> Result<()>, - UninstallStandard: FnOnce(bool, bool, bool, bool, hooks::init::InitContext) -> Result<()>, + UninstallStandard: + FnOnce(bool, bool, bool, bool, bool, hooks::init::InitContext) -> Result<()>, { if agent == Some(AgentTarget::Hermes) { uninstall_hermes(ctx) } else { let cursor = agent == Some(AgentTarget::Cursor); - uninstall_standard(global, gemini, codex, cursor, ctx) + let antigravity_cli = agent == Some(AgentTarget::Agy); + uninstall_standard(global, gemini, codex, cursor, antigravity_cli, ctx) } } @@ -1848,10 +1855,12 @@ fn run_cli() -> Result { } else if agent == Some(AgentTarget::Antigravity) { if global { anyhow::bail!( - "Antigravity is project-scoped. Use: rtk init --agent antigravity" + "Antigravity IDE is project-scoped. Use: rtk init --agent antigravity" ); } hooks::init::run_antigravity_mode(ctx)?; + } else if agent == Some(AgentTarget::Agy) { + hooks::init::run_agy_cli_mode(global, ctx)?; } else if agent == Some(AgentTarget::Hermes) { hooks::init::run_hermes_mode(ctx)?; } else { @@ -2187,6 +2196,10 @@ fn run_cli() -> Result { hooks::hook_cmd::run_copilot()?; 0 } + HookCommands::Antigravity => { + hooks::hook_cmd::run_antigravity()?; + 0 + } HookCommands::Check { agent: _, command } => { use crate::discover::registry::rewrite_command; let raw = command.join(" "); @@ -2705,7 +2718,7 @@ mod tests { assert!(ctx.dry_run); Ok(()) }, - |_, _, _, _, _| { + |_, _, _, _, _, _| { standard_called.set(true); Ok(()) }, From 3087260beaca1ee84b28e5d09c3611c081c09d5e Mon Sep 17 00:00:00 2001 From: lok1s Date: Mon, 25 May 2026 00:45:33 +0300 Subject: [PATCH 02/17] fix: replace floor_char_boundary with is_char_boundary loop (Homebrew compat) floor_char_boundary is stable since Rust 1.87 but the Homebrew toolchain does not expose it. Inline the equivalent scan using is_char_boundary which has been stable since Rust 1.0. Co-Authored-By: Claude Sonnet 4.6 --- src/cmds/system/json_cmd.rs | 3 ++- src/cmds/system/pipe_cmd.rs | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/cmds/system/json_cmd.rs b/src/cmds/system/json_cmd.rs index 1482214443..aca92e0567 100644 --- a/src/cmds/system/json_cmd.rs +++ b/src/cmds/system/json_cmd.rs @@ -106,7 +106,8 @@ fn compact_json(value: &Value, depth: usize, max_depth: usize) -> String { Value::Number(n) => format!("{}{}", indent, n), Value::String(s) => { if s.len() > 80 { - let end = s.floor_char_boundary(77); + let mut end = 77.min(s.len()); + while !s.is_char_boundary(end) { end -= 1; } format!("{}\"{}...\"", indent, &s[..end]) } else { format!("{}\"{}\"", indent, s) diff --git a/src/cmds/system/pipe_cmd.rs b/src/cmds/system/pipe_cmd.rs index 6dcc4cdb61..a2b584ec42 100644 --- a/src/cmds/system/pipe_cmd.rs +++ b/src/cmds/system/pipe_cmd.rs @@ -140,9 +140,9 @@ fn find_wrapper(input: &str) -> String { } pub fn auto_detect_filter(input: &str) -> fn(&str) -> String { - let end = input.len().min(1024); + let mut end = input.len().min(1024); // Avoid panic: byte 1024 may fall inside a multi-byte UTF-8 char - let end = input.floor_char_boundary(end); + while !input.is_char_boundary(end) { end -= 1; } let first_1k = &input[..end]; if first_1k.contains("test result:") && first_1k.contains("passed;") { From 4faf02f477a76a7f0e6e6ea72970c77451db9a95 Mon Sep 17 00:00:00 2001 From: lok1s Date: Mon, 25 May 2026 01:17:31 +0300 Subject: [PATCH 03/17] fix(antigravity): correct agy CLI hook config location and payload format - Write hook config to ~/.gemini/antigravity-cli/settings.json (not ~/.agents/hooks.json) - Use BeforeTool/matcher format with per-tool entries for run_command and Bash - Handle both agy payload formats: simple (tool_name/tool_input) and rich (toolCall.args) - Simple payloads respond with hookSpecificOutput.tool_input; rich preserve all extra args - Add AGY_CLI_SUBDIR constant; suppress dead_code on ANTIGRAVITY_DIR Co-Authored-By: Claude Sonnet 4.6 --- src/hooks/constants.rs | 5 +- src/hooks/hook_cmd.rs | 186 +++++++++++++++++++++++++++-------- src/hooks/init.rs | 218 +++++++++++++++++++++++------------------ 3 files changed, 270 insertions(+), 139 deletions(-) diff --git a/src/hooks/constants.rs b/src/hooks/constants.rs index 8e273750dc..12f4a42d1a 100644 --- a/src/hooks/constants.rs +++ b/src/hooks/constants.rs @@ -22,12 +22,13 @@ pub const CURSOR_DIR: &str = ".cursor"; pub const CODEX_DIR: &str = ".codex"; pub const GEMINI_DIR: &str = ".gemini"; +#[allow(dead_code)] pub const ANTIGRAVITY_DIR: &str = ".agents"; /// Native Rust hook command for Google Antigravity CLI (agy). pub const ANTIGRAVITY_HOOK_COMMAND: &str = "rtk hook antigravity"; -/// Regex matcher covering both Antigravity shell-execution tool names. -pub const ANTIGRAVITY_TOOL_MATCHER: &str = "^(run_command|Bash)$"; +/// Config subdirectory for the Antigravity CLI (agy) under ~/.gemini/ +pub const AGY_CLI_SUBDIR: &str = "antigravity-cli"; pub const HERMES_DIR: &str = ".hermes"; pub const HERMES_PLUGINS_SUBDIR: &str = "plugins"; diff --git a/src/hooks/hook_cmd.rs b/src/hooks/hook_cmd.rs index e23cf1b12d..421c845163 100644 --- a/src/hooks/hook_cmd.rs +++ b/src/hooks/hook_cmd.rs @@ -524,14 +524,87 @@ fn run_cursor_inner_with_rules( } // ── Google Antigravity (agy) hook ───────────────────────────── +// +// agy sends two distinct payload shapes depending on context: +// +// Simple (older/Bash tool): +// {"tool_name": "Bash", "tool_input": {"command": "git status"}} +// {"tool_name": "run_command", "tool_input": {"CommandLine": "git status"}} +// +// Rich (newer/run_command tool — includes metadata fields): +// {"toolCall": {"name": "run_command", "args": {"CommandLine": "git status", "Cwd": "..."}}, ...} +// {"toolCall": {"name": "Bash", "args": {"command": "git status", ...}}, ...} fn process_antigravity_payload(v: &Value) -> PayloadAction { - let tool_name = v - .pointer("/toolCall/name") - .and_then(|t| t.as_str()) - .unwrap_or(""); + // Simple format: uses top-level "tool_name" key (same as Gemini CLI). + if let Some(tool_name) = v.get("tool_name").and_then(|t| t.as_str()) { + return process_agy_simple(v, tool_name); + } + + // Rich format: command buried inside toolCall.{name,args}. + if let Some(tool_name) = v.pointer("/toolCall/name").and_then(|t| t.as_str()) { + return process_agy_rich(v, tool_name); + } + + PayloadAction::Ignore +} + +fn process_agy_simple(v: &Value, tool_name: &str) -> PayloadAction { + let (cmd, field) = match tool_name { + "run_command" => { + let c = v + .pointer("/tool_input/CommandLine") + .and_then(|c| c.as_str()) + .unwrap_or(""); + (c, "CommandLine") + } + "Bash" | "bash" => { + let c = v + .pointer("/tool_input/command") + .and_then(|c| c.as_str()) + .unwrap_or(""); + (c, "command") + } + _ => return PayloadAction::Ignore, + }; + + if cmd.is_empty() { + return PayloadAction::Ignore; + } + + let verdict = permissions::check_command(cmd); + if verdict == PermissionVerdict::Deny { + return PayloadAction::Skip { + reason: "skip:deny_rule", + cmd: cmd.to_string(), + }; + } + + let rewritten = match get_rewritten(cmd) { + Some(r) => r, + None => { + return PayloadAction::Skip { + reason: "skip:no_match", + cmd: cmd.to_string(), + } + } + }; + + let output = json!({ + "decision": "allow", + "hookSpecificOutput": { + "tool_input": { field: rewritten.clone() } + } + }); + + PayloadAction::Rewrite { + cmd: cmd.to_string(), + rewritten, + output, + } +} - // Map tool name → command field name. Non-shell tools are ignored. +fn process_agy_rich(v: &Value, tool_name: &str) -> PayloadAction { let (cmd, field) = match tool_name { "run_command" => { let c = v @@ -572,8 +645,8 @@ fn process_antigravity_payload(v: &Value) -> PayloadAction { } }; - // Mirror the full args object from the request, updating only the - // command field so extra fields like `Cwd` are preserved unchanged. + // Mirror the full args object so extra fields (Cwd, WaitMsBeforeAsync, …) + // are preserved unchanged in the rewritten call. let updated_args = { let mut args = v .pointer("/toolCall/args") @@ -1113,79 +1186,106 @@ mod tests { ); } - // --- Antigravity (agy) hook --- + // --- Antigravity (agy) — simple format (tool_name / tool_input) --- - fn agy_input_run_command(cmd: &str) -> String { - json!({ - "toolCall": { - "name": "run_command", - "args": { "CommandLine": cmd } - } - }) - .to_string() + fn agy_simple_bash(cmd: &str) -> String { + json!({"tool_name": "Bash", "tool_input": {"command": cmd}}).to_string() } - fn agy_input_bash(cmd: &str) -> String { - json!({ - "toolCall": { - "name": "Bash", - "args": { "command": cmd } - } - }) - .to_string() + fn agy_simple_run_command(cmd: &str) -> String { + json!({"tool_name": "run_command", "tool_input": {"CommandLine": cmd}}).to_string() } #[test] - fn test_agy_run_command_rewrite() { - let out = run_antigravity_inner(&agy_input_run_command("git status")).unwrap(); + fn test_agy_simple_bash_rewrite() { + let out = run_antigravity_inner(&agy_simple_bash("git status")).unwrap(); let v: Value = serde_json::from_str(&out).unwrap(); assert_eq!(v["decision"], "allow"); - assert_eq!(v["toolCall"]["args"]["CommandLine"], "rtk git status"); + assert_eq!( + v["hookSpecificOutput"]["tool_input"]["command"], + "rtk git status" + ); } #[test] - fn test_agy_bash_rewrite() { - let out = run_antigravity_inner(&agy_input_bash("cargo test")).unwrap(); + fn test_agy_simple_run_command_rewrite() { + let out = run_antigravity_inner(&agy_simple_run_command("git status")).unwrap(); let v: Value = serde_json::from_str(&out).unwrap(); assert_eq!(v["decision"], "allow"); - assert_eq!(v["toolCall"]["args"]["command"], "rtk cargo test"); + assert_eq!( + v["hookSpecificOutput"]["tool_input"]["CommandLine"], + "rtk git status" + ); } #[test] - fn test_agy_non_shell_tool_returns_none() { - let input = json!({ - "toolCall": { "name": "ListPermissions", "args": {} } - }) - .to_string(); - assert!(run_antigravity_inner(&input).is_none()); + fn test_agy_simple_unsupported_returns_none() { + assert!(run_antigravity_inner(&agy_simple_bash("htop")).is_none()); } #[test] - fn test_agy_already_rtk_returns_none() { - assert!(run_antigravity_inner(&agy_input_run_command("rtk git status")).is_none()); + fn test_agy_simple_non_shell_returns_none() { + assert!( + run_antigravity_inner(&json!({"tool_name": "ListPermissions"}).to_string()).is_none() + ); + } + + // --- Antigravity (agy) — rich format (toolCall / args) --- + + fn agy_rich_run_command(cmd: &str) -> String { + json!({"toolCall": {"name": "run_command", "args": {"CommandLine": cmd}}}).to_string() + } + + fn agy_rich_bash(cmd: &str) -> String { + json!({"toolCall": {"name": "Bash", "args": {"command": cmd}}}).to_string() + } + + #[test] + fn test_agy_rich_run_command_rewrite() { + let out = run_antigravity_inner(&agy_rich_run_command("git status")).unwrap(); + let v: Value = serde_json::from_str(&out).unwrap(); + assert_eq!(v["decision"], "allow"); + assert_eq!(v["toolCall"]["args"]["CommandLine"], "rtk git status"); } #[test] - fn test_agy_unsupported_command_returns_none() { - assert!(run_antigravity_inner(&agy_input_run_command("htop")).is_none()); + fn test_agy_rich_bash_rewrite() { + let out = run_antigravity_inner(&agy_rich_bash("cargo test")).unwrap(); + let v: Value = serde_json::from_str(&out).unwrap(); + assert_eq!(v["decision"], "allow"); + assert_eq!(v["toolCall"]["args"]["command"], "rtk cargo test"); } #[test] - fn test_agy_extra_args_preserved() { - // Extra fields in toolCall.args (e.g. Cwd) must survive the rewrite + fn test_agy_rich_extra_args_preserved() { let input = json!({ "toolCall": { "name": "run_command", - "args": { "CommandLine": "git status", "Cwd": "/tmp" } + "args": {"CommandLine": "git status", "Cwd": "/tmp", "WaitMsBeforeAsync": 2000} } }) .to_string(); let out = run_antigravity_inner(&input).unwrap(); let v: Value = serde_json::from_str(&out).unwrap(); assert_eq!(v["toolCall"]["args"]["Cwd"], "/tmp"); + assert_eq!(v["toolCall"]["args"]["WaitMsBeforeAsync"], 2000); assert_eq!(v["toolCall"]["args"]["CommandLine"], "rtk git status"); } + #[test] + fn test_agy_rich_non_shell_returns_none() { + assert!(run_antigravity_inner( + &json!({"toolCall": {"name": "ListPermissions", "args": {}}}).to_string() + ) + .is_none()); + } + + #[test] + fn test_agy_already_rtk_returns_none() { + assert!(run_antigravity_inner(&agy_simple_bash("rtk git status")).is_none()); + assert!(run_antigravity_inner(&agy_rich_run_command("rtk git status")).is_none()); + } + #[test] fn test_agy_empty_input_returns_none() { assert!(run_antigravity_inner("").is_none()); diff --git a/src/hooks/init.rs b/src/hooks/init.rs index c319302d0f..fdf9c2258c 100644 --- a/src/hooks/init.rs +++ b/src/hooks/init.rs @@ -8,15 +8,15 @@ use std::path::{Path, PathBuf}; use tempfile::NamedTempFile; use crate::hooks::constants::{ - CONFIG_DIR, CURSOR_DIR, GEMINI_DIR, OPENCODE_PLUGIN_FILE, OPENCODE_SUBDIR, PLUGIN_SUBDIR, + CONFIG_DIR, CURSOR_DIR, GEMINI_DIR, HOOKS_JSON, OPENCODE_PLUGIN_FILE, OPENCODE_SUBDIR, + PLUGIN_SUBDIR, }; use super::constants::{ - ANTIGRAVITY_DIR, ANTIGRAVITY_HOOK_COMMAND, ANTIGRAVITY_TOOL_MATCHER, BEFORE_TOOL_KEY, - CLAUDE_DIR, CLAUDE_HOOK_COMMAND, CODEX_DIR, CURSOR_HOOK_COMMAND, GEMINI_HOOK_FILE, HERMES_DIR, - HERMES_PLUGINS_SUBDIR, HERMES_PLUGIN_INIT_FILE, HERMES_PLUGIN_MANIFEST_FILE, - HERMES_PLUGIN_NAME, HOOKS_JSON, HOOKS_SUBDIR, PRE_TOOL_USE_KEY, REWRITE_HOOK_FILE, - SETTINGS_JSON, + AGY_CLI_SUBDIR, ANTIGRAVITY_HOOK_COMMAND, BEFORE_TOOL_KEY, CLAUDE_DIR, CLAUDE_HOOK_COMMAND, + CODEX_DIR, CURSOR_HOOK_COMMAND, GEMINI_HOOK_FILE, HERMES_DIR, HERMES_PLUGINS_SUBDIR, + HERMES_PLUGIN_INIT_FILE, HERMES_PLUGIN_MANIFEST_FILE, HERMES_PLUGIN_NAME, HOOKS_SUBDIR, + PRE_TOOL_USE_KEY, REWRITE_HOOK_FILE, SETTINGS_JSON, }; use super::integrity; @@ -1743,135 +1743,151 @@ fn run_antigravity_mode_at(base_dir: &Path, ctx: InitContext) -> Result<()> { } // ─── Google Antigravity CLI (agy) hook support ───────────────── +// +// agy stores config in ~/.gemini/antigravity-cli/settings.json and uses the +// same BeforeTool hook format as Gemini CLI. Two matchers are registered — +// one for "run_command" (CommandLine field) and one for "Bash" (command field) +// — because agy uses both depending on context. -/// Entry point for `rtk init --agent agy`. +/// Entry point for `rtk init --agent agy` (global-only, like Gemini CLI). /// -/// global=true → `~/.agents/hooks.json` -/// global=false → `.agents/hooks.json` (project-local) +/// Writes hook entries into `~/.gemini/antigravity-cli/settings.json`. pub fn run_agy_cli_mode(global: bool, ctx: InitContext) -> Result<()> { let InitContext { dry_run, .. } = ctx; - let hooks_json_path = if global { - let agents_dir = resolve_home_subdir(ANTIGRAVITY_DIR)?; - agents_dir.join(HOOKS_JSON) - } else { - PathBuf::from(ANTIGRAVITY_DIR).join(HOOKS_JSON) - }; + if !global { + anyhow::bail!("agy CLI support is global-only. Use: rtk init -g --agent agy"); + } + + let agy_dir = resolve_agy_dir()?; + let settings_path = agy_dir.join(SETTINGS_JSON); if !dry_run { - if let Some(parent) = hooks_json_path.parent() { - fs::create_dir_all(parent) - .with_context(|| format!("Failed to create directory: {}", parent.display()))?; - } + fs::create_dir_all(&agy_dir) + .with_context(|| format!("Failed to create agy config dir: {}", agy_dir.display()))?; } - let patched = patch_agy_hooks_json(&hooks_json_path, ctx)?; + let patched = patch_agy_settings(&settings_path, ctx)?; if dry_run { print_dry_run_footer(); } else { - let scope = if global { "global" } else { "project-local" }; if patched { - println!("\nGoogle Antigravity CLI (agy) hook installed ({scope}).\n"); + println!("\nGoogle Antigravity CLI (agy) hook installed (global).\n"); } else { - println!("\nGoogle Antigravity CLI (agy) hook already present ({scope}).\n"); + println!("\nGoogle Antigravity CLI (agy) hook already present (global).\n"); } - println!(" hooks.json: {}", hooks_json_path.display()); + println!(" settings.json: {}", settings_path.display()); println!(" Restart agy. Test with: git status\n"); } Ok(()) } -/// Patch `hooks.json` with the RTK PreToolUse hook for agy. +fn resolve_agy_dir() -> Result { + let gemini_dir = resolve_home_subdir(GEMINI_DIR)?; + Ok(gemini_dir.join(AGY_CLI_SUBDIR)) +} + +/// Patch `~/.gemini/antigravity-cli/settings.json` with RTK BeforeTool hooks. /// Returns true if the file was modified. -fn patch_agy_hooks_json(path: &Path, ctx: InitContext) -> Result { +fn patch_agy_settings(path: &Path, ctx: InitContext) -> Result { let InitContext { verbose, dry_run } = ctx; - let mut root = if path.exists() { + let mut settings: serde_json::Value = if path.exists() { let content = fs::read_to_string(path) .with_context(|| format!("Failed to read {}", path.display()))?; - if content.trim().is_empty() { - serde_json::json!({}) - } else { - serde_json::from_str(&content) - .with_context(|| format!("Failed to parse {} as JSON", path.display()))? - } + serde_json::from_str(&content).unwrap_or(serde_json::json!({})) } else { serde_json::json!({}) }; - if agy_hook_already_present(&root) { + if agy_hook_already_present(&settings) { if verbose > 0 { - eprintln!("agy hooks.json: RTK hook already present"); + eprintln!("agy settings.json: RTK hook already present"); } return Ok(false); } - insert_agy_hook_entry(&mut root)?; + insert_agy_hook_entries(&mut settings)?; let serialized = - serde_json::to_string_pretty(&root).context("Failed to serialize hooks.json")?; + serde_json::to_string_pretty(&settings).context("Failed to serialize agy settings.json")?; if dry_run { - println!("[dry-run] would patch agy hooks.json: {}", path.display()); + println!( + "[dry-run] would patch agy settings.json: {}", + path.display() + ); if verbose > 0 { println!("[dry-run] content:\n{}", serialized); } return Ok(true); } - if path.exists() { - let backup = path.with_extension("json.bak"); - fs::copy(path, &backup) - .with_context(|| format!("Failed to backup to {}", backup.display()))?; - if verbose > 0 { - eprintln!("Backup: {}", backup.display()); - } + let tmp = NamedTempFile::new_in( + path.parent() + .context("agy settings.json has no parent directory")?, + )?; + fs::write(tmp.path(), &serialized)?; + tmp.persist(path) + .with_context(|| format!("Failed to write {}", path.display()))?; + + if verbose > 0 { + eprintln!("Patched {}", path.display()); } - atomic_write(path, &serialized)?; Ok(true) } -/// Returns true if an RTK hook is already in the agy hooks.json PreToolUse array. -fn agy_hook_already_present(root: &serde_json::Value) -> bool { - root.get("hooks") - .and_then(|h| h.get(PRE_TOOL_USE_KEY)) +/// Returns true if an RTK BeforeTool hook is already present in agy settings.json. +fn agy_hook_already_present(settings: &serde_json::Value) -> bool { + settings + .pointer(&format!("/hooks/{}", BEFORE_TOOL_KEY)) .and_then(|v| v.as_array()) .is_some_and(|arr| { arr.iter().any(|entry| { entry - .get("command") - .and_then(|c| c.as_str()) - .is_some_and(|cmd| cmd.contains("rtk")) + .get("hooks") + .and_then(|h| h.as_array()) + .is_some_and(|hooks| { + hooks.iter().any(|h| { + h.get("command") + .and_then(|c| c.as_str()) + .is_some_and(|cmd| cmd.contains("rtk")) + }) + }) }) }) } -/// Insert the RTK PreToolUse hook entry into the agy hooks.json root. -fn insert_agy_hook_entry(root: &mut serde_json::Value) -> Result<()> { - let root_obj = root +/// Insert RTK BeforeTool hook entries into agy settings.json. +/// +/// Registers one entry per tool name so agy calls the hook for both +/// shell-execution tools it uses: "run_command" and "Bash". +fn insert_agy_hook_entries(settings: &mut serde_json::Value) -> Result<()> { + let settings_obj = settings .as_object_mut() - .context("agy hooks.json root is not an object")?; + .context("agy settings.json is not an object")?; - let hooks = root_obj + let hooks = settings_obj .entry("hooks") .or_insert_with(|| serde_json::json!({})) .as_object_mut() .context("hooks value is not an object")?; - let pre_tool_use = hooks - .entry(PRE_TOOL_USE_KEY) + let before_tool = hooks + .entry(BEFORE_TOOL_KEY) .or_insert_with(|| serde_json::json!([])) .as_array_mut() - .context("PreToolUse value is not an array")?; + .context("BeforeTool value is not an array")?; - pre_tool_use.push(serde_json::json!({ - "type": "command", - "command": ANTIGRAVITY_HOOK_COMMAND, - "toolNameMatcher": ANTIGRAVITY_TOOL_MATCHER - })); + for matcher in &["run_command", "Bash"] { + before_tool.push(serde_json::json!({ + "matcher": matcher, + "hooks": [{"type": "command", "command": ANTIGRAVITY_HOOK_COMMAND}] + })); + } Ok(()) } @@ -1880,56 +1896,70 @@ fn insert_agy_hook_entry(root: &mut serde_json::Value) -> Result<()> { fn uninstall_agy_cli(global: bool, ctx: InitContext) -> Result<()> { let InitContext { verbose, dry_run } = ctx; - let hooks_json_path = if global { - let agents_dir = match resolve_home_subdir(ANTIGRAVITY_DIR) { - Ok(d) => d, - Err(_) => { - println!("RTK agy hook was not installed (nothing to remove)"); - return Ok(()); - } - }; - agents_dir.join(HOOKS_JSON) - } else { - PathBuf::from(ANTIGRAVITY_DIR).join(HOOKS_JSON) + if !global { + println!("RTK agy hook was not installed (nothing to remove)"); + return Ok(()); + } + + let settings_path = match resolve_agy_dir() { + Ok(d) => d.join(SETTINGS_JSON), + Err(_) => { + println!("RTK agy hook was not installed (nothing to remove)"); + return Ok(()); + } }; let mut removed = Vec::new(); - if hooks_json_path.exists() { - let content = fs::read_to_string(&hooks_json_path) - .with_context(|| format!("Failed to read {}", hooks_json_path.display()))?; + if settings_path.exists() { + let content = fs::read_to_string(&settings_path) + .with_context(|| format!("Failed to read {}", settings_path.display()))?; - if let Ok(mut root) = serde_json::from_str::(&content) { - if let Some(arr) = root - .pointer_mut(&format!("/hooks/{}", PRE_TOOL_USE_KEY)) + if let Ok(mut settings) = serde_json::from_str::(&content) { + if let Some(arr) = settings + .pointer_mut(&format!("/hooks/{}", BEFORE_TOOL_KEY)) .and_then(|v| v.as_array_mut()) { let before = arr.len(); arr.retain(|entry| { !entry - .get("command") - .and_then(|c| c.as_str()) - .is_some_and(|cmd| cmd.contains("rtk")) + .get("hooks") + .and_then(|h| h.as_array()) + .is_some_and(|hooks| { + hooks.iter().any(|h| { + h.get("command") + .and_then(|c| c.as_str()) + .is_some_and(|cmd| cmd.contains("rtk")) + }) + }) }); if arr.len() < before { if dry_run { println!( - "[dry-run] would remove RTK hook from agy hooks.json: {}", - hooks_json_path.display() + "[dry-run] would remove RTK hooks from agy settings.json: {}", + settings_path.display() ); } else { - let serialized = serde_json::to_string_pretty(&root)?; - atomic_write(&hooks_json_path, &serialized)?; + let serialized = serde_json::to_string_pretty(&settings)?; + let tmp = NamedTempFile::new_in( + settings_path + .parent() + .context("settings.json has no parent")?, + )?; + fs::write(tmp.path(), &serialized)?; + tmp.persist(&settings_path).with_context(|| { + format!("Failed to write {}", settings_path.display()) + })?; if verbose > 0 { eprintln!( - "Removed RTK hook from agy hooks.json: {}", - hooks_json_path.display() + "Removed RTK hooks from agy settings.json: {}", + settings_path.display() ); } } removed.push(format!( - "agy hooks.json: removed RTK hook ({})", - hooks_json_path.display() + "agy settings.json: removed RTK hooks ({})", + settings_path.display() )); } } From b76dd431d969a4ddb193aba169efcac0f78aa7b6 Mon Sep 17 00:00:00 2001 From: lok1s Date: Mon, 25 May 2026 01:25:25 +0300 Subject: [PATCH 04/17] fix(antigravity): write absolute rtk path into agy settings.json agy runs hooks outside the user's shell, so bare `rtk` is not on PATH. Resolve current_exe() at init time and write the full binary path (e.g. /home/user/.cargo/bin/rtk hook antigravity) so the hook is always found. Re-init strips stale relative-path entries before inserting the new absolute-path command. Co-Authored-By: Claude Sonnet 4.6 --- src/hooks/constants.rs | 2 -- src/hooks/init.rs | 54 +++++++++++++++++++++++++++++++++--------- 2 files changed, 43 insertions(+), 13 deletions(-) diff --git a/src/hooks/constants.rs b/src/hooks/constants.rs index 12f4a42d1a..92c7b1d7d4 100644 --- a/src/hooks/constants.rs +++ b/src/hooks/constants.rs @@ -25,8 +25,6 @@ pub const GEMINI_DIR: &str = ".gemini"; #[allow(dead_code)] pub const ANTIGRAVITY_DIR: &str = ".agents"; -/// Native Rust hook command for Google Antigravity CLI (agy). -pub const ANTIGRAVITY_HOOK_COMMAND: &str = "rtk hook antigravity"; /// Config subdirectory for the Antigravity CLI (agy) under ~/.gemini/ pub const AGY_CLI_SUBDIR: &str = "antigravity-cli"; diff --git a/src/hooks/init.rs b/src/hooks/init.rs index fdf9c2258c..5cfe3ef3ab 100644 --- a/src/hooks/init.rs +++ b/src/hooks/init.rs @@ -13,8 +13,8 @@ use crate::hooks::constants::{ }; use super::constants::{ - AGY_CLI_SUBDIR, ANTIGRAVITY_HOOK_COMMAND, BEFORE_TOOL_KEY, CLAUDE_DIR, CLAUDE_HOOK_COMMAND, - CODEX_DIR, CURSOR_HOOK_COMMAND, GEMINI_HOOK_FILE, HERMES_DIR, HERMES_PLUGINS_SUBDIR, + AGY_CLI_SUBDIR, BEFORE_TOOL_KEY, CLAUDE_DIR, CLAUDE_HOOK_COMMAND, CODEX_DIR, + CURSOR_HOOK_COMMAND, GEMINI_HOOK_FILE, HERMES_DIR, HERMES_PLUGINS_SUBDIR, HERMES_PLUGIN_INIT_FILE, HERMES_PLUGIN_MANIFEST_FILE, HERMES_PLUGIN_NAME, HOOKS_SUBDIR, PRE_TOOL_USE_KEY, REWRITE_HOOK_FILE, SETTINGS_JSON, }; @@ -1759,6 +1759,13 @@ pub fn run_agy_cli_mode(global: bool, ctx: InitContext) -> Result<()> { anyhow::bail!("agy CLI support is global-only. Use: rtk init -g --agent agy"); } + // Use the absolute path so agy can find the binary regardless of its PATH. + let rtk_bin = std::env::current_exe() + .context("Failed to resolve rtk binary path")? + .to_string_lossy() + .to_string(); + let hook_command = format!("{} hook antigravity", rtk_bin); + let agy_dir = resolve_agy_dir()?; let settings_path = agy_dir.join(SETTINGS_JSON); @@ -1767,7 +1774,7 @@ pub fn run_agy_cli_mode(global: bool, ctx: InitContext) -> Result<()> { .with_context(|| format!("Failed to create agy config dir: {}", agy_dir.display()))?; } - let patched = patch_agy_settings(&settings_path, ctx)?; + let patched = patch_agy_settings(&settings_path, ctx, &hook_command)?; if dry_run { print_dry_run_footer(); @@ -1791,7 +1798,7 @@ fn resolve_agy_dir() -> Result { /// Patch `~/.gemini/antigravity-cli/settings.json` with RTK BeforeTool hooks. /// Returns true if the file was modified. -fn patch_agy_settings(path: &Path, ctx: InitContext) -> Result { +fn patch_agy_settings(path: &Path, ctx: InitContext, hook_command: &str) -> Result { let InitContext { verbose, dry_run } = ctx; let mut settings: serde_json::Value = if path.exists() { @@ -1802,14 +1809,17 @@ fn patch_agy_settings(path: &Path, ctx: InitContext) -> Result { serde_json::json!({}) }; - if agy_hook_already_present(&settings) { + if agy_hook_command_matches(&settings, hook_command) { if verbose > 0 { eprintln!("agy settings.json: RTK hook already present"); } return Ok(false); } - insert_agy_hook_entries(&mut settings)?; + // Remove any stale RTK entries (e.g. old installs with bare `rtk` command). + remove_agy_rtk_entries(&mut settings); + + insert_agy_hook_entries(&mut settings, hook_command)?; let serialized = serde_json::to_string_pretty(&settings).context("Failed to serialize agy settings.json")?; @@ -1840,8 +1850,9 @@ fn patch_agy_settings(path: &Path, ctx: InitContext) -> Result { Ok(true) } -/// Returns true if an RTK BeforeTool hook is already present in agy settings.json. -fn agy_hook_already_present(settings: &serde_json::Value) -> bool { +/// Returns true when an existing entry already uses exactly `hook_command`. +/// A mismatch (bare `rtk` vs absolute path) triggers re-installation. +fn agy_hook_command_matches(settings: &serde_json::Value, hook_command: &str) -> bool { settings .pointer(&format!("/hooks/{}", BEFORE_TOOL_KEY)) .and_then(|v| v.as_array()) @@ -1854,18 +1865,39 @@ fn agy_hook_already_present(settings: &serde_json::Value) -> bool { hooks.iter().any(|h| { h.get("command") .and_then(|c| c.as_str()) - .is_some_and(|cmd| cmd.contains("rtk")) + .is_some_and(|cmd| cmd == hook_command) }) }) }) }) } +/// Remove all RTK-owned BeforeTool entries (any command containing "rtk"). +fn remove_agy_rtk_entries(settings: &mut serde_json::Value) { + if let Some(arr) = settings + .pointer_mut(&format!("/hooks/{}", BEFORE_TOOL_KEY)) + .and_then(|v| v.as_array_mut()) + { + arr.retain(|entry| { + !entry + .get("hooks") + .and_then(|h| h.as_array()) + .is_some_and(|hooks| { + hooks.iter().any(|h| { + h.get("command") + .and_then(|c| c.as_str()) + .is_some_and(|cmd| cmd.contains("rtk")) + }) + }) + }); + } +} + /// Insert RTK BeforeTool hook entries into agy settings.json. /// /// Registers one entry per tool name so agy calls the hook for both /// shell-execution tools it uses: "run_command" and "Bash". -fn insert_agy_hook_entries(settings: &mut serde_json::Value) -> Result<()> { +fn insert_agy_hook_entries(settings: &mut serde_json::Value, hook_command: &str) -> Result<()> { let settings_obj = settings .as_object_mut() .context("agy settings.json is not an object")?; @@ -1885,7 +1917,7 @@ fn insert_agy_hook_entries(settings: &mut serde_json::Value) -> Result<()> { for matcher in &["run_command", "Bash"] { before_tool.push(serde_json::json!({ "matcher": matcher, - "hooks": [{"type": "command", "command": ANTIGRAVITY_HOOK_COMMAND}] + "hooks": [{"type": "command", "command": hook_command}] })); } From 5c0964a1c79dfc013d0e7c72f89cd467ca2f6de1 Mon Sep 17 00:00:00 2001 From: lok1s Date: Mon, 25 May 2026 01:35:17 +0300 Subject: [PATCH 05/17] fix(gemini): handle agy rich payload format in rtk hook gemini agy (Antigravity CLI) fires the run_shell_command BeforeTool hook from ~/.gemini/settings.json but sends a different payload than Gemini CLI: command is at toolCall.args.CommandLine, not tool_input.command. The handler was returning pass-through for every agy call. Detect the rich format first (toolCall.args.CommandLine present), rewrite the command, and return toolCall.args with CommandLine updated while preserving Cwd, WaitMsBeforeAsync, and any other fields. Co-Authored-By: Claude Sonnet 4.6 --- src/hooks/hook_cmd.rs | 47 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/src/hooks/hook_cmd.rs b/src/hooks/hook_cmd.rs index 421c845163..b79e36be2a 100644 --- a/src/hooks/hook_cmd.rs +++ b/src/hooks/hook_cmd.rs @@ -185,6 +185,17 @@ pub fn run_gemini() -> Result<()> { let json: Value = serde_json::from_str(&input).context("Failed to parse hook input as JSON")?; + // agy (Antigravity CLI) sends a rich format: toolCall.args.CommandLine. + // Gemini CLI sends the simple format: tool_name + tool_input.command. + if let Some(cmd) = json + .pointer("/toolCall/args/CommandLine") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + { + return handle_gemini_rich(&json, cmd); + } + + // Simple Gemini CLI format. let tool_name = json.get("tool_name").and_then(|v| v.as_str()).unwrap_or(""); if tool_name != "run_shell_command" { @@ -226,6 +237,42 @@ pub fn run_gemini() -> Result<()> { Ok(()) } +/// Handle agy's rich payload format (toolCall.args.CommandLine). +/// Preserves all extra args (Cwd, WaitMsBeforeAsync, etc.) and returns a toolCall.args response. +fn handle_gemini_rich(json: &Value, cmd: &str) -> Result<()> { + if permissions::check_command(cmd) == PermissionVerdict::Deny { + let _ = writeln!( + io::stdout(), + r#"{{"decision":"deny","reason":"Blocked by RTK permission rule"}}"# + ); + return Ok(()); + } + + let (excluded, transparent_prefixes) = crate::core::config::Config::load() + .map(|c| (c.hooks.exclude_commands, c.hooks.transparent_prefixes)) + .unwrap_or_default(); + + match rewrite_command(cmd, &excluded, &transparent_prefixes) { + Some(ref rewritten) => { + audit_log("rewrite", cmd, rewritten); + let mut updated_args = json + .pointer("/toolCall/args") + .and_then(|v| v.as_object()) + .cloned() + .unwrap_or_default(); + updated_args.insert("CommandLine".to_string(), Value::String(rewritten.clone())); + let output = json!({ + "decision": "allow", + "toolCall": {"args": updated_args} + }); + let _ = writeln!(io::stdout(), "{}", output); + } + None => print_allow(), + } + + Ok(()) +} + fn print_allow() { let _ = writeln!(io::stdout(), r#"{{"decision":"allow"}}"#); } From 70cde6e33e5d275b30b741cb0b7cc502000946f5 Mon Sep 17 00:00:00 2001 From: lok1s Date: Mon, 25 May 2026 15:10:18 +0300 Subject: [PATCH 06/17] fix(antigravity): use hooks.json and correct PreToolHookResult overwrite format Fixes the agy hook integration by addressing two root causes found via binary analysis of the agy binary: 1. Config target: agy reads hooks from ~/.gemini/config/hooks.json (named hook format), not from ~/.gemini/antigravity-cli/settings.json (settings format). Rewrote init.rs to write the correct named-hook structure: { "rtk-antigravity": { "enabled": true, "PreToolUse": [...] } } 2. Response format: PreToolHookResult expects "overwrite" (HookToolCall) not "toolCall" to modify a tool call. Confirmed via agy binary protobuf symbols. Updated hook_cmd.rs to output: {"overwrite": {"name": "run_command", "args": {"CommandLine": "rtk ..."}}} Hooks are now confirmed firing and rewriting commands (verified via agy cli.log showing hook execution and the prior error message identifying the field name mismatch). Co-Authored-By: Claude Sonnet 4.6 --- src/hooks/constants.rs | 1 + src/hooks/hook_cmd.rs | 45 ++++---- src/hooks/init.rs | 231 +++++++++++++++++------------------------ 3 files changed, 120 insertions(+), 157 deletions(-) diff --git a/src/hooks/constants.rs b/src/hooks/constants.rs index 92c7b1d7d4..e3cccb73fd 100644 --- a/src/hooks/constants.rs +++ b/src/hooks/constants.rs @@ -26,6 +26,7 @@ pub const GEMINI_DIR: &str = ".gemini"; pub const ANTIGRAVITY_DIR: &str = ".agents"; /// Config subdirectory for the Antigravity CLI (agy) under ~/.gemini/ +#[allow(dead_code)] pub const AGY_CLI_SUBDIR: &str = "antigravity-cli"; pub const HERMES_DIR: &str = ".hermes"; diff --git a/src/hooks/hook_cmd.rs b/src/hooks/hook_cmd.rs index b79e36be2a..9d108bf769 100644 --- a/src/hooks/hook_cmd.rs +++ b/src/hooks/hook_cmd.rs @@ -637,10 +637,11 @@ fn process_agy_simple(v: &Value, tool_name: &str) -> PayloadAction { } }; + // agy PreToolHookResult: use "overwrite" to replace the tool call. let output = json!({ - "decision": "allow", - "hookSpecificOutput": { - "tool_input": { field: rewritten.clone() } + "overwrite": { + "name": tool_name, + "args": { field: rewritten.clone() } } }); @@ -705,9 +706,14 @@ fn process_agy_rich(v: &Value, tool_name: &str) -> PayloadAction { args }; + // agy PreToolHookResult: use "overwrite" (HookToolCall) to replace the call. + // Mirror the tool name and all args so extra fields (Cwd, WaitMsBeforeAsync, …) + // are preserved. let output = json!({ - "decision": "allow", - "toolCall": { "args": updated_args } + "overwrite": { + "name": tool_name, + "args": updated_args + } }); PayloadAction::Rewrite { @@ -1247,22 +1253,16 @@ mod tests { fn test_agy_simple_bash_rewrite() { let out = run_antigravity_inner(&agy_simple_bash("git status")).unwrap(); let v: Value = serde_json::from_str(&out).unwrap(); - assert_eq!(v["decision"], "allow"); - assert_eq!( - v["hookSpecificOutput"]["tool_input"]["command"], - "rtk git status" - ); + assert_eq!(v["overwrite"]["name"], "Bash"); + assert_eq!(v["overwrite"]["args"]["command"], "rtk git status"); } #[test] fn test_agy_simple_run_command_rewrite() { let out = run_antigravity_inner(&agy_simple_run_command("git status")).unwrap(); let v: Value = serde_json::from_str(&out).unwrap(); - assert_eq!(v["decision"], "allow"); - assert_eq!( - v["hookSpecificOutput"]["tool_input"]["CommandLine"], - "rtk git status" - ); + assert_eq!(v["overwrite"]["name"], "run_command"); + assert_eq!(v["overwrite"]["args"]["CommandLine"], "rtk git status"); } #[test] @@ -1291,16 +1291,16 @@ mod tests { fn test_agy_rich_run_command_rewrite() { let out = run_antigravity_inner(&agy_rich_run_command("git status")).unwrap(); let v: Value = serde_json::from_str(&out).unwrap(); - assert_eq!(v["decision"], "allow"); - assert_eq!(v["toolCall"]["args"]["CommandLine"], "rtk git status"); + assert_eq!(v["overwrite"]["name"], "run_command"); + assert_eq!(v["overwrite"]["args"]["CommandLine"], "rtk git status"); } #[test] fn test_agy_rich_bash_rewrite() { let out = run_antigravity_inner(&agy_rich_bash("cargo test")).unwrap(); let v: Value = serde_json::from_str(&out).unwrap(); - assert_eq!(v["decision"], "allow"); - assert_eq!(v["toolCall"]["args"]["command"], "rtk cargo test"); + assert_eq!(v["overwrite"]["name"], "Bash"); + assert_eq!(v["overwrite"]["args"]["command"], "rtk cargo test"); } #[test] @@ -1314,9 +1314,10 @@ mod tests { .to_string(); let out = run_antigravity_inner(&input).unwrap(); let v: Value = serde_json::from_str(&out).unwrap(); - assert_eq!(v["toolCall"]["args"]["Cwd"], "/tmp"); - assert_eq!(v["toolCall"]["args"]["WaitMsBeforeAsync"], 2000); - assert_eq!(v["toolCall"]["args"]["CommandLine"], "rtk git status"); + assert_eq!(v["overwrite"]["name"], "run_command"); + assert_eq!(v["overwrite"]["args"]["Cwd"], "/tmp"); + assert_eq!(v["overwrite"]["args"]["WaitMsBeforeAsync"], 2000); + assert_eq!(v["overwrite"]["args"]["CommandLine"], "rtk git status"); } #[test] diff --git a/src/hooks/init.rs b/src/hooks/init.rs index 5cfe3ef3ab..673f6013e8 100644 --- a/src/hooks/init.rs +++ b/src/hooks/init.rs @@ -13,10 +13,10 @@ use crate::hooks::constants::{ }; use super::constants::{ - AGY_CLI_SUBDIR, BEFORE_TOOL_KEY, CLAUDE_DIR, CLAUDE_HOOK_COMMAND, CODEX_DIR, - CURSOR_HOOK_COMMAND, GEMINI_HOOK_FILE, HERMES_DIR, HERMES_PLUGINS_SUBDIR, - HERMES_PLUGIN_INIT_FILE, HERMES_PLUGIN_MANIFEST_FILE, HERMES_PLUGIN_NAME, HOOKS_SUBDIR, - PRE_TOOL_USE_KEY, REWRITE_HOOK_FILE, SETTINGS_JSON, + BEFORE_TOOL_KEY, CLAUDE_DIR, CLAUDE_HOOK_COMMAND, CODEX_DIR, CURSOR_HOOK_COMMAND, + GEMINI_HOOK_FILE, HERMES_DIR, HERMES_PLUGINS_SUBDIR, HERMES_PLUGIN_INIT_FILE, + HERMES_PLUGIN_MANIFEST_FILE, HERMES_PLUGIN_NAME, HOOKS_SUBDIR, PRE_TOOL_USE_KEY, + REWRITE_HOOK_FILE, SETTINGS_JSON, }; use super::integrity; @@ -1744,14 +1744,21 @@ fn run_antigravity_mode_at(base_dir: &Path, ctx: InitContext) -> Result<()> { // ─── Google Antigravity CLI (agy) hook support ───────────────── // -// agy stores config in ~/.gemini/antigravity-cli/settings.json and uses the -// same BeforeTool hook format as Gemini CLI. Two matchers are registered — -// one for "run_command" (CommandLine field) and one for "Bash" (command field) -// — because agy uses both depending on context. +// agy reads hooks from ~/.gemini/config/hooks.json using a named-hook format: +// { "hook-name": { "enabled": true, "PreToolUse": [{ "toolNameMatcher": "...", "hooks": [...] }] } } +// +// This is distinct from Gemini CLI's settings.json BeforeTool format. +// The hook command receives the tool call as JSON (stdin) and writes a +// PreToolHookResult protojson (stdout). See `rtk hook antigravity`. + +/// Named key for RTK's entry in agy's hooks.json. +const AGY_HOOK_NAME: &str = "rtk-antigravity"; +/// Subdirectory of ~/.gemini that holds agy's hooks.json. +const AGY_CONFIG_SUBDIR: &str = "config"; -/// Entry point for `rtk init --agent agy` (global-only, like Gemini CLI). +/// Entry point for `rtk init --agent agy` (global-only). /// -/// Writes hook entries into `~/.gemini/antigravity-cli/settings.json`. +/// Writes the RTK hook entry into `~/.gemini/config/hooks.json`. pub fn run_agy_cli_mode(global: bool, ctx: InitContext) -> Result<()> { let InitContext { dry_run, .. } = ctx; @@ -1766,15 +1773,16 @@ pub fn run_agy_cli_mode(global: bool, ctx: InitContext) -> Result<()> { .to_string(); let hook_command = format!("{} hook antigravity", rtk_bin); - let agy_dir = resolve_agy_dir()?; - let settings_path = agy_dir.join(SETTINGS_JSON); + let config_dir = resolve_agy_config_dir()?; + let hooks_path = config_dir.join(HOOKS_JSON); if !dry_run { - fs::create_dir_all(&agy_dir) - .with_context(|| format!("Failed to create agy config dir: {}", agy_dir.display()))?; + fs::create_dir_all(&config_dir).with_context(|| { + format!("Failed to create agy config dir: {}", config_dir.display()) + })?; } - let patched = patch_agy_settings(&settings_path, ctx, &hook_command)?; + let patched = patch_agy_hooks_json(&hooks_path, ctx, &hook_command)?; if dry_run { print_dry_run_footer(); @@ -1784,24 +1792,24 @@ pub fn run_agy_cli_mode(global: bool, ctx: InitContext) -> Result<()> { } else { println!("\nGoogle Antigravity CLI (agy) hook already present (global).\n"); } - println!(" settings.json: {}", settings_path.display()); + println!(" hooks.json: {}", hooks_path.display()); println!(" Restart agy. Test with: git status\n"); } Ok(()) } -fn resolve_agy_dir() -> Result { +fn resolve_agy_config_dir() -> Result { let gemini_dir = resolve_home_subdir(GEMINI_DIR)?; - Ok(gemini_dir.join(AGY_CLI_SUBDIR)) + Ok(gemini_dir.join(AGY_CONFIG_SUBDIR)) } -/// Patch `~/.gemini/antigravity-cli/settings.json` with RTK BeforeTool hooks. +/// Patch `~/.gemini/config/hooks.json` with RTK's named PreToolUse hook. /// Returns true if the file was modified. -fn patch_agy_settings(path: &Path, ctx: InitContext, hook_command: &str) -> Result { +fn patch_agy_hooks_json(path: &Path, ctx: InitContext, hook_command: &str) -> Result { let InitContext { verbose, dry_run } = ctx; - let mut settings: serde_json::Value = if path.exists() { + let mut hooks: serde_json::Value = if path.exists() { let content = fs::read_to_string(path) .with_context(|| format!("Failed to read {}", path.display()))?; serde_json::from_str(&content).unwrap_or(serde_json::json!({})) @@ -1809,26 +1817,22 @@ fn patch_agy_settings(path: &Path, ctx: InitContext, hook_command: &str) -> Resu serde_json::json!({}) }; - if agy_hook_command_matches(&settings, hook_command) { + if agy_hook_command_matches(&hooks, hook_command) { if verbose > 0 { - eprintln!("agy settings.json: RTK hook already present"); + eprintln!("agy hooks.json: RTK hook already present"); } return Ok(false); } - // Remove any stale RTK entries (e.g. old installs with bare `rtk` command). - remove_agy_rtk_entries(&mut settings); - - insert_agy_hook_entries(&mut settings, hook_command)?; + // Remove any stale RTK entry (e.g. old install with different path). + remove_agy_hook_entry(&mut hooks); + insert_agy_hook_entry(&mut hooks, hook_command)?; let serialized = - serde_json::to_string_pretty(&settings).context("Failed to serialize agy settings.json")?; + serde_json::to_string_pretty(&hooks).context("Failed to serialize agy hooks.json")?; if dry_run { - println!( - "[dry-run] would patch agy settings.json: {}", - path.display() - ); + println!("[dry-run] would patch agy hooks.json: {}", path.display()); if verbose > 0 { println!("[dry-run] content:\n{}", serialized); } @@ -1837,7 +1841,7 @@ fn patch_agy_settings(path: &Path, ctx: InitContext, hook_command: &str) -> Resu let tmp = NamedTempFile::new_in( path.parent() - .context("agy settings.json has no parent directory")?, + .context("agy hooks.json has no parent directory")?, )?; fs::write(tmp.path(), &serialized)?; tmp.persist(path) @@ -1850,19 +1854,19 @@ fn patch_agy_settings(path: &Path, ctx: InitContext, hook_command: &str) -> Resu Ok(true) } -/// Returns true when an existing entry already uses exactly `hook_command`. -/// A mismatch (bare `rtk` vs absolute path) triggers re-installation. -fn agy_hook_command_matches(settings: &serde_json::Value, hook_command: &str) -> bool { - settings - .pointer(&format!("/hooks/{}", BEFORE_TOOL_KEY)) +/// Returns true when the RTK hook entry already uses exactly `hook_command`. +fn agy_hook_command_matches(hooks: &serde_json::Value, hook_command: &str) -> bool { + hooks + .get(AGY_HOOK_NAME) + .and_then(|entry| entry.get("PreToolUse")) .and_then(|v| v.as_array()) .is_some_and(|arr| { - arr.iter().any(|entry| { - entry + arr.iter().any(|matcher| { + matcher .get("hooks") .and_then(|h| h.as_array()) - .is_some_and(|hooks| { - hooks.iter().any(|h| { + .is_some_and(|hook_arr| { + hook_arr.iter().any(|h| { h.get("command") .and_then(|c| c.as_str()) .is_some_and(|cmd| cmd == hook_command) @@ -1872,54 +1876,31 @@ fn agy_hook_command_matches(settings: &serde_json::Value, hook_command: &str) -> }) } -/// Remove all RTK-owned BeforeTool entries (any command containing "rtk"). -fn remove_agy_rtk_entries(settings: &mut serde_json::Value) { - if let Some(arr) = settings - .pointer_mut(&format!("/hooks/{}", BEFORE_TOOL_KEY)) - .and_then(|v| v.as_array_mut()) - { - arr.retain(|entry| { - !entry - .get("hooks") - .and_then(|h| h.as_array()) - .is_some_and(|hooks| { - hooks.iter().any(|h| { - h.get("command") - .and_then(|c| c.as_str()) - .is_some_and(|cmd| cmd.contains("rtk")) - }) - }) - }); +/// Remove RTK's named hook entry from hooks.json. +fn remove_agy_hook_entry(hooks: &mut serde_json::Value) { + if let Some(obj) = hooks.as_object_mut() { + obj.remove(AGY_HOOK_NAME); } } -/// Insert RTK BeforeTool hook entries into agy settings.json. -/// -/// Registers one entry per tool name so agy calls the hook for both -/// shell-execution tools it uses: "run_command" and "Bash". -fn insert_agy_hook_entries(settings: &mut serde_json::Value, hook_command: &str) -> Result<()> { - let settings_obj = settings - .as_object_mut() - .context("agy settings.json is not an object")?; - - let hooks = settings_obj - .entry("hooks") - .or_insert_with(|| serde_json::json!({})) +/// Insert RTK's named PreToolUse hook entry into hooks.json. +fn insert_agy_hook_entry(hooks: &mut serde_json::Value, hook_command: &str) -> Result<()> { + let obj = hooks .as_object_mut() - .context("hooks value is not an object")?; - - let before_tool = hooks - .entry(BEFORE_TOOL_KEY) - .or_insert_with(|| serde_json::json!([])) - .as_array_mut() - .context("BeforeTool value is not an array")?; - - for matcher in &["run_command", "Bash"] { - before_tool.push(serde_json::json!({ - "matcher": matcher, - "hooks": [{"type": "command", "command": hook_command}] - })); - } + .context("agy hooks.json is not an object")?; + + obj.insert( + AGY_HOOK_NAME.to_string(), + serde_json::json!({ + "enabled": true, + "PreToolUse": [ + { + "toolNameMatcher": "^(run_command|Bash)$", + "hooks": [{"type": "command", "command": hook_command}] + } + ] + }), + ); Ok(()) } @@ -1933,8 +1914,8 @@ fn uninstall_agy_cli(global: bool, ctx: InitContext) -> Result<()> { return Ok(()); } - let settings_path = match resolve_agy_dir() { - Ok(d) => d.join(SETTINGS_JSON), + let hooks_path = match resolve_agy_config_dir() { + Ok(d) => d.join(HOOKS_JSON), Err(_) => { println!("RTK agy hook was not installed (nothing to remove)"); return Ok(()); @@ -1943,57 +1924,37 @@ fn uninstall_agy_cli(global: bool, ctx: InitContext) -> Result<()> { let mut removed = Vec::new(); - if settings_path.exists() { - let content = fs::read_to_string(&settings_path) - .with_context(|| format!("Failed to read {}", settings_path.display()))?; + if hooks_path.exists() { + let content = fs::read_to_string(&hooks_path) + .with_context(|| format!("Failed to read {}", hooks_path.display()))?; - if let Ok(mut settings) = serde_json::from_str::(&content) { - if let Some(arr) = settings - .pointer_mut(&format!("/hooks/{}", BEFORE_TOOL_KEY)) - .and_then(|v| v.as_array_mut()) - { - let before = arr.len(); - arr.retain(|entry| { - !entry - .get("hooks") - .and_then(|h| h.as_array()) - .is_some_and(|hooks| { - hooks.iter().any(|h| { - h.get("command") - .and_then(|c| c.as_str()) - .is_some_and(|cmd| cmd.contains("rtk")) - }) - }) - }); - if arr.len() < before { - if dry_run { - println!( - "[dry-run] would remove RTK hooks from agy settings.json: {}", - settings_path.display() + if let Ok(mut hooks) = serde_json::from_str::(&content) { + if hooks.get(AGY_HOOK_NAME).is_some() { + if dry_run { + println!( + "[dry-run] would remove RTK hook from agy hooks.json: {}", + hooks_path.display() + ); + } else { + remove_agy_hook_entry(&mut hooks); + let serialized = serde_json::to_string_pretty(&hooks)?; + let tmp = NamedTempFile::new_in( + hooks_path.parent().context("hooks.json has no parent")?, + )?; + fs::write(tmp.path(), &serialized)?; + tmp.persist(&hooks_path) + .with_context(|| format!("Failed to write {}", hooks_path.display()))?; + if verbose > 0 { + eprintln!( + "Removed RTK hook from agy hooks.json: {}", + hooks_path.display() ); - } else { - let serialized = serde_json::to_string_pretty(&settings)?; - let tmp = NamedTempFile::new_in( - settings_path - .parent() - .context("settings.json has no parent")?, - )?; - fs::write(tmp.path(), &serialized)?; - tmp.persist(&settings_path).with_context(|| { - format!("Failed to write {}", settings_path.display()) - })?; - if verbose > 0 { - eprintln!( - "Removed RTK hooks from agy settings.json: {}", - settings_path.display() - ); - } } - removed.push(format!( - "agy settings.json: removed RTK hooks ({})", - settings_path.display() - )); } + removed.push(format!( + "agy hooks.json: removed RTK hook ({})", + hooks_path.display() + )); } } } From eb73c4a9a05d867b3e8ca634aa1c6cc9778d10d9 Mon Sep 17 00:00:00 2001 From: lok1s Date: Mon, 25 May 2026 15:18:00 +0300 Subject: [PATCH 07/17] fix(antigravity): always output {} for non-shell tools to prevent denials agy interprets empty hook stdout as "deny". The hook was previously outputting nothing for non-shell tools (Read, ListPermissions, etc.), causing those tool calls to be blocked. Fix: always write at least "{}" (allow, no modification) for any tool call that doesn't need a command rewrite. Confirmed by live testing where Read and ListPermissions were denied by our hook before this change. Co-Authored-By: Claude Sonnet 4.6 --- src/hooks/hook_cmd.rs | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/hooks/hook_cmd.rs b/src/hooks/hook_cmd.rs index 9d108bf769..e82f26c28b 100644 --- a/src/hooks/hook_cmd.rs +++ b/src/hooks/hook_cmd.rs @@ -726,12 +726,15 @@ fn process_agy_rich(v: &Value, tool_name: &str) -> PayloadAction { /// Run the Google Antigravity (agy) CLI PreToolUse hook. /// /// Reads a JSON payload from stdin, rewrites shell commands through RTK, -/// and outputs the modified tool call. Non-shell tools and commands that -/// don't match any RTK filter produce no output so the tool runs unchanged. +/// and outputs the result as a PreToolHookResult protojson. +/// +/// IMPORTANT: agy interprets empty stdout as "deny" — always output at +/// least `{}` (allow, no modification) so non-shell tools are not blocked. pub fn run_antigravity() -> Result<()> { let input = read_stdin_limited()?; let input = input.trim(); if input.is_empty() { + // No payload — nothing to process, nothing to allow. return Ok(()); } @@ -739,6 +742,8 @@ pub fn run_antigravity() -> Result<()> { Ok(v) => v, Err(e) => { let _ = writeln!(io::stderr(), "[rtk hook] Failed to parse JSON input: {e}"); + // Still output allow so a parse error doesn't block the tool. + let _ = writeln!(io::stdout(), "{{}}"); return Ok(()); } }; @@ -754,10 +759,12 @@ pub fn run_antigravity() -> Result<()> { } PayloadAction::Skip { reason, cmd } => { audit_log(reason, &cmd, ""); - // No stdout output — the Antigravity CLI runs the tool unchanged. + // No rewrite needed — allow unchanged. + let _ = writeln!(io::stdout(), "{{}}"); } PayloadAction::Ignore => { - // Non-shell tool — no output, let it pass through natively. + // Non-shell tool — allow unchanged. + let _ = writeln!(io::stdout(), "{{}}"); } } From f0cb27984092d2221ceef76d4ac4ed1e5c0b69da Mon Sep 17 00:00:00 2001 From: lok1s Date: Mon, 25 May 2026 15:45:20 +0300 Subject: [PATCH 08/17] fix(antigravity): set allowTool:true in all hook responses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit agy's PreToolHookResult.allow_tool (proto field 1, bool) defaults to false — an empty {} is an implicit deny, not a pass-through. Every response must include {"allowTool":true} to permit tool execution. Also adds allowTool:true alongside overwrite responses so rewritten shell commands are not blocked by the missing allow flag. Co-Authored-By: Claude Sonnet 4.6 --- src/hooks/hook_cmd.rs | 29 +++++++++++++++++++---------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/src/hooks/hook_cmd.rs b/src/hooks/hook_cmd.rs index e82f26c28b..4bb1b29f79 100644 --- a/src/hooks/hook_cmd.rs +++ b/src/hooks/hook_cmd.rs @@ -637,8 +637,10 @@ fn process_agy_simple(v: &Value, tool_name: &str) -> PayloadAction { } }; - // agy PreToolHookResult: use "overwrite" to replace the tool call. + // agy PreToolHookResult: allowTool must be true to permit execution; + // overwrite replaces the tool call with the rewritten command. let output = json!({ + "allowTool": true, "overwrite": { "name": tool_name, "args": { field: rewritten.clone() } @@ -706,10 +708,12 @@ fn process_agy_rich(v: &Value, tool_name: &str) -> PayloadAction { args }; - // agy PreToolHookResult: use "overwrite" (HookToolCall) to replace the call. + // agy PreToolHookResult: allowTool must be true to permit execution; + // overwrite replaces the tool call with the rewritten command. // Mirror the tool name and all args so extra fields (Cwd, WaitMsBeforeAsync, …) // are preserved. let output = json!({ + "allowTool": true, "overwrite": { "name": tool_name, "args": updated_args @@ -728,13 +732,13 @@ fn process_agy_rich(v: &Value, tool_name: &str) -> PayloadAction { /// Reads a JSON payload from stdin, rewrites shell commands through RTK, /// and outputs the result as a PreToolHookResult protojson. /// -/// IMPORTANT: agy interprets empty stdout as "deny" — always output at -/// least `{}` (allow, no modification) so non-shell tools are not blocked. +/// IMPORTANT: agy's PreToolHookResult.allow_tool defaults to false (deny). +/// Every response must include `{"allowTool": true}` to permit the tool. +/// `{}` is NOT an allow — it's an implicit deny. pub fn run_antigravity() -> Result<()> { let input = read_stdin_limited()?; let input = input.trim(); if input.is_empty() { - // No payload — nothing to process, nothing to allow. return Ok(()); } @@ -742,8 +746,8 @@ pub fn run_antigravity() -> Result<()> { Ok(v) => v, Err(e) => { let _ = writeln!(io::stderr(), "[rtk hook] Failed to parse JSON input: {e}"); - // Still output allow so a parse error doesn't block the tool. - let _ = writeln!(io::stdout(), "{{}}"); + // Parse error — allow the tool so the user isn't blocked. + let _ = writeln!(io::stdout(), r#"{{"allowTool":true}}"#); return Ok(()); } }; @@ -759,12 +763,12 @@ pub fn run_antigravity() -> Result<()> { } PayloadAction::Skip { reason, cmd } => { audit_log(reason, &cmd, ""); - // No rewrite needed — allow unchanged. - let _ = writeln!(io::stdout(), "{{}}"); + // No rewrite needed — allow the original tool call unchanged. + let _ = writeln!(io::stdout(), r#"{{"allowTool":true}}"#); } PayloadAction::Ignore => { // Non-shell tool — allow unchanged. - let _ = writeln!(io::stdout(), "{{}}"); + let _ = writeln!(io::stdout(), r#"{{"allowTool":true}}"#); } } @@ -1260,6 +1264,7 @@ mod tests { fn test_agy_simple_bash_rewrite() { let out = run_antigravity_inner(&agy_simple_bash("git status")).unwrap(); let v: Value = serde_json::from_str(&out).unwrap(); + assert_eq!(v["allowTool"], true); assert_eq!(v["overwrite"]["name"], "Bash"); assert_eq!(v["overwrite"]["args"]["command"], "rtk git status"); } @@ -1268,6 +1273,7 @@ mod tests { fn test_agy_simple_run_command_rewrite() { let out = run_antigravity_inner(&agy_simple_run_command("git status")).unwrap(); let v: Value = serde_json::from_str(&out).unwrap(); + assert_eq!(v["allowTool"], true); assert_eq!(v["overwrite"]["name"], "run_command"); assert_eq!(v["overwrite"]["args"]["CommandLine"], "rtk git status"); } @@ -1298,6 +1304,7 @@ mod tests { fn test_agy_rich_run_command_rewrite() { let out = run_antigravity_inner(&agy_rich_run_command("git status")).unwrap(); let v: Value = serde_json::from_str(&out).unwrap(); + assert_eq!(v["allowTool"], true); assert_eq!(v["overwrite"]["name"], "run_command"); assert_eq!(v["overwrite"]["args"]["CommandLine"], "rtk git status"); } @@ -1306,6 +1313,7 @@ mod tests { fn test_agy_rich_bash_rewrite() { let out = run_antigravity_inner(&agy_rich_bash("cargo test")).unwrap(); let v: Value = serde_json::from_str(&out).unwrap(); + assert_eq!(v["allowTool"], true); assert_eq!(v["overwrite"]["name"], "Bash"); assert_eq!(v["overwrite"]["args"]["command"], "rtk cargo test"); } @@ -1321,6 +1329,7 @@ mod tests { .to_string(); let out = run_antigravity_inner(&input).unwrap(); let v: Value = serde_json::from_str(&out).unwrap(); + assert_eq!(v["allowTool"], true); assert_eq!(v["overwrite"]["name"], "run_command"); assert_eq!(v["overwrite"]["args"]["Cwd"], "/tmp"); assert_eq!(v["overwrite"]["args"]["WaitMsBeforeAsync"], 2000); From 9cdcb68c098e4bdfad8d007cd5836fd3ed699ea4 Mon Sep 17 00:00:00 2001 From: lok1s Date: Mon, 25 May 2026 15:50:46 +0300 Subject: [PATCH 09/17] fix(antigravity): overwrite implies allow; allowTool only for passthrough MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit agy's PreToolHookResult semantics: - {"overwrite": {...}} → replace the tool call (implicitly allowed) - {"allowTool": true} → allow the original call unchanged - {"allowTool": true, "overwrite": {...}} → allowTool wins, overwrite ignored Previously: {} for passthrough → implicit deny (allow_tool defaults to false) Previously: {allowTool+overwrite} → overwrite silently dropped Now: overwrite-only for rewrites, allowTool-only for passthrough/skip/ignore. Co-Authored-By: Claude Sonnet 4.6 --- src/hooks/hook_cmd.rs | 35 ++++++++++++++++++++++++----------- 1 file changed, 24 insertions(+), 11 deletions(-) diff --git a/src/hooks/hook_cmd.rs b/src/hooks/hook_cmd.rs index 4bb1b29f79..6d5913d464 100644 --- a/src/hooks/hook_cmd.rs +++ b/src/hooks/hook_cmd.rs @@ -637,10 +637,9 @@ fn process_agy_simple(v: &Value, tool_name: &str) -> PayloadAction { } }; - // agy PreToolHookResult: allowTool must be true to permit execution; - // overwrite replaces the tool call with the rewritten command. + // agy PreToolHookResult: "overwrite" replaces the tool call and implicitly + // allows it. Do NOT include "allowTool" — it overrides the overwrite. let output = json!({ - "allowTool": true, "overwrite": { "name": tool_name, "args": { field: rewritten.clone() } @@ -708,12 +707,11 @@ fn process_agy_rich(v: &Value, tool_name: &str) -> PayloadAction { args }; - // agy PreToolHookResult: allowTool must be true to permit execution; - // overwrite replaces the tool call with the rewritten command. + // agy PreToolHookResult: "overwrite" replaces the tool call and implicitly + // allows it. Do NOT include "allowTool" — it overrides the overwrite. // Mirror the tool name and all args so extra fields (Cwd, WaitMsBeforeAsync, …) // are preserved. let output = json!({ - "allowTool": true, "overwrite": { "name": tool_name, "args": updated_args @@ -1264,7 +1262,10 @@ mod tests { fn test_agy_simple_bash_rewrite() { let out = run_antigravity_inner(&agy_simple_bash("git status")).unwrap(); let v: Value = serde_json::from_str(&out).unwrap(); - assert_eq!(v["allowTool"], true); + assert!( + v.get("allowTool").is_none(), + "allowTool must not appear alongside overwrite" + ); assert_eq!(v["overwrite"]["name"], "Bash"); assert_eq!(v["overwrite"]["args"]["command"], "rtk git status"); } @@ -1273,7 +1274,10 @@ mod tests { fn test_agy_simple_run_command_rewrite() { let out = run_antigravity_inner(&agy_simple_run_command("git status")).unwrap(); let v: Value = serde_json::from_str(&out).unwrap(); - assert_eq!(v["allowTool"], true); + assert!( + v.get("allowTool").is_none(), + "allowTool must not appear alongside overwrite" + ); assert_eq!(v["overwrite"]["name"], "run_command"); assert_eq!(v["overwrite"]["args"]["CommandLine"], "rtk git status"); } @@ -1304,7 +1308,10 @@ mod tests { fn test_agy_rich_run_command_rewrite() { let out = run_antigravity_inner(&agy_rich_run_command("git status")).unwrap(); let v: Value = serde_json::from_str(&out).unwrap(); - assert_eq!(v["allowTool"], true); + assert!( + v.get("allowTool").is_none(), + "allowTool must not appear alongside overwrite" + ); assert_eq!(v["overwrite"]["name"], "run_command"); assert_eq!(v["overwrite"]["args"]["CommandLine"], "rtk git status"); } @@ -1313,7 +1320,10 @@ mod tests { fn test_agy_rich_bash_rewrite() { let out = run_antigravity_inner(&agy_rich_bash("cargo test")).unwrap(); let v: Value = serde_json::from_str(&out).unwrap(); - assert_eq!(v["allowTool"], true); + assert!( + v.get("allowTool").is_none(), + "allowTool must not appear alongside overwrite" + ); assert_eq!(v["overwrite"]["name"], "Bash"); assert_eq!(v["overwrite"]["args"]["command"], "rtk cargo test"); } @@ -1329,7 +1339,10 @@ mod tests { .to_string(); let out = run_antigravity_inner(&input).unwrap(); let v: Value = serde_json::from_str(&out).unwrap(); - assert_eq!(v["allowTool"], true); + assert!( + v.get("allowTool").is_none(), + "allowTool must not appear alongside overwrite" + ); assert_eq!(v["overwrite"]["name"], "run_command"); assert_eq!(v["overwrite"]["args"]["Cwd"], "/tmp"); assert_eq!(v["overwrite"]["args"]["WaitMsBeforeAsync"], 2000); From 407776bda98be95a4e52bd43d1ca0814c3b66262 Mon Sep 17 00:00:00 2001 From: lok1s Date: Mon, 25 May 2026 16:36:23 +0300 Subject: [PATCH 10/17] fix(antigravity): grant command(rtk) permission during rtk init MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the hook rewrites git status → rtk git status, agy's permission system checks whether rtk is in the allowed-commands list. Without the grant, the rewritten command is denied even though the original command would have been approved by the user. rtk init -g --agent agy now: - Writes the PreToolUse hook entry into ~/.gemini/config/hooks.json - Adds command(rtk) to permissions.allow in ~/.gemini/antigravity-cli/settings.json rtk init -g --uninstall --agent agy reverts both. Also adds RTK_HOOK_DEBUG=1 support in run_antigravity() for future protocol debugging (dumps raw stdin to ~/.local/share/rtk/agy-hook-debug.log). Co-Authored-By: Claude Sonnet 4.6 --- src/hooks/constants.rs | 1 - src/hooks/hook_cmd.rs | 16 +++++ src/hooks/init.rs | 146 +++++++++++++++++++++++++++++++++++++++-- 3 files changed, 158 insertions(+), 5 deletions(-) diff --git a/src/hooks/constants.rs b/src/hooks/constants.rs index e3cccb73fd..92c7b1d7d4 100644 --- a/src/hooks/constants.rs +++ b/src/hooks/constants.rs @@ -26,7 +26,6 @@ pub const GEMINI_DIR: &str = ".gemini"; pub const ANTIGRAVITY_DIR: &str = ".agents"; /// Config subdirectory for the Antigravity CLI (agy) under ~/.gemini/ -#[allow(dead_code)] pub const AGY_CLI_SUBDIR: &str = "antigravity-cli"; pub const HERMES_DIR: &str = ".hermes"; diff --git a/src/hooks/hook_cmd.rs b/src/hooks/hook_cmd.rs index 6d5913d464..daf20ee6c8 100644 --- a/src/hooks/hook_cmd.rs +++ b/src/hooks/hook_cmd.rs @@ -740,6 +740,22 @@ pub fn run_antigravity() -> Result<()> { return Ok(()); } + // RTK_HOOK_DEBUG=1 — dump raw stdin to ~/.local/share/rtk/agy-hook-debug.log + if std::env::var("RTK_HOOK_DEBUG").as_deref() == Ok("1") { + let _ = (|| -> Option<()> { + let dir = dirs::home_dir()?.join(".local").join("share").join("rtk"); + std::fs::create_dir_all(&dir).ok()?; + let mut f = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(dir.join("agy-hook-debug.log")) + .ok()?; + let ts = chrono::Local::now().format("%Y-%m-%dT%H:%M:%S"); + let _ = writeln!(f, "--- {} ---\n{}\n", ts, input); + Some(()) + })(); + } + let v: Value = match serde_json::from_str(input) { Ok(v) => v, Err(e) => { diff --git a/src/hooks/init.rs b/src/hooks/init.rs index 673f6013e8..609071664c 100644 --- a/src/hooks/init.rs +++ b/src/hooks/init.rs @@ -8,8 +8,8 @@ use std::path::{Path, PathBuf}; use tempfile::NamedTempFile; use crate::hooks::constants::{ - CONFIG_DIR, CURSOR_DIR, GEMINI_DIR, HOOKS_JSON, OPENCODE_PLUGIN_FILE, OPENCODE_SUBDIR, - PLUGIN_SUBDIR, + AGY_CLI_SUBDIR, CONFIG_DIR, CURSOR_DIR, GEMINI_DIR, HOOKS_JSON, OPENCODE_PLUGIN_FILE, + OPENCODE_SUBDIR, PLUGIN_SUBDIR, }; use super::constants::{ @@ -1755,12 +1755,14 @@ fn run_antigravity_mode_at(base_dir: &Path, ctx: InitContext) -> Result<()> { const AGY_HOOK_NAME: &str = "rtk-antigravity"; /// Subdirectory of ~/.gemini that holds agy's hooks.json. const AGY_CONFIG_SUBDIR: &str = "config"; +/// Permission token added to agy's settings.json so rewritten commands can run. +const AGY_RTK_PERMISSION: &str = "command(rtk)"; /// Entry point for `rtk init --agent agy` (global-only). /// /// Writes the RTK hook entry into `~/.gemini/config/hooks.json`. pub fn run_agy_cli_mode(global: bool, ctx: InitContext) -> Result<()> { - let InitContext { dry_run, .. } = ctx; + let InitContext { dry_run, verbose } = ctx; if !global { anyhow::bail!("agy CLI support is global-only. Use: rtk init -g --agent agy"); @@ -1784,6 +1786,10 @@ pub fn run_agy_cli_mode(global: bool, ctx: InitContext) -> Result<()> { let patched = patch_agy_hooks_json(&hooks_path, ctx, &hook_command)?; + // Also grant command(rtk) permission so the rewritten commands are auto-allowed. + let settings_path = resolve_agy_settings_path()?; + let permission_granted = grant_agy_rtk_permission(&settings_path, dry_run, verbose)?; + if dry_run { print_dry_run_footer(); } else { @@ -1792,7 +1798,10 @@ pub fn run_agy_cli_mode(global: bool, ctx: InitContext) -> Result<()> { } else { println!("\nGoogle Antigravity CLI (agy) hook already present (global).\n"); } - println!(" hooks.json: {}", hooks_path.display()); + println!(" hooks.json: {}", hooks_path.display()); + if permission_granted { + println!(" settings.json: added command(rtk) to allow list"); + } println!(" Restart agy. Test with: git status\n"); } @@ -1804,6 +1813,124 @@ fn resolve_agy_config_dir() -> Result { Ok(gemini_dir.join(AGY_CONFIG_SUBDIR)) } +fn resolve_agy_settings_path() -> Result { + let gemini_dir = resolve_home_subdir(GEMINI_DIR)?; + Ok(gemini_dir.join(AGY_CLI_SUBDIR).join(SETTINGS_JSON)) +} + +/// Add `command(rtk)` to `permissions.allow` in agy's settings.json. +/// Returns true if the file was modified. +fn grant_agy_rtk_permission(path: &Path, dry_run: bool, verbose: u8) -> Result { + let mut settings: serde_json::Value = if path.exists() { + let content = fs::read_to_string(path) + .with_context(|| format!("Failed to read {}", path.display()))?; + serde_json::from_str(&content).unwrap_or(serde_json::json!({})) + } else { + serde_json::json!({}) + }; + + let already = settings + .pointer("/permissions/allow") + .and_then(|v| v.as_array()) + .is_some_and(|arr| arr.iter().any(|v| v.as_str() == Some(AGY_RTK_PERMISSION))); + + if already { + if verbose > 0 { + eprintln!("agy settings.json: {} already present", AGY_RTK_PERMISSION); + } + return Ok(false); + } + + if settings.get("permissions").is_none() { + settings["permissions"] = serde_json::json!({"allow": []}); + } + if settings["permissions"].get("allow").is_none() { + settings["permissions"]["allow"] = serde_json::json!([]); + } + settings["permissions"]["allow"] + .as_array_mut() + .context("permissions.allow is not an array")? + .push(serde_json::Value::String(AGY_RTK_PERMISSION.to_string())); + + let serialized = + serde_json::to_string_pretty(&settings).context("Failed to serialize agy settings.json")?; + + if dry_run { + println!( + "[dry-run] would add {} to agy settings.json: {}", + AGY_RTK_PERMISSION, + path.display() + ); + return Ok(true); + } + + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + let tmp = NamedTempFile::new_in(path.parent().context("agy settings.json has no parent")?)?; + fs::write(tmp.path(), &serialized)?; + tmp.persist(path) + .with_context(|| format!("Failed to write {}", path.display()))?; + + if verbose > 0 { + eprintln!("Added {} to {}", AGY_RTK_PERMISSION, path.display()); + } + + Ok(true) +} + +/// Remove `command(rtk)` from `permissions.allow` in agy's settings.json. +/// Returns true if the file was modified. +fn revoke_agy_rtk_permission(path: &Path, dry_run: bool, verbose: u8) -> Result { + if !path.exists() { + return Ok(false); + } + + let content = + fs::read_to_string(path).with_context(|| format!("Failed to read {}", path.display()))?; + let mut settings: serde_json::Value = match serde_json::from_str(&content) { + Ok(v) => v, + Err(_) => return Ok(false), + }; + + let allow = match settings + .pointer_mut("/permissions/allow") + .and_then(|v| v.as_array_mut()) + { + Some(arr) => arr, + None => return Ok(false), + }; + + let before = allow.len(); + allow.retain(|v| v.as_str() != Some(AGY_RTK_PERMISSION)); + if allow.len() == before { + return Ok(false); + } + + let serialized = + serde_json::to_string_pretty(&settings).context("Failed to serialize agy settings.json")?; + + if dry_run { + println!( + "[dry-run] would remove {} from agy settings.json: {}", + AGY_RTK_PERMISSION, + path.display() + ); + return Ok(true); + } + + let tmp = NamedTempFile::new_in(path.parent().context("agy settings.json has no parent")?)?; + fs::write(tmp.path(), &serialized)?; + tmp.persist(path) + .with_context(|| format!("Failed to write {}", path.display()))?; + + if verbose > 0 { + eprintln!("Removed {} from {}", AGY_RTK_PERMISSION, path.display()); + } + + Ok(true) +} + /// Patch `~/.gemini/config/hooks.json` with RTK's named PreToolUse hook. /// Returns true if the file was modified. fn patch_agy_hooks_json(path: &Path, ctx: InitContext, hook_command: &str) -> Result { @@ -1959,6 +2086,17 @@ fn uninstall_agy_cli(global: bool, ctx: InitContext) -> Result<()> { } } + // Also revoke command(rtk) from settings.json. + if let Ok(settings_path) = resolve_agy_settings_path() { + if let Ok(true) = revoke_agy_rtk_permission(&settings_path, dry_run, verbose) { + removed.push(format!( + "agy settings.json: removed {} ({})", + AGY_RTK_PERMISSION, + settings_path.display() + )); + } + } + if dry_run { print_dry_run_footer(); } else if !removed.is_empty() { From 4078ac20217bed7fdc70f83b5ffbb1874d3d1d71 Mon Sep 17 00:00:00 2001 From: lok1s Date: Mon, 25 May 2026 17:58:48 +0300 Subject: [PATCH 11/17] fix(agy): switch from overwrite to deny-with-reason for agy hook The PreToolHookResult overwrite mechanism is broken under agy's toolPermission=request-review setting. When the hook returns an overwrite, the permission review fires on the original binary (git, cargo, etc.) rather than the overwritten one (rtk), so the model never sees the rewritten command. Evidence: turnGrants=[command(git)] on user approval of overwritten command. Switch to deny-with-reason: deny the original command with a denyReason that names the exact rtk-prefixed replacement. The model reads the reason and retries with e.g. 'rtk git status', which hits the Allow list (command(rtk)) directly and bypasses the request-review gate entirely. Co-Authored-By: Claude Sonnet 4.6 --- src/hooks/hook_cmd.rs | 167 ++++++++++++++++++++---------------------- 1 file changed, 80 insertions(+), 87 deletions(-) diff --git a/src/hooks/hook_cmd.rs b/src/hooks/hook_cmd.rs index daf20ee6c8..c7c84edc7f 100644 --- a/src/hooks/hook_cmd.rs +++ b/src/hooks/hook_cmd.rs @@ -597,21 +597,15 @@ fn process_antigravity_payload(v: &Value) -> PayloadAction { } fn process_agy_simple(v: &Value, tool_name: &str) -> PayloadAction { - let (cmd, field) = match tool_name { - "run_command" => { - let c = v - .pointer("/tool_input/CommandLine") - .and_then(|c| c.as_str()) - .unwrap_or(""); - (c, "CommandLine") - } - "Bash" | "bash" => { - let c = v - .pointer("/tool_input/command") - .and_then(|c| c.as_str()) - .unwrap_or(""); - (c, "command") - } + let cmd = match tool_name { + "run_command" => v + .pointer("/tool_input/CommandLine") + .and_then(|c| c.as_str()) + .unwrap_or(""), + "Bash" | "bash" => v + .pointer("/tool_input/command") + .and_then(|c| c.as_str()) + .unwrap_or(""), _ => return PayloadAction::Ignore, }; @@ -637,13 +631,16 @@ fn process_agy_simple(v: &Value, tool_name: &str) -> PayloadAction { } }; - // agy PreToolHookResult: "overwrite" replaces the tool call and implicitly - // allows it. Do NOT include "allowTool" — it overrides the overwrite. + // agy deny-with-reason: deny the original command and tell the model the + // exact replacement. The model retries with the rtk-prefixed command, which + // hits the Allow list (command(rtk)) and bypasses the request-review gate. + // The overwrite mechanism is broken under toolPermission=request-review — + // the review fires on the original binary, not the overwritten one. let output = json!({ - "overwrite": { - "name": tool_name, - "args": { field: rewritten.clone() } - } + "denyReason": format!( + "RTK: use '{}' instead of '{}' (60-90% token savings — see CLAUDE.md)", + rewritten, cmd + ) }); PayloadAction::Rewrite { @@ -654,21 +651,15 @@ fn process_agy_simple(v: &Value, tool_name: &str) -> PayloadAction { } fn process_agy_rich(v: &Value, tool_name: &str) -> PayloadAction { - let (cmd, field) = match tool_name { - "run_command" => { - let c = v - .pointer("/toolCall/args/CommandLine") - .and_then(|c| c.as_str()) - .unwrap_or(""); - (c, "CommandLine") - } - "Bash" | "bash" => { - let c = v - .pointer("/toolCall/args/command") - .and_then(|c| c.as_str()) - .unwrap_or(""); - (c, "command") - } + let cmd = match tool_name { + "run_command" => v + .pointer("/toolCall/args/CommandLine") + .and_then(|c| c.as_str()) + .unwrap_or(""), + "Bash" | "bash" => v + .pointer("/toolCall/args/command") + .and_then(|c| c.as_str()) + .unwrap_or(""), _ => return PayloadAction::Ignore, }; @@ -694,28 +685,14 @@ fn process_agy_rich(v: &Value, tool_name: &str) -> PayloadAction { } }; - // Mirror the full args object so extra fields (Cwd, WaitMsBeforeAsync, …) - // are preserved unchanged in the rewritten call. - let updated_args = { - let mut args = v - .pointer("/toolCall/args") - .cloned() - .unwrap_or_else(|| json!({})); - if let Some(obj) = args.as_object_mut() { - obj.insert(field.to_string(), Value::String(rewritten.clone())); - } - args - }; - - // agy PreToolHookResult: "overwrite" replaces the tool call and implicitly - // allows it. Do NOT include "allowTool" — it overrides the overwrite. - // Mirror the tool name and all args so extra fields (Cwd, WaitMsBeforeAsync, …) - // are preserved. + // agy deny-with-reason: deny the original command and tell the model the + // exact replacement. The model retries with the rtk-prefixed command, which + // hits the Allow list (command(rtk)) and bypasses the request-review gate. let output = json!({ - "overwrite": { - "name": tool_name, - "args": updated_args - } + "denyReason": format!( + "RTK: use '{}' instead of '{}' (60-90% token savings — see CLAUDE.md)", + rewritten, cmd + ) }); PayloadAction::Rewrite { @@ -727,12 +704,14 @@ fn process_agy_rich(v: &Value, tool_name: &str) -> PayloadAction { /// Run the Google Antigravity (agy) CLI PreToolUse hook. /// -/// Reads a JSON payload from stdin, rewrites shell commands through RTK, -/// and outputs the result as a PreToolHookResult protojson. +/// Reads a JSON payload from stdin and outputs a PreToolHookResult protojson. /// -/// IMPORTANT: agy's PreToolHookResult.allow_tool defaults to false (deny). -/// Every response must include `{"allowTool": true}` to permit the tool. -/// `{}` is NOT an allow — it's an implicit deny. +/// Strategy: deny commands that need rewriting with a `denyReason` that names +/// the rtk-prefixed replacement. The model retries with the exact replacement, +/// which agy allows immediately because `command(rtk)` is in the Allow list +/// (bypassing the `toolPermission=request-review` gate). The `overwrite` field +/// was abandoned because under request-review, the review fires on the original +/// binary — the model never sees the overwritten command. pub fn run_antigravity() -> Result<()> { let input = read_stdin_limited()?; let input = input.trim(); @@ -1278,12 +1257,17 @@ mod tests { fn test_agy_simple_bash_rewrite() { let out = run_antigravity_inner(&agy_simple_bash("git status")).unwrap(); let v: Value = serde_json::from_str(&out).unwrap(); + // deny-with-reason: no overwrite, no allowTool — just denyReason + assert!( + v.get("overwrite").is_none(), + "must not use overwrite (broken under request-review)" + ); + assert!(v.get("allowTool").is_none()); + let reason = v["denyReason"].as_str().unwrap(); assert!( - v.get("allowTool").is_none(), - "allowTool must not appear alongside overwrite" + reason.contains("rtk git status"), + "deny reason must name the replacement: {reason}" ); - assert_eq!(v["overwrite"]["name"], "Bash"); - assert_eq!(v["overwrite"]["args"]["command"], "rtk git status"); } #[test] @@ -1291,11 +1275,15 @@ mod tests { let out = run_antigravity_inner(&agy_simple_run_command("git status")).unwrap(); let v: Value = serde_json::from_str(&out).unwrap(); assert!( - v.get("allowTool").is_none(), - "allowTool must not appear alongside overwrite" + v.get("overwrite").is_none(), + "must not use overwrite (broken under request-review)" + ); + assert!(v.get("allowTool").is_none()); + let reason = v["denyReason"].as_str().unwrap(); + assert!( + reason.contains("rtk git status"), + "deny reason must name the replacement: {reason}" ); - assert_eq!(v["overwrite"]["name"], "run_command"); - assert_eq!(v["overwrite"]["args"]["CommandLine"], "rtk git status"); } #[test] @@ -1325,11 +1313,15 @@ mod tests { let out = run_antigravity_inner(&agy_rich_run_command("git status")).unwrap(); let v: Value = serde_json::from_str(&out).unwrap(); assert!( - v.get("allowTool").is_none(), - "allowTool must not appear alongside overwrite" + v.get("overwrite").is_none(), + "must not use overwrite (broken under request-review)" + ); + assert!(v.get("allowTool").is_none()); + let reason = v["denyReason"].as_str().unwrap(); + assert!( + reason.contains("rtk git status"), + "deny reason must name the replacement: {reason}" ); - assert_eq!(v["overwrite"]["name"], "run_command"); - assert_eq!(v["overwrite"]["args"]["CommandLine"], "rtk git status"); } #[test] @@ -1337,15 +1329,21 @@ mod tests { let out = run_antigravity_inner(&agy_rich_bash("cargo test")).unwrap(); let v: Value = serde_json::from_str(&out).unwrap(); assert!( - v.get("allowTool").is_none(), - "allowTool must not appear alongside overwrite" + v.get("overwrite").is_none(), + "must not use overwrite (broken under request-review)" + ); + assert!(v.get("allowTool").is_none()); + let reason = v["denyReason"].as_str().unwrap(); + assert!( + reason.contains("rtk cargo test"), + "deny reason must name the replacement: {reason}" ); - assert_eq!(v["overwrite"]["name"], "Bash"); - assert_eq!(v["overwrite"]["args"]["command"], "rtk cargo test"); } #[test] - fn test_agy_rich_extra_args_preserved() { + fn test_agy_rich_deny_reason_names_replacement() { + // Extra args are no longer in the response (deny has no args to preserve), + // but the deny reason must still name the correct rewritten command. let input = json!({ "toolCall": { "name": "run_command", @@ -1355,14 +1353,9 @@ mod tests { .to_string(); let out = run_antigravity_inner(&input).unwrap(); let v: Value = serde_json::from_str(&out).unwrap(); - assert!( - v.get("allowTool").is_none(), - "allowTool must not appear alongside overwrite" - ); - assert_eq!(v["overwrite"]["name"], "run_command"); - assert_eq!(v["overwrite"]["args"]["Cwd"], "/tmp"); - assert_eq!(v["overwrite"]["args"]["WaitMsBeforeAsync"], 2000); - assert_eq!(v["overwrite"]["args"]["CommandLine"], "rtk git status"); + assert!(v.get("overwrite").is_none()); + let reason = v["denyReason"].as_str().unwrap(); + assert!(reason.contains("rtk git status"), "deny reason: {reason}"); } #[test] From 26a56933c919c832e060d147b74a4adff3cc589f Mon Sep 17 00:00:00 2001 From: lok1s Date: Mon, 25 May 2026 18:04:56 +0300 Subject: [PATCH 12/17] fix(agy): reference GEMINI.md instead of CLAUDE.md in deny reason Co-Authored-By: Claude Sonnet 4.6 --- src/hooks/hook_cmd.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/hooks/hook_cmd.rs b/src/hooks/hook_cmd.rs index c7c84edc7f..90ed59c4b1 100644 --- a/src/hooks/hook_cmd.rs +++ b/src/hooks/hook_cmd.rs @@ -638,7 +638,7 @@ fn process_agy_simple(v: &Value, tool_name: &str) -> PayloadAction { // the review fires on the original binary, not the overwritten one. let output = json!({ "denyReason": format!( - "RTK: use '{}' instead of '{}' (60-90% token savings — see CLAUDE.md)", + "RTK: use '{}' instead of '{}' (60-90% token savings — see GEMINI.md)", rewritten, cmd ) }); @@ -690,7 +690,7 @@ fn process_agy_rich(v: &Value, tool_name: &str) -> PayloadAction { // hits the Allow list (command(rtk)) and bypasses the request-review gate. let output = json!({ "denyReason": format!( - "RTK: use '{}' instead of '{}' (60-90% token savings — see CLAUDE.md)", + "RTK: use '{}' instead of '{}' (60-90% token savings — see GEMINI.md)", rewritten, cmd ) }); From 9184533cbcf040f0d1633a02189a66ea5d7254f3 Mon Sep 17 00:00:00 2001 From: lok1s Date: Mon, 25 May 2026 18:34:03 +0300 Subject: [PATCH 13/17] chore: remove duplicate test and fix fmt from cherry-pick resolution Remove duplicated test_claude_md_mode_refuses_malformed_block introduced during conflict resolution across cherry-picks. Apply cargo fmt output. Co-Authored-By: Claude Sonnet 4.6 --- src/hooks/init.rs | 23 ----------------------- src/main.rs | 3 +-- 2 files changed, 1 insertion(+), 25 deletions(-) diff --git a/src/hooks/init.rs b/src/hooks/init.rs index 609071664c..4c22a2dab6 100644 --- a/src/hooks/init.rs +++ b/src/hooks/init.rs @@ -6056,29 +6056,6 @@ mod tests { ); } - #[test] - fn test_claude_md_mode_refuses_malformed_block() { - let tmp = TempDir::new().unwrap(); - with_claude_dir_override(&tmp, |claude_dir| { - let claude_md = claude_dir.join(CLAUDE_MD); - let malformed = format!( - "# Existing notes\n\n{}\nincomplete RTK block\n", - RTK_BLOCK_START - ); - fs::write(&claude_md, &malformed).unwrap(); - - let result = run_claude_md_mode(true, false, InitContext::default()); - - assert!( - result.is_err(), - "Malformed CLAUDE.md must cause a hard error, not silent skip" - ); - - let after = fs::read_to_string(&claude_md).unwrap(); - assert_eq!(after, malformed, "File must not be modified when malformed"); - }); - } - // ─── Copilot tests ─────────────────────────────────────────────── #[test] diff --git a/src/main.rs b/src/main.rs index f01c26e54a..cc1750e2a1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1381,8 +1381,7 @@ fn uninstall_init_dispatch( ) -> Result<()> where UninstallHermes: FnOnce(hooks::init::InitContext) -> Result<()>, - UninstallStandard: - FnOnce(bool, bool, bool, bool, bool, hooks::init::InitContext) -> Result<()>, + UninstallStandard: FnOnce(bool, bool, bool, bool, bool, hooks::init::InitContext) -> Result<()>, { if agent == Some(AgentTarget::Hermes) { uninstall_hermes(ctx) From adab348f9bd05e249a3e431173b5f4b9f96f0faf Mon Sep 17 00:00:00 2001 From: lok1s Date: Mon, 25 May 2026 21:06:31 +0300 Subject: [PATCH 14/17] fix(agy): switch back to overwrite for transparent rewriting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit denyReason always shows ⚠ warning in every permission mode. Under toolPermission=always-proceed there is no review gate, so overwrite works transparently: no warning, no extra LLM call, command silently rewritten to rtk-prefixed equivalent. Rich format preserves extra args (Cwd, WaitMsBeforeAsync, etc.) from the original toolCall.args so metadata is not lost on rewrite. Under request-review, overwrite is bypassed by the review gate (original command runs without RTK), but the user is not blocked. Co-Authored-By: Claude Sonnet 4.6 --- src/hooks/hook_cmd.rs | 158 +++++++++++++++++++----------------------- 1 file changed, 71 insertions(+), 87 deletions(-) diff --git a/src/hooks/hook_cmd.rs b/src/hooks/hook_cmd.rs index 90ed59c4b1..2e2350c64a 100644 --- a/src/hooks/hook_cmd.rs +++ b/src/hooks/hook_cmd.rs @@ -597,15 +597,19 @@ fn process_antigravity_payload(v: &Value) -> PayloadAction { } fn process_agy_simple(v: &Value, tool_name: &str) -> PayloadAction { - let cmd = match tool_name { - "run_command" => v - .pointer("/tool_input/CommandLine") - .and_then(|c| c.as_str()) - .unwrap_or(""), - "Bash" | "bash" => v - .pointer("/tool_input/command") - .and_then(|c| c.as_str()) - .unwrap_or(""), + let (cmd, arg_key) = match tool_name { + "run_command" => ( + v.pointer("/tool_input/CommandLine") + .and_then(|c| c.as_str()) + .unwrap_or(""), + "CommandLine", + ), + "Bash" | "bash" => ( + v.pointer("/tool_input/command") + .and_then(|c| c.as_str()) + .unwrap_or(""), + "command", + ), _ => return PayloadAction::Ignore, }; @@ -631,16 +635,11 @@ fn process_agy_simple(v: &Value, tool_name: &str) -> PayloadAction { } }; - // agy deny-with-reason: deny the original command and tell the model the - // exact replacement. The model retries with the rtk-prefixed command, which - // hits the Allow list (command(rtk)) and bypasses the request-review gate. - // The overwrite mechanism is broken under toolPermission=request-review — - // the review fires on the original binary, not the overwritten one. let output = json!({ - "denyReason": format!( - "RTK: use '{}' instead of '{}' (60-90% token savings — see GEMINI.md)", - rewritten, cmd - ) + "overwrite": { + "name": tool_name, + "args": { arg_key: rewritten } + } }); PayloadAction::Rewrite { @@ -651,15 +650,19 @@ fn process_agy_simple(v: &Value, tool_name: &str) -> PayloadAction { } fn process_agy_rich(v: &Value, tool_name: &str) -> PayloadAction { - let cmd = match tool_name { - "run_command" => v - .pointer("/toolCall/args/CommandLine") - .and_then(|c| c.as_str()) - .unwrap_or(""), - "Bash" | "bash" => v - .pointer("/toolCall/args/command") - .and_then(|c| c.as_str()) - .unwrap_or(""), + let (cmd, arg_key) = match tool_name { + "run_command" => ( + v.pointer("/toolCall/args/CommandLine") + .and_then(|c| c.as_str()) + .unwrap_or(""), + "CommandLine", + ), + "Bash" | "bash" => ( + v.pointer("/toolCall/args/command") + .and_then(|c| c.as_str()) + .unwrap_or(""), + "command", + ), _ => return PayloadAction::Ignore, }; @@ -685,14 +688,21 @@ fn process_agy_rich(v: &Value, tool_name: &str) -> PayloadAction { } }; - // agy deny-with-reason: deny the original command and tell the model the - // exact replacement. The model retries with the rtk-prefixed command, which - // hits the Allow list (command(rtk)) and bypasses the request-review gate. + // Preserve extra metadata fields (Cwd, WaitMsBeforeAsync, etc.) from the + // original args; only replace the command string itself. + let mut updated_args = v + .pointer("/toolCall/args") + .cloned() + .unwrap_or_else(|| json!({})); + if let Some(obj) = updated_args.as_object_mut() { + obj.insert(arg_key.to_string(), Value::String(rewritten.clone())); + } + let output = json!({ - "denyReason": format!( - "RTK: use '{}' instead of '{}' (60-90% token savings — see GEMINI.md)", - rewritten, cmd - ) + "overwrite": { + "name": tool_name, + "args": updated_args + } }); PayloadAction::Rewrite { @@ -706,12 +716,11 @@ fn process_agy_rich(v: &Value, tool_name: &str) -> PayloadAction { /// /// Reads a JSON payload from stdin and outputs a PreToolHookResult protojson. /// -/// Strategy: deny commands that need rewriting with a `denyReason` that names -/// the rtk-prefixed replacement. The model retries with the exact replacement, -/// which agy allows immediately because `command(rtk)` is in the Allow list -/// (bypassing the `toolPermission=request-review` gate). The `overwrite` field -/// was abandoned because under request-review, the review fires on the original -/// binary — the model never sees the overwritten command. +/// Strategy: rewrite the command in-place using the `overwrite` field +/// (PreToolHookResult.overwrite). Under `toolPermission=always-proceed` this +/// is transparent — no ⚠ warning, no extra LLM call. Under `request-review` +/// the review fires on the original binary so RTK is bypassed for that turn, +/// but the user is not blocked. pub fn run_antigravity() -> Result<()> { let input = read_stdin_limited()?; let input = input.trim(); @@ -1257,33 +1266,20 @@ mod tests { fn test_agy_simple_bash_rewrite() { let out = run_antigravity_inner(&agy_simple_bash("git status")).unwrap(); let v: Value = serde_json::from_str(&out).unwrap(); - // deny-with-reason: no overwrite, no allowTool — just denyReason - assert!( - v.get("overwrite").is_none(), - "must not use overwrite (broken under request-review)" - ); - assert!(v.get("allowTool").is_none()); - let reason = v["denyReason"].as_str().unwrap(); - assert!( - reason.contains("rtk git status"), - "deny reason must name the replacement: {reason}" - ); + assert!(v.get("denyReason").is_none(), "must use overwrite, not denyReason"); + let ow = &v["overwrite"]; + assert_eq!(ow["name"].as_str().unwrap(), "Bash"); + assert_eq!(ow["args"]["command"].as_str().unwrap(), "rtk git status"); } #[test] fn test_agy_simple_run_command_rewrite() { let out = run_antigravity_inner(&agy_simple_run_command("git status")).unwrap(); let v: Value = serde_json::from_str(&out).unwrap(); - assert!( - v.get("overwrite").is_none(), - "must not use overwrite (broken under request-review)" - ); - assert!(v.get("allowTool").is_none()); - let reason = v["denyReason"].as_str().unwrap(); - assert!( - reason.contains("rtk git status"), - "deny reason must name the replacement: {reason}" - ); + assert!(v.get("denyReason").is_none(), "must use overwrite, not denyReason"); + let ow = &v["overwrite"]; + assert_eq!(ow["name"].as_str().unwrap(), "run_command"); + assert_eq!(ow["args"]["CommandLine"].as_str().unwrap(), "rtk git status"); } #[test] @@ -1312,38 +1308,24 @@ mod tests { fn test_agy_rich_run_command_rewrite() { let out = run_antigravity_inner(&agy_rich_run_command("git status")).unwrap(); let v: Value = serde_json::from_str(&out).unwrap(); - assert!( - v.get("overwrite").is_none(), - "must not use overwrite (broken under request-review)" - ); - assert!(v.get("allowTool").is_none()); - let reason = v["denyReason"].as_str().unwrap(); - assert!( - reason.contains("rtk git status"), - "deny reason must name the replacement: {reason}" - ); + assert!(v.get("denyReason").is_none(), "must use overwrite, not denyReason"); + let ow = &v["overwrite"]; + assert_eq!(ow["name"].as_str().unwrap(), "run_command"); + assert_eq!(ow["args"]["CommandLine"].as_str().unwrap(), "rtk git status"); } #[test] fn test_agy_rich_bash_rewrite() { let out = run_antigravity_inner(&agy_rich_bash("cargo test")).unwrap(); let v: Value = serde_json::from_str(&out).unwrap(); - assert!( - v.get("overwrite").is_none(), - "must not use overwrite (broken under request-review)" - ); - assert!(v.get("allowTool").is_none()); - let reason = v["denyReason"].as_str().unwrap(); - assert!( - reason.contains("rtk cargo test"), - "deny reason must name the replacement: {reason}" - ); + assert!(v.get("denyReason").is_none(), "must use overwrite, not denyReason"); + let ow = &v["overwrite"]; + assert_eq!(ow["name"].as_str().unwrap(), "Bash"); + assert_eq!(ow["args"]["command"].as_str().unwrap(), "rtk cargo test"); } #[test] - fn test_agy_rich_deny_reason_names_replacement() { - // Extra args are no longer in the response (deny has no args to preserve), - // but the deny reason must still name the correct rewritten command. + fn test_agy_rich_overwrite_preserves_extra_args() { let input = json!({ "toolCall": { "name": "run_command", @@ -1353,9 +1335,11 @@ mod tests { .to_string(); let out = run_antigravity_inner(&input).unwrap(); let v: Value = serde_json::from_str(&out).unwrap(); - assert!(v.get("overwrite").is_none()); - let reason = v["denyReason"].as_str().unwrap(); - assert!(reason.contains("rtk git status"), "deny reason: {reason}"); + assert!(v.get("denyReason").is_none()); + let ow = &v["overwrite"]; + assert_eq!(ow["args"]["CommandLine"].as_str().unwrap(), "rtk git status"); + assert_eq!(ow["args"]["Cwd"].as_str().unwrap(), "/tmp"); + assert_eq!(ow["args"]["WaitMsBeforeAsync"].as_i64().unwrap(), 2000); } #[test] From 94a4b6d66a21a7c41c612eabe0cbc8d2231cc5b5 Mon Sep 17 00:00:00 2001 From: lok1s Date: Tue, 26 May 2026 02:50:00 +0300 Subject: [PATCH 15/17] fix(agy): add allowTool:true alongside overwrite to prevent denial MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit overwrite alone still hits allowTool=false (proto default), which agy treats as a denial and shows ⚠. Must set allowTool:true explicitly for agy to execute the rewritten command without showing a warning. Co-Authored-By: Claude Sonnet 4.6 --- src/hooks/hook_cmd.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/hooks/hook_cmd.rs b/src/hooks/hook_cmd.rs index 2e2350c64a..70542e34cd 100644 --- a/src/hooks/hook_cmd.rs +++ b/src/hooks/hook_cmd.rs @@ -636,6 +636,7 @@ fn process_agy_simple(v: &Value, tool_name: &str) -> PayloadAction { }; let output = json!({ + "allowTool": true, "overwrite": { "name": tool_name, "args": { arg_key: rewritten } @@ -699,6 +700,7 @@ fn process_agy_rich(v: &Value, tool_name: &str) -> PayloadAction { } let output = json!({ + "allowTool": true, "overwrite": { "name": tool_name, "args": updated_args @@ -1267,6 +1269,7 @@ mod tests { let out = run_antigravity_inner(&agy_simple_bash("git status")).unwrap(); let v: Value = serde_json::from_str(&out).unwrap(); assert!(v.get("denyReason").is_none(), "must use overwrite, not denyReason"); + assert_eq!(v["allowTool"], true, "allowTool must be true to permit the rewrite"); let ow = &v["overwrite"]; assert_eq!(ow["name"].as_str().unwrap(), "Bash"); assert_eq!(ow["args"]["command"].as_str().unwrap(), "rtk git status"); @@ -1277,6 +1280,7 @@ mod tests { let out = run_antigravity_inner(&agy_simple_run_command("git status")).unwrap(); let v: Value = serde_json::from_str(&out).unwrap(); assert!(v.get("denyReason").is_none(), "must use overwrite, not denyReason"); + assert_eq!(v["allowTool"], true, "allowTool must be true to permit the rewrite"); let ow = &v["overwrite"]; assert_eq!(ow["name"].as_str().unwrap(), "run_command"); assert_eq!(ow["args"]["CommandLine"].as_str().unwrap(), "rtk git status"); @@ -1309,6 +1313,7 @@ mod tests { let out = run_antigravity_inner(&agy_rich_run_command("git status")).unwrap(); let v: Value = serde_json::from_str(&out).unwrap(); assert!(v.get("denyReason").is_none(), "must use overwrite, not denyReason"); + assert_eq!(v["allowTool"], true); let ow = &v["overwrite"]; assert_eq!(ow["name"].as_str().unwrap(), "run_command"); assert_eq!(ow["args"]["CommandLine"].as_str().unwrap(), "rtk git status"); @@ -1319,6 +1324,7 @@ mod tests { let out = run_antigravity_inner(&agy_rich_bash("cargo test")).unwrap(); let v: Value = serde_json::from_str(&out).unwrap(); assert!(v.get("denyReason").is_none(), "must use overwrite, not denyReason"); + assert_eq!(v["allowTool"], true); let ow = &v["overwrite"]; assert_eq!(ow["name"].as_str().unwrap(), "Bash"); assert_eq!(ow["args"]["command"].as_str().unwrap(), "rtk cargo test"); From 2db27237fb0be54e5c82dac4835b9c1cb5317577 Mon Sep 17 00:00:00 2001 From: lok1s Date: Tue, 26 May 2026 03:04:47 +0300 Subject: [PATCH 16/17] =?UTF-8?q?fix(agy):=20revert=20to=20denyReason=20?= =?UTF-8?q?=E2=80=94=20overwrite=20silently=20ignored=20by=20agy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Debug logging confirmed: hook sends correct overwrite payload but agy executes the original command unchanged. Only allowTool and denyReason are honored by agy's hook system. denyReason: model reads the replacement command and retries with rtk prefix; command(rtk) in the allow list auto-approves the retry in always-proceed mode. Co-Authored-By: Claude Sonnet 4.6 --- src/hooks/hook_cmd.rs | 158 +++++++++++++++++++----------------------- 1 file changed, 73 insertions(+), 85 deletions(-) diff --git a/src/hooks/hook_cmd.rs b/src/hooks/hook_cmd.rs index 70542e34cd..58695b123d 100644 --- a/src/hooks/hook_cmd.rs +++ b/src/hooks/hook_cmd.rs @@ -597,19 +597,15 @@ fn process_antigravity_payload(v: &Value) -> PayloadAction { } fn process_agy_simple(v: &Value, tool_name: &str) -> PayloadAction { - let (cmd, arg_key) = match tool_name { - "run_command" => ( - v.pointer("/tool_input/CommandLine") - .and_then(|c| c.as_str()) - .unwrap_or(""), - "CommandLine", - ), - "Bash" | "bash" => ( - v.pointer("/tool_input/command") - .and_then(|c| c.as_str()) - .unwrap_or(""), - "command", - ), + let cmd = match tool_name { + "run_command" => v + .pointer("/tool_input/CommandLine") + .and_then(|c| c.as_str()) + .unwrap_or(""), + "Bash" | "bash" => v + .pointer("/tool_input/command") + .and_then(|c| c.as_str()) + .unwrap_or(""), _ => return PayloadAction::Ignore, }; @@ -635,12 +631,14 @@ fn process_agy_simple(v: &Value, tool_name: &str) -> PayloadAction { } }; + // agy's `overwrite` field in PreToolHookResult is silently ignored (confirmed + // via RTK_HOOK_DEBUG: correct payload sent, original command executed anyway). + // denyReason is the only hook mechanism agy actually implements. let output = json!({ - "allowTool": true, - "overwrite": { - "name": tool_name, - "args": { arg_key: rewritten } - } + "denyReason": format!( + "RTK: use '{}' instead of '{}' (60-90% token savings)", + rewritten, cmd + ) }); PayloadAction::Rewrite { @@ -651,19 +649,15 @@ fn process_agy_simple(v: &Value, tool_name: &str) -> PayloadAction { } fn process_agy_rich(v: &Value, tool_name: &str) -> PayloadAction { - let (cmd, arg_key) = match tool_name { - "run_command" => ( - v.pointer("/toolCall/args/CommandLine") - .and_then(|c| c.as_str()) - .unwrap_or(""), - "CommandLine", - ), - "Bash" | "bash" => ( - v.pointer("/toolCall/args/command") - .and_then(|c| c.as_str()) - .unwrap_or(""), - "command", - ), + let cmd = match tool_name { + "run_command" => v + .pointer("/toolCall/args/CommandLine") + .and_then(|c| c.as_str()) + .unwrap_or(""), + "Bash" | "bash" => v + .pointer("/toolCall/args/command") + .and_then(|c| c.as_str()) + .unwrap_or(""), _ => return PayloadAction::Ignore, }; @@ -689,22 +683,12 @@ fn process_agy_rich(v: &Value, tool_name: &str) -> PayloadAction { } }; - // Preserve extra metadata fields (Cwd, WaitMsBeforeAsync, etc.) from the - // original args; only replace the command string itself. - let mut updated_args = v - .pointer("/toolCall/args") - .cloned() - .unwrap_or_else(|| json!({})); - if let Some(obj) = updated_args.as_object_mut() { - obj.insert(arg_key.to_string(), Value::String(rewritten.clone())); - } - + // agy's `overwrite` field is silently ignored — only denyReason works. let output = json!({ - "allowTool": true, - "overwrite": { - "name": tool_name, - "args": updated_args - } + "denyReason": format!( + "RTK: use '{}' instead of '{}' (60-90% token savings)", + rewritten, cmd + ) }); PayloadAction::Rewrite { @@ -718,11 +702,13 @@ fn process_agy_rich(v: &Value, tool_name: &str) -> PayloadAction { /// /// Reads a JSON payload from stdin and outputs a PreToolHookResult protojson. /// -/// Strategy: rewrite the command in-place using the `overwrite` field -/// (PreToolHookResult.overwrite). Under `toolPermission=always-proceed` this -/// is transparent — no ⚠ warning, no extra LLM call. Under `request-review` -/// the review fires on the original binary so RTK is bypassed for that turn, -/// but the user is not blocked. +/// Strategy: deny commands that need rewriting with a `denyReason` naming the +/// rtk-prefixed replacement. The model retries with that command, which agy +/// auto-approves via `command(rtk)` in the allow list. +/// +/// NOTE: The `overwrite` field in PreToolHookResult is silently ignored by agy +/// regardless of permission mode — confirmed via debug logging. Only +/// `allowTool` and `denyReason` are honored. pub fn run_antigravity() -> Result<()> { let input = read_stdin_limited()?; let input = input.trim(); @@ -756,26 +742,38 @@ pub fn run_antigravity() -> Result<()> { } }; - match process_antigravity_payload(&v) { + let debug = std::env::var("RTK_HOOK_DEBUG").as_deref() == Ok("1"); + + let response = match process_antigravity_payload(&v) { PayloadAction::Rewrite { cmd, rewritten, output, } => { audit_log("rewrite", &cmd, &rewritten); - let _ = writeln!(io::stdout(), "{output}"); + output.to_string() } PayloadAction::Skip { reason, cmd } => { audit_log(reason, &cmd, ""); - // No rewrite needed — allow the original tool call unchanged. - let _ = writeln!(io::stdout(), r#"{{"allowTool":true}}"#); - } - PayloadAction::Ignore => { - // Non-shell tool — allow unchanged. - let _ = writeln!(io::stdout(), r#"{{"allowTool":true}}"#); + r#"{"allowTool":true}"#.to_string() } + PayloadAction::Ignore => r#"{"allowTool":true}"#.to_string(), + }; + + if debug { + let _ = (|| -> Option<()> { + let dir = dirs::home_dir()?.join(".local").join("share").join("rtk"); + let mut f = std::fs::OpenOptions::new() + .append(true) + .open(dir.join("agy-hook-debug.log")) + .ok()?; + let _ = writeln!(f, ">>> {}\n", response); + Some(()) + })(); } + let _ = writeln!(io::stdout(), "{response}"); + Ok(()) } @@ -1268,22 +1266,18 @@ mod tests { fn test_agy_simple_bash_rewrite() { let out = run_antigravity_inner(&agy_simple_bash("git status")).unwrap(); let v: Value = serde_json::from_str(&out).unwrap(); - assert!(v.get("denyReason").is_none(), "must use overwrite, not denyReason"); - assert_eq!(v["allowTool"], true, "allowTool must be true to permit the rewrite"); - let ow = &v["overwrite"]; - assert_eq!(ow["name"].as_str().unwrap(), "Bash"); - assert_eq!(ow["args"]["command"].as_str().unwrap(), "rtk git status"); + assert!(v.get("overwrite").is_none(), "overwrite is silently ignored by agy"); + let reason = v["denyReason"].as_str().unwrap(); + assert!(reason.contains("rtk git status"), "deny reason must name the replacement: {reason}"); } #[test] fn test_agy_simple_run_command_rewrite() { let out = run_antigravity_inner(&agy_simple_run_command("git status")).unwrap(); let v: Value = serde_json::from_str(&out).unwrap(); - assert!(v.get("denyReason").is_none(), "must use overwrite, not denyReason"); - assert_eq!(v["allowTool"], true, "allowTool must be true to permit the rewrite"); - let ow = &v["overwrite"]; - assert_eq!(ow["name"].as_str().unwrap(), "run_command"); - assert_eq!(ow["args"]["CommandLine"].as_str().unwrap(), "rtk git status"); + assert!(v.get("overwrite").is_none(), "overwrite is silently ignored by agy"); + let reason = v["denyReason"].as_str().unwrap(); + assert!(reason.contains("rtk git status"), "deny reason must name the replacement: {reason}"); } #[test] @@ -1312,26 +1306,22 @@ mod tests { fn test_agy_rich_run_command_rewrite() { let out = run_antigravity_inner(&agy_rich_run_command("git status")).unwrap(); let v: Value = serde_json::from_str(&out).unwrap(); - assert!(v.get("denyReason").is_none(), "must use overwrite, not denyReason"); - assert_eq!(v["allowTool"], true); - let ow = &v["overwrite"]; - assert_eq!(ow["name"].as_str().unwrap(), "run_command"); - assert_eq!(ow["args"]["CommandLine"].as_str().unwrap(), "rtk git status"); + assert!(v.get("overwrite").is_none(), "overwrite is silently ignored by agy"); + let reason = v["denyReason"].as_str().unwrap(); + assert!(reason.contains("rtk git status"), "deny reason: {reason}"); } #[test] fn test_agy_rich_bash_rewrite() { let out = run_antigravity_inner(&agy_rich_bash("cargo test")).unwrap(); let v: Value = serde_json::from_str(&out).unwrap(); - assert!(v.get("denyReason").is_none(), "must use overwrite, not denyReason"); - assert_eq!(v["allowTool"], true); - let ow = &v["overwrite"]; - assert_eq!(ow["name"].as_str().unwrap(), "Bash"); - assert_eq!(ow["args"]["command"].as_str().unwrap(), "rtk cargo test"); + assert!(v.get("overwrite").is_none(), "overwrite is silently ignored by agy"); + let reason = v["denyReason"].as_str().unwrap(); + assert!(reason.contains("rtk cargo test"), "deny reason: {reason}"); } #[test] - fn test_agy_rich_overwrite_preserves_extra_args() { + fn test_agy_rich_deny_reason_names_replacement() { let input = json!({ "toolCall": { "name": "run_command", @@ -1341,11 +1331,9 @@ mod tests { .to_string(); let out = run_antigravity_inner(&input).unwrap(); let v: Value = serde_json::from_str(&out).unwrap(); - assert!(v.get("denyReason").is_none()); - let ow = &v["overwrite"]; - assert_eq!(ow["args"]["CommandLine"].as_str().unwrap(), "rtk git status"); - assert_eq!(ow["args"]["Cwd"].as_str().unwrap(), "/tmp"); - assert_eq!(ow["args"]["WaitMsBeforeAsync"].as_i64().unwrap(), 2000); + assert!(v.get("overwrite").is_none()); + let reason = v["denyReason"].as_str().unwrap(); + assert!(reason.contains("rtk git status"), "deny reason: {reason}"); } #[test] From 5b90914e53498b5cf272bb5bb6f3723db3748f59 Mon Sep 17 00:00:00 2001 From: lok1s Date: Tue, 26 May 2026 03:15:56 +0300 Subject: [PATCH 17/17] =?UTF-8?q?fix(agy):=20address=20Copilot=20review=20?= =?UTF-8?q?=E2=80=94=20hardening,=20tests,=20and=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix debug log OpenOptions missing create(true); response log silently failed if file did not exist after the earlier input-logging block - Error instead of silent unwrap_or({}) when settings.json or hooks.json contains invalid JSON; corrupt config no longer silently overwritten - Quote rtk binary path in hook_command so spaces in install paths work - Expand toolNameMatcher to ^(run_command|Bash|bash)$ to match parser - Add 8 unit tests: file-missing, no-op/idempotent, invalid-JSON error, and lowercase-bash matcher coverage for agy install/uninstall paths - Update hooks/antigravity/README.md to document global-only install, actual file paths, and denyReason protocol (removes stale .agents/ refs) - Add agy CLI row to main README Quick Start and Supported AI Tools table Co-Authored-By: Claude Sonnet 4.6 --- README.md | 8 ++- hooks/antigravity/README.md | 41 +++++------ src/hooks/hook_cmd.rs | 31 ++++++-- src/hooks/init.rs | 139 ++++++++++++++++++++++++++++++++++-- 4 files changed, 183 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index f8d65efe52..30b2eaed07 100644 --- a/README.md +++ b/README.md @@ -106,10 +106,11 @@ rtk init -g # Claude Code / Copilot (default) rtk init -g --gemini # Gemini CLI rtk init -g --codex # Codex (OpenAI) rtk init -g --agent cursor # Cursor +rtk init -g --agent agy # Google Antigravity CLI (agy) rtk init --agent windsurf # Windsurf rtk init --agent cline # Cline / Roo Code rtk init --agent kilocode # Kilo Code -rtk init --agent antigravity # Google Antigravity +rtk init --agent antigravity # Google Antigravity IDE (rules-based) rtk init --agent hermes # Hermes # 2. Restart your AI tool, then test @@ -351,7 +352,7 @@ rtk git status ## Supported AI Tools -RTK supports 13 AI coding tools. Each integration rewrites shell commands to `rtk` equivalents for 60-90% token savings where the agent supports command interception. +RTK supports 14 AI coding tools. Each integration rewrites shell commands to `rtk` equivalents for 60-90% token savings where the agent supports command interception. | Tool | Install | Method | |------|---------|--------| @@ -360,6 +361,7 @@ RTK supports 13 AI coding tools. Each integration rewrites shell commands to `rt | **GitHub Copilot CLI** | `rtk init -g --copilot` | PreToolUse deny-with-suggestion (CLI limitation) | | **Cursor** | `rtk init -g --agent cursor` | preToolUse hook (hooks.json) | | **Gemini CLI** | `rtk init -g --gemini` | BeforeTool hook | +| **Google Antigravity CLI (`agy`)** | `rtk init -g --agent agy` | PreToolUse hook — deny-with-reason (global) | | **Codex** | `rtk init -g --codex` | AGENTS.md + RTK.md instructions | | **Windsurf** | `rtk init --agent windsurf` | .windsurfrules (project-scoped) | | **Cline / Roo Code** | `rtk init --agent cline` | .clinerules (project-scoped) | @@ -368,7 +370,7 @@ RTK supports 13 AI coding tools. Each integration rewrites shell commands to `rt | **Hermes** | `rtk init --agent hermes` | Python plugin adapter (terminal command mutation via `rtk rewrite`) | | **Mistral Vibe** | Planned ([#800](https://github.com/rtk-ai/rtk/issues/800)) | Blocked on upstream | | **Kilo Code** | `rtk init --agent kilocode` | .kilocode/rules/rtk-rules.md (project-scoped) | -| **Google Antigravity** | `rtk init --agent antigravity` | .agents/rules/antigravity-rtk-rules.md (project-scoped) | +| **Google Antigravity IDE** | `rtk init --agent antigravity` | .agents/rules/antigravity-rtk-rules.md (project-scoped) | For per-agent setup details, override controls, and graceful degradation, see the [Supported Agents guide](https://www.rtk-ai.app/guide/getting-started/supported-agents). The Hermes plugin source and tests live in `hooks/hermes/`; installed Hermes runtime files still live under `~/.hermes/plugins/rtk-rewrite/`. diff --git a/hooks/antigravity/README.md b/hooks/antigravity/README.md index e3b8684486..988cc6edb8 100644 --- a/hooks/antigravity/README.md +++ b/hooks/antigravity/README.md @@ -10,45 +10,44 @@ RTK supports two separate Google Antigravity integrations: The `agy` CLI (successor to Gemini CLI) supports a JSON hook protocol. RTK registers as a `PreToolUse` hook that rewrites shell commands before execution. -**Install (global):** +**Install (global only):** ```bash rtk init -g --agent agy ``` -**Install (project-local):** -```bash -rtk init --agent agy -``` +agy CLI hook support is **global-only** — there is no project-local install. The hook runner is registered system-wide so it intercepts all agy sessions. -**Hook location:** -- Global: `~/.agents/hooks.json` -- Project: `.agents/hooks.json` +**Files written:** +- `~/.gemini/config/hooks.json` — registers the `PreToolUse` hook +- `~/.gemini/antigravity-cli/settings.json` — grants `command(rtk)` permission -**Hook entry written:** +**Hook entry written to `~/.gemini/config/hooks.json`:** ```json { - "hooks": { + "rtk-antigravity": { + "enabled": true, "PreToolUse": [ { - "type": "command", - "command": "rtk hook antigravity", - "toolNameMatcher": "^(run_command|Bash)$" + "toolNameMatcher": "^(run_command|Bash|bash)$", + "hooks": [{"type": "command", "command": "/path/to/rtk hook antigravity"}] } ] } } ``` -**JSON protocol:** +**Hook protocol:** + +agy sends a JSON payload to stdin; the hook replies to stdout. Input (`run_command` tool): ```json {"toolCall": {"name": "run_command", "args": {"CommandLine": "git status"}}} ``` -Output (rewritten): +Output — deny with replacement name (model retries with `rtk git status`, auto-approved via `command(rtk)` allow entry): ```json -{"decision": "allow", "toolCall": {"args": {"CommandLine": "rtk git status"}}} +{"denyReason": "RTK: use 'rtk git status' instead of 'git status' (60-90% token savings)"} ``` Input (`Bash` tool): @@ -56,17 +55,13 @@ Input (`Bash` tool): {"toolCall": {"name": "Bash", "args": {"command": "cargo test"}}} ``` -Output (rewritten): -```json -{"decision": "allow", "toolCall": {"args": {"command": "rtk cargo test"}}} -``` +Non-shell tools and commands already prefixed with `rtk` receive `{"allowTool": true}` and pass through unchanged. -No output is produced for non-shell tools or commands that don't match any RTK filter — the tool runs unchanged. +> **Note:** agy's `overwrite` field in `PreToolHookResult` is silently ignored regardless of `toolPermission` mode. RTK uses the deny-and-retry pattern as a workaround until `overwrite` is implemented by agy. **Uninstall:** ```bash -rtk init --uninstall --agent agy # project-local -rtk init -g --uninstall --agent agy # global +rtk init -g --uninstall --agent agy ``` ### 2. Antigravity IDE — Prompt-Level Guidance diff --git a/src/hooks/hook_cmd.rs b/src/hooks/hook_cmd.rs index 58695b123d..896d12232d 100644 --- a/src/hooks/hook_cmd.rs +++ b/src/hooks/hook_cmd.rs @@ -764,6 +764,7 @@ pub fn run_antigravity() -> Result<()> { let _ = (|| -> Option<()> { let dir = dirs::home_dir()?.join(".local").join("share").join("rtk"); let mut f = std::fs::OpenOptions::new() + .create(true) .append(true) .open(dir.join("agy-hook-debug.log")) .ok()?; @@ -1266,18 +1267,30 @@ mod tests { fn test_agy_simple_bash_rewrite() { let out = run_antigravity_inner(&agy_simple_bash("git status")).unwrap(); let v: Value = serde_json::from_str(&out).unwrap(); - assert!(v.get("overwrite").is_none(), "overwrite is silently ignored by agy"); + assert!( + v.get("overwrite").is_none(), + "overwrite is silently ignored by agy" + ); let reason = v["denyReason"].as_str().unwrap(); - assert!(reason.contains("rtk git status"), "deny reason must name the replacement: {reason}"); + assert!( + reason.contains("rtk git status"), + "deny reason must name the replacement: {reason}" + ); } #[test] fn test_agy_simple_run_command_rewrite() { let out = run_antigravity_inner(&agy_simple_run_command("git status")).unwrap(); let v: Value = serde_json::from_str(&out).unwrap(); - assert!(v.get("overwrite").is_none(), "overwrite is silently ignored by agy"); + assert!( + v.get("overwrite").is_none(), + "overwrite is silently ignored by agy" + ); let reason = v["denyReason"].as_str().unwrap(); - assert!(reason.contains("rtk git status"), "deny reason must name the replacement: {reason}"); + assert!( + reason.contains("rtk git status"), + "deny reason must name the replacement: {reason}" + ); } #[test] @@ -1306,7 +1319,10 @@ mod tests { fn test_agy_rich_run_command_rewrite() { let out = run_antigravity_inner(&agy_rich_run_command("git status")).unwrap(); let v: Value = serde_json::from_str(&out).unwrap(); - assert!(v.get("overwrite").is_none(), "overwrite is silently ignored by agy"); + assert!( + v.get("overwrite").is_none(), + "overwrite is silently ignored by agy" + ); let reason = v["denyReason"].as_str().unwrap(); assert!(reason.contains("rtk git status"), "deny reason: {reason}"); } @@ -1315,7 +1331,10 @@ mod tests { fn test_agy_rich_bash_rewrite() { let out = run_antigravity_inner(&agy_rich_bash("cargo test")).unwrap(); let v: Value = serde_json::from_str(&out).unwrap(); - assert!(v.get("overwrite").is_none(), "overwrite is silently ignored by agy"); + assert!( + v.get("overwrite").is_none(), + "overwrite is silently ignored by agy" + ); let reason = v["denyReason"].as_str().unwrap(); assert!(reason.contains("rtk cargo test"), "deny reason: {reason}"); } diff --git a/src/hooks/init.rs b/src/hooks/init.rs index 4c22a2dab6..050f48a609 100644 --- a/src/hooks/init.rs +++ b/src/hooks/init.rs @@ -1773,7 +1773,7 @@ pub fn run_agy_cli_mode(global: bool, ctx: InitContext) -> Result<()> { .context("Failed to resolve rtk binary path")? .to_string_lossy() .to_string(); - let hook_command = format!("{} hook antigravity", rtk_bin); + let hook_command = format!("\"{}\" hook antigravity", rtk_bin); let config_dir = resolve_agy_config_dir()?; let hooks_path = config_dir.join(HOOKS_JSON); @@ -1824,7 +1824,12 @@ fn grant_agy_rtk_permission(path: &Path, dry_run: bool, verbose: u8) -> Result Re let mut hooks: serde_json::Value = if path.exists() { let content = fs::read_to_string(path) .with_context(|| format!("Failed to read {}", path.display()))?; - serde_json::from_str(&content).unwrap_or(serde_json::json!({})) + serde_json::from_str(&content).with_context(|| { + format!( + "agy hooks.json is not valid JSON: {}. Fix or remove it and retry.", + path.display() + ) + })? } else { serde_json::json!({}) }; @@ -2022,7 +2032,7 @@ fn insert_agy_hook_entry(hooks: &mut serde_json::Value, hook_command: &str) -> R "enabled": true, "PreToolUse": [ { - "toolNameMatcher": "^(run_command|Bash)$", + "toolNameMatcher": "^(run_command|Bash|bash)$", "hooks": [{"type": "command", "command": hook_command}] } ] @@ -6261,4 +6271,125 @@ mod tests { assert_eq!(after, malformed, "File must not be modified when malformed"); }); } + + // ── agy install/uninstall tests ─────────────────────────────────────────── + + fn make_tmp() -> TempDir { + tempfile::tempdir().expect("tempdir") + } + + #[test] + fn test_patch_agy_hooks_json_creates_file_when_missing() { + let tmp = make_tmp(); + let path = tmp.path().join("hooks.json"); + let patched = patch_agy_hooks_json( + &path, + InitContext::default(), + "/usr/bin/rtk hook antigravity", + ) + .unwrap(); + assert!(patched, "should return true when file is created"); + let content = fs::read_to_string(&path).unwrap(); + let v: serde_json::Value = serde_json::from_str(&content).unwrap(); + assert!( + v.get("rtk-antigravity").is_some(), + "hook entry must be written" + ); + } + + #[test] + fn test_patch_agy_hooks_json_noop_when_already_present() { + let tmp = make_tmp(); + let path = tmp.path().join("hooks.json"); + let cmd = "/usr/bin/rtk hook antigravity"; + patch_agy_hooks_json(&path, InitContext::default(), cmd).unwrap(); + let patched = patch_agy_hooks_json(&path, InitContext::default(), cmd).unwrap(); + assert!(!patched, "second call with same command must be a no-op"); + } + + #[test] + fn test_patch_agy_hooks_json_errors_on_invalid_json() { + let tmp = make_tmp(); + let path = tmp.path().join("hooks.json"); + fs::write(&path, "not json at all").unwrap(); + let result = patch_agy_hooks_json( + &path, + InitContext::default(), + "/usr/bin/rtk hook antigravity", + ); + assert!( + result.is_err(), + "corrupt hooks.json must produce an error, not silent overwrite" + ); + let original = fs::read_to_string(&path).unwrap(); + assert_eq!( + original, "not json at all", + "corrupt file must not be modified" + ); + } + + #[test] + fn test_grant_agy_rtk_permission_creates_file_when_missing() { + let tmp = make_tmp(); + let path = tmp.path().join("settings.json"); + let granted = grant_agy_rtk_permission(&path, false, 0).unwrap(); + assert!(granted); + let content = fs::read_to_string(&path).unwrap(); + let v: serde_json::Value = serde_json::from_str(&content).unwrap(); + let allow = v + .pointer("/permissions/allow") + .and_then(|a| a.as_array()) + .unwrap(); + assert!(allow.iter().any(|e| e.as_str() == Some(AGY_RTK_PERMISSION))); + } + + #[test] + fn test_grant_agy_rtk_permission_idempotent() { + let tmp = make_tmp(); + let path = tmp.path().join("settings.json"); + grant_agy_rtk_permission(&path, false, 0).unwrap(); + let granted = grant_agy_rtk_permission(&path, false, 0).unwrap(); + assert!(!granted, "second grant must be a no-op"); + } + + #[test] + fn test_grant_agy_rtk_permission_errors_on_invalid_json() { + let tmp = make_tmp(); + let path = tmp.path().join("settings.json"); + fs::write(&path, "{bad json}").unwrap(); + let result = grant_agy_rtk_permission(&path, false, 0); + assert!( + result.is_err(), + "corrupt settings.json must produce an error" + ); + let original = fs::read_to_string(&path).unwrap(); + assert_eq!(original, "{bad json}", "corrupt file must not be modified"); + } + + #[test] + fn test_revoke_agy_rtk_permission_idempotent() { + let tmp = make_tmp(); + let path = tmp.path().join("settings.json"); + // Nothing to revoke — missing file is a no-op. + let revoked = revoke_agy_rtk_permission(&path, false, 0).unwrap(); + assert!(!revoked); + // Grant then revoke then revoke again. + grant_agy_rtk_permission(&path, false, 0).unwrap(); + let revoked = revoke_agy_rtk_permission(&path, false, 0).unwrap(); + assert!(revoked); + let revoked = revoke_agy_rtk_permission(&path, false, 0).unwrap(); + assert!(!revoked, "second revoke must be a no-op"); + } + + #[test] + fn test_agy_hook_tool_name_matcher_includes_bash_lowercase() { + let tmp = make_tmp(); + let path = tmp.path().join("hooks.json"); + patch_agy_hooks_json(&path, InitContext::default(), "/rtk hook antigravity").unwrap(); + let content = fs::read_to_string(&path).unwrap(); + assert!( + content.contains("bash"), + "toolNameMatcher must include lowercase 'bash'" + ); + } }