use super::super::{
mcp_server_quiet_mode, resolve_binary_path, resolve_hook_command_binary, write_file,
};
pub(crate) fn install_antigravity_hook() {
let Some(home) = crate::core::home::resolve_home_dir() else {
tracing::error!("Cannot resolve home directory");
return;
};
install_antigravity_mcp_config(&home, "antigravity");
install_antigravity_gemini_hooks(&home);
}
pub(crate) fn install_antigravity_cli_hook() {
let Some(home) = crate::core::home::resolve_home_dir() else {
tracing::error!("Cannot resolve home directory");
return;
};
install_antigravity_mcp_config(&home, "antigravity-cli");
install_antigravity_cli_hooks(&home);
}
fn install_antigravity_mcp_config(home: &std::path::Path, subdir: &str) {
if !crate::core::config::Config::load()
.setup
.should_update_mcp()
{
return;
}
let binary = resolve_binary_path();
let config_path = home.join(".gemini").join(subdir).join("mcp_config.json");
if let Some(parent) = config_path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let existing = if config_path.exists() {
std::fs::read_to_string(&config_path).unwrap_or_default()
} else {
String::new()
};
let already_configured = existing.contains("lean-ctx");
if already_configured {
if !mcp_server_quiet_mode() {
let label = if subdir == "antigravity-cli" {
"Antigravity CLI"
} else {
"Antigravity"
};
eprintln!(
"{label} MCP: lean-ctx already configured at {}",
config_path.display()
);
}
return;
}
let config = serde_json::json!({
"mcpServers": {
"lean-ctx": {
"command": binary
}
}
});
if existing.is_empty() || existing.trim() == "{}" || existing.contains("\"mcpServers\": {}") {
write_file(
&config_path,
&serde_json::to_string_pretty(&config).unwrap_or_default(),
);
} else if let Ok(mut existing_json) = crate::core::jsonc::parse_jsonc(&existing)
&& let Some(obj) = existing_json.as_object_mut()
{
let servers = obj
.entry("mcpServers")
.or_insert_with(|| serde_json::json!({}));
if let Some(servers_obj) = servers.as_object_mut() {
servers_obj.insert(
"lean-ctx".to_string(),
serde_json::json!({ "command": binary }),
);
}
write_file(
&config_path,
&serde_json::to_string_pretty(&existing_json).unwrap_or_default(),
);
}
if !mcp_server_quiet_mode() {
eprintln!(
"Installed Antigravity MCP config at {}",
config_path.display()
);
}
}
fn install_antigravity_gemini_hooks(home: &std::path::Path) {
let binary = resolve_hook_command_binary();
let rewrite_cmd = format!("{binary} hook rewrite");
let redirect_cmd = format!("{binary} hook redirect");
let observe_cmd = format!("{binary} hook observe");
let settings_path = home.join(".gemini").join("settings.json");
let settings_content = if settings_path.exists() {
std::fs::read_to_string(&settings_path).unwrap_or_default()
} else {
String::new()
};
let has_hooks = settings_content.contains("hook rewrite")
&& settings_content.contains("hook redirect")
&& settings_content.contains("\"matcher\"");
let has_observe = settings_content.contains("hook observe");
if has_hooks && has_observe {
return;
}
let hook_config = serde_json::json!({
"hooks": {
"BeforeTool": [
{
"matcher": "shell|execute_command|run_shell_command|run_command",
"hooks": [{
"type": "command",
"command": rewrite_cmd
}]
},
{
"matcher": "read_file|view_file|read_many_files|grep|grep_search|search|list_dir",
"hooks": [{
"type": "command",
"command": redirect_cmd
}]
}
],
"AfterTool": [
{
"matcher": ".*",
"hooks": [{
"type": "command",
"command": observe_cmd
}]
}
]
}
});
if settings_content.is_empty() {
write_file(
&settings_path,
&serde_json::to_string_pretty(&hook_config).unwrap_or_default(),
);
} else if let Ok(mut existing) = crate::core::jsonc::parse_jsonc(&settings_content)
&& let Some(obj) = existing.as_object_mut()
{
if has_hooks && !has_observe {
let hooks = obj
.entry("hooks".to_string())
.or_insert_with(|| serde_json::json!({}));
if let Some(hooks_obj) = hooks.as_object_mut() {
hooks_obj.insert(
"AfterTool".to_string(),
hook_config["hooks"]["AfterTool"].clone(),
);
}
} else {
obj.insert("hooks".to_string(), hook_config["hooks"].clone());
}
write_file(
&settings_path,
&serde_json::to_string_pretty(&existing).unwrap_or_default(),
);
}
if !mcp_server_quiet_mode() {
eprintln!(
"Installed Gemini/Antigravity hooks at {}",
settings_path.parent().unwrap_or(&settings_path).display()
);
}
}
pub(crate) fn antigravity_cli_plugin_dir(home: &std::path::Path) -> std::path::PathBuf {
antigravity_cli_config_dir(home)
.join("plugins")
.join("lean-ctx")
}
pub(crate) fn antigravity_cli_config_dir(home: &std::path::Path) -> std::path::PathBuf {
home.join(".gemini").join("config")
}
fn install_antigravity_cli_hooks(home: &std::path::Path) {
let binary = resolve_hook_command_binary();
let observe_cmd = format!("{binary} hook observe");
let plugin_dir = antigravity_cli_plugin_dir(home);
let hooks_dir = plugin_dir.join("hooks");
if let Err(e) = std::fs::create_dir_all(&hooks_dir) {
tracing::error!("Cannot create Antigravity CLI plugin dir: {e}");
return;
}
let manifest = serde_json::json!({
"name": "lean-ctx",
"version": env!("CARGO_PKG_VERSION"),
"description":
"lean-ctx context engineering — session telemetry hooks; token \
compression is provided by the lean-ctx MCP tools (ctx_*).",
});
write_file(
&plugin_dir.join("plugin.json"),
&serde_json::to_string_pretty(&manifest).unwrap_or_default(),
);
let mcp_config = serde_json::json!({
"mcpServers": { "lean-ctx": { "command": binary } }
});
write_file(
&plugin_dir.join("mcp_config.json"),
&serde_json::to_string_pretty(&mcp_config).unwrap_or_default(),
);
let mut hook_map = serde_json::Map::new();
for event in ["PostToolUse", "SessionStart", "Stop"] {
hook_map.insert(
event.to_string(),
serde_json::json!([
{ "matcher": ".*", "hooks": [{ "type": "command", "command": observe_cmd }] }
]),
);
}
let hooks = serde_json::json!({ "hooks": serde_json::Value::Object(hook_map) });
write_file(
&hooks_dir.join("hooks.json"),
&serde_json::to_string_pretty(&hooks).unwrap_or_default(),
);
register_antigravity_plugin(&antigravity_cli_config_dir(home));
if !mcp_server_quiet_mode() {
eprintln!(
"Installed Antigravity CLI plugin at {}",
plugin_dir.display()
);
}
}
fn register_antigravity_plugin(config_dir: &std::path::Path) {
let manifest_path = config_dir.join("import_manifest.json");
let mut json = std::fs::read_to_string(&manifest_path)
.ok()
.and_then(|s| crate::core::jsonc::parse_jsonc(&s).ok())
.filter(serde_json::Value::is_object)
.unwrap_or_else(|| serde_json::json!({ "imports": [] }));
let Some(obj) = json.as_object_mut() else {
return;
};
let entry = obj
.entry("imports".to_string())
.or_insert_with(|| serde_json::json!([]));
if !entry.is_array() {
*entry = serde_json::json!([]);
}
let Some(arr) = entry.as_array_mut() else {
return;
};
let already = arr
.iter()
.any(|e| e.get("name").and_then(|n| n.as_str()) == Some("lean-ctx"));
if already {
return;
}
let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
arr.push(serde_json::json!({
"name": "lean-ctx",
"source": "local-install",
"importedAt": now,
"components": ["installed"],
}));
write_file(
&manifest_path,
&serde_json::to_string_pretty(&json).unwrap_or_default(),
);
}
pub(crate) fn uninstall_antigravity_cli_plugin(home: &std::path::Path) -> bool {
let mut changed = false;
let plugin_dir = antigravity_cli_plugin_dir(home);
if plugin_dir.exists() {
let _ = std::fs::remove_dir_all(&plugin_dir);
changed = true;
}
let manifest_path = antigravity_cli_config_dir(home).join("import_manifest.json");
if let Ok(content) = std::fs::read_to_string(&manifest_path)
&& let Ok(mut json) = crate::core::jsonc::parse_jsonc(&content)
&& let Some(arr) = json.get_mut("imports").and_then(|i| i.as_array_mut())
{
let before = arr.len();
arr.retain(|e| e.get("name").and_then(|n| n.as_str()) != Some("lean-ctx"));
if arr.len() != before {
changed = true;
let _ = std::fs::write(
&manifest_path,
serde_json::to_string_pretty(&json).unwrap_or_default(),
);
}
}
changed
}
#[cfg(test)]
mod tests {
use super::*;
fn read_json(path: &std::path::Path) -> serde_json::Value {
serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap()
}
#[test]
fn cli_hooks_install_as_plugin_not_settings_json() {
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path();
install_antigravity_cli_hooks(home);
let plugin_json = home.join(".gemini/config/plugins/lean-ctx/plugin.json");
let hooks_json = home.join(".gemini/config/plugins/lean-ctx/hooks/hooks.json");
assert!(
plugin_json.exists(),
"plugin.json must exist at {plugin_json:?}"
);
assert!(
hooks_json.exists(),
"hooks/hooks.json must exist at {hooks_json:?}"
);
let mcp_json = home.join(".gemini/config/plugins/lean-ctx/mcp_config.json");
assert!(
mcp_json.exists(),
"plugin-local mcp_config.json must exist at {mcp_json:?}"
);
let mcp = read_json(&mcp_json);
assert!(
mcp["mcpServers"]["lean-ctx"]["command"].is_string(),
"plugin mcp_config.json must define the lean-ctx MCP server"
);
let manifest = read_json(&plugin_json);
assert_eq!(manifest["name"], "lean-ctx", "plugin manifest needs name");
let hooks = read_json(&hooks_json);
let map = hooks["hooks"].as_object().unwrap();
for event in ["PostToolUse", "SessionStart", "Stop"] {
assert!(map.contains_key(event), "missing observe event {event}");
}
let content = std::fs::read_to_string(&hooks_json).unwrap();
assert!(
content.contains("hook observe"),
"must wire the observe hook"
);
assert!(
!content.contains("hook rewrite") && !content.contains("hook redirect"),
"rewrite/redirect cannot work on agy (no updatedInput) — must not be installed"
);
assert!(
!content.contains("BeforeTool") && !content.contains("AfterTool"),
"must not use the legacy Gemini event names"
);
let manifest_path = home.join(".gemini/config/import_manifest.json");
let imports = read_json(&manifest_path);
let arr = imports["imports"].as_array().unwrap();
assert_eq!(
arr.iter().filter(|e| e["name"] == "lean-ctx").count(),
1,
"exactly one lean-ctx import entry"
);
assert!(
!home.join(".gemini/antigravity-cli/settings.json").exists(),
"must not write hooks into the CLI settings.json (ignored by agy)"
);
assert!(
!home.join(".gemini/settings.json").exists(),
"CLI install must not touch the legacy ~/.gemini/settings.json"
);
}
#[test]
fn cli_plugin_install_is_idempotent() {
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path();
let manifest_path = home.join(".gemini/config/import_manifest.json");
install_antigravity_cli_hooks(home);
let first = std::fs::read_to_string(&manifest_path).unwrap();
install_antigravity_cli_hooks(home);
let second = std::fs::read_to_string(&manifest_path).unwrap();
let arr = read_json(&manifest_path);
assert_eq!(
arr["imports"]
.as_array()
.unwrap()
.iter()
.filter(|e| e["name"] == "lean-ctx")
.count(),
1,
"re-running install must not duplicate the import entry"
);
assert_eq!(
first, second,
"re-running install must not churn the manifest"
);
}
#[test]
fn register_normalizes_null_imports_from_uninstall() {
let tmp = tempfile::tempdir().unwrap();
let config_dir = antigravity_cli_config_dir(tmp.path());
std::fs::create_dir_all(&config_dir).unwrap();
std::fs::write(
config_dir.join("import_manifest.json"),
r#"{"imports": null}"#,
)
.unwrap();
register_antigravity_plugin(&config_dir);
let v = read_json(&config_dir.join("import_manifest.json"));
let arr = v["imports"]
.as_array()
.expect("imports normalised to array");
assert_eq!(arr.len(), 1);
assert_eq!(arr[0]["name"], "lean-ctx");
}
#[test]
fn register_preserves_foreign_imports() {
let tmp = tempfile::tempdir().unwrap();
let config_dir = antigravity_cli_config_dir(tmp.path());
std::fs::create_dir_all(&config_dir).unwrap();
std::fs::write(
config_dir.join("import_manifest.json"),
r#"{"imports":[{"name":"other-plugin","source":"local-install"}]}"#,
)
.unwrap();
register_antigravity_plugin(&config_dir);
let v = read_json(&config_dir.join("import_manifest.json"));
let arr = v["imports"].as_array().unwrap();
assert!(
arr.iter().any(|e| e["name"] == "other-plugin"),
"must preserve the user's other plugins"
);
assert!(arr.iter().any(|e| e["name"] == "lean-ctx"));
}
#[test]
fn uninstall_removes_plugin_and_manifest_entry_but_keeps_others() {
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path();
let config_dir = antigravity_cli_config_dir(home);
std::fs::create_dir_all(&config_dir).unwrap();
std::fs::write(
config_dir.join("import_manifest.json"),
r#"{"imports":[{"name":"other-plugin"}]}"#,
)
.unwrap();
install_antigravity_cli_hooks(home);
assert!(antigravity_cli_plugin_dir(home).exists());
let changed = uninstall_antigravity_cli_plugin(home);
assert!(changed, "uninstall must report a change");
assert!(
!antigravity_cli_plugin_dir(home).exists(),
"plugin dir must be removed"
);
let v = read_json(&config_dir.join("import_manifest.json"));
let arr = v["imports"].as_array().unwrap();
assert!(
!arr.iter().any(|e| e["name"] == "lean-ctx"),
"lean-ctx entry must be gone"
);
assert!(
arr.iter().any(|e| e["name"] == "other-plugin"),
"foreign entries must survive uninstall"
);
}
}