From 30fa87277d8a71ebec0b347e909bf2199010f55e Mon Sep 17 00:00:00 2001 From: fluzko Date: Tue, 1 Sep 2026 10:28:33 -0300 Subject: [PATCH 1/3] feat(agents): add Antigravity CLI support --- src/agents/mcp_server_registration.rs | 22 +- src/agents/mod.rs | 272 +++++++++- src/hook.rs | 12 + src/hook_schema.rs | 6 + src/hook_schema/antigravity.rs | 701 ++++++++++++++++++++++++++ src/plugins.rs | 2 + src/sync.rs | 52 +- tests/init_sync.rs | 55 ++ 8 files changed, 1102 insertions(+), 20 deletions(-) create mode 100644 src/hook_schema/antigravity.rs diff --git a/src/agents/mcp_server_registration.rs b/src/agents/mcp_server_registration.rs index 7eee1f61..b1daaecd 100644 --- a/src/agents/mcp_server_registration.rs +++ b/src/agents/mcp_server_registration.rs @@ -95,7 +95,7 @@ fn upsert_json_mcp_entry( } // --------------------------------------------------------------------------- -// JSON-based registration (Claude, Copilot, Gemini, Kiro, OpenCode) +// JSON-based registration (Antigravity, Claude, Copilot, Gemini, Kiro, OpenCode) // --------------------------------------------------------------------------- /// Register MCP servers into a JSON config file under a given container key. @@ -208,6 +208,26 @@ pub(super) fn unregister_claude_mcp_servers( unregister_json_mcp_servers(path, names, Some("mcpServers"), out) } +/// Antigravity CLI: `mcpServers.` in a dedicated `mcp_config.json`. +/// +/// Same entry shape as Claude, but its own file rather than sharing one with +/// hooks, so a broken hooks config cannot take MCP down with it. +pub(super) fn register_antigravity_mcp_servers( + path: &Path, + servers: &[McpServer], + out: &Output, +) -> Result<()> { + register_json_mcp_servers(path, servers, Some("mcpServers"), out) +} + +pub(super) fn unregister_antigravity_mcp_servers( + path: &Path, + names: &[&str], + out: &Output, +) -> Result<()> { + unregister_json_mcp_servers(path, names, Some("mcpServers"), out) +} + /// Codex CLI: `[mcp_servers.]` in config.toml pub(super) fn register_codex_mcp_servers( config_path: &Path, diff --git a/src/agents/mod.rs b/src/agents/mod.rs index 722645ee..4d393194 100644 --- a/src/agents/mod.rs +++ b/src/agents/mod.rs @@ -18,6 +18,7 @@ use crate::output::{Output, display_path}; /// Supported AI agents. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Agent { + Antigravity, Claude, Codex, Copilot, @@ -31,6 +32,7 @@ impl Agent { /// Parse an agent name from a config string. pub fn from_config_name(name: &str) -> Result { match name { + "antigravity" => Ok(Agent::Antigravity), "claude" => Ok(Agent::Claude), "codex" => Ok(Agent::Codex), "copilot" => Ok(Agent::Copilot), @@ -39,7 +41,7 @@ impl Agent { "kiro" => Ok(Agent::Kiro), "opencode" => Ok(Agent::OpenCode), other => bail!( - "unknown agent: {other} (expected claude, codex, copilot, gemini, goose, kiro, or opencode)" + "unknown agent: {other} (expected antigravity, claude, codex, copilot, gemini, goose, kiro, or opencode)" ), } } @@ -47,6 +49,7 @@ impl Agent { /// Config name as stored in TOML. pub fn config_name(&self) -> &'static str { match self { + Agent::Antigravity => "antigravity", Agent::Claude => "claude", Agent::Codex => "codex", Agent::Copilot => "copilot", @@ -60,6 +63,7 @@ impl Agent { /// Human-readable display name. pub fn display_name(&self) -> &'static str { match self { + Agent::Antigravity => "Antigravity CLI", Agent::Claude => "Claude Code", Agent::Codex => "Codex CLI", Agent::Copilot => "GitHub Copilot", @@ -73,6 +77,7 @@ impl Agent { /// All supported agents for interactive prompts. pub fn all() -> &'static [Agent] { &[ + Agent::Antigravity, Agent::Claude, Agent::Codex, Agent::Copilot, @@ -89,12 +94,12 @@ impl Agent { /// Project-level skill directory for a given skill name. /// - /// Claude Code requires `.claude/skills/`, while Copilot and Gemini - /// support the vendor-neutral `.agents/skills/` path. + /// Claude Code requires `.claude/skills/`, while Antigravity, Copilot and + /// Gemini support the vendor-neutral `.agents/skills/` path. pub fn project_skill_dir(&self, project_root: &Path, skill_name: &str) -> PathBuf { match self { Agent::Claude => project_root.join(".claude").join("skills").join(skill_name), - Agent::Codex | Agent::Copilot | Agent::Gemini => { + Agent::Antigravity | Agent::Codex | Agent::Copilot | Agent::Gemini => { project_root.join(".agents").join("skills").join(skill_name) } Agent::Goose => project_root.join(".agents").join("skills").join(skill_name), @@ -106,6 +111,14 @@ impl Agent { /// Global skill directory for a given skill name, if supported. pub fn global_skill_dir(&self, home: &Path, skill_name: &str) -> Option { match self { + // Antigravity's shared config root, the one location all three of its + // surfaces (CLI, IDE, web) read. + Agent::Antigravity => Some( + home.join(".gemini") + .join("config") + .join("skills") + .join(skill_name), + ), Agent::Claude => Some(home.join(".claude").join("skills").join(skill_name)), Agent::Codex => Some(home.join(".agents").join("skills").join(skill_name)), Agent::Copilot => None, // no global skills path @@ -128,6 +141,9 @@ impl Agent { out: &Output, ) -> Result<()> { match self { + Agent::Antigravity => { + register_antigravity_hooks(&project_root.join(".agents").join("hooks.json"), out) + } Agent::Claude => { register_claude_hooks(&project_root.join(".claude").join("settings.json"), out) } @@ -161,6 +177,10 @@ impl Agent { tracing::debug!(agent = %self.config_name(), "registering hooks"); // Register hooks match self { + Agent::Antigravity => register_antigravity_hooks( + &home.join(".gemini").join("config").join("hooks.json"), + out, + ), Agent::Claude => { register_claude_hooks(&home.join(".claude").join("settings.json"), out) } @@ -199,6 +219,11 @@ impl Agent { out: &Output, ) -> Result<()> { match self { + Agent::Antigravity => mcp_server_registration::register_antigravity_mcp_servers( + &project_root.join(".agents").join("mcp_config.json"), + servers, + out, + ), Agent::Claude => mcp_server_registration::register_claude_mcp_servers( &project_root.join(".claude").join("settings.json"), servers, @@ -246,6 +271,11 @@ impl Agent { ) -> Result<()> { tracing::debug!(agent = %self.config_name(), count = servers.len(), "registering MCP servers"); match self { + Agent::Antigravity => mcp_server_registration::register_antigravity_mcp_servers( + &home.join(".gemini").join("config").join("mcp_config.json"), + servers, + out, + ), Agent::Claude => mcp_server_registration::register_claude_mcp_servers( &home.join(".claude").join("settings.json"), servers, @@ -292,6 +322,11 @@ impl Agent { out: &Output, ) -> Result<()> { match self { + Agent::Antigravity => mcp_server_registration::unregister_antigravity_mcp_servers( + &project_root.join(".agents").join("mcp_config.json"), + names, + out, + ), Agent::Claude => mcp_server_registration::unregister_claude_mcp_servers( &project_root.join(".claude").join("settings.json"), names, @@ -338,6 +373,11 @@ impl Agent { out: &Output, ) -> Result<()> { match self { + Agent::Antigravity => mcp_server_registration::unregister_antigravity_mcp_servers( + &home.join(".gemini").join("config").join("mcp_config.json"), + names, + out, + ), Agent::Claude => mcp_server_registration::unregister_claude_mcp_servers( &home.join(".claude").join("settings.json"), names, @@ -379,6 +419,9 @@ impl Agent { /// Remove hooks from the project-level agent config. pub fn unregister_project_hooks(&self, project_root: &Path, _sym: &Symposium, out: &Output) { match self { + Agent::Antigravity => { + unregister_antigravity_hooks(&project_root.join(".agents").join("hooks.json"), out) + } Agent::Claude => { unregister_claude_hooks(&project_root.join(".claude").join("settings.json"), out) } @@ -400,6 +443,10 @@ impl Agent { /// Remove hooks from the global agent config. pub fn unregister_hooks(&self, home: &Path, _sym: &Symposium, out: &Output) { match self { + Agent::Antigravity => unregister_antigravity_hooks( + &home.join(".gemini").join("config").join("hooks.json"), + out, + ), Agent::Claude => { unregister_claude_hooks(&home.join(".claude").join("settings.json"), out) } @@ -696,6 +743,91 @@ fn copilot_hook_entries() -> Vec<(&'static str, serde_json::Value)> { ] } +// --------------------------------------------------------------------------- +// Antigravity CLI hook registration +// --------------------------------------------------------------------------- + +/// The key symposium owns in Antigravity's `hooks.json`. Registrations there are +/// keyed by a hook *name*, so several tools can share one file; symposium only +/// ever writes and reaps this one entry. +const ANTIGRAVITY_HOOK_NAME: &str = "symposium"; + +/// Antigravity's events, paired with the symposium event each maps to. +/// +/// `PreInvocation` stands in for `user-prompt-submit`: Antigravity has no +/// prompt event, and `PreInvocation` fires before every model call, so dispatch +/// gates it on the first invocation of a turn. `SessionStart` is undocumented +/// but real, and fires once per session. +const ANTIGRAVITY_EVENTS: &[(&str, &str)] = &[ + ("PreToolUse", "pre-tool-use"), + ("PostToolUse", "post-tool-use"), + ("PreInvocation", "user-prompt-submit"), + ("SessionStart", "session-start"), + ("Stop", "stop"), +]; + +/// Tool events wrap their handlers in a `matcher` group; lifecycle events take a +/// flat list of handlers. Getting this wrong is silent — Antigravity accepts an +/// unrecognised shape and simply never fires it. +fn antigravity_is_tool_event(event: &str) -> bool { + matches!(event, "PreToolUse" | "PostToolUse") +} + +fn antigravity_handler(cli_arg: &str) -> serde_json::Value { + json!({ + "type": "command", + "command": format!("cargo-agents hook antigravity {cli_arg}"), + "timeout": 30, + }) +} + +fn register_antigravity_hooks(hooks_path: &Path, out: &Output) -> Result<()> { + let mut config = load_json_or_empty(hooks_path)?; + let display = display_path(hooks_path); + + let mut entry = serde_json::Map::new(); + for (event, cli_arg) in ANTIGRAVITY_EVENTS { + let handler = antigravity_handler(cli_arg); + let value = if antigravity_is_tool_event(event) { + json!([{ "matcher": "*", "hooks": [handler] }]) + } else { + json!([handler]) + }; + entry.insert((*event).to_string(), value); + } + let entry = serde_json::Value::Object(entry); + + let obj = config.as_object_mut().unwrap(); + if obj.get(ANTIGRAVITY_HOOK_NAME) == Some(&entry) { + out.already_ok(format!("{display}: hooks already registered")); + return Ok(()); + } + + obj.insert(ANTIGRAVITY_HOOK_NAME.to_string(), entry); + save_json(hooks_path, &config)?; + let events: Vec<&str> = ANTIGRAVITY_EVENTS.iter().map(|(e, _)| *e).collect(); + out.done(format!("{display}: added hooks ({})", events.join(", "))); + Ok(()) +} + +/// Remove only symposium's own named entry, leaving hooks other tools or the +/// user registered in the same file untouched. +fn unregister_antigravity_hooks(hooks_path: &Path, out: &Output) { + let display = display_path(hooks_path); + + let Ok(mut config) = load_json_or_empty(hooks_path) else { + return; + }; + let Some(obj) = config.as_object_mut() else { + return; + }; + if obj.remove(ANTIGRAVITY_HOOK_NAME).is_some() + && let Ok(()) = save_json(hooks_path, &config) + { + out.removed(format!("{display}: removed hooks")); + } +} + // --------------------------------------------------------------------------- // Gemini CLI hook registration // --------------------------------------------------------------------------- @@ -1314,4 +1446,136 @@ mod tests { assert!(settings["hooks"]["BeforeAgent"].is_array()); assert!(settings["hooks"]["SessionStart"].is_array()); } + + // ── Antigravity ────────────────────────────────────────────────────── + + #[test] + fn antigravity_hooks_use_the_right_shape_per_event() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("hooks.json"); + register_antigravity_hooks(&path, &Output::quiet()).unwrap(); + + let cfg: serde_json::Value = + serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap(); + let entry = &cfg["symposium"]; + + // Tool events wrap handlers in a matcher group. + let pre_tool = &entry["PreToolUse"][0]; + assert_eq!(pre_tool["matcher"], "*"); + assert_eq!( + pre_tool["hooks"][0]["command"], + "cargo-agents hook antigravity pre-tool-use" + ); + + // Lifecycle events are flat handler lists — no matcher wrapper. + let pre_invocation = &entry["PreInvocation"][0]; + assert!(pre_invocation.get("matcher").is_none()); + assert_eq!( + pre_invocation["command"], + "cargo-agents hook antigravity user-prompt-submit" + ); + + // SessionStart is undocumented but real, and fires once per session. + assert_eq!( + entry["SessionStart"][0]["command"], + "cargo-agents hook antigravity session-start" + ); + assert_eq!( + entry["Stop"][0]["command"], + "cargo-agents hook antigravity stop" + ); + + // Timeout is in seconds for this agent, not milliseconds. + assert_eq!(pre_invocation["timeout"], 30); + } + + #[test] + fn antigravity_hooks_registration_is_idempotent() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("hooks.json"); + register_antigravity_hooks(&path, &Output::quiet()).unwrap(); + let first = fs::read_to_string(&path).unwrap(); + register_antigravity_hooks(&path, &Output::quiet()).unwrap(); + assert_eq!(first, fs::read_to_string(&path).unwrap()); + } + + /// The file is shared with whatever else the user registered, so reaping + /// must take symposium's named entry and nothing else. + #[test] + fn antigravity_unregister_leaves_other_named_hooks_alone() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("hooks.json"); + fs::write( + &path, + serde_json::to_string_pretty(&json!({ + "someone-elses-linter": { "PostToolUse": [{ "matcher": "*", "hooks": [] }] } + })) + .unwrap(), + ) + .unwrap(); + + register_antigravity_hooks(&path, &Output::quiet()).unwrap(); + unregister_antigravity_hooks(&path, &Output::quiet()); + + let cfg: serde_json::Value = + serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap(); + assert!(cfg.get("symposium").is_none(), "symposium entry removed"); + assert!( + cfg.get("someone-elses-linter").is_some(), + "unrelated hook preserved" + ); + } + + /// Antigravity keeps MCP in its own file rather than sharing one with + /// hooks, and the project and global locations are different shapes — so + /// the scope dispatch is worth pinning down. + #[test] + fn antigravity_mcp_lands_in_its_own_file_at_both_scopes() { + use sacp::schema::{McpServer, McpServerStdio}; + let servers = vec![McpServer::Stdio(McpServerStdio::new( + "symposium", + "/usr/local/bin/cargo-agents", + ))]; + + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + + Agent::Antigravity + .register_project_mcp_servers(root, &servers, &Output::quiet()) + .unwrap(); + let project = root.join(".agents/mcp_config.json"); + assert!( + project.is_file(), + "project MCP goes to .agents/mcp_config.json" + ); + let cfg: serde_json::Value = + serde_json::from_str(&fs::read_to_string(&project).unwrap()).unwrap(); + assert!(cfg["mcpServers"]["symposium"].is_object()); + + Agent::Antigravity + .register_global_mcp_servers(root, &servers, &Output::quiet()) + .unwrap(); + assert!( + root.join(".gemini/config/mcp_config.json").is_file(), + "global MCP goes to the shared config root" + ); + } + + #[test] + fn antigravity_skill_and_config_paths() { + let root = Path::new("/project"); + let home = Path::new("/home/user"); + assert_eq!( + Agent::Antigravity.project_skill_dir(root, "tokio"), + PathBuf::from("/project/.agents/skills/tokio") + ); + assert_eq!( + Agent::Antigravity.global_skill_dir(home, "tokio"), + Some(PathBuf::from("/home/user/.gemini/config/skills/tokio")) + ); + assert_eq!( + Agent::from_config_name("antigravity").unwrap(), + Agent::Antigravity + ); + } } diff --git a/src/hook.rs b/src/hook.rs index 29cba839..594e2b95 100644 --- a/src/hook.rs +++ b/src/hook.rs @@ -235,6 +235,18 @@ pub async fn execute_hook( event: HookEvent, input: &str, ) -> anyhow::Result> { + // Antigravity has no prompt event: `PreInvocation` stands in for it but + // fires before every model call, so without this gate a plugin's + // user-prompt hooks would run several times in a single turn. Empty stdout + // is Antigravity's "no opinion", so returning nothing is safe. + if agent == HookAgent::Antigravity + && event == HookEvent::UserPromptSubmit + && !crate::hook_schema::antigravity::is_first_invocation(input) + { + tracing::debug!("antigravity: not the first invocation of the turn, skipping prompt event"); + return Ok(Vec::new()); + } + let event_handler = agent.event(event); if let Some(handler) = event_handler { diff --git a/src/hook_schema.rs b/src/hook_schema.rs index e1cd90b8..cb95dd80 100644 --- a/src/hook_schema.rs +++ b/src/hook_schema.rs @@ -5,6 +5,7 @@ use serde::{Deserialize, Serialize}; use anyhow::Result; use std::{any::Any, fmt::Debug}; +pub mod antigravity; pub mod claude; pub mod codex; pub mod copilot; @@ -17,6 +18,9 @@ pub mod symposium; /// Agents supported by Symposium hooks. #[derive(Debug, Copy, Clone, clap::ValueEnum, Serialize, Deserialize, PartialEq, Eq)] pub enum HookAgent { + #[value(name = "antigravity")] + #[serde(rename = "antigravity")] + Antigravity, #[value(name = "claude")] #[serde(rename = "claude")] Claude, @@ -44,6 +48,7 @@ impl HookAgent { /// Canonical lowercase agent name (matches the config `[[agent]]` names). pub fn as_str(&self) -> &'static str { match self { + HookAgent::Antigravity => "antigravity", HookAgent::Claude => "claude", HookAgent::Codex => "codex", HookAgent::Copilot => "copilot", @@ -56,6 +61,7 @@ impl HookAgent { pub fn event(&self, event: HookEvent) -> Option> { match self { + HookAgent::Antigravity => antigravity::Antigravity.event(event), HookAgent::Claude => claude::ClaudeCode.event(event), HookAgent::Codex => codex::Codex.event(event), HookAgent::Copilot => copilot::Copilot.event(event), diff --git a/src/hook_schema/antigravity.rs b/src/hook_schema/antigravity.rs new file mode 100644 index 00000000..2cd2ae34 --- /dev/null +++ b/src/hook_schema/antigravity.rs @@ -0,0 +1,701 @@ +//! Antigravity CLI (`agy`) hook wire format. +//! +//! Three things here differ from every other agent and are load-bearing: +//! +//! 1. **A `PreToolUse` hook that writes `{}` denies the tool call.** Exit codes +//! are ignored entirely; only stdout decides. So [`AntigravityPreToolUseOutput`] +//! always serializes `decision`, and its default is `allow`. +//! 2. **The workspace comes from the payload, not the process.** A hook's working +//! directory is the directory holding its own `hooks.json`, which for a global +//! registration is `~/.gemini/config`. `workspacePaths[0]` is the only way to +//! learn the project, so it maps to the canonical `cwd`. +//! 3. **`PreInvocation` stands in for `user-prompt-submit`.** Antigravity has no +//! prompt event and `PreInvocation` fires before *every* model call, so +//! dispatch gates it on `invocationNum == 0` (see `hook.rs`). +//! +//! Context is returned as an `injectSteps` entry rather than an +//! `additionalContext` field. All keys are camelCase (protojson). + +use serde::{Deserialize, Serialize}; + +use crate::hook_schema::{ + Agent, AgentHookEvent, AgentHookInput, AgentHookOutput, erase_agent_hook_event, symposium, +}; + +pub struct Antigravity; +impl Agent for Antigravity { + fn event(&self, event: super::HookEvent) -> Option> { + match event { + super::HookEvent::PreToolUse => { + Some(erase_agent_hook_event(AntigravityPreToolUseEvent)) + } + super::HookEvent::PostToolUse => { + Some(erase_agent_hook_event(AntigravityPostToolUseEvent)) + } + super::HookEvent::UserPromptSubmit => { + Some(erase_agent_hook_event(AntigravityUserPromptSubmitEvent)) + } + super::HookEvent::SessionStart => { + Some(erase_agent_hook_event(AntigravitySessionStartEvent)) + } + super::HookEvent::Stop => Some(erase_agent_hook_event(AntigravityStopEvent)), + _ => None, + } + } +} + +macro_rules! antigravity_event { + ($event:ident, $input:ident, $output:ident) => { + pub struct $event; + impl AgentHookEvent for $event { + type Input = $input; + type Output = $output; + } + }; +} + +antigravity_event!( + AntigravityPreToolUseEvent, + AntigravityPreToolUseInput, + AntigravityPreToolUseOutput +); +antigravity_event!( + AntigravityPostToolUseEvent, + AntigravityPostToolUseInput, + AntigravityPostToolUseOutput +); +antigravity_event!( + AntigravityUserPromptSubmitEvent, + AntigravityInvocationInput, + AntigravityInjectStepsOutput +); +antigravity_event!( + AntigravitySessionStartEvent, + AntigravitySessionStartInput, + AntigravityInjectStepsOutput +); +antigravity_event!( + AntigravityStopEvent, + AntigravityStopInput, + AntigravityStopOutput +); + +// ── Common ──────────────────────────────────────────────────────────── + +/// Fields present on every Antigravity hook payload. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct AntigravityCommon { + #[serde( + rename = "conversationId", + default, + skip_serializing_if = "Option::is_none" + )] + pub conversation_id: Option, + /// Empty when no workspace was adopted (headless `agy -p` without + /// `--add-dir`), so callers must tolerate an absent project. + #[serde( + rename = "workspacePaths", + default, + skip_serializing_if = "Vec::is_empty" + )] + pub workspace_paths: Vec, + #[serde( + rename = "transcriptPath", + default, + skip_serializing_if = "Option::is_none" + )] + pub transcript_path: Option, + #[serde( + rename = "artifactDirectoryPath", + default, + skip_serializing_if = "Option::is_none" + )] + pub artifact_directory_path: Option, + #[serde(rename = "modelName", default, skip_serializing_if = "Option::is_none")] + pub model_name: Option, +} + +impl AntigravityCommon { + fn cwd(&self) -> Option { + self.workspace_paths.first().cloned() + } + + fn from_symposium(session_id: Option, cwd: Option) -> Self { + Self { + conversation_id: session_id, + workspace_paths: cwd.into_iter().collect(), + ..Default::default() + } + } +} + +/// A tool call as Antigravity reports it: name and arguments nested under +/// `toolCall`, rather than the flat `tool_name` / `tool_input` other agents use. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct AntigravityToolCall { + #[serde(default)] + pub name: String, + #[serde(default)] + pub args: serde_json::Value, +} + +/// Steps injected back into the conversation. `ephemeralMessage` is the +/// equivalent of Claude Code's `additionalContext`. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct AntigravityInjectedStep { + #[serde( + rename = "ephemeralMessage", + default, + skip_serializing_if = "Option::is_none" + )] + pub ephemeral_message: Option, + #[serde( + rename = "userMessage", + default, + skip_serializing_if = "Option::is_none" + )] + pub user_message: Option, + #[serde(rename = "toolCall", default, skip_serializing_if = "Option::is_none")] + pub tool_call: Option, +} + +// ── PreToolUse ──────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct AntigravityPreToolUseInput { + #[serde(rename = "toolCall", default)] + pub tool_call: AntigravityToolCall, + #[serde(rename = "stepIdx", default, skip_serializing_if = "Option::is_none")] + pub step_idx: Option, + #[serde(flatten)] + pub common: AntigravityCommon, +} + +/// `decision` is deliberately **not** optional and never skipped: an object +/// without it — `{}` included — is treated by Antigravity as a denial. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AntigravityPreToolUseOutput { + pub decision: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reason: Option, + /// Shallow-merged into the tool call's arguments before it runs. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub overwrite: Option, +} + +impl Default for AntigravityPreToolUseOutput { + fn default() -> Self { + Self { + decision: "allow".into(), + reason: None, + overwrite: None, + } + } +} + +impl AgentHookInput for AntigravityPreToolUseInput { + fn parse_input(payload: &str) -> anyhow::Result { + Ok(serde_json::from_str(payload)?) + } + fn to_symposium(&self) -> symposium::InputEvent { + symposium::InputEvent::PreToolUse(symposium::PreToolUseInput::new( + self.tool_call.name.clone(), + self.tool_call.args.clone(), + self.common.conversation_id.clone(), + self.common.cwd(), + )) + } + fn from_symposium(event: &symposium::InputEvent) -> Self { + let symposium::InputEvent::PreToolUse(p) = event else { + panic!("wrong event") + }; + Self { + tool_call: AntigravityToolCall { + name: p.tool_name.clone(), + args: p.tool_input.clone(), + }, + step_idx: None, + common: AntigravityCommon::from_symposium(p.session_id.clone(), p.cwd.clone()), + } + } + fn to_string(&self) -> anyhow::Result { + serde_json::to_string(self).map_err(Into::into) + } + fn into_any(self: Box) -> Box { + self + } +} + +impl AgentHookOutput for AntigravityPreToolUseOutput { + fn parse_output(output: &[u8]) -> anyhow::Result { + if output.is_empty() { + return Ok(Self::default()); + } + Ok(serde_json::from_slice(output)?) + } + fn from_symposium(event: &symposium::OutputEvent) -> Self { + let symposium::OutputEvent::PreToolUse(o) = event else { + return Self::default(); + }; + let denied = matches!(o.decision, symposium_sdk::hook::Decision::Deny); + Self { + decision: if denied { + "deny".into() + } else { + "allow".into() + }, + // A denial's explanation is the reason; otherwise context is + // dropped, since PreToolUse has nowhere to put it. + reason: o.additional_context.clone(), + overwrite: o.updated_input.clone(), + } + } + fn to_symposium(&self) -> symposium::OutputEvent { + let decision = match self.decision.as_str() { + "deny" | "deny_unless_prior_grant" => symposium_sdk::hook::Decision::Deny, + _ => symposium_sdk::hook::Decision::Allow, + }; + symposium::OutputEvent::PreToolUse(symposium::PreToolUseOutput::new( + decision, + self.reason.clone(), + self.overwrite.clone(), + )) + } + fn to_hook_output(&self) -> serde_json::Value { + serde_json::to_value(self).unwrap() + } + fn into_any(self: Box) -> Box { + self + } +} + +// ── PostToolUse ─────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct AntigravityPostToolUseInput { + #[serde(rename = "toolCall", default)] + pub tool_call: AntigravityToolCall, + #[serde(rename = "stepIdx", default, skip_serializing_if = "Option::is_none")] + pub step_idx: Option, + /// Set when the tool failed; empty string otherwise. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error: Option, + #[serde(flatten)] + pub common: AntigravityCommon, +} + +/// Antigravity expects an empty object here; there is nothing to return. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct AntigravityPostToolUseOutput {} + +impl AgentHookInput for AntigravityPostToolUseInput { + fn parse_input(payload: &str) -> anyhow::Result { + Ok(serde_json::from_str(payload)?) + } + fn to_symposium(&self) -> symposium::InputEvent { + symposium::InputEvent::PostToolUse(symposium::PostToolUseInput::new( + self.tool_call.name.clone(), + self.tool_call.args.clone(), + self.error + .clone() + .filter(|e| !e.is_empty()) + .map(serde_json::Value::String) + .unwrap_or(serde_json::Value::Null), + self.common.conversation_id.clone(), + self.common.cwd(), + )) + } + fn from_symposium(event: &symposium::InputEvent) -> Self { + let symposium::InputEvent::PostToolUse(p) = event else { + panic!("wrong event") + }; + Self { + tool_call: AntigravityToolCall { + name: p.tool_name.clone(), + args: p.tool_input.clone(), + }, + step_idx: None, + error: p.tool_response.as_str().map(str::to_string), + common: AntigravityCommon::from_symposium(p.session_id.clone(), p.cwd.clone()), + } + } + fn to_string(&self) -> anyhow::Result { + serde_json::to_string(self).map_err(Into::into) + } + fn into_any(self: Box) -> Box { + self + } +} + +impl AgentHookOutput for AntigravityPostToolUseOutput { + fn parse_output(_output: &[u8]) -> anyhow::Result { + Ok(Self::default()) + } + fn from_symposium(_event: &symposium::OutputEvent) -> Self { + Self::default() + } + fn to_symposium(&self) -> symposium::OutputEvent { + symposium::OutputEvent::PostToolUse(symposium::PostToolUseOutput::new(None)) + } + fn to_hook_output(&self) -> serde_json::Value { + serde_json::json!({}) + } + fn into_any(self: Box) -> Box { + self + } +} + +// ── PreInvocation (user-prompt-submit) and SessionStart ──────────────── + +/// `PreInvocation` / `PostInvocation` payload. +/// +/// `invocationNum` restarts at 0 on every turn, which is what makes it usable +/// as a "first call of this turn" gate but useless as a session marker. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct AntigravityInvocationInput { + #[serde(rename = "invocationNum", default)] + pub invocation_num: i64, + #[serde( + rename = "initialNumSteps", + default, + skip_serializing_if = "Option::is_none" + )] + pub initial_num_steps: Option, + #[serde(flatten)] + pub common: AntigravityCommon, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct AntigravitySessionStartInput { + #[serde(flatten)] + pub common: AntigravityCommon, +} + +/// Shared by the two events that can inject context back into the conversation. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct AntigravityInjectStepsOutput { + #[serde(rename = "injectSteps", default)] + pub inject_steps: Vec, +} + +impl AntigravityInjectStepsOutput { + fn from_context(context: Option<&str>) -> Self { + Self { + inject_steps: context + .map(|c| AntigravityInjectedStep { + ephemeral_message: Some(c.to_string()), + ..Default::default() + }) + .into_iter() + .collect(), + } + } + + fn context(&self) -> Option { + let joined: Vec<&str> = self + .inject_steps + .iter() + .filter_map(|s| s.ephemeral_message.as_deref()) + .collect(); + (!joined.is_empty()).then(|| joined.join("\n")) + } +} + +impl AgentHookInput for AntigravityInvocationInput { + fn parse_input(payload: &str) -> anyhow::Result { + Ok(serde_json::from_str(payload)?) + } + fn to_symposium(&self) -> symposium::InputEvent { + // Antigravity never sends the prompt text, so the canonical prompt is + // empty; plugins keyed on prompt content cannot fire on this agent. + symposium::InputEvent::UserPromptSubmit(symposium::UserPromptSubmitInput::new( + String::new(), + self.common.conversation_id.clone(), + self.common.cwd(), + )) + } + fn from_symposium(event: &symposium::InputEvent) -> Self { + let symposium::InputEvent::UserPromptSubmit(p) = event else { + panic!("wrong event") + }; + Self { + invocation_num: 0, + initial_num_steps: None, + common: AntigravityCommon::from_symposium(p.session_id.clone(), p.cwd.clone()), + } + } + fn to_string(&self) -> anyhow::Result { + serde_json::to_string(self).map_err(Into::into) + } + fn into_any(self: Box) -> Box { + self + } +} + +impl AgentHookInput for AntigravitySessionStartInput { + fn parse_input(payload: &str) -> anyhow::Result { + Ok(serde_json::from_str(payload)?) + } + fn to_symposium(&self) -> symposium::InputEvent { + symposium::InputEvent::SessionStart(symposium::SessionStartInput::new( + self.common.conversation_id.clone(), + self.common.cwd(), + )) + } + fn from_symposium(event: &symposium::InputEvent) -> Self { + let symposium::InputEvent::SessionStart(p) = event else { + panic!("wrong event") + }; + Self { + common: AntigravityCommon::from_symposium(p.session_id.clone(), p.cwd.clone()), + } + } + fn to_string(&self) -> anyhow::Result { + serde_json::to_string(self).map_err(Into::into) + } + fn into_any(self: Box) -> Box { + self + } +} + +impl AgentHookOutput for AntigravityInjectStepsOutput { + fn parse_output(output: &[u8]) -> anyhow::Result { + if output.is_empty() { + return Ok(Self::default()); + } + Ok(serde_json::from_slice(output)?) + } + fn from_symposium(event: &symposium::OutputEvent) -> Self { + Self::from_context(event.additional_context()) + } + fn to_symposium(&self) -> symposium::OutputEvent { + symposium::OutputEvent::SessionStart(symposium::SessionStartOutput::new(self.context())) + } + fn to_hook_output(&self) -> serde_json::Value { + serde_json::to_value(self).unwrap() + } + fn into_any(self: Box) -> Box { + self + } +} + +// ── Stop ────────────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct AntigravityStopInput { + #[serde( + rename = "executionNum", + default, + skip_serializing_if = "Option::is_none" + )] + pub execution_num: Option, + #[serde( + rename = "terminationReason", + default, + skip_serializing_if = "Option::is_none" + )] + pub termination_reason: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error: Option, + #[serde(rename = "fullyIdle", default, skip_serializing_if = "Option::is_none")] + pub fully_idle: Option, + #[serde(flatten)] + pub common: AntigravityCommon, +} + +/// `decision: "continue"` blocks the stop and re-enters the loop; any other +/// value lets the agent stop, so the field is omitted when there is nothing to say. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct AntigravityStopOutput { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub decision: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reason: Option, +} + +impl AgentHookInput for AntigravityStopInput { + fn parse_input(payload: &str) -> anyhow::Result { + Ok(serde_json::from_str(payload)?) + } + fn to_symposium(&self) -> symposium::InputEvent { + symposium::InputEvent::Stop(symposium::StopInput::new( + self.common.conversation_id.clone(), + self.common.cwd(), + )) + } + fn from_symposium(event: &symposium::InputEvent) -> Self { + let symposium::InputEvent::Stop(p) = event else { + panic!("wrong event") + }; + Self { + common: AntigravityCommon::from_symposium(p.session_id.clone(), p.cwd.clone()), + ..Default::default() + } + } + fn to_string(&self) -> anyhow::Result { + serde_json::to_string(self).map_err(Into::into) + } + fn into_any(self: Box) -> Box { + self + } +} + +impl AgentHookOutput for AntigravityStopOutput { + fn parse_output(output: &[u8]) -> anyhow::Result { + if output.is_empty() { + return Ok(Self::default()); + } + Ok(serde_json::from_slice(output)?) + } + fn from_symposium(event: &symposium::OutputEvent) -> Self { + // Context on Stop is only deliverable as the reason for continuing. + match event.additional_context() { + Some(ctx) => Self { + decision: Some("continue".into()), + reason: Some(ctx.to_string()), + }, + None => Self::default(), + } + } + fn to_symposium(&self) -> symposium::OutputEvent { + symposium::OutputEvent::Stop(symposium::StopOutput::new(self.reason.clone())) + } + fn to_hook_output(&self) -> serde_json::Value { + serde_json::to_value(self).unwrap() + } + fn into_any(self: Box) -> Box { + self + } +} + +/// Whether a `PreInvocation` payload is the first model call of its turn. +/// +/// `PreInvocation` stands in for `user-prompt-submit`, but it fires before every +/// model call — several times in a turn that uses tools — so dispatch runs the +/// prompt event only on invocation 0. A payload that will not parse counts as +/// first, so a wire change makes the hook fire too often rather than never. +pub fn is_first_invocation(payload: &str) -> bool { + serde_json::from_str::(payload) + .map(|p| p.invocation_num == 0) + .unwrap_or(true) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The whole point of the type: symposium contributes nothing on most tool + /// calls, and an object without `decision` — `{}` included — is a denial. + #[test] + fn a_default_pre_tool_use_output_allows_rather_than_denying() { + let json = AntigravityPreToolUseOutput::default().to_hook_output(); + assert_eq!(json["decision"], "allow"); + assert_ne!(json.to_string(), "{}"); + } + + #[test] + fn a_no_op_symposium_output_still_serializes_an_allow() { + let sym = symposium::OutputEvent::PreToolUse(symposium::PreToolUseOutput::default()); + let out = AntigravityPreToolUseOutput::from_symposium(&sym); + assert_eq!(out.to_hook_output()["decision"], "allow"); + } + + #[test] + fn a_denial_carries_its_reason() { + let sym = symposium::OutputEvent::PreToolUse(symposium::PreToolUseOutput::deny("nope")); + let out = AntigravityPreToolUseOutput::from_symposium(&sym); + assert_eq!(out.decision, "deny"); + assert_eq!(out.reason.as_deref(), Some("nope")); + } + + /// The hook's process cwd is wherever `hooks.json` lives, so the workspace + /// has to come from the payload or sync targets the wrong directory. + #[test] + fn workspace_paths_become_the_canonical_cwd() { + let input: AntigravityPreToolUseInput = serde_json::from_str( + r#"{"toolCall":{"name":"run_command","args":{"CommandLine":"ls"}}, + "conversationId":"abc","workspacePaths":["/repo"],"stepIdx":2}"#, + ) + .unwrap(); + let sym = input.to_symposium(); + assert_eq!(sym.cwd(), Some("/repo")); + assert_eq!(sym.session_id(), Some("abc")); + let symposium::InputEvent::PreToolUse(p) = &sym else { + panic!() + }; + assert_eq!(p.tool_name, "run_command"); + assert_eq!(p.tool_input["CommandLine"], "ls"); + } + + /// Headless `agy -p` without `--add-dir` sends no workspace at all. + #[test] + fn an_empty_workspace_list_yields_no_cwd() { + let input: AntigravitySessionStartInput = + serde_json::from_str(r#"{"conversationId":"abc","workspacePaths":[]}"#).unwrap(); + assert_eq!(input.to_symposium().cwd(), None); + } + + #[test] + fn context_round_trips_through_inject_steps() { + let sym = symposium::OutputEvent::SessionStart(symposium::SessionStartOutput::new(Some( + "hello".into(), + ))); + let out = AntigravityInjectStepsOutput::from_symposium(&sym); + assert_eq!( + out.to_hook_output()["injectSteps"][0]["ephemeralMessage"], + "hello" + ); + assert_eq!(out.context().as_deref(), Some("hello")); + } + + #[test] + fn no_context_injects_no_steps() { + let sym = symposium::OutputEvent::SessionStart(symposium::SessionStartOutput::new(None)); + let out = AntigravityInjectStepsOutput::from_symposium(&sym); + assert!(out.inject_steps.is_empty()); + } + + #[test] + fn post_tool_use_reports_an_error_and_returns_an_empty_object() { + let input: AntigravityPostToolUseInput = serde_json::from_str( + r#"{"toolCall":{"name":"run_command","args":{}},"error":"exit status 1", + "conversationId":"c","workspacePaths":["/repo"]}"#, + ) + .unwrap(); + let symposium::InputEvent::PostToolUse(p) = input.to_symposium() else { + panic!() + }; + assert_eq!(p.tool_response, serde_json::json!("exit status 1")); + assert_eq!( + AntigravityPostToolUseOutput::default().to_hook_output(), + serde_json::json!({}) + ); + } + + #[test] + fn stop_only_sets_continue_when_there_is_something_to_say() { + let quiet = symposium::OutputEvent::Stop(symposium::StopOutput::new(None)); + assert!( + AntigravityStopOutput::from_symposium(&quiet) + .decision + .is_none() + ); + + let loud = symposium::OutputEvent::Stop(symposium::StopOutput::new(Some("wait".into()))); + let out = AntigravityStopOutput::from_symposium(&loud); + assert_eq!(out.decision.as_deref(), Some("continue")); + assert_eq!(out.reason.as_deref(), Some("wait")); + } + + #[test] + fn only_the_first_invocation_of_a_turn_is_a_prompt() { + assert!(is_first_invocation(r#"{"invocationNum":0}"#)); + assert!(!is_first_invocation(r#"{"invocationNum":1}"#)); + assert!(!is_first_invocation(r#"{"invocationNum":7}"#)); + } + + #[test] + fn an_unparseable_invocation_payload_fires_rather_than_disappears() { + assert!(is_first_invocation("not json")); + } +} diff --git a/src/plugins.rs b/src/plugins.rs index efea5ea2..1f7b95c7 100644 --- a/src/plugins.rs +++ b/src/plugins.rs @@ -832,6 +832,7 @@ pub enum HookFormat { #[default] Symposium, /// A specific agent's wire format. + Antigravity, Claude, Codex, Copilot, @@ -844,6 +845,7 @@ impl HookFormat { pub fn as_agent(&self) -> Option { match self { HookFormat::Symposium => None, + HookFormat::Antigravity => Some(HookAgent::Antigravity), HookFormat::Claude => Some(HookAgent::Claude), HookFormat::Codex => Some(HookAgent::Codex), HookFormat::Copilot => Some(HookAgent::Copilot), diff --git a/src/sync.rs b/src/sync.rs index 71d534c8..65ca9e92 100644 --- a/src/sync.rs +++ b/src/sync.rs @@ -397,18 +397,31 @@ pub async fn sync(sym: &Symposium, deps: &Arc, update: UpdateLeve for agent_name in &agent_names { let agent = Agent::from_config_name(agent_name)?; - let hook_root = match sym.config.hook_scope { - crate::config::HookScope::Global => sym.home_dir().to_path_buf(), - crate::config::HookScope::Project => project_root.clone(), - }; - - // Register hooks and MCP servers - agent - .register_hooks(&hook_root, sym, out) - .context("failed to register hooks")?; - agent - .register_global_mcp_servers(&hook_root, &mcp_servers, out) - .context("failed to register MCP servers")?; + // Register hooks and MCP servers at the configured scope. + // + // The project and global locations are genuinely different files for + // some agents — Antigravity writes `.agents/hooks.json` but + // `~/.gemini/config/hooks.json`, and Copilot `.github/hooks/` but + // `~/.copilot/settings.json` — so project scope cannot be produced by + // rooting the global path at the workspace. + match sym.config.hook_scope { + crate::config::HookScope::Global => { + agent + .register_hooks(sym.home_dir(), sym, out) + .context("failed to register hooks")?; + agent + .register_global_mcp_servers(sym.home_dir(), &mcp_servers, out) + .context("failed to register MCP servers")?; + } + crate::config::HookScope::Project => { + agent + .register_project_hooks(&project_root, sym, out) + .context("failed to register hooks")?; + agent + .register_project_mcp_servers(&project_root, &mcp_servers, out) + .context("failed to register MCP servers")?; + } + } for (skill_name, origin_hash, skill_source) in &to_install { // `skill_source` is the path to the SKILL.md file; the skill @@ -531,11 +544,20 @@ pub async fn sync(sym: &Symposium, deps: &Arc, update: UpdateLeve } } - // Unregister hooks/MCP for agents no longer configured + // Unregister hooks/MCP for agents no longer configured, at whichever scope + // they would have been written to. for &agent in Agent::all() { if !agent_names.contains(&agent.config_name().to_string()) { - agent.unregister_hooks(sym.home_dir(), sym, out); - let _ = agent.unregister_global_mcp_servers(sym.home_dir(), &server_names, out); + match sym.config.hook_scope { + crate::config::HookScope::Global => { + agent.unregister_hooks(sym.home_dir(), sym, out); + let _ = agent.unregister_global_mcp_servers(sym.home_dir(), &server_names, out); + } + crate::config::HookScope::Project => { + agent.unregister_project_hooks(&project_root, sym, out); + let _ = agent.unregister_project_mcp_servers(&project_root, &server_names, out); + } + } } } diff --git a/tests/init_sync.rs b/tests/init_sync.rs index c418ec56..298021e9 100644 --- a/tests/init_sync.rs +++ b/tests/init_sync.rs @@ -160,6 +160,61 @@ async fn sync_installs_workspace_plugin_skills() { .unwrap(); } +/// Antigravity reads the vendor-neutral skills path, keeps MCP in its own file, +/// and takes hooks in a named-entry `hooks.json` — none of which share a +/// location with its global equivalents, so project scope is worth pinning. +#[tokio::test] +async fn sync_installs_antigravity_at_project_scope() { + with_fixture( + TestMode::SimulationOnly, + &["plugins0", "workspace0"], + async |mut ctx| { + ctx.symposium(&[ + "init", + "--add-agent", + "antigravity", + "--hook-scope", + "project", + ]) + .await?; + ctx.symposium(&["sync"]).await?; + + let root = ctx.workspace_root.as_ref().unwrap(); + + let skill_dir = find_installed_skill(&root.join(".agents/skills"), "serde-guidance"); + assert!( + skill_dir.join(".symposium").exists(), + "skill installs as symposium-managed under .agents/skills" + ); + + let hooks_path = root.join(".agents/hooks.json"); + assert!(hooks_path.exists(), "hooks go to .agents/hooks.json"); + let hooks: Value = serde_json::from_str(&std::fs::read_to_string(&hooks_path)?)?; + let entry = &hooks["symposium"]; + assert_eq!( + entry["PreToolUse"][0]["hooks"][0]["command"], + "cargo-agents hook antigravity pre-tool-use", + "tool events are wrapped in a matcher group" + ); + assert_eq!( + entry["SessionStart"][0]["command"], "cargo-agents hook antigravity session-start", + "lifecycle events are a flat handler list" + ); + + // The global locations live under ~/.gemini/config; nothing should + // have been rooted at the workspace by mistake. + assert!( + !root.join(".gemini").exists(), + "project scope must not write the global path shape into the repo" + ); + + Ok(()) + }, + ) + .await + .unwrap(); +} + /// `sync` installs skill files into the agent's expected location. #[tokio::test] async fn sync_installs_skills() { From 00e7b4dddb685f88c35aac8b2d69f331d9f9d170 Mon Sep 17 00:00:00 2001 From: fluzko Date: Tue, 1 Sep 2026 10:38:33 -0300 Subject: [PATCH 2/3] docs(agents): document Antigravity CLI --- README.md | 4 +- md/SUMMARY.md | 2 + md/design/agent-details/README.md | 23 +- md/design/agent-details/antigravity-cli.md | 235 +++++++++++++++++++++ md/design/agents.md | 84 +++++++- md/design/common-issues.md | 23 ++ md/design/module-structure.md | 4 +- md/design/sync-agent-flow.md | 2 +- md/install.md | 3 +- md/reference/agents/antigravity.md | 50 +++++ md/reference/configuration.md | 2 +- md/reference/supported-agents.md | 2 +- 12 files changed, 413 insertions(+), 21 deletions(-) create mode 100644 md/design/agent-details/antigravity-cli.md create mode 100644 md/reference/agents/antigravity.md diff --git a/README.md b/README.md index 2e165282..f7ae2ab0 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,8 @@ cargo agents init ```text Which agents do you use? (space to select, enter to confirm): -> [ ] Claude Code +> [ ] Antigravity CLI + [ ] Claude Code [x] Codex CLI [ ] GitHub Copilot [ ] Gemini CLI @@ -126,6 +127,7 @@ Every agent receives skill installation. Hook registration is available for a su | Agent | Skill directory | Hooks | |-------|-----------------|:-----:| +| Antigravity CLI | `.agents/skills/` | Yes | | Claude Code | `.claude/skills/` | Yes | | GitHub Copilot | `.agents/skills/` | Yes | | Gemini CLI | `.agents/skills/` | Yes | diff --git a/md/SUMMARY.md b/md/SUMMARY.md index 0ed860b4..78415d38 100644 --- a/md/SUMMARY.md +++ b/md/SUMMARY.md @@ -44,6 +44,7 @@ - [Unstable agent commands](./reference/cargo-agents-unstable.md) - [`cargo agents hook`](./reference/cargo-agents-hook.md) - [Supported agents](./reference/supported-agents.md) + - [Antigravity CLI](./reference/agents/antigravity.md) - [Claude Code](./reference/agents/claude.md) - [GitHub Copilot](./reference/agents/copilot.md) - [Gemini CLI](./reference/agents/gemini.md) @@ -78,6 +79,7 @@ - [Governance](./design/governance.md) - [Common issues](./design/common-issues.md) - [Agent details](./design/agent-details/README.md) + - [Antigravity CLI](./design/agent-details/antigravity-cli.md) - [Claude Code](./design/agent-details/claude-code.md) - [GitHub Copilot](./design/agent-details/copilot.md) - [Gemini CLI](./design/agent-details/gemini-cli.md) diff --git a/md/design/agent-details/README.md b/md/design/agent-details/README.md index 37f6b048..92c8e62b 100644 --- a/md/design/agent-details/README.md +++ b/md/design/agent-details/README.md @@ -18,6 +18,7 @@ The tables below summarize the answers for each agent. Individual agent pages co | Agent | Project config path | Global config path | Format | |---|---|---|---| +| [Antigravity CLI](./antigravity-cli.md) | `.agents/hooks.json` | `~/.gemini/config/hooks.json` | JSON, named entries per hook | | [Claude Code](./claude-code.md) | `.claude/settings.json` | `~/.claude/settings.json` | JSON, `hooks` key with matcher groups | | [GitHub Copilot](./copilot.md) | `.github/hooks/*.json` | `~/.copilot/config.json` | JSON, `version: 1` with `hooks` key | | [Gemini CLI](./gemini-cli.md) | `.gemini/settings.json` | `~/.gemini/settings.json` | JSON, `hooks` key with matcher groups | @@ -30,6 +31,7 @@ The tables below summarize the answers for each agent. Individual agent pages co | Agent | Command field | Platform-specific? | |---|---|---| +| Antigravity CLI | `command` | No | | Claude Code | `command` | No | | GitHub Copilot | `bash` / `powershell` | Yes | | Gemini CLI | `command` | No | @@ -42,6 +44,7 @@ The tables below summarize the answers for each agent. Individual agent pages co | Agent | Default timeout | Unit | |---|---|---| +| Antigravity CLI | 30 | seconds (`timeout`) | | Claude Code | 600 | seconds | | GitHub Copilot | 30 | seconds (`timeoutSec`) | | Gemini CLI | 60,000 | milliseconds (`timeout`) | @@ -54,12 +57,12 @@ The tables below summarize the answers for each agent. Individual agent pages co Symposium registers hooks for four events. Each agent uses different names and casing conventions. -| Symposium event | Claude Code | Copilot | Gemini CLI | Codex CLI | Kiro CLI | OpenCode | Goose | -|---|---|---|---|---|---|---|---| -| pre-tool-use | `PreToolUse` | `preToolUse` | `BeforeTool` | `PreToolUse` | `preToolUse` | `tool.execute.before` | N/A | -| post-tool-use | `PostToolUse` | `postToolUse` | `AfterTool` | `PostToolUse` | `postToolUse` | `tool.execute.after` | N/A | -| user-prompt-submit | `UserPromptSubmit` | `userPromptSubmitted` | `BeforeAgent` | `UserPromptSubmit` | `userPromptSubmit` | `message.updated` (filter by role) | N/A | -| session-start | `SessionStart` | `sessionStart` | `SessionStart` | `SessionStart` | `agentSpawn` | `session.created` | N/A | +| Symposium event | Antigravity CLI | Claude Code | Copilot | Gemini CLI | Codex CLI | Kiro CLI | OpenCode | Goose | +|---|---|---|---|---|---|---|---|---| +| pre-tool-use | `PreToolUse` | `PreToolUse` | `preToolUse` | `BeforeTool` | `PreToolUse` | `preToolUse` | `tool.execute.before` | N/A | +| post-tool-use | `PostToolUse` | `PostToolUse` | `postToolUse` | `AfterTool` | `PostToolUse` | `postToolUse` | `tool.execute.after` | N/A | +| user-prompt-submit | `PreInvocation` | `UserPromptSubmit` | `userPromptSubmitted` | `BeforeAgent` | `UserPromptSubmit` | `userPromptSubmit` | `message.updated` (filter by role) | N/A | +| session-start | `SessionStart` | `SessionStart` | `sessionStart` | `SessionStart` | `SessionStart` | `agentSpawn` | `session.created` | N/A | ### Blocking support @@ -67,6 +70,7 @@ Not all events can block the action in all agents. | Agent | Pre-tool-use can block? | Post-tool-use can block? | User-prompt can block? | Session-start can block? | |---|---|---|---|---| +| Antigravity CLI | Yes | No | No | No | | Claude Code | Yes | No | Yes (exit 2) | No | | GitHub Copilot | Yes | No | No | No | | Gemini CLI | Yes | Yes (block result) | Yes (deny discards message) | No | @@ -81,6 +85,7 @@ Not all events can block the action in all agents. | Agent | Tool name field | Tool args field | Session/context fields | |---|---|---|---| +| Antigravity CLI | `toolCall.name` | `toolCall.args` (object) | `conversationId`, `workspacePaths` (array), `stepIdx` | | Claude Code | `tool_name` | `tool_input` (object) | `session_id`, `cwd`, `hook_event_name` | | GitHub Copilot | `toolName` | `toolArgs` (JSON **string**) | `timestamp`, `cwd` | | Gemini CLI | `tool_name` | `tool_input` (object) | `session_id`, `cwd`, `hook_event_name`, `timestamp` | @@ -93,6 +98,7 @@ Not all events can block the action in all agents. | Agent | Permission decision field | Decision values | Modified input field | Nesting | |---|---|---|---|---| +| Antigravity CLI | `decision` (**required**) | allow, deny, ask, force_ask | `overwrite` | flat | | Claude Code | `permissionDecision` | allow, deny, ask, defer | `updatedInput` | nested in `hookSpecificOutput` | | GitHub Copilot | `permissionDecision` | allow, deny, ask | `modifiedArgs` | flat | | Gemini CLI | `decision` | allow, deny | `tool_input` | nested in `hookSpecificOutput` | @@ -111,7 +117,7 @@ All shell-based agents use the same convention (where applicable): | `2` | Block/deny; stderr used as reason | | Other | Non-blocking warning, action proceeds | -**Exceptions**: Copilot uses exit 0 = allow, non-zero = deny (no special meaning for exit 2). OpenCode uses JS exceptions, not exit codes. +**Exceptions**: Copilot uses exit 0 = allow, non-zero = deny (no special meaning for exit 2). OpenCode uses JS exceptions, not exit codes. **Antigravity ignores exit codes entirely** — only stdout decides, and on `PreToolUse` an object without a valid `decision` (`{}` included) is a denial. ## Extension installation @@ -119,6 +125,7 @@ All shell-based agents use the same convention (where applicable): | Agent | Project skills path | Global skills path | |---|---|---| +| Antigravity CLI | `.agents/skills//SKILL.md` | `~/.gemini/config/skills//SKILL.md` | | Claude Code | `.claude/skills//SKILL.md` | `~/.claude/skills//SKILL.md` | | GitHub Copilot | `.agents/skills//SKILL.md` | *(none)* | | Gemini CLI | `.agents/skills//SKILL.md` | `~/.gemini/skills//SKILL.md` | @@ -133,6 +140,7 @@ Symposium uses the vendor-neutral `.agents/skills/` path whenever the agent supp | Agent | Project instructions | Global instructions | |---|---|---| +| Antigravity CLI | `AGENTS.md`, `GEMINI.md`, `.agents/rules/*.md` | *(none)* | | Claude Code | `CLAUDE.md`, `.claude/CLAUDE.md` | `~/.claude/CLAUDE.md` | | GitHub Copilot | `.github/copilot-instructions.md`, `AGENTS.md` | `~/.copilot/copilot-instructions.md` | | Gemini CLI | `GEMINI.md` (walks up to `.git`) | `~/.gemini/GEMINI.md` | @@ -147,6 +155,7 @@ Relevant if symposium exposes functionality via MCP. | Agent | MCP config location | Format | |---|---|---| +| Antigravity CLI | `.agents/mcp_config.json` / `~/.gemini/config/mcp_config.json` (`mcpServers` key) | JSON | | Claude Code | `.claude/settings.json` (`mcpServers` key) | JSON | | GitHub Copilot | `.vscode/mcp.json` (VS Code), `~/.copilot/mcp-config.json` (CLI) | JSON | | Gemini CLI | `.gemini/settings.json` (`mcpServers` key) | JSON | diff --git a/md/design/agent-details/antigravity-cli.md b/md/design/agent-details/antigravity-cli.md new file mode 100644 index 00000000..6a9c3c26 --- /dev/null +++ b/md/design/agent-details/antigravity-cli.md @@ -0,0 +1,235 @@ +# Antigravity CLI Hooks Reference + +> **Disclaimer:** This document reflects our current understanding of Antigravity CLI's +> hook system. It is a working reference for symposium development, not a substitute for +> the official docs. Details may be outdated or incomplete — always consult the primary +> sources. +> +> **Primary sources:** +> [Hooks](https://antigravity.google/docs/hooks/) +> · [Plugins & Skills](https://antigravity.google/docs/cli/plugins/) +> · [GitHub repo](https://github.com/google-antigravity/antigravity-cli) +> · the bundled `agy-customizations` skill under +> `~/.gemini/antigravity-cli/builtin/skills/`, which is more precise than the website + +Google's Antigravity CLI (`agy`) exposes shell-command hooks through a dedicated +`hooks.json`. Antigravity ships three surfaces — the CLI, the IDE, and the web +app — which share the same configuration roots. + +## Configuration + +| File | Scope | +|---|---| +| `~/.gemini/config/hooks.json` | User-global | +| `/.agents/hooks.json` | Project-scoped | + +Both are additive; all matching hooks run, each with its working directory set to +the directory containing its own `hooks.json`. + +Project scope works in ordinary interactive use: `agy` walks up from the working +directory to find `.agents/`, and loads project hooks on a second pass once it has +adopted the workspace. + +**Headless print mode is the exception.** `agy -p` adopts no workspace unless +given `--add-dir ` — it cannot read project files and loads no +project `.agents/` configuration. A relative `--add-dir .` is ignored. Automation +driving `agy -p` against a project must pass an absolute path. + +### Configuration structure + +Each top-level key is a **hook name** mapping to its events. `PreToolUse` and +`PostToolUse` wrap handlers in a `matcher` group; `PreInvocation`, +`PostInvocation` and `Stop` take flat handler lists. + +```json +{ + "symposium": { + "PreToolUse": [ + { + "matcher": "*", + "hooks": [ + { "type": "command", "command": "cargo-agents hook antigravity pre-tool-use", "timeout": 30 } + ] + } + ], + "PreInvocation": [ + { "type": "command", "command": "cargo-agents hook antigravity pre-invocation" } + ] + } +} +``` + +`enabled: false` on a named hook disables all its handlers. `timeout` is in +**seconds** and defaults to 30. Only `type: "command"` is supported; commands run +via `sh -c` (`cmd /c` on Windows). + +## Events + +| Event | When it fires | Matcher | +|---|---|---| +| `PreToolUse` | before a tool step executes | tool name | +| `PostToolUse` | after a tool step completes | tool name | +| `PreInvocation` | before the model is called | n/a | +| `PostInvocation` | after tool calls finish | n/a | +| `Stop` | when the execution loop terminates | n/a | +| `SessionStart` | once per session | n/a | + +`SessionStart` is **absent from the official documentation** but present in the +binary's hook proto, and it works: registered in `hooks.json` it loads, fires +once per session, and carries a populated `workspacePaths`. `PreInvocation` by +contrast fires before every model call, and its `invocationNum` restarts at 0 +each turn, so it marks the start of a turn rather than of a session — which is +why symposium maps it to `user-prompt-submit` and gates it on `invocationNum == 0`. + +Unknown event keys are accepted silently and never fire, so a misspelled event +name fails with no error at all. + +Matchers are regexes over tool names, which are the lowercased step type without +its `CORTEX_STEP_TYPE_` prefix — `run_command`, `view_file`, `browser_.*`. A +matcher written for another agent's tool names (`Bash`, `Edit`) will not match. + +## Input Schema (stdin) + +All keys are camelCase (protojson). + +### Base fields (all events) + +```json +{ + "conversationId": "5e4f131c-…", + "workspacePaths": ["/path/to/workspace"], + "transcriptPath": "…/transcript_full.jsonl", + "artifactDirectoryPath": "…", + "modelName": "gemini-3.7-flash-high" +} +``` + +`workspacePaths` is populated once the workspace is adopted, and **empty** under +`agy -p` without `--add-dir`. A hook's working directory is always the directory +holding its own `hooks.json`, never the user's project. + +### PreToolUse / PostToolUse additions + +```json +{ + "toolCall": { "name": "run_command", "args": { "CommandLine": "npm test" } }, + "stepIdx": 2, + "error": "" +} +``` + +`error` is present on `PostToolUse` only. Both events carry `toolCall`. + +### PreInvocation / PostInvocation additions + +```json +{ "invocationNum": 0, "initialNumSteps": 1 } +``` + +### Stop additions + +```json +{ "executionNum": 0, "terminationReason": "NO_TOOL_CALL", "error": "", "fullyIdle": true } +``` + +## Output Schema (stdout) + +### PreToolUse + +```json +{ "decision": "allow", "reason": "optional", "permissionOverrides": [] } +``` + +`decision` is required: `allow`, `deny`, `ask`, `force_ask`, or +`deny_unless_prior_grant`. An `overwrite` object shallow-merges into the tool +call's arguments before it runs. + +**Writing `{}` denies the call.** So do `{"decision": ""}` and objects carrying +only other fields. Writing nothing at all allows. Any hook that does not intend to +block must emit an explicit `{"decision": "allow"}`. + +### PostToolUse + +Expects `{}`. + +### PreInvocation — inject context + +```json +{ "injectSteps": [{ "ephemeralMessage": "..." }] } +``` + +Each step accepts one of `toolCall`, `userMessage`, or `ephemeralMessage`. This is +the equivalent of Claude Code's `additionalContext`. + +### PostInvocation + +`injectSteps` as above, plus `terminationBehavior`: `force_continue`, `terminate`, +or omitted. + +### Stop + +```json +{ "decision": "continue", "reason": "required when continuing" } +``` + +Any value other than `continue` lets the agent stop. + +## Exit Codes + +**Ignored.** Exit 1 and exit 2 behave exactly as exit 0; only stdout decides the +outcome. This differs from every other agent symposium supports, where exit 2 +blocks. + +## Skills + +| Scope | Path | +|---|---| +| Project | `/.agents/skills//SKILL.md` | +| Global | `~/.gemini/config/skills//SKILL.md` | + +A skill is a directory containing `SKILL.md` with `name` and `description` +frontmatter. Additional files and subdirectories (`scripts/`, `examples/`, +`resources/`, `references/`) are supported, so symposium's `.symposium` marker and +`.gitignore` are preserved. The CLI also reads +`~/.gemini/antigravity-cli/skills/` and `~/.gemini/skills/`, but +`~/.gemini/config/skills/` is the location all three surfaces recognize. + +### Registering skills from another location + +`~/.gemini/config/skills.json` registers skill directories stored outside the +default locations, and is read on every run: + +```json +{ "entries": [{ "path": "/abs/path/to/repo/.agents/skills", "exclude": ["experimental-.*"] }] } +``` + +Paths must be **absolute**. The schema also documents workspace-relative paths, +but an entry of `.agents/skills` resolves to nothing in practice. + +The list is global with no notion of the active repository, so every indexed +directory loads in every session; `include_only` and `exclude` filter by skill +directory name, not by workspace. Symposium does not use this file — project +skills are found by ordinary discovery — but it is the mechanism for skills kept +outside the standard locations. + +Symlinks are followed, both a symlinked skill directory inside a skills folder +and a symlink of the folder itself. A separate report of symlinked skills being +ignored concerns the IDE and `~/.gemini/antigravity/skills/`. + +`plugins.json` follows the same schema for plugin directories. + +## MCP server configuration + +| Scope | Path | +|---|---| +| Project | `/.agents/mcp_config.json` | +| Global | `~/.gemini/config/mcp_config.json` | + +Standard `mcpServers` object; stdio entries use `command`/`args`/`env`, remote +entries use `serverUrl`. Unlike Gemini CLI, MCP configuration does **not** share a +file with hooks. `agy mcp add` has no scope flag and writes the global file. + +## Custom instructions + +`GEMINI.md` and `AGENTS.md` at the workspace root, plus `.agents/rules/*.md`, +loaded by walking up to the repository root. diff --git a/md/design/agents.md b/md/design/agents.md index 95a92486..27d5cae9 100644 --- a/md/design/agents.md +++ b/md/design/agents.md @@ -6,6 +6,7 @@ | Config name | Agent | |-------------|-------| +| `antigravity` | Antigravity CLI | | `claude` | Claude Code | | `copilot` | GitHub Copilot | | `gemini` | Gemini CLI | @@ -31,16 +32,17 @@ When installing skills, `cargo agents` prefers vendor-neutral paths where possib | Scope | Path | Supported by | |-------|------|-------------| -| Project skills | `.agents/skills//SKILL.md` | Copilot, Gemini, Codex, OpenCode, Goose | +| Project skills | `.agents/skills//SKILL.md` | Antigravity, Copilot, Gemini, Codex, OpenCode, Goose | | Project skills | `.claude/skills//SKILL.md` | Claude Code (does not support `.agents/skills/`) | | Project skills | `.kiro/skills//SKILL.md` | Kiro (uses its own path) | -At the project level, Claude Code requires `.claude/skills/`, Kiro requires `.kiro/skills/`, while Copilot, Gemini, Codex, OpenCode, and Goose all support `.agents/skills/`. `cargo agents` uses the vendor-neutral `.agents/skills/` path whenever the agent supports it. +At the project level, Claude Code requires `.claude/skills/`, Kiro requires `.kiro/skills/`, while Antigravity, Copilot, Gemini, Codex, OpenCode, and Goose all support `.agents/skills/`. `cargo agents` uses the vendor-neutral `.agents/skills/` path whenever the agent supports it. At the global level, each agent has its own path: | Agent | Global skills path | |-------|-------------------| +| Antigravity CLI | `~/.gemini/config/skills//SKILL.md` | | Claude Code | `~/.claude/skills//SKILL.md` | | Copilot | *(no global skills path)* | | Gemini | `~/.gemini/skills//SKILL.md` | @@ -248,6 +250,72 @@ The input payload includes `tool_name`, `tool_input`, `mcp_context`, `session_id --- +## Antigravity CLI + +[Hooks reference](./agent-details/antigravity-cli.md) + +### Hook registration + +Antigravity hooks live in a dedicated `hooks.json` under the customization root — +`.agents/` in a workspace, `~/.gemini/config/` globally. Entries are keyed by a +**hook name**, so symposium owns a single `symposium` key and leaves anything +else in the file untouched. + +| Scope | Path | +|-------|------| +| Global | `~/.gemini/config/hooks.json` | +| Project | `.agents/hooks.json` | + +```json +{ + "symposium": { + "PreToolUse": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "cargo-agents hook antigravity pre-tool-use", + "timeout": 30 + } + ] + } + ], + "SessionStart": [ + { + "type": "command", + "command": "cargo-agents hook antigravity session-start", + "timeout": 30 + } + ] + } +} +``` + +Note the two shapes: tool events (`PreToolUse`, `PostToolUse`) wrap their +handlers in a `matcher` group, while lifecycle events (`PreInvocation`, +`SessionStart`, `Stop`) take a flat list. Timeouts are in seconds, not +milliseconds. An unrecognised shape or an unknown event name is accepted +silently and simply never fires. + +`SessionStart` is missing from Antigravity's published documentation but present +in its hook proto, and fires once per session. There is no prompt event, so +`PreInvocation` stands in for `user-prompt-submit`; because it fires before every +model call, dispatch runs the prompt event only when `invocationNum == 0`. + +### Hook output + +Exit codes are ignored entirely — only stdout matters. On `PreToolUse`, an object +without a valid `decision` (including `{}`) is treated as a **denial**, so +symposium always emits `{"decision": "allow"}` unless a plugin actually denied. +Context is returned as an injected step rather than an `additionalContext` field: + +```json +{ "injectSteps": [{ "ephemeralMessage": "..." }] } +``` + +--- + ## Kiro [Hooks reference](./agent-details/kiro.md) @@ -401,12 +469,12 @@ Goose is supported as a **skills-only** agent — `cargo agents sync` will insta The following table maps symposium's internal event names to each agent's wire-format event name. `—` means the agent does not support shell-command hooks. -| Symposium event | Claude | Copilot | Gemini | Codex | Kiro | OpenCode | Goose | -|---|---|---|---|---|---|---|---| -| `pre-tool-use` | `PreToolUse` | `preToolUse` | `BeforeTool` | `PreToolUse` | `preToolUse` | — | — | -| `post-tool-use` | `PostToolUse` | `postToolUse` | `AfterTool` | `PostToolUse` | `postToolUse` | — | — | -| `user-prompt-submit` | `UserPromptSubmit` | `userPromptSubmitted` | `BeforeAgent` | `UserPromptSubmit` | `userPromptSubmit` | — | — | -| `session-start` | `SessionStart` | `sessionStart` | `SessionStart` | `SessionStart` | `agentSpawn` | — | — | +| Symposium event | Antigravity | Claude | Copilot | Gemini | Codex | Kiro | OpenCode | Goose | +|---|---|---|---|---|---|---|---|---| +| `pre-tool-use` | `PreToolUse` | `PreToolUse` | `preToolUse` | `BeforeTool` | `PreToolUse` | `preToolUse` | — | — | +| `post-tool-use` | `PostToolUse` | `PostToolUse` | `postToolUse` | `AfterTool` | `PostToolUse` | `postToolUse` | — | — | +| `user-prompt-submit` | `PreInvocation` | `UserPromptSubmit` | `userPromptSubmitted` | `BeforeAgent` | `UserPromptSubmit` | `userPromptSubmit` | — | — | +| `session-start` | `SessionStart` | `SessionStart` | `sessionStart` | `SessionStart` | `SessionStart` | `agentSpawn` | — | — | --- diff --git a/md/design/common-issues.md b/md/design/common-issues.md index b3b56dbb..a49a9606 100644 --- a/md/design/common-issues.md +++ b/md/design/common-issues.md @@ -16,6 +16,29 @@ Copilot sends `toolArgs` as a JSON *string* (not an object). Our `CopilotPreTool `ensure_gemini_hook_entry` uses `"matcher": ".*"` for all events including `SessionStart`. Per the Gemini reference, lifecycle events use exact-string matchers, not regex. Likely harmless in practice since `".*"` matches anything. +## Antigravity footguns + +Two Antigravity behaviours fail silently rather than loudly, so they are worth +knowing before debugging a hook that "does nothing". + +### `{}` on `PreToolUse` denies the tool call + +Antigravity ignores hook exit codes entirely; only stdout decides. On +`PreToolUse`, an object without a valid `decision` — `{}` included, as well as +`{"decision": ""}` — is treated as a **denial**, while writing nothing at all +allows. Symposium's dispatcher returns `{}` whenever no plugin contributed, which +is the common case, so `AntigravityPreToolUseOutput` keeps `decision` as a plain +always-serialized field defaulting to `allow`. Making it `Option` or adding +`skip_serializing_if` would block every tool call. + +### Unknown event names and shapes are accepted and never fire + +An unrecognised event key in `hooks.json`, or the wrong structure for a known one +(a flat handler list where a `matcher` group is expected, or vice versa), produces +no error — the hook simply never runs. `ANTIGRAVITY_EVENTS` and +`antigravity_is_tool_event` in `agents/mod.rs` are the single source of truth for +both, and the unit tests assert the shape per event for exactly this reason. + ## Windows portability (tests) The test suite runs on `windows-latest`. A few patterns recur when writing tests that touch paths or scripts: diff --git a/md/design/module-structure.md b/md/design/module-structure.md index d2590a0b..2e50cdad 100644 --- a/md/design/module-structure.md +++ b/md/design/module-structure.md @@ -16,7 +16,9 @@ The cargo-workspace resolution is **CargoPm's**, so it lives in the cargo PM's m ### `agents.rs` — agent abstraction -Centralizes agent-specific knowledge: hook registration file paths, skill installation directories, and hook registration logic for each supported agent (Claude Code, GitHub Copilot, Gemini CLI, Codex CLI, Kiro, OpenCode, Goose). Handles the differences between agents — e.g., Claude Code uses `.claude/skills/` and Kiro uses `.kiro/skills/`, while Copilot, Gemini, Codex, OpenCode, and Goose use the vendor-neutral `.agents/skills/`. OpenCode and Goose are skills-only agents (no hook registration). +Centralizes agent-specific knowledge: hook registration file paths, skill installation directories, and hook registration logic for each supported agent (Antigravity CLI, Claude Code, GitHub Copilot, Gemini CLI, Codex CLI, Kiro, OpenCode, Goose). Handles the differences between agents — e.g., Claude Code uses `.claude/skills/` and Kiro uses `.kiro/skills/`, while Antigravity, Copilot, Gemini, Codex, OpenCode, and Goose use the vendor-neutral `.agents/skills/`. OpenCode and Goose are skills-only agents (no hook registration). + +Hook and MCP registration is scope-dispatched: the project and global locations are genuinely different files for some agents (Antigravity writes `.agents/hooks.json` but `~/.gemini/config/hooks.json`; Copilot `.github/hooks/` but `~/.copilot/settings.json`), so `sync` calls the project-scoped functions at project scope rather than rooting the global path at the workspace. ### `init.rs` — initialization command diff --git a/md/design/sync-agent-flow.md b/md/design/sync-agent-flow.md index 3ef94650..fcc74d6c 100644 --- a/md/design/sync-agent-flow.md +++ b/md/design/sync-agent-flow.md @@ -27,7 +27,7 @@ Scans workspace dependencies, installs applicable skills into agent directories, ## Marker file -Each skill directory symposium installs contains an empty `.symposium` file. Cleanup walks every agent's skills parent directory (`.claude/skills/`, `.agents/skills/`, `.kiro/skills/`, `.gemini/skills/`) and reaps any subdirectory whose marker is present but which wasn't installed this sync. This lets symposium reclaim stale skills (including those left behind by agents removed from the config) without touching user-managed skills, which are identified by the absence of the marker. +Each skill directory symposium installs contains an empty `.symposium` file. Cleanup walks every agent's skills parent directory (`.claude/skills/`, `.agents/skills/`, `.kiro/skills/`) and reaps any subdirectory whose marker is present but which wasn't installed this sync. This lets symposium reclaim stale skills (including those left behind by agents removed from the config) without touching user-managed skills, which are identified by the absence of the marker. ## Gitignore diff --git a/md/install.md b/md/install.md index ef39e970..481aab7e 100644 --- a/md/install.md +++ b/md/install.md @@ -28,7 +28,8 @@ This will prompt you to select the agents you use (Claude Code, Copilot, Gemini, ```bash Which agents do you use? (space to select, enter to confirm): -> [ ] Claude Code +> [ ] Antigravity CLI + [ ] Claude Code [x] Codex CLI [ ] GitHub Copilot [ ] Gemini CLI diff --git a/md/reference/agents/antigravity.md b/md/reference/agents/antigravity.md new file mode 100644 index 00000000..1c41e66c --- /dev/null +++ b/md/reference/agents/antigravity.md @@ -0,0 +1,50 @@ +# Antigravity CLI + +Config name: `antigravity` + +Google's Antigravity CLI, invoked as `agy`. Antigravity ships three surfaces — +the CLI, the IDE and the web app — which share the same configuration roots, so +what symposium writes applies to all of them. + +## Skills + +| Scope | Path | +|-------|------| +| Project | `.agents/skills//SKILL.md` | +| Global | `~/.gemini/config/skills//SKILL.md` | + +## Hooks + +Symposium merges a single named entry, `symposium`, into Antigravity's +`hooks.json`. Other entries in the file are left alone, and only symposium's own +is removed when the agent is unconfigured. + +| Scope | File | +|-------|------| +| Project | `.agents/hooks.json` | +| Global | `~/.gemini/config/hooks.json` | + +Events registered: `PreToolUse`, `PostToolUse`, `PreInvocation`, `SessionStart`, +`Stop`. + +Output format: JSON. Timeouts are in **seconds** (30 by default). Exit codes are +ignored — only what a hook writes to stdout affects the agent. + +**Caveat:** `PreInvocation` stands in for symposium's `user-prompt-submit` +because Antigravity has no prompt event. It fires before *every* model call, so +symposium runs the prompt event only on the first invocation of a turn. + +**Caveat:** in headless print mode (`agy -p`), Antigravity adopts no workspace +unless it is given `--add-dir ` — it cannot read project files and +loads no project `.agents/` configuration. A relative path is ignored. Ordinary +interactive use is unaffected; this only matters for automation and CI. + +## MCP servers + +| Scope | File | Key | +|-------|------|-----| +| Project | `.agents/mcp_config.json` | `mcpServers.` | +| Global | `~/.gemini/config/mcp_config.json` | `mcpServers.` | + +Unlike Gemini CLI, MCP configuration lives in its own file rather than sharing +one with hooks. `agy mcp add` has no scope flag and writes the global file. diff --git a/md/reference/configuration.md b/md/reference/configuration.md index ffcdae98..89d34937 100644 --- a/md/reference/configuration.md +++ b/md/reference/configuration.md @@ -74,7 +74,7 @@ Each `[[agent]]` entry identifies an agent you use. You can configure multiple a | Key | Type | Default | Description | |-----|------|---------|-------------| -| `name` | string | *(required)* | Agent name: `claude`, `codex`, `copilot`, `gemini`, `goose`, `kiro`, or `opencode`. | +| `name` | string | *(required)* | Agent name: `antigravity`, `claude`, `codex`, `copilot`, `gemini`, `goose`, `kiro`, or `opencode`. | ## `[logging]` diff --git a/md/reference/supported-agents.md b/md/reference/supported-agents.md index 2a91eb45..ea7675f5 100644 --- a/md/reference/supported-agents.md +++ b/md/reference/supported-agents.md @@ -1,3 +1,3 @@ # Supported agents -Symposium supports seven AI coding agents. Each agent gets skill installation; hook support varies by agent. +Symposium supports eight AI coding agents. Each agent gets skill installation; hook support varies by agent. From bbbbdbdcc03b8d2e18acc98d0afc30819225e296 Mon Sep 17 00:00:00 2001 From: fluzko Date: Tue, 1 Sep 2026 10:51:29 -0300 Subject: [PATCH 3/3] feat!: retire the Gemini CLI agent Gemini CLI's consumer sign-in is closed -- Google's own message directs those users to Antigravity, which symposium now supports. Removes the agent, its hook wire format and its MCP registration. Removing an agent also removes the code that would otherwise reap what symposium installed for it, so the leftovers are handled rather than abandoned: - A `gemini` entry in the user config is reported and skipped by `Agent::from_configured_name` rather than failing the command. A user config outlives the release that drops an agent, so erroring would break every invocation until the file was edited by hand. - `HookAgentArg` parses a retired name to nothing to dispatch, so a hook registration still sitting in `.gemini/settings.json` exits cleanly instead of failing inside that agent's session. - `HookFormat::Retired` keeps a published plugin manifest declaring `format = "gemini"` loadable, with only that hook skipped. - `migrations.rs` runs once per config directory, keyed by id in `state.toml`: it drops the config entry, unregisters those hooks, and reaps the marker-bearing skill directories under `~/.gemini/skills/`. It deliberately does not touch `~/.gemini/config/`, which is where Antigravity keeps its own. Project skills lived in the shared `.agents/skills/`, which other agents still use, so the ordinary marker-based cleanup handles those. --- CHANGELOG.md | 25 ++ README.md | 2 - md/SUMMARY.md | 2 - md/design/agent-details/README.md | 21 +- md/design/agent-details/gemini-cli.md | 253 -------------- md/design/agents.md | 91 +---- md/design/common-issues.md | 4 - md/design/hooks.md | 8 +- md/design/init-user-flow.md | 4 +- md/design/module-structure.md | 4 +- md/install.md | 3 +- md/reference/agents/gemini.md | 30 -- md/reference/agents/kiro.md | 2 +- md/reference/cargo-agents-init.md | 8 +- md/reference/cargo-agents-sync.md | 2 +- md/reference/configuration.md | 6 +- md/reference/plugin-definition.md | 13 +- md/reference/supported-agents.md | 2 +- src/agents/mcp_server_registration.rs | 21 +- src/agents/mod.rs | 186 ++-------- src/bin/cargo-agents.rs | 12 +- src/cli.rs | 6 +- src/config.rs | 2 +- src/hook.rs | 11 +- src/hook_schema.rs | 54 ++- src/hook_schema/gemini.rs | 479 -------------------------- src/init.rs | 22 +- src/lib.rs | 1 + src/migrations.rs | 176 ++++++++++ src/plugins.rs | 19 +- src/state.rs | 39 +++ src/sync.rs | 8 +- symposium-testlib/src/lib.rs | 5 +- tests/init_sync.rs | 68 +++- 34 files changed, 488 insertions(+), 1101 deletions(-) delete mode 100644 md/design/agent-details/gemini-cli.md delete mode 100644 md/reference/agents/gemini.md delete mode 100644 src/hook_schema/gemini.rs create mode 100644 src/migrations.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index a7eb3163..5324ef1d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- support Antigravity CLI (`agy`) as an agent: skills, MCP servers and hooks + +### Removed + +- drop support for Gemini CLI + + Its consumer sign-in is closed — Google now directs those users to + Antigravity. A `gemini` entry in `~/.symposium/config.toml` is reported and + skipped rather than failing, a plugin manifest declaring `format = "gemini"` + still loads with that hook skipped, and a hook registration left in + `.gemini/settings.json` exits cleanly. A one-shot migration drops the config + entry, unregisters those hooks, and removes the skill directories symposium + installed under `~/.gemini/skills/`. + +### Fixed + +- register hooks and MCP servers at the configured scope + + `sync` passed the workspace root to the global registration functions, which + only produces the right path for agents whose project and global locations + share a shape. Copilot's project hooks were going to `.copilot/settings.json` + instead of `.github/hooks/`. + ## [0.4.0](https://github.com/symposium-dev/symposium/compare/symposium-v0.3.0...symposium-v0.4.0) - 2026-05-14 ### Added diff --git a/README.md b/README.md index f7ae2ab0..f38bb02a 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,6 @@ Which agents do you use? (space to select, enter to confirm): [ ] Claude Code [x] Codex CLI [ ] GitHub Copilot - [ ] Gemini CLI [ ] Goose [x] Kiro [x] OpenCode @@ -130,7 +129,6 @@ Every agent receives skill installation. Hook registration is available for a su | Antigravity CLI | `.agents/skills/` | Yes | | Claude Code | `.claude/skills/` | Yes | | GitHub Copilot | `.agents/skills/` | Yes | -| Gemini CLI | `.agents/skills/` | Yes | | Codex CLI | `.agents/skills/` | Yes | | Kiro | `.kiro/skills/` | Yes | | OpenCode | `.agents/skills/` | No | diff --git a/md/SUMMARY.md b/md/SUMMARY.md index 78415d38..186fa4bf 100644 --- a/md/SUMMARY.md +++ b/md/SUMMARY.md @@ -47,7 +47,6 @@ - [Antigravity CLI](./reference/agents/antigravity.md) - [Claude Code](./reference/agents/claude.md) - [GitHub Copilot](./reference/agents/copilot.md) - - [Gemini CLI](./reference/agents/gemini.md) - [Codex CLI](./reference/agents/codex.md) - [Kiro](./reference/agents/kiro.md) - [OpenCode](./reference/agents/opencode.md) @@ -82,7 +81,6 @@ - [Antigravity CLI](./design/agent-details/antigravity-cli.md) - [Claude Code](./design/agent-details/claude-code.md) - [GitHub Copilot](./design/agent-details/copilot.md) - - [Gemini CLI](./design/agent-details/gemini-cli.md) - [Codex CLI](./design/agent-details/codex-cli.md) - [Goose](./design/agent-details/goose.md) - [Kiro](./design/agent-details/kiro.md) diff --git a/md/design/agent-details/README.md b/md/design/agent-details/README.md index 92c8e62b..ab114deb 100644 --- a/md/design/agent-details/README.md +++ b/md/design/agent-details/README.md @@ -21,7 +21,6 @@ The tables below summarize the answers for each agent. Individual agent pages co | [Antigravity CLI](./antigravity-cli.md) | `.agents/hooks.json` | `~/.gemini/config/hooks.json` | JSON, named entries per hook | | [Claude Code](./claude-code.md) | `.claude/settings.json` | `~/.claude/settings.json` | JSON, `hooks` key with matcher groups | | [GitHub Copilot](./copilot.md) | `.github/hooks/*.json` | `~/.copilot/config.json` | JSON, `version: 1` with `hooks` key | -| [Gemini CLI](./gemini-cli.md) | `.gemini/settings.json` | `~/.gemini/settings.json` | JSON, `hooks` key with matcher groups | | [Codex CLI](./codex-cli.md) | `.codex/hooks.json` | `~/.codex/hooks.json` | JSON, `hooks` key with matcher groups | | [Kiro](./kiro.md) | `.kiro/agents/*.json` | `~/.kiro/agents/*.json` | JSON, `hooks` key in agent config | | [OpenCode](./opencode.md) | `.opencode/plugins/` | `~/.config/opencode/plugins/` | JS/TS plugins (not shell hooks) | @@ -34,7 +33,6 @@ The tables below summarize the answers for each agent. Individual agent pages co | Antigravity CLI | `command` | No | | Claude Code | `command` | No | | GitHub Copilot | `bash` / `powershell` | Yes | -| Gemini CLI | `command` | No | | Codex CLI | `command` | No | | Kiro | `command` | No | | OpenCode | N/A (JS function) | N/A | @@ -47,7 +45,6 @@ The tables below summarize the answers for each agent. Individual agent pages co | Antigravity CLI | 30 | seconds (`timeout`) | | Claude Code | 600 | seconds | | GitHub Copilot | 30 | seconds (`timeoutSec`) | -| Gemini CLI | 60,000 | milliseconds (`timeout`) | | Codex CLI | 600 | seconds (`timeout` or `timeoutSec`) | | Kiro | 30,000 | milliseconds (`timeout_ms`) | | OpenCode | 60,000 | milliseconds (community hooks plugin) | @@ -57,12 +54,12 @@ The tables below summarize the answers for each agent. Individual agent pages co Symposium registers hooks for four events. Each agent uses different names and casing conventions. -| Symposium event | Antigravity CLI | Claude Code | Copilot | Gemini CLI | Codex CLI | Kiro CLI | OpenCode | Goose | -|---|---|---|---|---|---|---|---|---| -| pre-tool-use | `PreToolUse` | `PreToolUse` | `preToolUse` | `BeforeTool` | `PreToolUse` | `preToolUse` | `tool.execute.before` | N/A | -| post-tool-use | `PostToolUse` | `PostToolUse` | `postToolUse` | `AfterTool` | `PostToolUse` | `postToolUse` | `tool.execute.after` | N/A | -| user-prompt-submit | `PreInvocation` | `UserPromptSubmit` | `userPromptSubmitted` | `BeforeAgent` | `UserPromptSubmit` | `userPromptSubmit` | `message.updated` (filter by role) | N/A | -| session-start | `SessionStart` | `SessionStart` | `sessionStart` | `SessionStart` | `SessionStart` | `agentSpawn` | `session.created` | N/A | +| Symposium event | Antigravity CLI | Claude Code | Copilot | Codex CLI | Kiro CLI | OpenCode | Goose | +|---|---|---|---|---|---|---|---| +| pre-tool-use | `PreToolUse` | `PreToolUse` | `preToolUse` | `PreToolUse` | `preToolUse` | `tool.execute.before` | N/A | +| post-tool-use | `PostToolUse` | `PostToolUse` | `postToolUse` | `PostToolUse` | `postToolUse` | `tool.execute.after` | N/A | +| user-prompt-submit | `PreInvocation` | `UserPromptSubmit` | `userPromptSubmitted` | `UserPromptSubmit` | `userPromptSubmit` | `message.updated` (filter by role) | N/A | +| session-start | `SessionStart` | `SessionStart` | `sessionStart` | `SessionStart` | `agentSpawn` | `session.created` | N/A | ### Blocking support @@ -73,7 +70,6 @@ Not all events can block the action in all agents. | Antigravity CLI | Yes | No | No | No | | Claude Code | Yes | No | Yes (exit 2) | No | | GitHub Copilot | Yes | No | No | No | -| Gemini CLI | Yes | Yes (block result) | Yes (deny discards message) | No | | Codex CLI | Yes | Yes (`continue: false`) | Yes (`continue: false`) | Yes (`continue: false`) | | Kiro | Yes (exit 2) | No | No | No | | OpenCode | Yes (throw Error) | No | No (observe only) | No (observe only) | @@ -88,7 +84,6 @@ Not all events can block the action in all agents. | Antigravity CLI | `toolCall.name` | `toolCall.args` (object) | `conversationId`, `workspacePaths` (array), `stepIdx` | | Claude Code | `tool_name` | `tool_input` (object) | `session_id`, `cwd`, `hook_event_name` | | GitHub Copilot | `toolName` | `toolArgs` (JSON **string**) | `timestamp`, `cwd` | -| Gemini CLI | `tool_name` | `tool_input` (object) | `session_id`, `cwd`, `hook_event_name`, `timestamp` | | Codex CLI | `tool_name` | `tool_input` (object) | `session_id`, `cwd`, `hook_event_name`, `model` | | Kiro | `tool_name` | `tool_input` (object) | `hook_event_name`, `cwd` | | OpenCode | `tool` | `args` (mutable output object) | `sessionID`, `callID` | @@ -101,7 +96,6 @@ Not all events can block the action in all agents. | Antigravity CLI | `decision` (**required**) | allow, deny, ask, force_ask | `overwrite` | flat | | Claude Code | `permissionDecision` | allow, deny, ask, defer | `updatedInput` | nested in `hookSpecificOutput` | | GitHub Copilot | `permissionDecision` | allow, deny, ask | `modifiedArgs` | flat | -| Gemini CLI | `decision` | allow, deny | `tool_input` | nested in `hookSpecificOutput` | | Codex CLI | `decision` or `permissionDecision` | block/deny | *(not yet implemented)* | flat or nested `hookSpecificOutput` | | Kiro | *(exit code only)* | exit 0 = allow, exit 2 = block | *(not supported)* | N/A | | OpenCode | *(throw to block)* | allow (return) / deny (throw) | mutate `output.args` | JS mutation | @@ -128,7 +122,6 @@ All shell-based agents use the same convention (where applicable): | Antigravity CLI | `.agents/skills//SKILL.md` | `~/.gemini/config/skills//SKILL.md` | | Claude Code | `.claude/skills//SKILL.md` | `~/.claude/skills//SKILL.md` | | GitHub Copilot | `.agents/skills//SKILL.md` | *(none)* | -| Gemini CLI | `.agents/skills//SKILL.md` | `~/.gemini/skills//SKILL.md` | | Codex CLI | `.agents/skills//SKILL.md` | `~/.agents/skills//SKILL.md` | | Kiro | `.kiro/skills//SKILL.md` | `~/.kiro/skills//SKILL.md` | | OpenCode | `.agents/skills//SKILL.md` | `~/.agents/skills//SKILL.md` | @@ -143,7 +136,6 @@ Symposium uses the vendor-neutral `.agents/skills/` path whenever the agent supp | Antigravity CLI | `AGENTS.md`, `GEMINI.md`, `.agents/rules/*.md` | *(none)* | | Claude Code | `CLAUDE.md`, `.claude/CLAUDE.md` | `~/.claude/CLAUDE.md` | | GitHub Copilot | `.github/copilot-instructions.md`, `AGENTS.md` | `~/.copilot/copilot-instructions.md` | -| Gemini CLI | `GEMINI.md` (walks up to `.git`) | `~/.gemini/GEMINI.md` | | Codex CLI | `AGENTS.md` (each dir level) | `~/.codex/AGENTS.md` | | Kiro | `.kiro/steering/*.md`, `AGENTS.md` | `~/.kiro/steering/*.md` | | OpenCode | `AGENTS.md`, `CLAUDE.md` | `~/.config/opencode/AGENTS.md` | @@ -158,7 +150,6 @@ Relevant if symposium exposes functionality via MCP. | Antigravity CLI | `.agents/mcp_config.json` / `~/.gemini/config/mcp_config.json` (`mcpServers` key) | JSON | | Claude Code | `.claude/settings.json` (`mcpServers` key) | JSON | | GitHub Copilot | `.vscode/mcp.json` (VS Code), `~/.copilot/mcp-config.json` (CLI) | JSON | -| Gemini CLI | `.gemini/settings.json` (`mcpServers` key) | JSON | | Codex CLI | `.codex/config.toml` / `~/.codex/config.toml` (`mcp_servers` key) | TOML | | Kiro | `.kiro/settings/mcp.json`, `~/.kiro/settings/mcp.json` | JSON | | OpenCode | `opencode.json` (`mcp` key) | JSON | diff --git a/md/design/agent-details/gemini-cli.md b/md/design/agent-details/gemini-cli.md deleted file mode 100644 index 2a8d38b8..00000000 --- a/md/design/agent-details/gemini-cli.md +++ /dev/null @@ -1,253 +0,0 @@ -# Gemini CLI Hooks Reference - -> **Disclaimer:** This document reflects our current understanding of Gemini CLI's hook system. -> It is a working reference for symposium development, not a substitute for the official docs. -> Details may be outdated or incomplete — always consult the primary sources. -> -> **Primary sources:** -> [Hooks reference](https://github.com/google-gemini/gemini-cli/blob/main/docs/hooks/reference.md) -> · [Extensions reference](https://github.com/google-gemini/gemini-cli/blob/main/docs/extensions/reference.md) -> · [GitHub repo](https://github.com/google-gemini/gemini-cli) - -Gemini CLI's hook system (v0.26.0, January 2026) mirrors Claude Code's JSON-over-stdin contract and exit-code semantics. It adds model-level and tool-selection interception events unique to Gemini. - -## Hook Types - -Only `type: "command"` is currently supported. - -## Events - -| Event | Trigger | Can block? | Category | -|---|---|---|---| -| `BeforeTool` | Before tool invocation | Yes | Tool | -| `AfterTool` | After tool execution | Yes (block result) | Tool | -| `BeforeAgent` | User submits prompt, before planning | Yes | Agent | -| `AfterAgent` | Agent loop ends (final response) | Yes (retry/halt) | Agent | -| `BeforeModel` | Before sending request to LLM | Yes (mock response) | Model | -| `AfterModel` | After receiving LLM response (per-chunk during streaming) | Yes (redact) | Model | -| `BeforeToolSelection` | Before LLM selects tools | Filter tools only | Model | -| `SessionStart` | Session begins | No (advisory) | Lifecycle | -| `SessionEnd` | Session ends | No (best-effort) | Lifecycle | -| `Notification` | System notification (e.g., `ToolPermission`) | No (advisory) | Lifecycle | -| `PreCompress` | Before context compression | No (async, cannot block) | Lifecycle | - -### Model-level events (unique to Gemini) - -- **`BeforeModel`**: can swap models, modify temperature, or return a synthetic response to skip the LLM call entirely. -- **`BeforeToolSelection`**: can filter the candidate tool list using `toolConfig.mode` (`AUTO`/`ANY`/`NONE`) and `allowedFunctionNames` whitelists. Multiple hooks use **union aggregation** across allowed function lists. -- **`AfterModel`**: can redact or modify the LLM response per-chunk during streaming. - -## Configuration - -Four-tier precedence: **Project → User → System → Extensions**. - -| File | Scope | -|---|---| -| `.gemini/settings.json` | Project | -| `~/.gemini/settings.json` | User | -| `/etc/gemini-cli/settings.json` | System | -| Extensions | Plugin-provided | - -### Configuration structure - -```json -{ - "hooks": { - "BeforeTool": [ - { - "matcher": "write_file|replace", - "sequential": false, - "hooks": [ - { - "name": "secret-scanner", - "type": "command", - "command": "$GEMINI_PROJECT_DIR/.gemini/hooks/block-secrets.sh", - "timeout": 5000, - "description": "Prevent committing secrets" - } - ] - } - ] - } -} -``` - -- **`matcher`**: regex for tool events, exact string for lifecycle events. -- **`sequential`**: boolean (default false). When true, hooks run in order with output chaining. -- **`timeout`**: milliseconds (default **60,000**). - -## Input Schema (stdin) - -### Base fields (all events) - -```json -{ - "session_id": "string", - "transcript_path": "string", - "cwd": "string", - "hook_event_name": "string", - "timestamp": "2026-03-03T10:30:00Z" -} -``` - -### BeforeTool additions - -- `tool_name`: string -- `tool_input`: object (raw model arguments) -- `mcp_context`: object (optional) -- `original_request_name`: string (optional) - -### AfterTool additions - -- `tool_name`, `tool_input` (same as BeforeTool) -- `tool_response`: object containing `llmContent`, `returnDisplay`, and optional `error` - -### BeforeModel additions - -- `llm_request`: object with `model`, `messages`, `config`, `toolConfig` - -### BeforeAgent additions - -- `prompt`: string (the user's original prompt text) - -### AfterAgent additions - -- `stop_hook_active`: boolean (loop detection) - -## Output Schema (stdout) - -### Universal fields - -| Field | Type | Description | -|---|---|---| -| `decision` | string | `"allow"` or `"deny"` (alias `"block"`) | -| `reason` | string | Feedback sent to agent when denied | -| `systemMessage` | string | Displayed to user | -| `continue` | boolean | `false` kills agent loop | -| `stopReason` | string | Message when continue is false | -| `suppressOutput` | boolean | Hide from logs/telemetry | - -### Event-specific output via `hookSpecificOutput` - -**BeforeTool**: `tool_input` — merges with and overrides model arguments. - -**AfterTool**: -- `additionalContext`: string appended to tool result -- `tailToolCallRequest`: object triggering a follow-up tool call - -**AfterAgent**: when denied, `reason` is sent as a new prompt for retry. - -**BeforeAgent**: `additionalContext` — string appended to the prompt for that turn. `decision: "deny"` discards the user's message from history; `continue: false` preserves it. - -**BeforeModel**: -- `llm_request`: overrides outgoing request (swap model, modify temperature, etc.) -- `llm_response`: provides synthetic response that skips the LLM call - -## Exit Codes - -| Code | Meaning | -|---|---| -| `0` | Success; stdout parsed as JSON | -| `2` | System block — stderr used as reason | -| Other | Warning (non-fatal), action proceeds | - -## Execution Behavior - -- Hooks run **in parallel by default**; set `sequential: true` for ordered execution with output chaining. -- Default timeout: **60,000ms**. - -## Environment Variables - -| Variable | Description | -|---|---| -| `GEMINI_PROJECT_DIR` | Absolute path to project root | -| `GEMINI_SESSION_ID` | Current session ID | -| `GEMINI_CWD` | Current working directory | -| `CLAUDE_PROJECT_DIR` | Compatibility alias for `GEMINI_PROJECT_DIR` | - -Environment redaction for sensitive variables (KEY, TOKEN patterns) is available but disabled by default. - -## Migration from Claude Code - -```bash -gemini hooks migrate --from-claude -``` - -Converts `.claude` configurations to `.gemini` format. Tool name mappings: - -| Claude Code | Gemini CLI | -|---|---| -| `Bash` | `run_shell_command` | -| `Edit` | `edit_file` | -| `Write` | `write_file` | -| `Read` | `read_file` | - -## Custom Instructions - -Gemini CLI reads `GEMINI.md` files at multiple levels: - -| Scope | Path | -|---|---| -| Global | `~/.gemini/GEMINI.md` | -| Project | `GEMINI.md` in CWD and parent directories up to `.git` root | -| Just-in-time | `GEMINI.md` discovered when tools access a file/directory | - -The filename is configurable via `context.fileName` in `settings.json` (e.g., `["AGENTS.md", "GEMINI.md"]`). Supports `@file.md` import syntax for including content from other files. - -## Skills - -| Scope | Path | Notes | -|---|---|---| -| Workspace | `.agents/skills/` or `.gemini/skills/` | `.agents/` takes precedence | -| User | `~/.agents/skills/` or `~/.gemini/skills/` | `.agents/` takes precedence | -| Extension | `~/.gemini/extensions//skills/` | Bundled with extensions | - -Skills use `SKILL.md` with YAML frontmatter (`name`, `description`). Metadata is injected at session startup; full content loads on demand via `activate_skill`. - -## MCP Server Configuration - -Configured under `mcpServers` in `.gemini/settings.json` or `~/.gemini/settings.json`: - -```json -{ - "mcpServers": { - "serverName": { - "command": "path/to/executable", - "args": ["--arg1"], - "env": { "API_KEY": "$MY_TOKEN" }, - "timeout": 30000 - } - } -} -``` - -Transport is auto-selected by key: `command`+`args` (stdio), `url` (SSE), `httpUrl` (streamable HTTP). - -## MCP Server Registration - -In addition to hooks, symposium registers itself as an MCP server in the -agent's settings file. This provides an alternative integration path -alongside the hook-based approach. - -### Configuration structure - -The MCP server entry is added under `mcpServers` in the same settings -file used for hooks: - -```json -{ - "mcpServers": { - "symposium": { - "command": "/path/to/cargo-agents", - "args": ["mcp"] - } - } -} -``` - -- **Project-level**: `.gemini/settings.json` -- **User-level**: `~/.gemini/settings.json` - -Registration is idempotent — if the entry already exists with the -correct values, no changes are made. If the entry exists but has stale -values (e.g. the binary moved), it is updated in place. diff --git a/md/design/agents.md b/md/design/agents.md index 27d5cae9..0b48db85 100644 --- a/md/design/agents.md +++ b/md/design/agents.md @@ -9,7 +9,6 @@ | `antigravity` | Antigravity CLI | | `claude` | Claude Code | | `copilot` | GitHub Copilot | -| `gemini` | Gemini CLI | | `codex` | Codex CLI | | `kiro` | Kiro | | `opencode` | OpenCode | @@ -32,11 +31,11 @@ When installing skills, `cargo agents` prefers vendor-neutral paths where possib | Scope | Path | Supported by | |-------|------|-------------| -| Project skills | `.agents/skills//SKILL.md` | Antigravity, Copilot, Gemini, Codex, OpenCode, Goose | +| Project skills | `.agents/skills//SKILL.md` | Antigravity, Copilot, Codex, OpenCode, Goose | | Project skills | `.claude/skills//SKILL.md` | Claude Code (does not support `.agents/skills/`) | | Project skills | `.kiro/skills//SKILL.md` | Kiro (uses its own path) | -At the project level, Claude Code requires `.claude/skills/`, Kiro requires `.kiro/skills/`, while Antigravity, Copilot, Gemini, Codex, OpenCode, and Goose all support `.agents/skills/`. `cargo agents` uses the vendor-neutral `.agents/skills/` path whenever the agent supports it. +At the project level, Claude Code requires `.claude/skills/`, Kiro requires `.kiro/skills/`, while Antigravity, Copilot, Codex, OpenCode, and Goose all support `.agents/skills/`. `cargo agents` uses the vendor-neutral `.agents/skills/` path whenever the agent supports it. At the global level, each agent has its own path: @@ -45,7 +44,6 @@ At the global level, each agent has its own path: | Antigravity CLI | `~/.gemini/config/skills//SKILL.md` | | Claude Code | `~/.claude/skills//SKILL.md` | | Copilot | *(no global skills path)* | -| Gemini | `~/.gemini/skills//SKILL.md` | | Codex | `~/.agents/skills//SKILL.md` | | Kiro | `~/.kiro/skills//SKILL.md` | | OpenCode | `~/.agents/skills//SKILL.md` | @@ -177,79 +175,6 @@ Valid `permissionDecision` values: `"allow"`, `"deny"`, `"ask"`. --- -## Gemini CLI - -[Hooks reference](https://geminicli.com/docs/hooks/reference/) · [Configuration reference](https://geminicli.com/docs/reference/configuration/) · [Skills reference](https://geminicli.com/docs/cli/skills/) · [Extensions reference](https://geminicli.com/docs/extensions/reference/) - -### Hook registration - -Gemini CLI hooks live under the `"hooks"` key in `settings.json`. Hook groups use regex matchers for tool events and exact-string matchers for lifecycle events. - -| Scope | File | -|-------|------| -| Global | `~/.gemini/settings.json` | -| Project | `.gemini/settings.json` | - -Example hook registration: - -```json -{ - "hooks": { - "BeforeTool": [ - { - "matcher": ".*", - "hooks": [ - { - "name": "symposium", - "type": "command", - "command": "cargo-agents hook gemini pre-tool-use", - "timeout": 10000 - } - ] - } - ] - } -} -``` - -Note: Gemini uses `BeforeTool` (not `PreToolUse`), and timeouts are in milliseconds (default: 60000). - -### Supported events - -| Event | Type | Description | -|-------|------|-------------| -| `BeforeTool` | Tool | Before a tool is invoked. | -| `AfterTool` | Tool | After a tool completes. | -| `BeforeToolSelection` | Tool | Before the LLM selects tools. | -| `BeforeModel` | Model | Before LLM requests. | -| `AfterModel` | Model | After LLM responses. | -| `BeforeAgent` | Lifecycle | Before agent loop starts. | -| `AfterAgent` | Lifecycle | After agent loop completes. | -| `SessionStart` | Lifecycle | When a session starts. | -| `SessionEnd` | Lifecycle | When a session ends. | -| `PreCompress` | Lifecycle | Before history compression. | -| `Notification` | Lifecycle | On notification events. | - -### Hook payload/output - -Gemini uses a structure similar to Claude Code, with a nested `hookSpecificOutput`: - -```json -{ - "decision": "allow", - "reason": "...", - "hookSpecificOutput": { - "hookEventName": "BeforeTool", - "additionalContext": "...", - "tool_input": { ... } - } -} -``` - -The input payload includes `tool_name`, `tool_input`, `mcp_context`, `session_id`, and `transcript_path`. - ---- - ## Antigravity CLI [Hooks reference](./agent-details/antigravity-cli.md) @@ -469,12 +394,12 @@ Goose is supported as a **skills-only** agent — `cargo agents sync` will insta The following table maps symposium's internal event names to each agent's wire-format event name. `—` means the agent does not support shell-command hooks. -| Symposium event | Antigravity | Claude | Copilot | Gemini | Codex | Kiro | OpenCode | Goose | -|---|---|---|---|---|---|---|---|---| -| `pre-tool-use` | `PreToolUse` | `PreToolUse` | `preToolUse` | `BeforeTool` | `PreToolUse` | `preToolUse` | — | — | -| `post-tool-use` | `PostToolUse` | `PostToolUse` | `postToolUse` | `AfterTool` | `PostToolUse` | `postToolUse` | — | — | -| `user-prompt-submit` | `PreInvocation` | `UserPromptSubmit` | `userPromptSubmitted` | `BeforeAgent` | `UserPromptSubmit` | `userPromptSubmit` | — | — | -| `session-start` | `SessionStart` | `SessionStart` | `sessionStart` | `SessionStart` | `SessionStart` | `agentSpawn` | — | — | +| Symposium event | Antigravity | Claude | Copilot | Codex | Kiro | OpenCode | Goose | +|---|---|---|---|---|---|---|---| +| `pre-tool-use` | `PreToolUse` | `PreToolUse` | `preToolUse` | `PreToolUse` | `preToolUse` | — | — | +| `post-tool-use` | `PostToolUse` | `PostToolUse` | `postToolUse` | `PostToolUse` | `postToolUse` | — | — | +| `user-prompt-submit` | `PreInvocation` | `UserPromptSubmit` | `userPromptSubmitted` | `UserPromptSubmit` | `userPromptSubmit` | — | — | +| `session-start` | `SessionStart` | `SessionStart` | `sessionStart` | `SessionStart` | `agentSpawn` | — | — | --- diff --git a/md/design/common-issues.md b/md/design/common-issues.md index a49a9606..918cca62 100644 --- a/md/design/common-issues.md +++ b/md/design/common-issues.md @@ -12,10 +12,6 @@ Copilot sends `toolArgs` as a JSON *string* (not an object). Our `CopilotPreTool `CopilotPreToolUseOutput::from_hook_output()` never maps `permissionDecision` or `permissionDecisionReason` from the builtin hook output. If a builtin handler wants to deny a tool call, the decision is silently lost in Copilot output. -### Gemini `SessionStart` matcher - -`ensure_gemini_hook_entry` uses `"matcher": ".*"` for all events including `SessionStart`. Per the Gemini reference, lifecycle events use exact-string matchers, not regex. Likely harmless in practice since `".*"` matches anything. - ## Antigravity footguns Two Antigravity behaviours fail silently rather than loudly, so they are worth diff --git a/md/design/hooks.md b/md/design/hooks.md index ebb25564..60c412b2 100644 --- a/md/design/hooks.md +++ b/md/design/hooks.md @@ -7,7 +7,7 @@ Symposium's hook system is guided by the project [tenets](./tenets.md): symposiu A plugin hook declares which wire format its handler expects: - `format = "symposium"` (default) — the handler receives symposium canonical JSON. This is portable across all agents. -- `format = "claude"` / `"copilot"` / `"gemini"` / `"codex"` / `"kiro"` — the handler receives that agent's native wire format. +- `format = "antigravity"` / `"claude"` / `"copilot"` / `"codex"` / `"kiro"` — the handler receives that agent's native wire format. ## Dispatch rule @@ -17,13 +17,13 @@ When symposium's global handler receives an event from agent A, it loads all plu 2. Otherwise, if the plugin declares a **symposium-format** hook → convert to symposium canonical and deliver. 3. Otherwise → nothing fires for this plugin. -Symposium never converts between agent-specific formats. A `format = "claude"` hook will only fire on Claude — it won't be translated for Copilot or Gemini. If you want cross-agent coverage, provide a symposium-format hook as a fallback. +Symposium never converts between agent-specific formats. A `format = "claude"` hook will only fire on Claude — it won't be translated for Copilot or Antigravity. If you want cross-agent coverage, provide a symposium-format hook as a fallback. ### Example -A plugin with hooks for `claude`, `gemini`, and `symposium`: +A plugin with hooks for `claude`, `antigravity`, and `symposium`: - On Claude: the `format = "claude"` hook receives Claude's native JSON. -- On Gemini: the `format = "gemini"` hook receives Gemini's native JSON. +- On Antigravity: the `format = "antigravity"` hook receives Antigravity's native JSON. - On Copilot: no native handler → the `format = "symposium"` hook receives symposium canonical JSON. A plugin with only `format = "symposium"`: diff --git a/md/design/init-user-flow.md b/md/design/init-user-flow.md index 3bce893a..e72b126a 100644 --- a/md/design/init-user-flow.md +++ b/md/design/init-user-flow.md @@ -4,7 +4,7 @@ Sets up the user-wide configuration. ## Flow -1. **Prompt for agents** — ask which agents the user uses (e.g., Claude Code, Copilot, Gemini). Multiple agents can be selected. +1. **Prompt for agents** — ask which agents the user uses (e.g., Antigravity CLI, Claude Code, Copilot). Multiple agents can be selected. 2. **Write user config** — create `~/.symposium/config.toml` with the `[[agent]]` entries populated: @@ -13,7 +13,7 @@ Sets up the user-wide configuration. name = "claude" [[agent]] - name = "gemini" + name = "antigravity" ``` 3. **Register hooks** — register global hooks and MCP servers for each selected agent. Also unregisters hooks for any agents that were removed. diff --git a/md/design/module-structure.md b/md/design/module-structure.md index 2e50cdad..bd97974b 100644 --- a/md/design/module-structure.md +++ b/md/design/module-structure.md @@ -16,7 +16,9 @@ The cargo-workspace resolution is **CargoPm's**, so it lives in the cargo PM's m ### `agents.rs` — agent abstraction -Centralizes agent-specific knowledge: hook registration file paths, skill installation directories, and hook registration logic for each supported agent (Antigravity CLI, Claude Code, GitHub Copilot, Gemini CLI, Codex CLI, Kiro, OpenCode, Goose). Handles the differences between agents — e.g., Claude Code uses `.claude/skills/` and Kiro uses `.kiro/skills/`, while Antigravity, Copilot, Gemini, Codex, OpenCode, and Goose use the vendor-neutral `.agents/skills/`. OpenCode and Goose are skills-only agents (no hook registration). +Centralizes agent-specific knowledge: hook registration file paths, skill installation directories, and hook registration logic for each supported agent (Antigravity CLI, Claude Code, GitHub Copilot, Codex CLI, Kiro, OpenCode, Goose). Handles the differences between agents — e.g., Claude Code uses `.claude/skills/` and Kiro uses `.kiro/skills/`, while Antigravity, Copilot, Codex, OpenCode, and Goose use the vendor-neutral `.agents/skills/`. OpenCode and Goose are skills-only agents (no hook registration). + +An agent symposium drops leaves state behind that nothing would otherwise reap, so `RETIRED_AGENTS` records the names it no longer supports. A retired name in the user config is reported and skipped by `Agent::from_configured_name` rather than failing the command, `HookAgentArg` parses it to nothing to dispatch so a stale hook registration exits cleanly, and `HookFormat::Retired` keeps a plugin manifest naming one loadable with that hook skipped. `migrations.rs` then clears the leftovers once, keyed by id in `state.toml`. Hook and MCP registration is scope-dispatched: the project and global locations are genuinely different files for some agents (Antigravity writes `.agents/hooks.json` but `~/.gemini/config/hooks.json`; Copilot `.github/hooks/` but `~/.copilot/settings.json`), so `sync` calls the project-scoped functions at project scope rather than rooting the global path at the workspace. diff --git a/md/install.md b/md/install.md index 481aab7e..d60ca02e 100644 --- a/md/install.md +++ b/md/install.md @@ -24,7 +24,7 @@ cargo agents init ### Select your agents -This will prompt you to select the agents you use (Claude Code, Copilot, Gemini, etc.) — you can pick more than one: +This will prompt you to select the agents you use (Antigravity, Claude Code, Copilot, etc.) — you can pick more than one: ```bash Which agents do you use? (space to select, enter to confirm): @@ -32,7 +32,6 @@ Which agents do you use? (space to select, enter to confirm): [ ] Claude Code [x] Codex CLI [ ] GitHub Copilot - [ ] Gemini CLI [ ] Goose [x] Kiro [x] OpenCode diff --git a/md/reference/agents/gemini.md b/md/reference/agents/gemini.md deleted file mode 100644 index b29652ed..00000000 --- a/md/reference/agents/gemini.md +++ /dev/null @@ -1,30 +0,0 @@ -# Gemini CLI - -Config name: `gemini` - -## Skills - -| Scope | Path | -|-------|------| -| Project | `.agents/skills//SKILL.md` | -| Global | `~/.gemini/skills//SKILL.md` | - -## Hooks - -Symposium merges hook entries into Gemini's `settings.json`. - -| Scope | File | -|-------|------| -| Project | `.gemini/settings.json` | -| Global | `~/.gemini/settings.json` | - -Events registered: `BeforeTool`, `AfterTool`, `BeforeAgent`, `SessionStart` (Gemini's own naming). - -Output format: JSON with nested matcher groups. Timeouts in milliseconds. - -## MCP servers - -| Scope | File | Key | -|-------|------|-----| -| Project | `.gemini/settings.json` | `mcpServers.` | -| Global | `~/.gemini/settings.json` | `mcpServers.` | diff --git a/md/reference/agents/kiro.md b/md/reference/agents/kiro.md index 72ba0b70..9f29ceb5 100644 --- a/md/reference/agents/kiro.md +++ b/md/reference/agents/kiro.md @@ -26,7 +26,7 @@ Output format: plain text on stdout (not JSON). Exit code 2 blocks `preToolUse` The generated agent file includes `"tools": ["*"]` (all tools available) and `"resources": ["skill://.kiro/skills/**/SKILL.md"]` (auto-discover skills). Without `tools`, a Kiro custom agent has zero tools. -**Caveat:** Kiro uses a flat hook entry format (`{ "command": "..." }`) unlike the nested format used by Claude/Gemini/Codex. Unregistration deletes the `symposium.json` file entirely. +**Caveat:** Kiro uses a flat hook entry format (`{ "command": "..." }`) unlike the nested format used by Claude/Codex. Unregistration deletes the `symposium.json` file entirely. ## MCP servers diff --git a/md/reference/cargo-agents-init.md b/md/reference/cargo-agents-init.md index b50bf2c3..2a565568 100644 --- a/md/reference/cargo-agents-init.md +++ b/md/reference/cargo-agents-init.md @@ -10,7 +10,7 @@ cargo agents init [OPTIONS] ## Behavior -Prompts for which agents you use (e.g., Claude Code, Copilot, Gemini) and where to install hooks, writes `~/.symposium/config.toml`, and registers hooks for each selected agent. +Prompts for which agents you use (e.g., Antigravity CLI, Claude Code, Copilot) and where to install hooks, writes `~/.symposium/config.toml`, and registers hooks for each selected agent. If a user config already exists, `init` updates it (preserving existing settings not affected by the flags). @@ -18,7 +18,7 @@ If a user config already exists, `init` updates it (preserving existing settings | Flag | Description | |------|-------------| -| `--add-agent ` | Add an agent (e.g., `claude`, `copilot`, `gemini`). Repeatable. Skips the interactive prompt. | +| `--add-agent ` | Add an agent (e.g., `antigravity`, `claude`, `copilot`). Repeatable. Skips the interactive prompt. | | `--remove-agent ` | Remove an agent. Repeatable. | | `--hook-scope ` | Where to install hooks: `global` (default, writes to `~/`) or `project` (writes to the project directory). | @@ -33,7 +33,7 @@ cargo agents init Non-interactive, specifying agents directly: ```bash -cargo agents init --add-agent claude --add-agent gemini +cargo agents init --add-agent claude --add-agent antigravity ``` Adding an agent to an existing config: @@ -45,5 +45,5 @@ cargo agents init --add-agent copilot Removing an agent: ```bash -cargo agents init --remove-agent gemini +cargo agents init --remove-agent antigravity ``` diff --git a/md/reference/cargo-agents-sync.md b/md/reference/cargo-agents-sync.md index 3c916e97..d49a7dea 100644 --- a/md/reference/cargo-agents-sync.md +++ b/md/reference/cargo-agents-sync.md @@ -20,7 +20,7 @@ Must be run from within a Rust workspace. Performs the following steps: 3. **Discover applicable skills** — loads plugin sources (from user config) and matches skill predicates against workspace dependencies. -4. **Install skills** — for each configured agent, copies applicable `SKILL.md` files into the agent's expected skill directory (e.g., `.claude/skills/` for Claude Code, `.agents/skills/` for Copilot/Gemini/Codex). A `.gitignore` containing `*` is written into every new skill directory (and its `skills/` parent if new), and an empty `.symposium` marker file is dropped into each installed skill directory. +4. **Install skills** — for each configured agent, copies applicable `SKILL.md` files into the agent's expected skill directory (e.g., `.claude/skills/` for Claude Code, `.agents/skills/` for Antigravity/Copilot/Codex). A `.gitignore` containing `*` is written into every new skill directory (and its `skills/` parent if new), and an empty `.symposium` marker file is dropped into each installed skill directory. 5. **Mirror workspace skills** — if `agents-syncing` is enabled (default), user-authored skills in `/.agents/skills/` are propagated into the skill directories of any configured agent that doesn't natively use `.agents/skills/` (e.g., `.claude/skills/`, `.kiro/skills/`). See [Workspace skills](../workspace-skills.md). diff --git a/md/reference/configuration.md b/md/reference/configuration.md index 89d34937..6b2706e4 100644 --- a/md/reference/configuration.md +++ b/md/reference/configuration.md @@ -14,7 +14,7 @@ auto-update = "on" name = "claude" [[agent]] -name = "gemini" +name = "antigravity" [logging] level = "info" @@ -43,7 +43,7 @@ path = "my-plugins" ### Agents syncing: mirror user-authored skills -Agents such as Copilot, Gemini, Codex, Goose, and OpenCode all read skills from the vendor-neutral `.agents/skills/` directory, but Claude Code and Kiro use their own paths (`.claude/skills/` and `.kiro/skills/`). When `agents-syncing` is enabled, every [workspace plugin](../workspace-skills.md) — the workspace root and each member directory — carries a second default skill group, gated by the `workspace-member()` predicate: +Agents such as Antigravity, Copilot, Codex, Goose, and OpenCode all read skills from the vendor-neutral `.agents/skills/` directory, but Claude Code and Kiro use their own paths (`.claude/skills/` and `.kiro/skills/`). When `agents-syncing` is enabled, every [workspace plugin](../workspace-skills.md) — the workspace root and each member directory — carries a second default skill group, gated by the `workspace-member()` predicate: ```toml [[skills]] @@ -74,7 +74,7 @@ Each `[[agent]]` entry identifies an agent you use. You can configure multiple a | Key | Type | Default | Description | |-----|------|---------|-------------| -| `name` | string | *(required)* | Agent name: `antigravity`, `claude`, `codex`, `copilot`, `gemini`, `goose`, `kiro`, or `opencode`. | +| `name` | string | *(required)* | Agent name: `antigravity`, `claude`, `codex`, `copilot`, `goose`, `kiro`, or `opencode`. | ## `[logging]` diff --git a/md/reference/plugin-definition.md b/md/reference/plugin-definition.md index 04f4b111..f3c128bc 100644 --- a/md/reference/plugin-definition.md +++ b/md/reference/plugin-definition.md @@ -228,8 +228,8 @@ Each `[[hooks]]` entry declares a hook that responds to agent events. For the JS | `script` | string (optional) | Path to a shell script to run via `sh`. Same exclusivity rule as `executable`. | | `args` | array (optional) | Invocation arguments. Forbidden when the installation also declares `args`. | | `requirements` | array (optional) | Installations to acquire before running. Same shape as `command` (string name or inline declaration). | -| `agent` | string (optional) | Restrict the hook to a specific agent (`claude`, `copilot`, `gemini`, `kiro`, …). | -| `format` | string | Wire format the handler expects on stdin. `symposium` (default): symposium converts the agent's event to its canonical format before delivering. Any agent name (`claude`, `codex`, `copilot`, `gemini`, `kiro`): the handler receives that agent's native wire format. Symposium always intermediates — it never registers plugin hooks directly into agent configs. See [Hooks](../crate-authors/authoring-a-plugin.md#hooks). | +| `agent` | string (optional) | Restrict the hook to a specific agent (`antigravity`, `claude`, `copilot`, `kiro`, …). | +| `format` | string | Wire format the handler expects on stdin. `symposium` (default): symposium converts the agent's event to its canonical format before delivering. Any agent name (`antigravity`, `claude`, `codex`, `copilot`, `kiro`): the handler receives that agent's native wire format. Symposium always intermediates — it never registers plugin hooks directly into agent configs. See [Hooks](../crate-authors/authoring-a-plugin.md#hooks). | | `predicates` | array (optional) | Predicates (`depends-on`, `shell`, `path_exists`, `env`, `workspace-member`, `not`, `any`, `all`) that must all hold for the hook to dispatch. Evaluated per-dispatch. See [Predicates](./predicates.md). | ### Examples @@ -394,9 +394,9 @@ Global cargo installs (`global = true`) don't set `$SYMPOSIUM_DIR_` or aug ### Agent → hook name mapping -| Tool / Event | Claude (`claude`) | Copilot (`copilot`) | Gemini (`gemini`) | -|--------------|------------------------------------:|-------------------:|------------------:| -| `PreToolUse` | `PreToolUse` | `PreToolUse` | `BeforeTool` | +| Tool / Event | Claude (`claude`) | Copilot (`copilot`) | Antigravity (`antigravity`) | +|--------------|------------------:|--------------------:|----------------------------:| +| `PreToolUse` | `PreToolUse` | `PreToolUse` | `PreToolUse` | ### Hook semantics @@ -417,7 +417,7 @@ Use the CLI to test a hook with sample input: echo '{"tool": "Bash", "input": "cargo test"}' | cargo agents hook claude pre-tool-use ``` -You can also use `copilot`, `gemini`, `codex`, or `kiro` as the agent name. +You can also use `antigravity`, `copilot`, `codex`, or `kiro` as the agent name. ## `[[predicate]]` @@ -552,7 +552,6 @@ All supported agents have MCP server configuration. Symposium handles the format |-------|----------------|-----| | Claude Code | `.claude/settings.json` | `mcpServers.` | | GitHub Copilot | `.vscode/mcp.json` | `` (top-level) | -| Gemini CLI | `.gemini/settings.json` | `mcpServers.` | | Codex CLI | `.codex/config.toml` | `[mcp_servers.]` | | Kiro | `.kiro/settings/mcp.json` | `mcpServers.` | | OpenCode | `opencode.json` | `mcp.` | diff --git a/md/reference/supported-agents.md b/md/reference/supported-agents.md index ea7675f5..2a91eb45 100644 --- a/md/reference/supported-agents.md +++ b/md/reference/supported-agents.md @@ -1,3 +1,3 @@ # Supported agents -Symposium supports eight AI coding agents. Each agent gets skill installation; hook support varies by agent. +Symposium supports seven AI coding agents. Each agent gets skill installation; hook support varies by agent. diff --git a/src/agents/mcp_server_registration.rs b/src/agents/mcp_server_registration.rs index b1daaecd..145b5845 100644 --- a/src/agents/mcp_server_registration.rs +++ b/src/agents/mcp_server_registration.rs @@ -95,7 +95,7 @@ fn upsert_json_mcp_entry( } // --------------------------------------------------------------------------- -// JSON-based registration (Antigravity, Claude, Copilot, Gemini, Kiro, OpenCode) +// JSON-based registration (Antigravity, Claude, Copilot, Kiro, OpenCode) // --------------------------------------------------------------------------- /// Register MCP servers into a JSON config file under a given container key. @@ -355,23 +355,6 @@ pub(super) fn unregister_copilot_mcp_servers( unregister_json_mcp_servers(path, names, None, out) } -/// Gemini CLI: same format as Claude (`mcpServers.`) -pub(super) fn register_gemini_mcp_servers( - path: &Path, - servers: &[McpServer], - out: &Output, -) -> Result<()> { - register_claude_mcp_servers(path, servers, out) -} - -pub(super) fn unregister_gemini_mcp_servers( - path: &Path, - names: &[&str], - out: &Output, -) -> Result<()> { - unregister_claude_mcp_servers(path, names, out) -} - /// Kiro: `mcpServers.` in mcp.json pub(super) fn register_kiro_mcp_servers( path: &Path, @@ -571,7 +554,7 @@ mod tests { vec!["symposium"] } - // -- Claude MCP (also covers Gemini and Kiro via delegation) -- + // -- Claude MCP (also covers Kiro via delegation) -- #[test] fn register_claude_creates_config() { diff --git a/src/agents/mod.rs b/src/agents/mod.rs index 4d393194..7d0ac65d 100644 --- a/src/agents/mod.rs +++ b/src/agents/mod.rs @@ -15,6 +15,13 @@ use serde_json::json; use crate::config::Symposium; use crate::output::{Output, display_path}; +/// Agent names symposium used to support and no longer does. +/// +/// A user config outlives the release that drops an agent, so a retired name is +/// reported and skipped rather than failing every command until the file is +/// edited by hand. +pub const RETIRED_AGENTS: &[&str] = &["gemini"]; + /// Supported AI agents. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Agent { @@ -22,13 +29,31 @@ pub enum Agent { Claude, Codex, Copilot, - Gemini, Goose, Kiro, OpenCode, } impl Agent { + /// Whether `name` refers to an agent symposium has retired. + pub fn is_retired(name: &str) -> bool { + RETIRED_AGENTS.contains(&name) + } + + /// Resolve a configured agent name, tolerating one symposium has retired. + /// + /// `Ok(None)` means the name was retired and reported; an unknown name is + /// still an error, since that is a typo rather than an outdated config. + pub fn from_configured_name(name: &str, out: &Output) -> Result> { + if Self::is_retired(name) { + out.warn(format!( + "agent `{name}` is no longer supported; ignoring it" + )); + return Ok(None); + } + Self::from_config_name(name).map(Some) + } + /// Parse an agent name from a config string. pub fn from_config_name(name: &str) -> Result { match name { @@ -36,12 +61,14 @@ impl Agent { "claude" => Ok(Agent::Claude), "codex" => Ok(Agent::Codex), "copilot" => Ok(Agent::Copilot), - "gemini" => Ok(Agent::Gemini), "goose" => Ok(Agent::Goose), "kiro" => Ok(Agent::Kiro), "opencode" => Ok(Agent::OpenCode), + other if Self::is_retired(other) => { + bail!("agent `{other}` is no longer supported by symposium") + } other => bail!( - "unknown agent: {other} (expected antigravity, claude, codex, copilot, gemini, goose, kiro, or opencode)" + "unknown agent: {other} (expected antigravity, claude, codex, copilot, goose, kiro, or opencode)" ), } } @@ -53,7 +80,6 @@ impl Agent { Agent::Claude => "claude", Agent::Codex => "codex", Agent::Copilot => "copilot", - Agent::Gemini => "gemini", Agent::Goose => "goose", Agent::Kiro => "kiro", Agent::OpenCode => "opencode", @@ -67,7 +93,6 @@ impl Agent { Agent::Claude => "Claude Code", Agent::Codex => "Codex CLI", Agent::Copilot => "GitHub Copilot", - Agent::Gemini => "Gemini CLI", Agent::Goose => "Goose", Agent::Kiro => "Kiro", Agent::OpenCode => "OpenCode", @@ -81,7 +106,6 @@ impl Agent { Agent::Claude, Agent::Codex, Agent::Copilot, - Agent::Gemini, Agent::Goose, Agent::Kiro, Agent::OpenCode, @@ -95,11 +119,12 @@ impl Agent { /// Project-level skill directory for a given skill name. /// /// Claude Code requires `.claude/skills/`, while Antigravity, Copilot and - /// Gemini support the vendor-neutral `.agents/skills/` path. + /// Antigravity, Codex and Copilot support the vendor-neutral + /// `.agents/skills/` path. pub fn project_skill_dir(&self, project_root: &Path, skill_name: &str) -> PathBuf { match self { Agent::Claude => project_root.join(".claude").join("skills").join(skill_name), - Agent::Antigravity | Agent::Codex | Agent::Copilot | Agent::Gemini => { + Agent::Antigravity | Agent::Codex | Agent::Copilot => { project_root.join(".agents").join("skills").join(skill_name) } Agent::Goose => project_root.join(".agents").join("skills").join(skill_name), @@ -122,7 +147,6 @@ impl Agent { Agent::Claude => Some(home.join(".claude").join("skills").join(skill_name)), Agent::Codex => Some(home.join(".agents").join("skills").join(skill_name)), Agent::Copilot => None, // no global skills path - Agent::Gemini => Some(home.join(".gemini").join("skills").join(skill_name)), Agent::Goose => Some(home.join(".agents").join("skills").join(skill_name)), Agent::Kiro => Some(home.join(".kiro").join("skills").join(skill_name)), Agent::OpenCode => Some(home.join(".agents").join("skills").join(skill_name)), @@ -153,9 +177,6 @@ impl Agent { Agent::Copilot => { register_copilot_hooks(&project_root.join(".github").join("hooks"), out) } - Agent::Gemini => { - register_gemini_hooks(&project_root.join(".gemini").join("settings.json"), out) - } Agent::Kiro => register_kiro_hooks(&project_root.join(".kiro").join("agents"), out), Agent::Goose => { out.info( @@ -188,9 +209,6 @@ impl Agent { Agent::Copilot => { register_copilot_hooks_global(&home.join(".copilot").join("settings.json"), out) } - Agent::Gemini => { - register_gemini_hooks(&home.join(".gemini").join("settings.json"), out) - } Agent::Kiro => register_kiro_hooks(&home.join(".kiro").join("agents"), out), Agent::Goose => { out.info( @@ -239,11 +257,6 @@ impl Agent { servers, out, ), - Agent::Gemini => mcp_server_registration::register_gemini_mcp_servers( - &project_root.join(".gemini").join("settings.json"), - servers, - out, - ), Agent::Kiro => mcp_server_registration::register_kiro_mcp_servers( &project_root.join(".kiro").join("settings").join("mcp.json"), servers, @@ -291,11 +304,6 @@ impl Agent { servers, out, ), - Agent::Gemini => mcp_server_registration::register_gemini_mcp_servers( - &home.join(".gemini").join("settings.json"), - servers, - out, - ), Agent::Kiro => mcp_server_registration::register_kiro_mcp_servers( &home.join(".kiro").join("settings").join("mcp.json"), servers, @@ -342,11 +350,6 @@ impl Agent { names, out, ), - Agent::Gemini => mcp_server_registration::unregister_gemini_mcp_servers( - &project_root.join(".gemini").join("settings.json"), - names, - out, - ), Agent::Kiro => mcp_server_registration::unregister_kiro_mcp_servers( &project_root.join(".kiro").join("settings").join("mcp.json"), names, @@ -393,11 +396,6 @@ impl Agent { names, out, ), - Agent::Gemini => mcp_server_registration::unregister_gemini_mcp_servers( - &home.join(".gemini").join("settings.json"), - names, - out, - ), Agent::Kiro => mcp_server_registration::unregister_kiro_mcp_servers( &home.join(".kiro").join("settings").join("mcp.json"), names, @@ -431,9 +429,6 @@ impl Agent { Agent::Copilot => { unregister_copilot_hooks(&project_root.join(".github").join("hooks"), out) } - Agent::Gemini => { - unregister_gemini_hooks(&project_root.join(".gemini").join("settings.json"), out) - } Agent::Kiro => unregister_kiro_hooks(&project_root.join(".kiro").join("agents"), out), Agent::Goose => {} // no hooks to unregister Agent::OpenCode => {} // no hooks to unregister @@ -454,9 +449,6 @@ impl Agent { Agent::Copilot => { unregister_copilot_hooks_global(&home.join(".copilot").join("settings.json"), out) } - Agent::Gemini => { - unregister_gemini_hooks(&home.join(".gemini").join("settings.json"), out) - } Agent::Kiro => unregister_kiro_hooks(&home.join(".kiro").join("agents"), out), Agent::Goose => {} // no hooks to unregister Agent::OpenCode => {} // no hooks to unregister @@ -827,91 +819,6 @@ fn unregister_antigravity_hooks(hooks_path: &Path, out: &Output) { out.removed(format!("{display}: removed hooks")); } } - -// --------------------------------------------------------------------------- -// Gemini CLI hook registration -// --------------------------------------------------------------------------- - -fn register_gemini_hooks(settings_path: &Path, out: &Output) -> Result<()> { - let mut settings = load_json_or_empty(settings_path)?; - let display = display_path(settings_path); - - let hooks = settings - .as_object_mut() - .unwrap() - .entry("hooks") - .or_insert_with(|| json!({})); - - let hooks_obj = hooks.as_object_mut().unwrap(); - - let mut added = Vec::new(); - - let events = [ - ("BeforeTool", "pre-tool-use"), - ("AfterTool", "post-tool-use"), - ("BeforeAgent", "user-prompt-submit"), - ("SessionStart", "session-start"), - ]; - - for (gemini_event, cli_arg) in events { - let command = format!("cargo-agents hook gemini {cli_arg}"); - if ensure_gemini_hook_entry(hooks_obj, gemini_event, &command) { - added.push(gemini_event); - } - } - - if added.is_empty() { - out.already_ok(format!("{display}: hooks already registered")); - } else { - save_json(settings_path, &settings)?; - out.done(format!("{display}: added hooks ({})", added.join(", "))); - } - - Ok(()) -} - -/// Returns `true` if a new entry was added, `false` if already registered. -fn ensure_gemini_hook_entry( - hooks: &mut serde_json::Map, - event: &str, - command: &str, -) -> bool { - let event_hooks = hooks.entry(event).or_insert_with(|| json!([])); - - let arr = match event_hooks.as_array_mut() { - Some(a) => a, - None => return false, - }; - - let already_registered = arr.iter().any(|group| { - group - .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(|c| c.starts_with("cargo-agents hook")) - }) - }) - }); - - if already_registered { - return false; - } - - arr.push(json!({ - "matcher": ".*", - "hooks": [{ - "name": "symposium", - "type": "command", - "command": command, - "timeout": 10000 - }] - })); - true -} - // --------------------------------------------------------------------------- // Kiro hook registration // --------------------------------------------------------------------------- @@ -1059,9 +966,8 @@ fn unregister_kiro_hooks(agents_dir: &Path, out: &Output) { // Hook unregistration // --------------------------------------------------------------------------- -/// Remove symposium hooks from a Claude/Gemini settings.json file. -/// Shared by both Claude and Gemini since they use the same structure. -fn unregister_settings_hooks(settings_path: &Path, command_prefix: &str, out: &Output) { +/// Remove symposium hooks from a Claude-style settings.json file. +pub(crate) fn unregister_settings_hooks(settings_path: &Path, command_prefix: &str, out: &Output) { let display = display_path(settings_path); let Ok(mut settings) = load_json_or_empty(settings_path) else { @@ -1103,10 +1009,6 @@ fn unregister_claude_hooks(settings_path: &Path, out: &Output) { unregister_settings_hooks(settings_path, "cargo-agents hook", out); } -fn unregister_gemini_hooks(settings_path: &Path, out: &Output) { - unregister_settings_hooks(settings_path, "cargo-agents hook", out); -} - /// Remove symposium hooks from a Copilot project hooks directory. fn unregister_copilot_hooks(hooks_dir: &Path, out: &Output) { let hook_file = hooks_dir.join("symposium.json"); @@ -1127,7 +1029,7 @@ fn unregister_copilot_hooks_global(config_path: &Path, out: &Output) { /// with the command in `command_key` (e.g., `"command"` for Kiro, `"bash"` for Copilot). /// /// Contrasts with `unregister_settings_hooks` which handles the nested -/// `{ "hooks": [{ "command": "..." }] }` structure used by Claude/Gemini/Codex. +/// `{ "hooks": [{ "command": "..." }] }` structure used by Claude/Codex. fn unregister_flat_hooks(config_path: &Path, command_key: &str, out: &Output) { let display = display_path(config_path); @@ -1205,7 +1107,6 @@ mod tests { assert_eq!(Agent::from_config_name("claude").unwrap(), Agent::Claude); assert_eq!(Agent::from_config_name("codex").unwrap(), Agent::Codex); assert_eq!(Agent::from_config_name("copilot").unwrap(), Agent::Copilot); - assert_eq!(Agent::from_config_name("gemini").unwrap(), Agent::Gemini); assert!(Agent::from_config_name("unknown").is_err()); } @@ -1432,21 +1333,6 @@ mod tests { Some(PathBuf::from("/home/user/.agents/skills/tokio")) ); } - - #[test] - fn register_gemini_hooks_creates_settings() { - let tmp = tempfile::tempdir().unwrap(); - let settings_path = tmp.path().join("settings.json"); - register_gemini_hooks(&settings_path, &Output::quiet()).unwrap(); - - let settings: serde_json::Value = - serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap(); - assert!(settings["hooks"]["BeforeTool"].is_array()); - assert!(settings["hooks"]["AfterTool"].is_array()); - assert!(settings["hooks"]["BeforeAgent"].is_array()); - assert!(settings["hooks"]["SessionStart"].is_array()); - } - // ── Antigravity ────────────────────────────────────────────────────── #[test] diff --git a/src/bin/cargo-agents.rs b/src/bin/cargo-agents.rs index cab3773d..98e7dc53 100644 --- a/src/bin/cargo-agents.rs +++ b/src/bin/cargo-agents.rs @@ -111,6 +111,10 @@ async fn main() -> ExitCode { Output::normal() }; + // Clean up after anything a past release installed and this one no longer + // maintains. Recorded by id in state.toml, so a no-op after the first run. + symposium::migrations::run_pending(&mut sym, &out); + // Ensure git-based plugin sources are up to date (non-blocking on failure). // SessionStart runs once per session, so we force a real freshness check // there; other invocations use the `--update` level (debounced by default). @@ -141,7 +145,13 @@ async fn main() -> ExitCode { match cli.command { // Commands that need direct I/O (stdin/stdout) stay in the binary - Some(Commands::Hook { agent, event }) => hook::run(&sym, agent, event).await, + // A retired agent parses to `None`: a hook registration left behind by + // an agent symposium no longer supports exits cleanly instead of failing + // in that agent's session on every event. + Some(Commands::Hook { agent, event }) => match agent.0 { + Some(agent) => hook::run(&sym, agent, event).await, + None => ExitCode::SUCCESS, + }, Some(Commands::Plugin { command }) => { let code = handle_plugin_command(&sym, command).await; diff --git a/src/cli.rs b/src/cli.rs index 4868a998..ad4aa970 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -63,7 +63,7 @@ pub struct Cli { pub enum Commands { /// Set up user-wide configuration Init { - /// Agent to configure (e.g., claude, copilot, gemini). Repeatable. + /// Agent to configure (e.g., antigravity, claude, copilot). Repeatable. /// Skips the interactive prompt. #[arg(long = "add-agent")] agents: Vec, @@ -106,8 +106,8 @@ pub enum Commands { /// Hook entry point invoked by your agent (internal) #[command(hide = true)] Hook { - /// The agent (claude, copilot, gemini) - agent: hook::HookAgent, + /// The agent (antigravity, claude, copilot, ...) + agent: hook::HookAgentArg, /// The hook event (e.g., pre-tool-use, post-tool-use) event: hook::HookEvent, diff --git a/src/config.rs b/src/config.rs index 6e743b63..8f10f8ab 100644 --- a/src/config.rs +++ b/src/config.rs @@ -233,7 +233,7 @@ impl UseEntry { /// An `[[agent]]` entry — just identifies an agent by name. #[derive(Debug, Deserialize, Serialize, Clone)] pub struct AgentEntry { - /// Agent name (e.g., "claude", "copilot", "gemini"). + /// Agent name (e.g., "antigravity", "claude", "copilot"). pub name: String, } diff --git a/src/hook.rs b/src/hook.rs index 594e2b95..14fc03b8 100644 --- a/src/hook.rs +++ b/src/hook.rs @@ -224,7 +224,7 @@ fn spawn_from_spec(spec: SpawnSpec) -> std::io::Result { } // Re-export hook schema types for convenience. -pub use crate::hook_schema::{HookAgent, HookEvent}; +pub use crate::hook_schema::{HookAgent, HookAgentArg, HookEvent}; /// Core hook pipeline: sync → parse → builtin → plugins → serialize. /// /// Takes the raw agent wire-format input, returns agent wire-format output bytes. @@ -878,6 +878,15 @@ fn dispatched_hooks_for_payload( continue; } + if hook.format.is_retired() { + tracing::warn!( + plugin = %parsed_plugin.plugin.name, + hook = %hook.name, + "hook format names an agent symposium no longer supports; skipping" + ); + continue; + } + match hook.format.as_agent() { Some(agent) if agent == host_agent => { native_match = Some(hook); diff --git a/src/hook_schema.rs b/src/hook_schema.rs index cb95dd80..e9893340 100644 --- a/src/hook_schema.rs +++ b/src/hook_schema.rs @@ -9,7 +9,6 @@ pub mod antigravity; pub mod claude; pub mod codex; pub mod copilot; -pub mod gemini; pub mod goose; pub mod kiro; pub mod opencode; @@ -30,9 +29,6 @@ pub enum HookAgent { #[value(name = "copilot")] #[serde(rename = "copilot")] Copilot, - #[value(name = "gemini")] - #[serde(rename = "gemini")] - Gemini, #[value(name = "goose")] #[serde(rename = "goose")] Goose, @@ -52,7 +48,6 @@ impl HookAgent { HookAgent::Claude => "claude", HookAgent::Codex => "codex", HookAgent::Copilot => "copilot", - HookAgent::Gemini => "gemini", HookAgent::Goose => "goose", HookAgent::Kiro => "kiro", HookAgent::OpenCode => "opencode", @@ -65,7 +60,6 @@ impl HookAgent { HookAgent::Claude => claude::ClaudeCode.event(event), HookAgent::Codex => codex::Codex.event(event), HookAgent::Copilot => copilot::Copilot.event(event), - HookAgent::Gemini => gemini::Gemini.event(event), HookAgent::Goose => goose::Goose.event(event), HookAgent::Kiro => kiro::Kiro.event(event), HookAgent::OpenCode => opencode::OpenCode.event(event), @@ -73,6 +67,28 @@ impl HookAgent { } } +/// Hook agent names symposium used to support and no longer does. +pub const RETIRED_HOOK_AGENTS: &[&str] = &["gemini"]; + +/// The `cargo agents hook ` argument. +/// +/// `None` is a retired agent: a hook registration written before that agent was +/// dropped keeps firing until something cleans the config, and it must exit +/// quietly rather than spraying a parse error into every session. +#[derive(Debug, Clone, Copy)] +pub struct HookAgentArg(pub Option); + +impl std::str::FromStr for HookAgentArg { + type Err = String; + + fn from_str(name: &str) -> Result { + if RETIRED_HOOK_AGENTS.contains(&name) { + return Ok(HookAgentArg(None)); + } + clap::ValueEnum::from_str(name, true).map(|a| HookAgentArg(Some(a))) + } +} + pub use symposium_sdk::hook::HookEvent; /// Represents the data sent *from* an agent *to* a hook. @@ -205,3 +221,29 @@ where { Box::new(ErasedAgentHookEventImpl(e)) } + +#[cfg(test)] +mod tests { + use super::*; + use std::str::FromStr; + + #[test] + fn a_supported_agent_parses_to_itself() { + assert_eq!( + HookAgentArg::from_str("antigravity").unwrap().0, + Some(HookAgent::Antigravity) + ); + } + + /// A hook registration outlives the release that drops its agent, so the + /// retired name must parse and produce nothing to dispatch. + #[test] + fn a_retired_agent_parses_to_nothing_to_dispatch() { + assert_eq!(HookAgentArg::from_str("gemini").unwrap().0, None); + } + + #[test] + fn an_unknown_agent_is_still_an_error() { + assert!(HookAgentArg::from_str("notanagent").is_err()); + } +} diff --git a/src/hook_schema/gemini.rs b/src/hook_schema/gemini.rs deleted file mode 100644 index f15368a9..00000000 --- a/src/hook_schema/gemini.rs +++ /dev/null @@ -1,479 +0,0 @@ -use serde::{Deserialize, Serialize}; - -use crate::hook_schema::{ - Agent, AgentHookEvent, AgentHookInput, AgentHookOutput, erase_agent_hook_event, symposium, -}; - -pub struct Gemini; -impl Agent for Gemini { - fn event(&self, event: super::HookEvent) -> Option> { - match event { - super::HookEvent::PreToolUse => Some(erase_agent_hook_event(GeminiPreToolUseEvent)), - super::HookEvent::PostToolUse => Some(erase_agent_hook_event(GeminiPostToolUseEvent)), - super::HookEvent::UserPromptSubmit => { - Some(erase_agent_hook_event(GeminiUserPromptSubmitEvent)) - } - super::HookEvent::SessionStart => Some(erase_agent_hook_event(GeminiSessionStartEvent)), - _ => None, - } - } -} - -macro_rules! gemini_event { - ($event:ident, $input:ident, $output:ident) => { - pub struct $event; - impl AgentHookEvent for $event { - type Input = $input; - type Output = $output; - } - }; -} - -gemini_event!( - GeminiPreToolUseEvent, - GeminiPreToolUseInput, - GeminiPreToolUseOutput -); -gemini_event!( - GeminiPostToolUseEvent, - GeminiPostToolUseInput, - GeminiPostToolUseOutput -); -gemini_event!( - GeminiUserPromptSubmitEvent, - GeminiUserPromptSubmitInput, - GeminiUserPromptSubmitOutput -); -gemini_event!( - GeminiSessionStartEvent, - GeminiSessionStartInput, - GeminiSessionStartOutput -); - -fn gemini_hook_output_from_symposium( - event_name: &str, - event: &symposium::OutputEvent, -) -> Option { - let ctx = event.additional_context()?; - Some(serde_json::json!({ - "hookSpecificOutput": { - "hookEventName": event_name, - "additionalContext": ctx, - } - })) -} - -// ── PreToolUse (BeforeTool) ─────────────────────────────────────────── - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct GeminiPreToolUseInput { - pub hook_event_name: String, - pub tool_name: String, - #[serde(default)] - pub tool_input: serde_json::Value, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub session_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub cwd: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub transcript_path: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub timestamp: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub mcp_context: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub original_request_name: Option, - #[serde(flatten)] - pub rest: serde_json::Map, -} - -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct GeminiPreToolUseOutput { - #[serde(skip_serializing_if = "Option::is_none")] - pub decision: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub reason: Option, - #[serde(rename = "systemMessage", skip_serializing_if = "Option::is_none")] - pub system_message: Option, - #[serde(rename = "suppressOutput", skip_serializing_if = "Option::is_none")] - pub suppress_output: Option, - #[serde(rename = "continue", skip_serializing_if = "Option::is_none")] - pub continue_: Option, - #[serde(rename = "stopReason", skip_serializing_if = "Option::is_none")] - pub stop_reason: Option, - #[serde(rename = "hookSpecificOutput", skip_serializing_if = "Option::is_none")] - pub hook_specific_output: Option, - #[serde(flatten)] - pub rest: serde_json::Map, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct GeminiPreToolUseHookOutput { - #[serde(rename = "hookEventName")] - pub hook_event_name: String, - #[serde(rename = "additionalContext", skip_serializing_if = "Option::is_none")] - pub additional_context: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub tool_input: Option, - #[serde(flatten)] - pub rest: serde_json::Map, -} - -impl AgentHookInput for GeminiPreToolUseInput { - fn parse_input(payload: &str) -> anyhow::Result { - Ok(serde_json::from_str(payload)?) - } - fn to_symposium(&self) -> symposium::InputEvent { - symposium::InputEvent::PreToolUse(symposium::PreToolUseInput::new( - self.tool_name.clone(), - self.tool_input.clone(), - self.session_id.clone(), - self.cwd.clone(), - )) - } - fn from_symposium(event: &symposium::InputEvent) -> Self { - let symposium::InputEvent::PreToolUse(p) = event else { - panic!("wrong event type") - }; - Self { - hook_event_name: "BeforeTool".into(), - tool_name: p.tool_name.clone(), - tool_input: p.tool_input.clone(), - session_id: p.session_id.clone(), - cwd: p.cwd.clone(), - transcript_path: None, - timestamp: None, - mcp_context: None, - original_request_name: None, - rest: serde_json::Map::new(), - } - } - fn to_string(&self) -> anyhow::Result { - serde_json::to_string(self).map_err(Into::into) - } - fn into_any(self: Box) -> Box { - self - } -} - -impl AgentHookOutput for GeminiPreToolUseOutput { - fn parse_output(output: &[u8]) -> anyhow::Result { - if output.is_empty() { - return Ok(Self::default()); - } - Ok(serde_json::from_slice(output)?) - } - fn from_symposium(event: &symposium::OutputEvent) -> Self { - match gemini_hook_output_from_symposium("BeforeTool", event) { - Some(v) => serde_json::from_value(v).unwrap_or_default(), - None => Self::default(), - } - } - fn to_symposium(&self) -> symposium::OutputEvent { - let h = self.hook_specific_output.as_ref(); - let decision = match self.decision.as_deref() { - Some("deny") | Some("block") => symposium_sdk::hook::Decision::Deny, - _ => symposium_sdk::hook::Decision::Allow, - }; - symposium::OutputEvent::PreToolUse(symposium::PreToolUseOutput::new( - decision, - h.and_then(|h| h.additional_context.clone()), - h.and_then(|h| h.tool_input.clone()), - )) - } - fn to_hook_output(&self) -> serde_json::Value { - serde_json::to_value(self).unwrap() - } - fn into_any(self: Box) -> Box { - self - } -} - -// ── PostToolUse (AfterTool) ─────────────────────────────────────────── - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct GeminiPostToolUseInput { - pub hook_event_name: String, - pub tool_name: String, - #[serde(default)] - pub tool_input: serde_json::Value, - #[serde(default)] - pub tool_response: serde_json::Value, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub session_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub cwd: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub transcript_path: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub timestamp: Option, - #[serde(flatten)] - pub rest: serde_json::Map, -} - -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct GeminiPostToolUseOutput { - #[serde(skip_serializing_if = "Option::is_none")] - pub decision: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub reason: Option, - #[serde(rename = "hookSpecificOutput", skip_serializing_if = "Option::is_none")] - pub hook_specific_output: Option, - #[serde(flatten)] - pub rest: serde_json::Map, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct GeminiPostToolUseHookOutput { - #[serde(rename = "hookEventName")] - pub hook_event_name: String, - #[serde(rename = "additionalContext", skip_serializing_if = "Option::is_none")] - pub additional_context: Option, - #[serde(flatten)] - pub rest: serde_json::Map, -} - -impl AgentHookInput for GeminiPostToolUseInput { - fn parse_input(payload: &str) -> anyhow::Result { - Ok(serde_json::from_str(payload)?) - } - fn to_symposium(&self) -> symposium::InputEvent { - symposium::InputEvent::PostToolUse(symposium::PostToolUseInput::new( - self.tool_name.clone(), - self.tool_input.clone(), - self.tool_response.clone(), - self.session_id.clone(), - self.cwd.clone(), - )) - } - fn from_symposium(event: &symposium::InputEvent) -> Self { - let symposium::InputEvent::PostToolUse(p) = event else { - panic!("wrong event type") - }; - Self { - hook_event_name: "AfterTool".into(), - tool_name: p.tool_name.clone(), - tool_input: p.tool_input.clone(), - tool_response: p.tool_response.clone(), - session_id: p.session_id.clone(), - cwd: p.cwd.clone(), - transcript_path: None, - timestamp: None, - rest: serde_json::Map::new(), - } - } - fn to_string(&self) -> anyhow::Result { - serde_json::to_string(self).map_err(Into::into) - } - fn into_any(self: Box) -> Box { - self - } -} - -impl AgentHookOutput for GeminiPostToolUseOutput { - fn parse_output(output: &[u8]) -> anyhow::Result { - if output.is_empty() { - return Ok(Self::default()); - } - Ok(serde_json::from_slice(output)?) - } - fn from_symposium(event: &symposium::OutputEvent) -> Self { - match gemini_hook_output_from_symposium("AfterTool", event) { - Some(v) => serde_json::from_value(v).unwrap_or_default(), - None => Self::default(), - } - } - fn to_symposium(&self) -> symposium::OutputEvent { - symposium::OutputEvent::PostToolUse(symposium::PostToolUseOutput::new( - self.hook_specific_output - .as_ref() - .and_then(|h| h.additional_context.clone()), - )) - } - fn to_hook_output(&self) -> serde_json::Value { - serde_json::to_value(self).unwrap() - } - fn into_any(self: Box) -> Box { - self - } -} - -// ── UserPromptSubmit ────────────────────────────────────────────────── - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct GeminiUserPromptSubmitInput { - pub hook_event_name: String, - #[serde(default)] - pub prompt: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub session_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub cwd: Option, - #[serde(flatten)] - pub rest: serde_json::Map, -} - -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct GeminiUserPromptSubmitOutput { - #[serde(rename = "hookSpecificOutput", skip_serializing_if = "Option::is_none")] - pub hook_specific_output: Option, - #[serde(flatten)] - pub rest: serde_json::Map, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct GeminiUserPromptSubmitHookOutput { - #[serde(rename = "hookEventName")] - pub hook_event_name: String, - #[serde(rename = "additionalContext", skip_serializing_if = "Option::is_none")] - pub additional_context: Option, - #[serde(flatten)] - pub rest: serde_json::Map, -} - -impl AgentHookInput for GeminiUserPromptSubmitInput { - fn parse_input(payload: &str) -> anyhow::Result { - Ok(serde_json::from_str(payload)?) - } - fn to_symposium(&self) -> symposium::InputEvent { - symposium::InputEvent::UserPromptSubmit(symposium::UserPromptSubmitInput::new( - self.prompt.clone(), - self.session_id.clone(), - self.cwd.clone(), - )) - } - fn from_symposium(event: &symposium::InputEvent) -> Self { - let symposium::InputEvent::UserPromptSubmit(p) = event else { - panic!("wrong event type") - }; - Self { - hook_event_name: "UserPromptSubmit".into(), - prompt: p.prompt.clone(), - session_id: p.session_id.clone(), - cwd: p.cwd.clone(), - rest: serde_json::Map::new(), - } - } - fn to_string(&self) -> anyhow::Result { - serde_json::to_string(self).map_err(Into::into) - } - fn into_any(self: Box) -> Box { - self - } -} - -impl AgentHookOutput for GeminiUserPromptSubmitOutput { - fn parse_output(output: &[u8]) -> anyhow::Result { - if output.is_empty() { - return Ok(Self::default()); - } - Ok(serde_json::from_slice(output)?) - } - fn from_symposium(event: &symposium::OutputEvent) -> Self { - match gemini_hook_output_from_symposium("UserPromptSubmit", event) { - Some(v) => serde_json::from_value(v).unwrap_or_default(), - None => Self::default(), - } - } - fn to_symposium(&self) -> symposium::OutputEvent { - symposium::OutputEvent::UserPromptSubmit(symposium::UserPromptSubmitOutput::new( - self.hook_specific_output - .as_ref() - .and_then(|h| h.additional_context.clone()), - )) - } - fn to_hook_output(&self) -> serde_json::Value { - serde_json::to_value(self).unwrap() - } - fn into_any(self: Box) -> Box { - self - } -} - -// ── SessionStart ────────────────────────────────────────────────────── - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct GeminiSessionStartInput { - pub hook_event_name: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub session_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub cwd: Option, - #[serde(flatten)] - pub rest: serde_json::Map, -} - -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct GeminiSessionStartOutput { - #[serde(rename = "hookSpecificOutput", skip_serializing_if = "Option::is_none")] - pub hook_specific_output: Option, - #[serde(flatten)] - pub rest: serde_json::Map, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct GeminiSessionStartHookOutput { - #[serde(rename = "hookEventName")] - pub hook_event_name: String, - #[serde(rename = "additionalContext", skip_serializing_if = "Option::is_none")] - pub additional_context: Option, - #[serde(flatten)] - pub rest: serde_json::Map, -} - -impl AgentHookInput for GeminiSessionStartInput { - fn parse_input(payload: &str) -> anyhow::Result { - Ok(serde_json::from_str(payload)?) - } - fn to_symposium(&self) -> symposium::InputEvent { - symposium::InputEvent::SessionStart(symposium::SessionStartInput::new( - self.session_id.clone(), - self.cwd.clone(), - )) - } - fn from_symposium(event: &symposium::InputEvent) -> Self { - let symposium::InputEvent::SessionStart(p) = event else { - panic!("wrong event type") - }; - Self { - hook_event_name: "SessionStart".into(), - session_id: p.session_id.clone(), - cwd: p.cwd.clone(), - rest: serde_json::Map::new(), - } - } - fn to_string(&self) -> anyhow::Result { - serde_json::to_string(self).map_err(Into::into) - } - fn into_any(self: Box) -> Box { - self - } -} - -impl AgentHookOutput for GeminiSessionStartOutput { - fn parse_output(output: &[u8]) -> anyhow::Result { - if output.is_empty() { - return Ok(Self::default()); - } - Ok(serde_json::from_slice(output)?) - } - fn from_symposium(event: &symposium::OutputEvent) -> Self { - match gemini_hook_output_from_symposium("SessionStart", event) { - Some(v) => serde_json::from_value(v).unwrap_or_default(), - None => Self::default(), - } - } - fn to_symposium(&self) -> symposium::OutputEvent { - symposium::OutputEvent::SessionStart(symposium::SessionStartOutput::new( - self.hook_specific_output - .as_ref() - .and_then(|h| h.additional_context.clone()), - )) - } - fn to_hook_output(&self) -> serde_json::Value { - serde_json::to_value(self).unwrap() - } - fn into_any(self: Box) -> Box { - self - } -} diff --git a/src/init.rs b/src/init.rs index 3bc561ff..001e086b 100644 --- a/src/init.rs +++ b/src/init.rs @@ -31,20 +31,32 @@ fn resolve_agents( opts: &InitOpts, existing: &[AgentEntry], should_prompt: bool, + out: &Output, ) -> Result> { if !opts.agents.is_empty() || !opts.remove_agents.is_empty() { let mut names: Vec = existing.iter().map(|e| e.name.clone()).collect(); for name in &opts.agents { - Agent::from_config_name(name)?; + // A retired name cannot be added, but naming one is an outdated + // request rather than a typo, so it is reported and dropped. + if Agent::from_configured_name(name, out)?.is_none() { + continue; + } if !names.contains(name) { names.push(name.clone()); } } for name in &opts.remove_agents { - Agent::from_config_name(name)?; + // Removing a retired name must keep working: that is how a user + // clears a stale entry the migration did not reach. + if !Agent::is_retired(name) { + Agent::from_config_name(name)?; + } names.retain(|n| n != name); } - return names.iter().map(|n| Agent::from_config_name(n)).collect(); + return names + .iter() + .filter_map(|n| Agent::from_configured_name(n, out).transpose()) + .collect(); } if should_prompt { return prompt_for_agents(existing); @@ -52,7 +64,7 @@ fn resolve_agents( if !existing.is_empty() { return existing .iter() - .map(|e| Agent::from_config_name(&e.name)) + .filter_map(|e| Agent::from_configured_name(&e.name, out).transpose()) .collect(); } Ok(vec![Agent::all()[0]]) @@ -71,7 +83,7 @@ pub async fn init(sym: &mut Symposium, out: &Output, opts: &InitOpts) -> Result< let should_prompt = !cli_driven && interactive(out); // Resolve each setting: CLI flag > interactive prompt > keep existing. - let agents = resolve_agents(opts, &sym.config.agents, should_prompt)?; + let agents = resolve_agents(opts, &sym.config.agents, should_prompt, out)?; sym.config.agents = agents .iter() diff --git a/src/lib.rs b/src/lib.rs index 7ca53ed2..32dfe4d8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,6 +8,7 @@ pub mod help_render; pub mod hook; pub mod hook_schema; pub(crate) mod installation; +pub mod migrations; pub mod output; pub mod plugins; pub mod pm; diff --git a/src/migrations.rs b/src/migrations.rs new file mode 100644 index 00000000..e6b15fe0 --- /dev/null +++ b/src/migrations.rs @@ -0,0 +1,176 @@ +//! One-shot cleanups for state a past release wrote and the current one no +//! longer maintains. +//! +//! Dropping an agent also drops the code that would otherwise reap what +//! symposium installed for it, so the files outlive the support: hook entries +//! keep invoking a subcommand that no longer accepts them, and skill +//! directories sit in a tree nothing scans. Each migration is recorded by id in +//! `state.toml`, so it runs once per config directory regardless of which +//! release the user came from. + +use std::fs; +use std::path::Path; + +use crate::config::Symposium; +use crate::output::{Output, display_path}; +use crate::state; + +/// Removes what symposium installed for Gemini CLI, whose support was dropped. +const GEMINI_REMOVAL: &str = "remove-gemini-support"; + +/// Apply any pending one-shot migrations. +/// +/// Every step is individually best-effort and returns nothing to propagate: +/// this runs on the startup path of every command, hook dispatch included, so a +/// leftover it cannot remove must not fail the command the user actually asked +/// for. A migration is marked applied either way rather than retried forever. +pub fn run_pending(sym: &mut Symposium, out: &Output) { + if !state::migration_applied(sym.config_dir(), GEMINI_REMOVAL) { + remove_gemini_support(sym, out); + state::record_migration(sym.config_dir(), GEMINI_REMOVAL); + } +} + +/// Undo the Gemini installation: drop the config entry, unregister the hooks +/// that would now invoke a removed subcommand, and reap the skill directories +/// symposium owns under `~/.gemini/`. +fn remove_gemini_support(sym: &mut Symposium, out: &Output) { + let home = sym.home_dir().to_path_buf(); + + // The hook entries matter most: Gemini keeps firing them, and the command + // they name no longer accepts `gemini`. + crate::agents::unregister_settings_hooks( + &home.join(".gemini").join("settings.json"), + "cargo-agents hook", + out, + ); + + // Only Gemini's own skills directory is ours to reap. Project skills went + // to the shared `.agents/skills/`, which other agents still use, so the + // ordinary marker-based cleanup handles those. Antigravity's directories + // live under `.gemini/config/`, which this must not touch. + reap_marked_dirs(&home.join(".gemini").join("skills"), out); + + let before = sym.config.agents.len(); + sym.config + .agents + .retain(|a| !crate::agents::Agent::is_retired(&a.name)); + if sym.config.agents.len() != before && sym.save_config().is_ok() { + out.removed("removed the retired `gemini` agent from your symposium config"); + } +} + +/// Remove every immediate subdirectory of `parent` carrying the symposium +/// marker, leaving user-authored skills alone. +fn reap_marked_dirs(parent: &Path, out: &Output) { + let Ok(entries) = fs::read_dir(parent) else { + return; + }; + for entry in entries.flatten() { + let dir = entry.path(); + if !dir.join(crate::sync::MARKER_FILE).exists() { + continue; + } + if fs::remove_dir_all(&dir).is_ok() { + out.removed(format!("{}: removed", display_path(&dir))); + } + } + // An emptied parent is ours too, but a non-empty one is not an error. + let _ = fs::remove_dir(parent); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::AgentEntry; + + #[test] + fn gemini_removal_clears_config_hooks_and_managed_skills() { + let tmp = tempfile::tempdir().unwrap(); + let mut sym = Symposium::from_dir(tmp.path()); + let home = sym.home_dir().to_path_buf(); + + sym.config.agents = vec![ + AgentEntry { + name: "antigravity".into(), + }, + AgentEntry { + name: "gemini".into(), + }, + ]; + sym.save_config().unwrap(); + + let settings = home.join(".gemini").join("settings.json"); + fs::create_dir_all(settings.parent().unwrap()).unwrap(); + fs::write( + &settings, + serde_json::json!({ + "hooks": { + "BeforeTool": [{ + "matcher": ".*", + "hooks": [{ "command": "cargo-agents hook gemini pre-tool-use" }] + }] + }, + "theme": "dark" + }) + .to_string(), + ) + .unwrap(); + + let managed = home.join(".gemini").join("skills").join("serde-guidance"); + let user_authored = home.join(".gemini").join("skills").join("mine"); + fs::create_dir_all(&managed).unwrap(); + fs::write(managed.join(crate::sync::MARKER_FILE), "").unwrap(); + fs::create_dir_all(&user_authored).unwrap(); + + // Antigravity lives under the same `.gemini` root; reaping must not + // reach into its directories. + let antigravity_skill = home + .join(".gemini") + .join("config") + .join("skills") + .join("kept"); + fs::create_dir_all(&antigravity_skill).unwrap(); + fs::write(antigravity_skill.join(crate::sync::MARKER_FILE), "").unwrap(); + + remove_gemini_support(&mut sym, &Output::quiet()); + + assert_eq!(sym.config.agents.len(), 1, "the retired entry is dropped"); + assert_eq!(sym.config.agents[0].name, "antigravity"); + + let after = fs::read_to_string(&settings).unwrap(); + assert!( + !after.contains("cargo-agents hook"), + "the hook that would invoke a removed subcommand is gone" + ); + let parsed: serde_json::Value = serde_json::from_str(&after).unwrap(); + assert_eq!(parsed["theme"], "dark", "unrelated settings are preserved"); + + assert!(!managed.exists(), "a symposium-managed skill is reaped"); + assert!( + user_authored.exists(), + "a skill symposium did not install is left alone" + ); + assert!( + antigravity_skill.exists(), + "Antigravity's own skills under .gemini/config are untouched" + ); + } + + #[test] + fn the_migration_runs_once() { + let tmp = tempfile::tempdir().unwrap(); + let mut sym = Symposium::from_dir(tmp.path()); + + run_pending(&mut sym, &Output::quiet()); + assert!(state::migration_applied(sym.config_dir(), GEMINI_REMOVAL)); + + // A second run is a no-op: a config the user has since re-added is not + // silently rewritten. + sym.config.agents = vec![AgentEntry { + name: "gemini".into(), + }]; + run_pending(&mut sym, &Output::quiet()); + assert_eq!(sym.config.agents.len(), 1); + } +} diff --git a/src/plugins.rs b/src/plugins.rs index 1f7b95c7..7c6e2bb1 100644 --- a/src/plugins.rs +++ b/src/plugins.rs @@ -836,23 +836,36 @@ pub enum HookFormat { Claude, Codex, Copilot, - Gemini, Kiro, + /// A wire format symposium has retired along with its agent. + /// + /// A published manifest outlives the release that drops an agent, so the + /// spelling still deserializes; the hook is skipped rather than failing the + /// plugin that carries it. + #[serde(rename = "gemini")] + Retired, } impl HookFormat { /// Convert to the corresponding HookAgent, if this is an agent format. + /// + /// `None` covers both the symposium format and a retired one, so callers + /// deciding whether a hook fires must check `is_retired` first. pub fn as_agent(&self) -> Option { match self { - HookFormat::Symposium => None, + HookFormat::Symposium | HookFormat::Retired => None, HookFormat::Antigravity => Some(HookAgent::Antigravity), HookFormat::Claude => Some(HookAgent::Claude), HookFormat::Codex => Some(HookAgent::Codex), HookFormat::Copilot => Some(HookAgent::Copilot), - HookFormat::Gemini => Some(HookAgent::Gemini), HookFormat::Kiro => Some(HookAgent::Kiro), } } + + /// Whether this names an agent symposium no longer supports. + pub fn is_retired(&self) -> bool { + matches!(self, HookFormat::Retired) + } } #[derive(Debug, serde::Serialize)] diff --git a/src/state.rs b/src/state.rs index b105e22e..616704ac 100644 --- a/src/state.rs +++ b/src/state.rs @@ -25,6 +25,13 @@ pub struct State { /// Last time we checked crates.io for a newer version. #[serde(default, rename = "last-update-check")] pub last_update_check: Option>, + + /// Ids of one-shot migrations already applied to this directory. + /// + /// Recorded by id rather than inferred from `version` so a migration runs + /// exactly once regardless of which release the user upgraded from. + #[serde(default, rename = "applied-migrations")] + pub applied_migrations: Vec, } #[derive(Debug, Deserialize)] @@ -32,6 +39,8 @@ struct RawState { version: String, #[serde(default, rename = "last-update-check")] last_update_check: Option>, + #[serde(default, rename = "applied-migrations")] + applied_migrations: Vec, } impl RawState { @@ -39,6 +48,7 @@ impl RawState { State { version: self.version, last_update_check: self.last_update_check, + applied_migrations: self.applied_migrations, } } } @@ -48,6 +58,7 @@ impl Default for State { Self { version: CURRENT_VERSION.to_string(), last_update_check: None, + applied_migrations: Vec::new(), } } } @@ -95,6 +106,20 @@ pub fn ensure_current(config_dir: &Path) -> Option { prev_version } +/// Whether the one-shot migration `id` has already been applied here. +pub fn migration_applied(config_dir: &Path, id: &str) -> bool { + load(config_dir).is_some_and(|s| s.applied_migrations.iter().any(|m| m == id)) +} + +/// Record that the one-shot migration `id` has been applied. +pub fn record_migration(config_dir: &Path, id: &str) { + let mut state = load(config_dir).unwrap_or_default(); + if !state.applied_migrations.iter().any(|m| m == id) { + state.applied_migrations.push(id.to_string()); + } + save(config_dir, &state); +} + /// Whether enough time has elapsed since the last update check. pub fn should_check_for_update(config_dir: &Path) -> bool { let Some(state) = load(config_dir) else { @@ -146,6 +171,7 @@ mod tests { let old = State { version: "0.1.0".to_string(), last_update_check: None, + applied_migrations: Vec::new(), }; fs::write( tmp.path().join(STATE_FILE), @@ -199,4 +225,17 @@ mod tests { let after = load(tmp.path()).unwrap().last_update_check; assert_eq!(before, after); } + + #[test] + fn migrations_are_recorded_once() { + let tmp = tempfile::tempdir().unwrap(); + assert!(!migration_applied(tmp.path(), "drop-something")); + + record_migration(tmp.path(), "drop-something"); + assert!(migration_applied(tmp.path(), "drop-something")); + + record_migration(tmp.path(), "drop-something"); + let state = load(tmp.path()).unwrap(); + assert_eq!(state.applied_migrations, vec!["drop-something".to_string()]); + } } diff --git a/src/sync.rs b/src/sync.rs index 65ca9e92..f8dab3f2 100644 --- a/src/sync.rs +++ b/src/sync.rs @@ -395,7 +395,9 @@ pub async fn sync(sym: &Symposium, deps: &Arc, update: UpdateLeve let mut installed_dirs: BTreeSet = BTreeSet::new(); for agent_name in &agent_names { - let agent = Agent::from_config_name(agent_name)?; + let Some(agent) = Agent::from_configured_name(agent_name, out)? else { + continue; + }; // Register hooks and MCP servers at the configured scope. // @@ -596,7 +598,9 @@ pub async fn register_hooks(sym: &Symposium, out: &Output) -> Result<()> { let agent_names: Vec = sym.config.agents.iter().map(|a| a.name.clone()).collect(); for agent_name in &agent_names { - let agent = Agent::from_config_name(agent_name)?; + let Some(agent) = Agent::from_configured_name(agent_name, out)? else { + continue; + }; agent.register_hooks(sym.home_dir(), sym, out)?; agent.register_global_mcp_servers(sym.home_dir(), &mcp_servers, out)?; } diff --git a/symposium-testlib/src/lib.rs b/symposium-testlib/src/lib.rs index 4d46f513..cbab9a1b 100644 --- a/symposium-testlib/src/lib.rs +++ b/symposium-testlib/src/lib.rs @@ -25,9 +25,9 @@ pub enum TestAgent { ClaudeSdk, /// ACP agent from the ACP registry (via acpr). Acp { - /// Agent name in the ACP registry (e.g., "claude-acp", "gemini"). + /// Agent name in the ACP registry (e.g., "claude-acp", "goose"). registry_name: String, - /// Symposium agent name (e.g., "claude", "gemini"). + /// Symposium agent name (e.g., "claude", "goose"). agent_name: String, }, /// ACP agent with a custom command (not in the registry). @@ -64,7 +64,6 @@ fn infer_agent_name(registry_name: &str) -> &str { match registry_name { "claude-acp" => "claude", "codex-acp" => "codex", - "gemini" => "gemini", "goose" => "goose", "opencode" => "opencode", other => other, diff --git a/tests/init_sync.rs b/tests/init_sync.rs index 298021e9..d1e20988 100644 --- a/tests/init_sync.rs +++ b/tests/init_sync.rs @@ -103,7 +103,7 @@ async fn init_preserves_existing_hook_scope() { with_fixture(TestMode::SimulationOnly, &[], async |mut ctx| { ctx.symposium(&["init", "--add-agent", "claude", "--hook-scope", "project"]) .await?; - ctx.symposium(&["init", "--add-agent", "gemini"]).await?; + ctx.symposium(&["init", "--add-agent", "codex"]).await?; let content = read_user_config(&ctx); assert!( @@ -399,22 +399,22 @@ async fn removing_agent_removes_hooks() { "--add-agent", "claude", "--add-agent", - "gemini", + "codex", ]) .await?; let claude_settings = ctx.sym.home_dir().join(".claude/settings.json"); - let gemini_settings = ctx.sym.home_dir().join(".gemini/settings.json"); + let codex_hooks = ctx.sym.home_dir().join(".codex/hooks.json"); assert!(claude_settings.exists(), "claude settings should exist"); - assert!(gemini_settings.exists(), "gemini settings should exist"); + assert!(codex_hooks.exists(), "codex hooks should exist"); assert!( - std::fs::read_to_string(&gemini_settings) + std::fs::read_to_string(&codex_hooks) .unwrap() .contains("cargo-agents hook"), - "gemini should have symposium hooks" + "codex should have symposium hooks" ); - ctx.symposium(&["init", "--hook-scope", "global", "--remove-agent", "gemini"]) + ctx.symposium(&["init", "--hook-scope", "global", "--remove-agent", "codex"]) .await?; let contents = std::fs::read_to_string(&claude_settings).unwrap(); @@ -423,10 +423,10 @@ async fn removing_agent_removes_hooks() { "claude hooks should remain" ); - let contents = std::fs::read_to_string(&gemini_settings).unwrap(); + let contents = std::fs::read_to_string(&codex_hooks).unwrap(); assert!( !contents.contains("cargo-agents hook"), - "gemini hooks should be removed" + "codex hooks should be removed" ); Ok(()) }) @@ -440,7 +440,7 @@ async fn add_agent_is_additive() { with_fixture(TestMode::SimulationOnly, &["plugins0"], async |mut ctx| { ctx.symposium(&["init", "--hook-scope", "global", "--add-agent", "claude"]) .await?; - ctx.symposium(&["init", "--hook-scope", "global", "--add-agent", "gemini"]) + ctx.symposium(&["init", "--hook-scope", "global", "--add-agent", "codex"]) .await?; let config = symposium::config::Symposium::from_dir(ctx.sym.config_dir()); @@ -450,17 +450,17 @@ async fn add_agent_is_additive() { .iter() .map(|a| a.name.as_str()) .collect(); - assert_eq!(agent_names, vec!["claude", "gemini"]); + assert_eq!(agent_names, vec!["claude", "codex"]); let claude_settings = ctx.sym.home_dir().join(".claude/settings.json"); - let gemini_settings = ctx.sym.home_dir().join(".gemini/settings.json"); + let codex_hooks = ctx.sym.home_dir().join(".codex/hooks.json"); assert!( std::fs::read_to_string(&claude_settings) .unwrap() .contains("cargo-agents hook") ); assert!( - std::fs::read_to_string(&gemini_settings) + std::fs::read_to_string(&codex_hooks) .unwrap() .contains("cargo-agents hook") ); @@ -470,6 +470,48 @@ async fn add_agent_is_additive() { .unwrap(); } +/// A config naming an agent symposium has retired keeps working: the entry is +/// reported and skipped rather than failing the command. A user config outlives +/// the release that removes an agent, so erroring here would break every command +/// until the file was edited by hand. +#[tokio::test] +async fn retired_agent_in_config_is_ignored_not_fatal() { + with_fixture( + TestMode::SimulationOnly, + &["plugins0", "workspace0"], + async |mut ctx| { + ctx.symposium(&["init", "--hook-scope", "global", "--add-agent", "claude"]) + .await?; + + let config_path = ctx.sym.config_dir().join("config.toml"); + let existing = std::fs::read_to_string(&config_path)?; + std::fs::write( + &config_path, + format!("{existing}\n[[agent]]\nname = \"gemini\"\n"), + )?; + + // Sync must succeed despite the retired entry. + ctx.symposium(&["sync"]).await?; + + let claude_settings = ctx.sym.home_dir().join(".claude/settings.json"); + assert!( + std::fs::read_to_string(&claude_settings) + .unwrap() + .contains("cargo-agents hook"), + "the supported agent is still configured" + ); + assert!( + !ctx.sym.home_dir().join(".gemini").exists(), + "nothing is written for a retired agent" + ); + + Ok(()) + }, + ) + .await + .unwrap(); +} + /// `sync` filters MCP servers by their `depends-on` predicates. #[tokio::test] async fn sync_filters_mcp_servers_by_crates() {