lean_ctx/core/
governor_hosts.rs1use std::path::{Path, PathBuf};
14
15pub struct GovernorDetect {
17 pub key: &'static str,
19 pub is_installed: fn(&Path) -> bool,
20 pub mcp_config_path: fn(&Path) -> PathBuf,
22}
23
24const CODEBUDDY: GovernorDetect = GovernorDetect {
25 key: "codebuddy",
26 is_installed: codebuddy::is_installed,
27 mcp_config_path: codebuddy::mcp_config_path,
28};
29const CURSOR: GovernorDetect = GovernorDetect {
30 key: "cursor",
31 is_installed: cursor::is_installed,
32 mcp_config_path: cursor::mcp_config_path,
33};
34const GEMINI: GovernorDetect = GovernorDetect {
35 key: "gemini",
36 is_installed: gemini::is_installed,
37 mcp_config_path: gemini::settings_path,
38};
39const CODEX: GovernorDetect = GovernorDetect {
40 key: "codex",
41 is_installed: codex::is_installed,
42 mcp_config_path: codex::config_path,
43};
44const COPILOT: GovernorDetect = GovernorDetect {
45 key: "copilot",
46 is_installed: copilot::is_installed,
47 mcp_config_path: copilot::mcp_config_path,
48};
49
50pub fn detect(agent: &str) -> Option<&'static GovernorDetect> {
53 match agent.trim().to_ascii_lowercase().as_str() {
54 "codebuddy" => Some(&CODEBUDDY),
55 "cursor" => Some(&CURSOR),
56 "gemini" => Some(&GEMINI),
57 "codex" => Some(&CODEX),
58 "copilot" => Some(&COPILOT),
59 _ => None,
60 }
61}
62
63pub fn is_installed(agent: &str, home: &Path) -> bool {
65 detect(agent).is_some_and(|d| (d.is_installed)(home))
66}
67
68pub fn suppresses_mcp(agent_key: &str, config_path: &Path) -> bool {
71 let Some(d) = detect(agent_key) else {
72 return false;
73 };
74 let Some(home) = crate::core::home::resolve_home_dir() else {
75 return false;
76 };
77 (d.is_installed)(&home) && (d.mcp_config_path)(&home) == config_path
78}
79
80pub fn load_json(path: &Path) -> serde_json::Value {
84 std::fs::read_to_string(path)
85 .ok()
86 .filter(|c| !c.trim().is_empty())
87 .and_then(|c| crate::core::jsonc::parse_jsonc(&c).ok())
88 .filter(serde_json::Value::is_object)
89 .unwrap_or_else(|| serde_json::json!({}))
90}
91
92pub fn has_group_hook(root: &serde_json::Value, actions: &[&str]) -> bool {
95 root.get("hooks")
96 .and_then(|h| h.as_object())
97 .into_iter()
98 .flat_map(|h| h.values())
99 .filter_map(|v| v.as_array())
100 .flatten()
101 .filter_map(|g| g.get("hooks").and_then(|h| h.as_array()))
102 .flatten()
103 .filter_map(|h| h.get("command").and_then(|c| c.as_str()))
104 .any(|cmd| {
105 actions
106 .iter()
107 .any(|a| crate::core::portable_binary::is_lean_ctx_hook_command(cmd, a))
108 })
109}
110
111pub fn is_lean_ctx_flat_entry(
114 entry: &serde_json::Value,
115 command_keys: &[&str],
116 actions: &[&str],
117) -> bool {
118 command_keys
119 .iter()
120 .filter_map(|k| entry.get(*k).and_then(|c| c.as_str()))
121 .any(|cmd| {
122 actions
123 .iter()
124 .any(|a| crate::core::portable_binary::is_lean_ctx_hook_command(cmd, a))
125 })
126}
127
128pub fn has_flat_entry(path: &Path, command_keys: &[&str], actions: &[&str]) -> bool {
131 load_json(path)
132 .get("hooks")
133 .and_then(|h| h.as_object())
134 .into_iter()
135 .flat_map(|h| h.values())
136 .filter_map(|v| v.as_array())
137 .flatten()
138 .any(|e| is_lean_ctx_flat_entry(e, command_keys, actions))
139}
140
141fn env_dir_or(var: &str, fallback: PathBuf) -> PathBuf {
143 std::env::var(var)
144 .ok()
145 .map(|v| v.trim().to_string())
146 .filter(|v| !v.is_empty())
147 .map_or(fallback, PathBuf::from)
148}
149
150pub mod codebuddy {
153 use std::path::{Path, PathBuf};
154
155 pub fn state_dir(home: &Path) -> PathBuf {
156 crate::core::editor_registry::codebuddy_state_dir(home)
157 }
158
159 pub fn settings_path(home: &Path) -> PathBuf {
160 state_dir(home).join("settings.json")
161 }
162
163 pub fn mcp_config_path(home: &Path) -> PathBuf {
164 crate::core::editor_registry::codebuddy_mcp_json_path(home)
165 }
166
167 pub fn is_installed(home: &Path) -> bool {
168 super::has_group_hook(&super::load_json(&settings_path(home)), &["govern"])
169 }
170}
171
172pub mod cursor {
173 use std::path::{Path, PathBuf};
174
175 pub const COMMAND_KEYS: &[&str] = &["command"];
177
178 pub fn hooks_path(home: &Path) -> PathBuf {
179 home.join(".cursor/hooks.json")
180 }
181
182 pub fn mcp_config_path(home: &Path) -> PathBuf {
183 home.join(".cursor/mcp.json")
184 }
185
186 pub fn has_entry(home: &Path, actions: &[&str]) -> bool {
187 super::has_flat_entry(&hooks_path(home), COMMAND_KEYS, actions)
188 }
189
190 pub fn is_installed(home: &Path) -> bool {
191 has_entry(home, &["rewrite-enforce"])
192 }
193}
194
195pub mod gemini {
196 use std::path::{Path, PathBuf};
197
198 pub fn settings_path(home: &Path) -> PathBuf {
199 home.join(".gemini/settings.json")
200 }
201
202 pub fn is_installed(home: &Path) -> bool {
203 super::has_group_hook(
204 &super::load_json(&settings_path(home)),
205 &["rewrite-enforce"],
206 )
207 }
208}
209
210pub mod codex {
211 use std::path::{Path, PathBuf};
212
213 pub fn codex_dir(home: &Path) -> PathBuf {
215 super::env_dir_or("CODEX_HOME", home.join(".codex"))
216 }
217
218 pub fn hooks_path(home: &Path) -> PathBuf {
219 codex_dir(home).join("hooks.json")
220 }
221
222 pub fn config_path(home: &Path) -> PathBuf {
223 codex_dir(home).join("config.toml")
224 }
225
226 pub fn is_installed(home: &Path) -> bool {
227 super::has_group_hook(&super::load_json(&hooks_path(home)), &["codex-enforce"])
228 }
229}
230
231pub mod copilot {
232 use std::path::{Path, PathBuf};
233
234 pub const COMMAND_KEYS: &[&str] = &["bash", "command"];
236
237 pub fn hooks_path(home: &Path) -> PathBuf {
239 super::env_dir_or("COPILOT_HOME", home.join(".copilot")).join("hooks/hooks.json")
240 }
241
242 pub fn mcp_config_path(home: &Path) -> PathBuf {
244 home.join(".copilot/mcp-config.json")
245 }
246
247 pub fn has_entry(home: &Path, actions: &[&str]) -> bool {
248 super::has_flat_entry(&hooks_path(home), COMMAND_KEYS, actions)
249 }
250
251 pub fn is_installed(home: &Path) -> bool {
252 has_entry(home, &["rewrite-enforce"])
253 }
254}
255
256#[cfg(test)]
257mod tests {
258 use super::*;
259
260 #[test]
261 fn detection_lists_only_verified_hook_hosts() {
262 for key in [
263 "codebuddy",
264 "cursor",
265 "gemini",
266 "codex",
267 "copilot",
268 "Cursor",
269 ] {
270 let d = detect(key).unwrap_or_else(|| panic!("{key}"));
271 assert_eq!(d.key, key.to_ascii_lowercase());
272 }
273 for key in ["claude", "vscode", "windsurf", "qoder", "antigravity"] {
274 assert!(detect(key).is_none(), "{key}");
275 }
276 }
277
278 #[test]
279 fn hook_command_match_is_exact() {
280 let cmd = "lean-ctx hook rewrite-enforce";
281 assert!(crate::core::portable_binary::is_lean_ctx_hook_command(
282 cmd,
283 "rewrite-enforce"
284 ));
285 assert!(!crate::core::portable_binary::is_lean_ctx_hook_command(
286 cmd, "rewrite"
287 ));
288 assert!(!crate::core::portable_binary::is_lean_ctx_hook_command(
289 "my-wrapper hook rewrite-enforce",
290 "rewrite-enforce"
291 ));
292 }
293
294 #[test]
295 fn nothing_is_installed_in_an_empty_home() {
296 let home = tempfile::tempdir().unwrap();
297 for key in ["cursor", "gemini"] {
300 assert!(!is_installed(key, home.path()), "{key}");
301 }
302 }
303}