Skip to main content

lean_ctx/core/
governor_hosts.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Governor detection on hosts other than Claude Code: which hosts have a
4//! governor setup, where their files live, and whether it is installed.
5//!
6//! The installers (steps, revert, refresh) live with the hook installers
7//! (`hooks::agents::governor_hosts` and the `<host>_governor` modules); the
8//! engine only needs the read-only predicates, so the MCP writer and the
9//! rules injection can respect a governor setup without depending on the
10//! installer code. Each `<host>_governor` spec points its `is_installed` and
11//! `mcp_config_path` at the functions here, so detection has one source.
12
13use std::path::{Path, PathBuf};
14
15/// The read-only part of one host's governor setup.
16pub struct GovernorDetect {
17    /// Agent key as `init --agent` spells it.
18    pub key: &'static str,
19    pub is_installed: fn(&Path) -> bool,
20    /// The MCP config file the governor keeps free of the LeanCTX server.
21    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
50/// The per-host capability switch: `Some` where a governor setup exists.
51/// Hosts whose hook dialect is unverified stay on today's setup (`None`).
52pub 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
63/// True when `agent` has the governor setup under `home`.
64pub fn is_installed(agent: &str, home: &Path) -> bool {
65    detect(agent).is_some_and(|d| (d.is_installed)(home))
66}
67
68/// True when the MCP writer must leave `config_path` without the LeanCTX
69/// server: the governor setup of `agent_key` owns that file.
70pub 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
80// --- JSON helpers ----------------------------------------------------------
81
82/// A JSON(C) object config, or `{}` when missing, empty or not an object.
83pub 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
92/// True when a Claude-dialect settings file carries a LeanCTX hook for any of
93/// `actions` (`"rewrite"`, …).
94pub 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
111/// True when a flat hook entry carries a LeanCTX hook for one of `actions`
112/// in any of `command_keys` (Cursor: `command`; Copilot: `bash`, `command`).
113pub 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
128/// True when the flat-dialect hooks file at `path` has an entry for one of
129/// `actions`.
130pub 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
141/// `$VAR` when set and non-blank, else `fallback`.
142fn 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
150// --- Hosts -----------------------------------------------------------------
151
152pub 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    /// Flat Cursor entries (`{matcher, command}`).
176    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    /// `$CODEX_HOME`, else `~/.codex`.
214    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    /// Copilot entries (`{type, bash, powershell, timeoutSec}`).
235    pub const COMMAND_KEYS: &[&str] = &["bash", "command"];
236
237    /// `$COPILOT_HOME/hooks/hooks.json`, else `~/.copilot/hooks/hooks.json` (#381).
238    pub fn hooks_path(home: &Path) -> PathBuf {
239        super::env_dir_or("COPILOT_HOME", home.join(".copilot")).join("hooks/hooks.json")
240    }
241
242    /// The Copilot CLI MCP file as the editor registry names it.
243    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        // codex and copilot honour `CODEX_HOME` / `COPILOT_HOME`, so they are
298        // covered by the installer tests under an isolated environment.
299        for key in ["cursor", "gemini"] {
300            assert!(!is_installed(key, home.path()), "{key}");
301        }
302    }
303}