diff --git a/README.md b/README.md index 8d6892ab58..a177e62e5e 100644 --- a/README.md +++ b/README.md @@ -214,7 +214,7 @@ Click the Coucou icon in the menu bar (macOS) or in the system tray (Windows, Li | **Claude Code hooks** | live sessions and approvals | **Install hooks** — Coucou backs up `~/.claude/settings.json`, merges its hooks and shows you the diff before writing anything | | **Claude plan** *(macOS, GitHub build)* | Plan usage gauge in the notch header | **Install relay** in Settings → Agents → Plan usage, then enable "Show in the notch" | | **Gemini CLI hooks** *(macOS)* | Gemini CLI sessions in the island | **Install hooks** in Settings → Gemini CLI — backs up `~/.gemini/settings.json` | -| **Antigravity (agy) hooks** *(macOS)* | agy sessions in the island | **Install hooks** in Settings → Antigravity — backs up `~/.gemini/config/hooks.json` | +| **Antigravity (agy) hooks** *(macOS, Windows)* | agy sessions in the island | **Install hooks** in Settings → Antigravity — backs up `~/.gemini/config/hooks.json` (`%USERPROFILE%\.gemini\config\hooks.json` on Windows) | | **Anthropic API key** | chat and questions about files | Settings → Anthropic API · Keychain / Windows Credential Manager / Secret Service | | **Google AI API key** *(macOS)* | chat with Google AI (Gemini) | Settings → Chat — other providers · Keychain | | **OpenAI API key** *(macOS)* | chat with OpenAI | Settings → Chat — other providers · Keychain | @@ -232,7 +232,7 @@ If Coucou isn't running, the hook exits immediately: **Claude Code is never bloc |---|---|---| | Claude Code | Settings → Claude Code → **Install hooks** | No | | Gemini CLI | Settings → Gemini CLI → **Install hooks** | Mac only | -| Antigravity | Settings → Antigravity → **Install hooks** | Mac only | +| Antigravity | Settings → Antigravity → **Install hooks** | No | | Cursor | Hooks installed automatically alongside Claude Code | No | | Codex | `--agent codex` flag; Settings → Codex → **Install hooks** | No | | Copilot CLI | `--agent copilot` flag + camelCase events | No | diff --git a/docs/AGENTS.md b/docs/AGENTS.md index f5e5a241dc..100c8b4a6e 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -123,12 +123,13 @@ Coucou events automatically. `AfterModel` is not installed — it fires on every response chunk and would flood the island. -### Antigravity — `agy` (macOS) +### Antigravity — `agy` (macOS & Windows) Coucou supports Antigravity out of the box via **Settings → Antigravity → Install hooks**. -The installer writes to `~/.gemini/config/hooks.json` (timeouts in seconds) and uses -`--agent antigravity`. The relay translates `toolCall.name` / `conversationId` to the -island's `tool_name` / `session_id`. +The installer writes to `~/.gemini/config/hooks.json` on macOS or `%USERPROFILE%\.gemini\config\hooks.json` on Windows +(timeouts in seconds) and uses `--agent antigravity`. The relay translates `toolCall.name` / `conversationId` to the +island's `tool_name` / `session_id`, and safely outputs `{"decision":"allow"}` on `PreToolUse` so Antigravity execution +proceeds uninterrupted. | Antigravity event | Canonical event | |---|---| diff --git a/windows/README.md b/windows/README.md index 2b897bd3d0..80de3c89dc 100644 --- a/windows/README.md +++ b/windows/README.md @@ -144,7 +144,7 @@ The relay (`coucou-hook.exe`) works with any tool that can run a command on hook |---|---|---| | Claude Code | **Settings → Claude Code → Install hooks** | `%USERPROFILE%\.claude\settings.json` | | Gemini CLI | `--agent gemini` positional arg | `%USERPROFILE%\.gemini\settings.json` | -| Antigravity | `--agent antigravity` positional arg | `%USERPROFILE%\.config\antigravity\hooks.json` | +| Antigravity | **Settings → Antigravity → Install hooks** | `%USERPROFILE%\.gemini\config\hooks.json` | | Cursor | hooks installed automatically | `%USERPROFILE%\.claude\settings.json` | | Codex | `--agent codex` positional arg | `%USERPROFILE%\.codex\hooks.json` | | Copilot CLI | `--agent copilot` positional arg + camelCase events | `%USERPROFILE%\.copilot\hooks\coucou.json` | @@ -153,6 +153,16 @@ The relay (`coucou-hook.exe`) works with any tool that can run a command on hook OpenCode, Amp and Hermes are not yet supported on Windows or Linux. Their integration uses a plugin that calls `/bin/sh` with macOS-specific paths; the plugin installer lives in the Mac app only. +### Google Antigravity + +Antigravity hooks are natively supported and manageable through the settings UI: + +- **1-Click Install:** In **Settings → Antigravity**, click **Install hooks** (or **Preview changes** to see a unified diff). +- **Config location:** Configured in `%USERPROFILE%\.gemini\config\hooks.json` with timestamped `.bak` backups before write. +- **Windows Command Safety:** Generates clean unquoted command lines (`coucou-hook.exe --agent antigravity `) so Windows `cmd.exe /c` does not fail on escape quotes. +- **Protocol Normalization:** `coucou-hook.exe` automatically translates Antigravity protojson payloads (`toolCall` args/name, `conversationId` to `session_id`, `workspacePaths` to `cwd`, `PreInvocation` to `UserPromptSubmit`, `PostInvocation` to `PostToolUse`). +- **Non-blocking Contract:** Always responds `{"decision":"allow"}` on `PreToolUse` so Antigravity tools run unhindered while keeping Coucou's top island ticker live. + ## What's different from the Mac version - No notch, so the island lives at the top centre of the screen and retracts into diff --git a/windows/hook/src/main.rs b/windows/hook/src/main.rs index 5bb3637818..85b52f830f 100644 --- a/windows/hook/src/main.rs +++ b/windows/hook/src/main.rs @@ -45,15 +45,15 @@ mod unix; use unix::connect; fn main() { - let Some((payload, event)) = read_event() else { std::process::exit(0) }; + let Some((payload, event, agent)) = read_event() else { + std::process::exit(0) + }; let waits_for_answer = event == "PermissionRequest"; let budget = if waits_for_answer { DECISION_BUDGET } else { FIRE_AND_FORGET_BUDGET }; // The worker owns every blocking call. If it overruns the budget we simply // stop listening and exit: the process dying takes the pipe handle with it. - // (No catch_unwind here — the release profile is panic = "abort", so it would - // be dead code. `talk` is written to have nothing to panic on instead.) let (tx, rx) = mpsc::channel::>(); std::thread::spawn(move || { let _ = tx.send(talk(&payload, waits_for_answer)); @@ -66,7 +66,22 @@ fn main() { let _ = out.flush(); } } - // Nothing printed: Claude Code asks in the terminal, as if we were not here. + + // Antigravity, Gemini CLI, Muse Code and Copilot expect valid JSON on stdout + if agent.eq_ignore_ascii_case("antigravity") { + let mut out = std::io::stdout(); + if event == "PreToolUse" { + let _ = writeln!(out, r#"{{"decision":"allow"}}"#); + } else { + let _ = writeln!(out, "{{}}"); + } + let _ = out.flush(); + } else if agent == "gemini" || agent == "muse" || agent == "copilot" { + let mut out = std::io::stdout(); + let _ = writeln!(out, "{{}}"); + let _ = out.flush(); + } + std::process::exit(0); } @@ -86,8 +101,90 @@ fn decision_json(decision: &str) -> Option { )) } -/// Reads stdin and returns the payload to forward plus the event name. -fn read_event() -> Option<(String, String)> { +fn normalize_event_name(raw: &str) -> String { + match raw { + "PreInvocation" | "BeforeAgent" | "pre_invocation" | "user_prompt_submit" | "userPromptSubmitted" => { + "UserPromptSubmit".into() + } + "PostInvocation" | "AfterModel" | "post_invocation" => "PostToolUse".into(), + "BeforeTool" | "BeforeToolSelection" | "pre_tool_use" | "preToolUse" => "PreToolUse".into(), + "AfterTool" | "post_tool_use" | "postToolUse" => "PostToolUse".into(), + "AfterAgent" | "agentStop" | "stop" => "Stop".into(), + "startup" | "session_start" | "sessionStart" => "SessionStart".into(), + "exit" | "session_end" | "sessionEnd" => "SessionEnd".into(), + other => other.to_string(), + } +} + +fn normalize_payload(map: &mut serde_json::Map) { + // 1. Session ID normalization (Antigravity conversationId -> session_id) + if !map.contains_key("session_id") { + for key in ["conversationId", "conversation_id", "sessionId"] { + if let Some(v) = map.get(key).and_then(|x| x.as_str()) { + if !v.is_empty() { + map.insert("session_id".into(), serde_json::Value::String(v.to_string())); + break; + } + } + } + } + + // 2. Tool name normalization (Antigravity toolCall.name -> tool_name) + if !map.contains_key("tool_name") { + if let Some(name) = map.get("toolName").and_then(|x| x.as_str()) { + map.insert("tool_name".into(), serde_json::Value::String(name.to_string())); + } else if let Some(tc) = map.get("toolCall").and_then(|x| x.as_object()) { + if let Some(name) = tc.get("name").and_then(|x| x.as_str()) { + map.insert("tool_name".into(), serde_json::Value::String(name.to_string())); + } + } else if let Some(tool) = map.get("tool").and_then(|x| x.as_str()) { + map.insert("tool_name".into(), serde_json::Value::String(tool.to_string())); + } + } + + // 3. Tool input normalization (Antigravity toolCall.args -> tool_input) + if !map.contains_key("tool_input") { + if let Some(args) = map.get("toolArgs").and_then(|x| x.as_object()) { + map.insert("tool_input".into(), serde_json::Value::Object(args.clone())); + } else if let Some(tc) = map.get("toolCall").and_then(|x| x.as_object()) { + if let Some(args) = tc.get("args").and_then(|x| x.as_object()) { + let mut flat = args.clone(); + for (src, dst) in [ + ("CommandLine", "command"), + ("FilePath", "file_path"), + ("Path", "path"), + ("Url", "url"), + ("Query", "query"), + ("Pattern", "pattern"), + ] { + if let Some(v) = flat.get(src).cloned() { + flat.insert(dst.into(), v); + } + } + map.insert("tool_input".into(), serde_json::Value::Object(flat)); + } + } + } + + // 4. Working directory normalization (Antigravity workspacePaths -> cwd) + let cwd_missing = map + .get("cwd") + .and_then(|v| v.as_str()) + .map(str::is_empty) + .unwrap_or(true); + if cwd_missing { + if let Some(paths) = map.get("workspacePaths").and_then(|v| v.as_array()) { + if let Some(first) = paths.first().and_then(|p| p.as_str()) { + map.insert("cwd".into(), serde_json::Value::String(first.to_string())); + } + } else if let Some(workdir) = map.get("workdir").and_then(|v| v.as_str()) { + map.insert("cwd".into(), serde_json::Value::String(workdir.to_string())); + } + } +} + +/// Reads stdin and returns the payload to forward plus the event name and agent. +fn read_event() -> Option<(String, String, String)> { let mut raw = Vec::new(); if std::io::stdin().read_to_end(&mut raw).is_err() || raw.is_empty() { return None; @@ -102,7 +199,6 @@ fn read_event() -> Option<(String, String)> { // Parse argv: "coucou-hook.exe [--agent ] []" // --agent tags the payload with coucou_agent so the app routes to the right pill. - // Absent or invalid names are validated and discarded by the app, not here. let mut agent = String::new(); let mut arg_event = String::new(); { @@ -115,19 +211,22 @@ fn read_event() -> Option<(String, String)> { } } } - // Which agent this hook was installed for. Absent means Claude Code, - // so existing hook commands keep working unchanged. if !agent.is_empty() { - map.insert("coucou_agent".into(), serde_json::Value::String(agent)); + map.insert("coucou_agent".into(), serde_json::Value::String(agent.clone())); } - let event = map + + let raw_event = map .get("hook_event_name") .and_then(|v| v.as_str()) .map(str::to_string) .filter(|s| !s.is_empty()) .unwrap_or(arg_event); + + let event = normalize_event_name(&raw_event); map.insert("hook_event_name".into(), serde_json::Value::String(event.clone())); + normalize_payload(map); + for field in DROPPED_FIELDS { map.remove(*field); } @@ -165,7 +264,7 @@ fn read_event() -> Option<(String, String)> { let mut line = payload.to_string(); line.push('\n'); - Some((line, event)) + Some((line, event, agent)) } /// Caps every string in the payload. A single Write can carry a whole file. @@ -253,4 +352,38 @@ mod tests { assert!(s.len() <= MAX_FIELD_LEN + 4); assert!(s.ends_with('…')); } + + #[test] + fn antigravity_payload_normalization() { + let mut map = match serde_json::json!({ + "conversationId": "conv-12345", + "workspacePaths": ["C:\\Projects\\test"], + "toolCall": { + "name": "run_command", + "args": { + "CommandLine": "cargo check" + } + } + }) { + serde_json::Value::Object(m) => m, + _ => unreachable!(), + }; + + normalize_payload(&mut map); + + assert_eq!(map.get("session_id").unwrap(), "conv-12345"); + assert_eq!(map.get("cwd").unwrap(), "C:\\Projects\\test"); + assert_eq!(map.get("tool_name").unwrap(), "run_command"); + let tool_input = map.get("tool_input").unwrap().as_object().unwrap(); + assert_eq!(tool_input.get("command").unwrap(), "cargo check"); + } + + #[test] + fn antigravity_event_normalization() { + assert_eq!(normalize_event_name("PreInvocation"), "UserPromptSubmit"); + assert_eq!(normalize_event_name("PostInvocation"), "PostToolUse"); + assert_eq!(normalize_event_name("PreToolUse"), "PreToolUse"); + assert_eq!(normalize_event_name("PostToolUse"), "PostToolUse"); + assert_eq!(normalize_event_name("Stop"), "Stop"); + } } diff --git a/windows/package-lock.json b/windows/package-lock.json index 5e06f8bcb9..9361b06364 100644 --- a/windows/package-lock.json +++ b/windows/package-lock.json @@ -1,12 +1,12 @@ { "name": "coucou-windows", - "version": "0.1.0", + "version": "0.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "coucou-windows", - "version": "0.1.0", + "version": "0.1.1", "dependencies": { "@tauri-apps/api": "^2" }, @@ -466,9 +466,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -570,9 +567,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -587,9 +581,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -604,9 +595,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -621,9 +609,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -638,9 +623,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -655,9 +637,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -672,9 +651,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -689,9 +665,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -706,9 +679,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -723,9 +693,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -740,9 +707,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -757,9 +721,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -774,9 +735,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -966,9 +924,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ @@ -986,9 +941,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ @@ -1006,9 +958,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ @@ -1026,9 +975,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ @@ -1046,9 +992,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ diff --git a/windows/src-tauri/src/antigravity.rs b/windows/src-tauri/src/antigravity.rs new file mode 100644 index 0000000000..6ec9ed54ba --- /dev/null +++ b/windows/src-tauri/src/antigravity.rs @@ -0,0 +1,248 @@ +// Antigravity hook installation. +// +// Manages %USERPROFILE%\.gemini\config\hooks.json following the Coucou rules: +// take a dated backup, merge without touching any other custom hooks, show the diff, +// and write only after an explicit click. Uninstall removes Coucou's entries only. + +use std::path::PathBuf; +use serde_json::{json, Map, Value}; +use crate::hooks::{self, HookPreview, HookStatus}; +use crate::{platform, settings}; + +const MARKER: &str = "coucou-hook"; + +pub fn hooks_path() -> PathBuf { + platform::home_dir().join(".gemini").join("config").join("hooks.json") +} + +fn read_hooks_json() -> Result { + let path = hooks_path(); + match std::fs::read(&path) { + Ok(bytes) => parse_hooks_json(&bytes, &path.display().to_string()), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(json!({})), + Err(err) => Err(format!("Can't read {}: {err}", path.display())), + } +} + +fn parse_hooks_json(bytes: &[u8], path: &str) -> Result { + let text = bytes.strip_prefix(&[0xEF, 0xBB, 0xBF]).unwrap_or(bytes); + if text.iter().all(u8::is_ascii_whitespace) { + return Ok(json!({})); + } + match serde_json::from_slice::(text) { + Ok(v) if v.is_object() => Ok(v), + Ok(_) => Err(format!("{path} isn't a JSON object — Coucou won't touch it.")), + Err(err) => Err(format!( + "{path} isn't valid JSON ({err}). Fix or move it, then try again — Coucou won't overwrite it." + )), + } +} + +fn read_hooks_lossy() -> Value { + read_hooks_json().unwrap_or_else(|_| json!({})) +} + +#[cfg(windows)] +fn hook_command(event: &str) -> String { + let exe = settings::hook_exe_path().to_string_lossy().to_string(); + format!("{exe} --agent antigravity {event}") +} + +#[cfg(unix)] +fn hook_command(event: &str) -> String { + let exe = settings::hook_exe_path().to_string_lossy(); + format!("\"{exe}\" --agent antigravity {event}") +} + +fn build_coucou_block() -> Value { + let mut coucou = Map::new(); + + // PreToolUse and PostToolUse are tool hooks — require matcher group + for event in ["PreToolUse", "PostToolUse"] { + coucou.insert( + event.to_string(), + json!([ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": hook_command(event), + "timeout": 10 + } + ] + } + ]), + ); + } + + // PreInvocation, PostInvocation and Stop are lifecycle hooks — flat list + for event in ["PreInvocation", "PostInvocation", "Stop"] { + coucou.insert( + event.to_string(), + json!([ + { + "type": "command", + "command": hook_command(event), + "timeout": 10 + } + ]), + ); + } + + Value::Object(coucou) +} + +fn merged(existing: &Value) -> Value { + let mut root = existing.as_object().cloned().unwrap_or_default(); + root.insert("coucou".into(), build_coucou_block()); + Value::Object(root) +} + +fn without_ours(existing: &Value) -> Value { + let mut root = existing.as_object().cloned().unwrap_or_default(); + root.remove("coucou"); + Value::Object(root) +} + +fn pretty(v: &Value) -> String { + serde_json::to_string_pretty(v).unwrap_or_default() +} + +fn stamp() -> String { + let t = platform::local_time(); + format!( + "{:04}{:02}{:02}-{:02}{:02}{:02}", + t.year, t.month, t.day, t.hour, t.minute, t.second + ) +} + +fn backup_path() -> PathBuf { + let p = hooks_path(); + p.with_file_name(format!("hooks.json.bak-{}", stamp())) +} + +fn fingerprint(bytes: &[u8]) -> String { + let mut hash: u64 = 0xcbf2_9ce4_8422_2325; + for b in bytes { + hash ^= *b as u64; + hash = hash.wrapping_mul(0x1000_0000_01b3); + } + format!("{hash:016x}") +} + +fn current_fingerprint() -> String { + match std::fs::read(hooks_path()) { + Ok(bytes) => fingerprint(&bytes), + Err(_) => fingerprint(b""), + } +} + +// ── Public API ──────────────────────────────────────────────────────────────── + +pub fn status() -> HookStatus { + let current = read_hooks_lossy(); + let installed = current + .get("coucou") + .and_then(Value::as_object) + .map(|coucou| serde_json::to_string(coucou).map(|s| s.contains(MARKER)).unwrap_or(false)) + .unwrap_or(false); + + HookStatus { + installed, + settings_path: hooks_path().to_string_lossy().to_string(), + hook_path: settings::hook_exe_path().to_string_lossy().to_string(), + hook_ready: settings::hook_exe_path().exists(), + } +} + +pub fn preview(install: bool) -> Result { + let current = read_hooks_json()?; + let path = hooks_path(); + let exists = path.exists(); + if !install && !exists { + return Err("No Antigravity hooks to remove.".into()); + } + + let before_str = if exists { pretty(¤t) } else { String::new() }; + let after_val = if install { merged(¤t) } else { without_ours(¤t) }; + let after_str = pretty(&after_val); + + let diff = hooks::unified_diff(&before_str, &after_str); + let backup = if exists { + backup_path().to_string_lossy().to_string() + } else { + "(none — file will be created)".into() + }; + + Ok(HookPreview { + diff, + backup, + settings_path: path.to_string_lossy().to_string(), + fingerprint: current_fingerprint(), + }) +} + +pub fn write(install: bool, expected_fingerprint: &str) -> Result { + let path = hooks_path(); + let current_bytes = std::fs::read(&path).unwrap_or_default(); + if fingerprint(¤t_bytes) != expected_fingerprint { + return Err( + "hooks.json changed since the preview was shown. Review the new changes and try again." + .into(), + ); + } + + let current = read_hooks_json()?; + let next_val = if install { merged(¤t) } else { without_ours(¤t) }; + let mut next_bytes = serde_json::to_vec_pretty(&next_val).map_err(|e| e.to_string())?; + next_bytes.push(b'\n'); + + let backup = if path.exists() { + let b = backup_path(); + std::fs::write(&b, ¤t_bytes) + .map_err(|e| format!("Could not write backup {}: {e}", b.display()))?; + b.to_string_lossy().to_string() + } else { + "(file created, no previous content)".into() + }; + + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| format!("Could not create directory {}: {e}", parent.display()))?; + } + + let temp = path.with_extension(format!("new-{}", std::process::id())); + std::fs::write(&temp, &next_bytes) + .map_err(|e| format!("Could not write {}: {e}", temp.display()))?; + std::fs::rename(&temp, &path) + .map_err(|e| format!("Could not replace {}: {e}", path.display()))?; + + Ok(backup) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn merging_and_removing_antigravity_hooks() { + let existing = json!({ + "existing-hook": { + "PreToolUse": [{ "command": "my-script.bat" }] + } + }); + + let after = merged(&existing); + assert!(after.get("existing-hook").is_some()); + let coucou = after.get("coucou").unwrap(); + assert!(coucou.get("PreToolUse").is_some()); + assert!(coucou.get("PostToolUse").is_some()); + assert!(coucou.get("PreInvocation").is_some()); + assert!(coucou.get("PostInvocation").is_some()); + assert!(coucou.get("Stop").is_some()); + + let cleaned = without_ours(&after); + assert_eq!(cleaned, existing); + } +} diff --git a/windows/src-tauri/src/hooks.rs b/windows/src-tauri/src/hooks.rs index 3da1d0e5a7..8425370373 100644 --- a/windows/src-tauri/src/hooks.rs +++ b/windows/src-tauri/src/hooks.rs @@ -432,7 +432,7 @@ fn install_relay(src: &Path, dest: &Path) { // ── Minimal unified diff (LCS) ──────────────────────────────────────────────── /// settings.json is short, so a plain O(n·m) LCS is the simplest honest diff. -fn unified_diff(before: &str, after: &str) -> String { +pub(crate) fn unified_diff(before: &str, after: &str) -> String { let a: Vec<&str> = before.lines().collect(); let b: Vec<&str> = after.lines().collect(); let (n, m) = (a.len(), b.len()); diff --git a/windows/src-tauri/src/lib.rs b/windows/src-tauri/src/lib.rs index d9ec3353e6..1d26512b08 100644 --- a/windows/src-tauri/src/lib.rs +++ b/windows/src-tauri/src/lib.rs @@ -1,5 +1,6 @@ // Coucou for Windows — app wiring and the commands the island calls. +mod antigravity; mod claude; mod files; mod hooks; @@ -213,6 +214,30 @@ fn hooks_apply( Ok(backup) } +// ── Antigravity hooks ────────────────────────────────────────────────────────── + +#[tauri::command] +fn antigravity_hooks_status() -> HookStatus { + antigravity::status() +} + +#[tauri::command] +fn antigravity_hooks_preview(install: bool) -> Result { + antigravity::preview(install) +} + +#[tauri::command] +fn antigravity_hooks_apply( + app: AppHandle, + install: bool, + fingerprint: String, +) -> Result { + let backup = antigravity::write(install, &fingerprint)?; + let status = antigravity::status(); + let _ = app.emit("antigravity-hooks-changed", status.installed); + Ok(backup) +} + #[tauri::command] fn approval_decision(app: AppHandle, request_id: String, decision: String) { pipe::answer(&app, &request_id, &decision); @@ -389,6 +414,9 @@ pub fn run() { hooks_status, hooks_preview, hooks_apply, + antigravity_hooks_status, + antigravity_hooks_preview, + antigravity_hooks_apply, approval_decision, approval_ack, approval_decline, diff --git a/windows/src/core/bridge.ts b/windows/src/core/bridge.ts index 24aaf55ff9..3b712daf8d 100644 --- a/windows/src/core/bridge.ts +++ b/windows/src/core/bridge.ts @@ -73,6 +73,13 @@ export const Bridge = { hooksApply: (install: boolean, fingerprint: string) => callOrThrow("hooks_apply", { install, fingerprint }), + // ── Antigravity hooks ───────────────────────────────────────────────────── + antigravityHooksStatus: () => call("antigravity_hooks_status"), + antigravityHooksPreview: (install: boolean) => + callOrThrow("antigravity_hooks_preview", { install }), + antigravityHooksApply: (install: boolean, fingerprint: string) => + callOrThrow("antigravity_hooks_apply", { install, fingerprint }), + approvalDecision: (requestId: string, decision: "allow" | "deny") => call("approval_decision", { requestId, decision }), /** "The card is up" — until this lands the relay only waits a moment. */ diff --git a/windows/src/core/state.ts b/windows/src/core/state.ts index 01236b80a9..0d68751e36 100644 --- a/windows/src/core/state.ts +++ b/windows/src/core/state.ts @@ -59,6 +59,7 @@ const task = ( /** AgentTask.integrationAgents — same ids, names and colours as macOS. */ export const INTEGRATION_AGENTS: AgentTask[] = [ task("integration_claude", "VS Code", "#F5F6F8", "claudeCode"), + task("agent_antigravity", "Antigravity", "#E879F9", "agent"), task("integration_resend", "Resend", "#22C55E", "n8n"), task("integration_n8n", "n8n", "#F29B38", "n8n"), task("integration_vercel", "Vercel", "#7C5CFF", "n8n"), @@ -69,6 +70,7 @@ export const INTEGRATION_AGENTS: AgentTask[] = [ ]; export const TOGGLEABLE_INTEGRATION_IDS = [ + "agent_antigravity", "integration_resend", "integration_n8n", "integration_vercel", "integration_github", "integration_notion", "integration_calcom", "integration_stripe", ]; @@ -90,6 +92,7 @@ export interface Settings { screen: "primary" | "cursor"; autostart: boolean; hooksInstalled: boolean; + antigravityHooksInstalled: boolean; /** Claude model used by the chat. */ model: string; } @@ -105,6 +108,7 @@ export const DEFAULT_SETTINGS: Settings = { screen: "primary", autostart: false, hooksInstalled: false, + antigravityHooksInstalled: false, model: "claude-opus-5", }; @@ -203,7 +207,9 @@ class AppState { loadIntegrationTasks() { for (const proto of INTEGRATION_AGENTS) { const shouldLoad = - proto.id === "integration_claude" || this.settings.activeIntegrations.includes(proto.id); + proto.id === "integration_claude" || + (proto.id === "agent_antigravity" && this.settings.antigravityHooksInstalled) || + this.settings.activeIntegrations.includes(proto.id); const idx = this.tasks.findIndex((t) => t.id === proto.id); if (shouldLoad && idx < 0) this.tasks.push({ ...proto, steps: [] }); if (!shouldLoad && idx >= 0) this.tasks.splice(idx, 1); diff --git a/windows/src/island/hooks.ts b/windows/src/island/hooks.ts index 573e1f6310..cc4f7afd6e 100644 --- a/windows/src/island/hooks.ts +++ b/windows/src/island/hooks.ts @@ -48,6 +48,7 @@ function validateAgent(raw: string | undefined): string | null { const FALLBACK_COLORS = ["#22C55E", "#EAB308", "#60A5FA", "#E879F9"]; function agentColor(name: string): string { + if (name === "antigravity") return "#E879F9"; let h = 0; for (let i = 0; i < name.length; i++) { h = (Math.imul(31, h) + name.charCodeAt(i)) | 0; @@ -55,6 +56,11 @@ function agentColor(name: string): string { return FALLBACK_COLORS[Math.abs(h) % FALLBACK_COLORS.length]; } +function agentName(name: string): string { + if (name === "antigravity") return "Antigravity"; + return name; +} + const PROJECT_ALIASES: Record = { "notch-buddy": "Notch Buddy", notchbuddy: "Notch Buddy", @@ -87,18 +93,27 @@ const TOOL_LABELS: Record = { MultiEdit: "Modifie", NotebookEdit: "Notebook", PowerShell: "Exécute", + run_command: "Exécute", + view_file: "Lit", + write_to_file: "Écrit", + replace_file_content: "Modifie", + read_url_content: "Récupère", + search_web: "Recherche web", + invoke_subagent: "Sous-agent", + manage_task: "Tâche", + ask_question: "Question", }; function stepLabel(tool: string, input: Record): string { const label = TOOL_LABELS[tool] ?? tool; const str = (k: string) => (typeof input[k] === "string" ? (input[k] as string) : null); - const cmd = str("command"); + const cmd = str("command") ?? str("CommandLine"); if (cmd) return `${label} · ${cmd.slice(0, 40)}`; - const path = str("path"); + const path = str("path") ?? str("AbsolutePath") ?? str("TargetFile") ?? str("file_path"); if (path) return `${label} · ${lastPathComponent(path)}`; - const file = str("file_path"); - if (file) return `${label} · ${lastPathComponent(file)}`; - const query = str("query"); + const url = str("url") ?? str("Url"); + if (url) return `${label} · ${url.slice(0, 40)}`; + const query = str("query") ?? str("Query"); if (query) return `${label} · ${query.slice(0, 40)}`; return label; } @@ -187,7 +202,7 @@ function handleHook(island: Island, payload: HookPayload) { /** Ensure the agent pill exists (no-op for Claude Code). */ const ensurePill = () => { if (isExternalAgent) { - State.upsertExternalAgent(agentId, validAgent!, agentColor(validAgent!)); + State.upsertExternalAgent(agentId, agentName(validAgent!), agentColor(validAgent!)); } else { upsert(projectName, cwd); } @@ -268,7 +283,12 @@ function handleHook(island: Island, payload: HookPayload) { window.setTimeout(() => { stopTimers.delete(agentId); if (isExternalAgent) { - State.removeTask(agentId); + if (agentId === "agent_antigravity") { + State.updateTask(agentId, "idle"); + State.setPillBadge(agentId, null); + } else { + State.removeTask(agentId); + } } else { State.updateTask(agentId, "idle"); State.setPillBadge(agentId, null); @@ -290,7 +310,11 @@ function handleHook(island: Island, payload: HookPayload) { // pill recreated within 5.2 s would be removed by it. cancelStopTimer(agentId); if (isExternalAgent) { - State.removeTask(agentId); + if (agentId === "agent_antigravity") { + State.updateTask(agentId, "idle"); + } else { + State.removeTask(agentId); + } } else { State.updateTask(agentId, "idle"); clearSession(); diff --git a/windows/src/island/integrations.ts b/windows/src/island/integrations.ts index 862ffb14b0..5d4574afed 100644 --- a/windows/src/island/integrations.ts +++ b/windows/src/island/integrations.ts @@ -22,6 +22,15 @@ const clearTimers = new Map(); export function registerIntegrationHandlers(island: Island) { void onEvent("integration", (update) => handle(island, update)); + void onEvent("antigravity-hooks-changed", (installed) => { + State.settings.antigravityHooksInstalled = installed; + const agy = State.integrations.agent_antigravity ?? { + data: {}, error: null, loaded: false, configured: false, + }; + State.integrations.agent_antigravity = { ...agy, configured: installed }; + State.loadIntegrationTasks(); + State.notify(); + }); void refreshConfigured(); } @@ -37,6 +46,14 @@ export async function refreshConfigured() { data: {}, error: null, loaded: false, configured: false, }; State.integrations.integration_claude = { ...claude, configured: hooks }; + + const agyStatus = await Bridge.antigravityHooksStatus(); + const agy = State.integrations.agent_antigravity ?? { + data: {}, error: null, loaded: false, configured: false, + }; + State.integrations.agent_antigravity = { ...agy, configured: agyStatus?.installed ?? false }; + State.settings.antigravityHooksInstalled = agyStatus?.installed ?? false; + State.loadIntegrationTasks(); State.notify(); } diff --git a/windows/src/settings/main.ts b/windows/src/settings/main.ts index 3ab9ab94a7..948192b11d 100644 --- a/windows/src/settings/main.ts +++ b/windows/src/settings/main.ts @@ -171,6 +171,132 @@ function claudeSection(status: HookStatus): HTMLElement { return section; } +// ── Antigravity section ─────────────────────────────────────────────────────── + +function antigravitySection(status: HookStatus): HTMLElement { + const body = h("div", { style: "display:flex;flex-direction:column;gap:12px" }); + const section = h( + "section", + {}, + h("h2", {}, statusDot(status.installed), h("span", { text: "Antigravity" })), + body, + ); + + const rebuild = async () => { + const fresh = await Bridge.antigravityHooksStatus(); + if (fresh) Object.assign(status, fresh); + clear(body); + draw(); + const head = section.querySelector("h2")!; + clear(head); + head.append(statusDot(status.installed), h("span", { text: "Antigravity" })); + }; + + function draw() { + body.append( + h("div", { + class: "hint", + text: status.installed + ? "Coucou is hooked into your Antigravity (agy) sessions. Tool calls and lifecycle events appear in the island." + : "Install the hooks to stream your Antigravity sessions to the island.", + }), + h("div", { class: "row" }, + h("label", { text: "hooks.json" }), + h("span", { class: "path", text: status.settingsPath }), + ), + h("div", { class: "row" }, + h("label", { text: "Relay" }), + h("span", { class: "path", text: status.hookPath }), + statusDot(status.hookReady), + ), + ); + + if (!status.hookReady) { + body.append(h("div", { + class: "notice warn", + text: "coucou-hook.exe is not in place yet. Restart Coucou; if it still fails, build it with `cargo build -p coucou-hook`.", + })); + } + + const actions = h("div", { class: "row" }); + const install = h("button", { + class: "primary", + text: status.installed ? "Reinstall hooks…" : "Install hooks…", + onclick: () => showPreview(true), + }); + if (!status.hookReady) { + install.disabled = true; + install.title = "The relay isn't installed yet."; + } + actions.append(install); + if (status.installed) { + actions.append(h("button", { + class: "danger", + text: "Uninstall hooks…", + onclick: () => showPreview(false), + })); + } + body.append(actions); + } + + async function showPreview(install: boolean) { + let preview; + try { + preview = await Bridge.antigravityHooksPreview(install); + } catch (err) { + clear(body); + body.append( + h("div", { class: "notice err", text: String(err).replace(/^Error:\s*/, "") }), + h("div", { class: "row" }, h("button", { + text: "Back", + onclick: () => { clear(body); draw(); }, + })), + ); + return; + } + if (!preview) return; + clear(body); + body.append( + h("div", { + class: "hint", + text: install + ? "This is exactly what will change in your hooks.json. Your own hooks are left untouched." + : "This removes Coucou's entries only. Your own hooks are left untouched.", + }), + renderDiff(preview.diff), + h("div", { class: "row" }, + h("span", { class: "path", text: `Backup → ${preview.backup}` }), + ), + ); + const confirm = h("button", { + class: install ? "primary" : "danger", + text: install ? "Back up and write" : "Back up and remove", + }); + confirm.addEventListener("click", async () => { + confirm.disabled = true; + try { + const backup = await Bridge.antigravityHooksApply(install, preview.fingerprint); + clear(body); + body.append(h("div", { + class: "notice ok", + text: `Done. Previous hooks saved as ${backup}. Restart Antigravity to activate them.`, + })); + window.setTimeout(() => void rebuild(), 2600); + } catch (err) { + confirm.disabled = false; + body.append(h("div", { class: "notice err", text: `Could not write: ${String(err)}` })); + } + }); + body.append(h("div", { class: "row" }, confirm, h("button", { + text: "Cancel", + onclick: () => { clear(body); draw(); }, + }))); + } + + draw(); + return section; +} + // ── Claude API section ──────────────────────────────────────────────────────── const MODELS: [string, string][] = [ @@ -428,6 +554,9 @@ async function main() { const status = (await Bridge.hooksStatus()) ?? { installed: false, settingsPath: "", hookPath: "", hookReady: false, }; + const agyStatus = (await Bridge.antigravityHooksStatus()) ?? { + installed: false, settingsPath: "", hookPath: "", hookReady: false, + }; const hasKey = (await Bridge.secretPresent("anthropic-api-key")) ?? false; @@ -442,6 +571,7 @@ async function main() { root.append( h("h1", {}, h("span", { text: "Coucou" }), h("span", { class: "version", text: version })), claudeSection(status), + antigravitySection(agyStatus), apiSection(hasKey), integrationsSection(present), generalSection(), diff --git a/windows/src/views/integrations.ts b/windows/src/views/integrations.ts index b8ad73c8fb..ba7290ed12 100644 --- a/windows/src/views/integrations.ts +++ b/windows/src/views/integrations.ts @@ -57,10 +57,9 @@ function idleCard(task: AgentTask, openSettings: () => void): HTMLElement { const info = State.integrations[task.id]; const configured = info?.configured ?? false; const error = info?.error ?? null; - // The Claude Code pill is about hooks, not a key — the macOS wording would be - // misleading here. - const missing = task.id === "integration_claude" ? "Hooks not installed" : "Key not configured"; - const label = error ?? (configured ? "Connected · loading…" : missing); + const isHooks = task.id === "integration_claude" || task.id === "agent_antigravity"; + const missing = isHooks ? "Hooks not installed" : "Key not configured"; + const label = error ?? (isHooks ? (configured ? "Hooks installed" : missing) : (configured ? "Connected · loading…" : missing)); const statusColor = error || !configured ? "#F4505E" : "#22C55E"; const actions = h("div", { class: "int-actions" }); @@ -73,6 +72,15 @@ function idleCard(task: AgentTask, openSettings: () => void): HTMLElement { onclick: () => void Bridge.openInVSCode(task.sessionCwd ?? null), }), ); + } else if (task.id === "agent_antigravity") { + actions.append( + h("button", { + class: "link-btn", + style: `color:${task.color}d9`, + text: configured ? "Configure hooks" : "Install hooks…", + onclick: openSettings, + }), + ); } else if (task.id === "integration_n8n") { actions.append( h("button", { @@ -92,7 +100,7 @@ function idleCard(task: AgentTask, openSettings: () => void): HTMLElement { }), ); } - if (configured) { + if (configured && !isHooks) { actions.append( h("button", { class: "link-btn", @@ -101,16 +109,17 @@ function idleCard(task: AgentTask, openSettings: () => void): HTMLElement { onclick: () => void Bridge.refreshIntegration(task.id), }), ); - } else { + } else if (!configured && task.id !== "agent_antigravity") { actions.append( h("button", { class: "link-btn", style: "color:#8e939c", text: "Settings…", onclick: openSettings }), ); } + const kind = task.id === "agent_antigravity" ? "Google DeepMind" : "Integration"; return h( "div", { class: "int-card" }, - header(task.color, task.id === "integration_claude" ? "VS Code" : task.name, "Integration"), + header(task.color, task.id === "integration_claude" ? "VS Code" : task.name, kind), h("div", { class: "int-status" }, dot(statusColor, 5), h("span", { text: label })), actions, ); diff --git a/windows/src/views/views.ts b/windows/src/views/views.ts index ac0ac7b6f7..2e0de54b4d 100644 --- a/windows/src/views/views.ts +++ b/windows/src/views/views.ts @@ -172,10 +172,11 @@ function buildOverview(actions: ViewActions): ViewHost { mode = null; } - // VS Code with a live Claude Code session keeps the ticker; every other - // pill shows its own card, exactly like IntegrationCardView. + // VS Code with a live Claude Code session or an active agent keeps the ticker; + // every other pill shows its own card, exactly like IntegrationCardView. + const isCodingTool = task?.id === "integration_claude" || task?.id.startsWith("agent_"); const sessionActive = - task?.id === "integration_claude" && (task.state !== "idle" || task.steps.length > 0); + Boolean(isCodingTool && task && (task.state !== "idle" || task.steps.length > 0)); if (task && sessionActive) { if (mode !== "ticker") { @@ -185,10 +186,16 @@ function buildOverview(actions: ViewActions): ViewHost { cardKey = ""; } clear(who); + const toolLabel = + task.source === "claudeCode" + ? "Claude Code" + : task.id === "agent_antigravity" + ? "Antigravity" + : task.name; who.append( dot(task.color, 7), h("span", { class: "name", text: task.name }), - h("span", { class: "tool", text: task.source === "claudeCode" ? "Claude Code" : "n8n" }), + h("span", { class: "tool", text: toolLabel }), ); if (task.steps.length > 1) { who.append(h("span", { @@ -374,7 +381,8 @@ function buildFinished(actions: ViewActions): ViewHost { el, sync() { clear(who); - who.append(agentWho(State.focusTask, "Claude Code finished")); + const isAgy = State.focusTask?.id === "agent_antigravity"; + who.append(agentWho(State.focusTask, isAgy ? "Antigravity finished" : "Claude Code finished")); title.textContent = State.focusTask?.steps.at(-1) ?? "Session finished"; }, };