use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
use serde_json::{Map, Value};
use super::cchooks::hook_is_portable;
use super::confedit::json_edit;
use super::mcpjson::{self, RemoteShape, ServerShape};
use super::report;
use super::statuslinejson::{self, SlotShape};
use super::{AgentBackend, BackendState};
use crate::components::HookBinding;
use crate::doctor::{CheckStatus, DoctorCheck, DoctorReport};
use crate::error::{Error, Result};
use crate::host::{Capabilities, Desired, Outcome, Plugin, Scope, Source};
pub(crate) struct AntigravityCliBackend;
const SHAPE: ServerShape = ServerShape::plain().with_remote(RemoteShape::ServerUrlSseOnly);
const STATUSLINE_SLOT: &[&str] = &["statusLine"];
const STATUSLINE_SHAPE: SlotShape = SlotShape::typed_command().carrying(&["enabled"], "Turn it back on with `/statusline on`.");
impl AgentBackend for AntigravityCliBackend {
fn id(&self) -> &'static str {
"antigravity-cli"
}
fn detect(&self) -> bool {
which::which("agy").is_ok() || dirs::home_dir().is_some_and(|h| h.join(".gemini").join("antigravity-cli").is_dir())
}
fn capabilities(&self) -> Capabilities {
Capabilities {
plugins: false,
mcp: true,
hooks: true,
commands: false,
agents: false,
skills: false,
instructions: false,
statusline: true,
scopes: &["user", "project"],
}
}
fn probe(&self, plugin: &Plugin, scope: &Scope, source: &Source) -> Result<BackendState> {
let comp = plugin.components(source)?.with_client(self.id());
let mcp = mcpjson::probe_surface(&mcp_path(scope)?, &["mcpServers"], &comp.mcp_servers, SHAPE)?;
let hooks = probe_hooks(&hooks_path(scope)?, plugin.name, render_hook_tree(&comp.hooks))?;
let statusline = match statusline_target(plugin, scope)? {
Some(path) => statuslinejson::state(&path, STATUSLINE_SLOT, plugin, scope, self.id(), STATUSLINE_SHAPE)?,
None => None,
};
Ok(report::compose([mcp, hooks, statusline].into_iter().flatten()))
}
fn reconcile(&self, plugin: &Plugin, desired: &Desired, scope: &Scope) -> Result<Outcome> {
let comp = plugin.components(&desired.source)?.with_client(self.id());
let mut changed = false;
changed |= mcpjson::reconcile(&mcp_path(scope)?, &["mcpServers"], &comp.mcp_servers, SHAPE)? != Outcome::NoOp;
changed |= reconcile_hooks(&hooks_path(scope)?, plugin.name, &comp.hooks, desired.reenable)?;
if let Some(retired) = retired_hooks_path(scope)? {
changed |= remove_hooks(&retired, plugin.name)?;
}
if let Some(path) = statusline_target(plugin, scope)? {
changed |= statuslinejson::reconcile(&path, STATUSLINE_SLOT, plugin, &desired.source, scope, self.id(), STATUSLINE_SHAPE)?;
}
Ok(if changed { Outcome::Installed } else { Outcome::NoOp })
}
fn remove(&self, plugin: &Plugin, scope: &Scope, source: &Source) -> Result<Outcome> {
let comp = plugin.components(source)?.with_client(self.id());
let mut changed = false;
changed |= mcpjson::remove(&mcp_path(scope)?, &["mcpServers"], &comp.mcp_servers, SHAPE)? != Outcome::NoOp;
changed |= remove_hooks(&hooks_path(scope)?, plugin.name)?;
if let Some(path) = statusline_target(plugin, scope)? {
changed |= statuslinejson::remove(&path, STATUSLINE_SLOT, plugin, scope, self.id(), STATUSLINE_SHAPE)?;
}
Ok(if changed { Outcome::Removed } else { Outcome::NoOp })
}
fn forget(&self, plugin: &Plugin, scope: &Scope) -> Result<()> {
let Some(path) = statusline_target(plugin, scope)? else {
return Ok(());
};
statuslinejson::remove(&path, STATUSLINE_SLOT, plugin, scope, self.id(), STATUSLINE_SHAPE).map(|_| ())
}
fn report(&self, plugin: &Plugin, source: &Source) -> DoctorReport {
DoctorReport::from_checks(report_checks(self, plugin, source))
}
}
fn gemini_home() -> Result<PathBuf> {
dirs::home_dir().map(|h| h.join(".gemini")).ok_or_else(|| Error::Tree("no home directory (HOME unset); cannot locate ~/.gemini".into()))
}
fn mcp_path(scope: &Scope) -> Result<PathBuf> {
match scope {
Scope::User => Ok(gemini_home()?.join("config").join("mcp_config.json")),
Scope::Project { path } => Ok(path.join(".agents").join("mcp_config.json")),
}
}
fn hooks_path(scope: &Scope) -> Result<PathBuf> {
match scope {
Scope::User => Ok(gemini_home()?.join("config").join("hooks.json")),
Scope::Project { path } => Ok(path.join(".agents").join("hooks.json")),
}
}
fn retired_hooks_path(scope: &Scope) -> Result<Option<PathBuf>> {
match scope {
Scope::User => Ok(Some(gemini_home()?.join("antigravity-cli").join("hooks.json"))),
Scope::Project { .. } => Ok(None),
}
}
fn statusline_file() -> Result<PathBuf> {
Ok(gemini_home()?.join("antigravity-cli").join("settings.json"))
}
fn statusline_target(plugin: &Plugin, scope: &Scope) -> Result<Option<PathBuf>> {
let Scope::User = scope else {
return Ok(None);
};
statuslinejson::target(plugin, AntigravityCliBackend.id(), STATUSLINE_SHAPE, statusline_file)
}
fn map_event(cc_event: &str) -> Option<&'static str> {
match cc_event {
"UserPromptSubmit" => Some("PreInvocation"),
"PreToolUse" => Some("PreToolUse"),
"PostToolUse" => Some("PostToolUse"),
"Stop" => Some("Stop"),
_ => None,
}
}
fn is_grouped(agy_event: &str) -> bool {
matches!(agy_event, "PreToolUse" | "PostToolUse")
}
fn render_handler(hook: &HookBinding) -> Value {
let mut handler = Map::new();
handler.insert("type".into(), Value::from("command"));
handler.insert("command".into(), Value::from(hook.command.clone()));
Value::Object(handler)
}
fn render_event(agy_event: &str, hooks: &[&HookBinding]) -> Value {
if !is_grouped(agy_event) {
return Value::Array(hooks.iter().map(|h| render_handler(h)).collect());
}
let mut groups: BTreeMap<&str, Vec<Value>> = BTreeMap::new();
for hook in hooks {
groups.entry(hook.matcher.as_deref().unwrap_or("*")).or_default().push(render_handler(hook));
}
Value::Array(
groups
.into_iter()
.map(|(matcher, handlers)| {
let mut group = Map::new();
group.insert("matcher".into(), Value::from(matcher));
group.insert("hooks".into(), Value::Array(handlers));
Value::Object(group)
})
.collect(),
)
}
fn render_hook_tree(hooks: &[HookBinding]) -> Option<Value> {
let writable: Vec<(&'static str, &HookBinding)> =
hooks.iter().filter(|h| hook_is_portable(h)).filter_map(|h| map_event(&h.event).map(|event| (event, h))).collect();
if writable.is_empty() {
return None;
}
let mut by_event: BTreeMap<&'static str, Vec<&HookBinding>> = BTreeMap::new();
for (event, hook) in writable {
by_event.entry(event).or_default().push(hook);
}
let mut events: Map<String, Value> = Map::new();
for (event, group) in &by_event {
events.insert((*event).to_string(), render_event(event, group));
}
Some(Value::Object(events))
}
fn subtree_disabled(path: &Path, plugin: &str) -> Result<bool> {
let bytes = match fs::read(path) {
Ok(b) => b,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(false),
Err(source) => return Err(Error::Io { context: format!("reading {}", path.display()), source }),
};
let root: Value =
serde_json::from_slice(&bytes).map_err(|e| Error::Config { path: path.display().to_string(), detail: e.to_string() })?;
Ok(root.get(plugin).and_then(|v| v.get("enabled")).and_then(Value::as_bool) == Some(false))
}
fn probe_hooks(path: &Path, plugin: &str, tree: Option<Value>) -> Result<Option<BackendState>> {
if tree.is_none() {
return Ok(None);
}
if subtree_disabled(path, plugin)? {
return Ok(Some(BackendState::Disabled));
}
report::probe_json_subtree(path, &[plugin], tree)
}
fn reconcile_hooks(path: &Path, plugin: &str, hooks: &[HookBinding], reenable: bool) -> Result<bool> {
let Some(mut tree) = render_hook_tree(hooks) else {
return Ok(false);
};
json_edit(path, |root| {
if let Value::Object(map) = root {
let currently_disabled = map.get(plugin).and_then(|v| v.get("enabled")).and_then(Value::as_bool) == Some(false);
if !reenable
&& currently_disabled
&& let Value::Object(events) = &mut tree
{
events.insert("enabled".to_string(), Value::Bool(false));
}
map.insert(plugin.to_string(), tree);
}
Ok(())
})
}
fn remove_hooks(path: &Path, plugin: &str) -> Result<bool> {
if !path.exists() {
return Ok(false);
}
json_edit(path, |root| {
if let Value::Object(map) = root {
map.remove(plugin);
}
Ok(())
})
}
fn report_checks(backend: &AntigravityCliBackend, plugin: &Plugin, source: &Source) -> Vec<DoctorCheck> {
let mut checks = Vec::new();
checks.push(if backend.detect() {
DoctorCheck {
name: "antigravity-cli detected",
status: CheckStatus::Ok("`agy` on PATH or ~/.gemini/antigravity-cli present".into()),
}
} else {
DoctorCheck {
name: "antigravity-cli detected",
status: CheckStatus::Fail {
problem: "antigravity-cli (`agy`) not detected".into(),
fix: "install it with `curl -fsSL https://antigravity.google/cli/install.sh | bash`".into(),
},
}
});
let mcp = match mcp_path(&Scope::User) {
Ok(mcp) => mcp,
Err(e) => {
checks.push(DoctorCheck { name: "mcp_config.json", status: CheckStatus::Warn(e.to_string()) });
return checks;
}
};
let root = report::read_json_config(&mut checks, "mcp_config.json", &mcp);
let Some(comp) = report::components(&mut checks, plugin, source).map(|c| c.with_client(backend.id())) else {
return checks;
};
checks.push(report::check_mcp_registered(
&comp.mcp_servers,
root.as_ref(),
&["mcpServers"],
"not in mcp_config.json",
"run the host's `setup`",
));
checks.push(report::check_mcp_command(&comp.mcp_servers));
checks.push(check_hooks_registered(plugin.name, &comp.hooks));
checks.extend(statuslinejson::check(STATUSLINE_SLOT, plugin, &Scope::User, backend.id(), STATUSLINE_SHAPE, "antigravity-cli", |_| {
statusline_file()
}));
checks
}
fn check_hooks_registered(plugin: &str, hooks: &[HookBinding]) -> DoctorCheck {
let name = "hooks registered";
let skipped = report::skipped_hooks(hooks);
let writable = hooks.iter().filter(|h| hook_is_portable(h)).any(|h| map_event(&h.event).is_some());
if !writable {
let check = DoctorCheck { name, status: CheckStatus::Ok("no portable, mappable hooks to register".into()) };
return report::note_skipped(check, &skipped);
}
let path = match hooks_path(&Scope::User) {
Ok(p) => p,
Err(e) => return DoctorCheck { name, status: CheckStatus::Warn(e.to_string()) },
};
let check = match fs::read(&path) {
Ok(bytes) => match serde_json::from_slice::<Value>(&bytes) {
Ok(v) if v.get(plugin).is_some() => DoctorCheck { name, status: CheckStatus::Ok(format!("{plugin} hook entry present")) },
Ok(_) => DoctorCheck {
name,
status: CheckStatus::Fail {
problem: format!("no `{plugin}` entry in {}", path.display()),
fix: "run the host's `setup`".into(),
},
},
Err(e) => DoctorCheck {
name,
status: CheckStatus::Fail {
problem: format!("{} does not parse: {e}", path.display()),
fix: "fix the JSON syntax or remove the file".into(),
},
},
},
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
DoctorCheck { name, status: CheckStatus::Warn(format!("{} does not exist yet (run setup)", path.display())) }
}
Err(e) => DoctorCheck { name, status: CheckStatus::Warn(format!("could not read {}: {e}", path.display())) },
};
report::note_skipped(check, &skipped)
}
#[cfg(test)]
#[path = "../../tests/unit/antigravity_cli.rs"]
mod antigravity_cli_tests;