Skip to main content

lean_ctx/core/
claude_host.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Claude Code host facts the engine reads without the hook installers.
3//!
4//! - the governor setup arms and the rule block each writes (measured by
5//!   `context_overhead`, installed by `hooks::agents::claude_governor`) and
6//!   whether that setup is installed (checked by the standard installers);
7//! - recognising lean-ctx's own status line command (HUD, statusline installer);
8//! - the Claude Code mod sources the binary embeds (`rule_artifacts` mirrors
9//!   them, `hooks::agents::claude_mod` installs them).
10
11/// The governor setup variant (owner decision O-2: both are measured).
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum GovernorArm {
14    /// No MCP server: 0 schema tokens, strong tools through Bash.
15    NoMcp,
16    /// MCP server without instructions, schemas deferred behind Tool Search.
17    McpDeferred,
18}
19
20impl GovernorArm {
21    /// `--mode` value → arm. `None` for every non-governor mode.
22    pub fn from_mode(raw: &str) -> Option<Self> {
23        match raw.trim().to_ascii_lowercase().as_str() {
24            "governor" | "governor-nomcp" => Some(Self::NoMcp),
25            "governor-mcp" => Some(Self::McpDeferred),
26            _ => None,
27        }
28    }
29
30    pub fn label(self) -> &'static str {
31        match self {
32            Self::NoMcp => "governor",
33            Self::McpDeferred => "governor-mcp",
34        }
35    }
36}
37
38const RULES_MCP: &str = "\
39<!-- lean-ctx -->
40<!-- lean-ctx-governor-v1 -->
41lean-ctx keeps native Read/Bash results lossless; an identical repeat may show `[unchanged …]` (the full result is earlier in this conversation).
42Optional `ctx_*` tools (e.g. ctx_compose for ranked files with source) load on demand via tool search.
43<!-- /lean-ctx -->";
44
45/// The rule block the arm writes — the only per-turn text it adds. The no-MCP
46/// arm writes none.
47pub fn rules_block(arm: GovernorArm) -> Option<&'static str> {
48    match arm {
49        GovernorArm::NoMcp => None,
50        GovernorArm::McpDeferred => Some(RULES_MCP),
51    }
52}
53
54/// True when the settings carry the governor's PostToolUse hook. Standard
55/// installers and refreshes check this so they never fight the profile.
56pub fn is_governor_installed(home: &std::path::Path) -> bool {
57    std::fs::read_to_string(
58        crate::core::editor_registry::claude_state_dir(home).join("settings.json"),
59    )
60    .is_ok_and(|c| settings_have_govern_hook(&c))
61}
62
63/// `… hook govern` in shell form, or exec form (`"args": ["hook", "govern"]`,
64/// written on Windows for a binary path with a space).
65pub(crate) fn settings_have_govern_hook(content: &str) -> bool {
66    fn exec_form(value: &serde_json::Value) -> bool {
67        match value {
68            serde_json::Value::Object(object) => {
69                object
70                    .get("args")
71                    .and_then(serde_json::Value::as_array)
72                    .is_some_and(|args| args.len() == 2 && args[0] == "hook" && args[1] == "govern")
73                    || object.values().any(exec_form)
74            }
75            serde_json::Value::Array(array) => array.iter().any(exec_form),
76            _ => false,
77        }
78    }
79    content.contains(" hook govern\"")
80        || (content.contains("\"govern\"")
81            && crate::core::jsonc::parse_jsonc(content).is_ok_and(|v| exec_form(&v)))
82}
83
84/// True when Claude Code's MCP config (`~/.claude.json`, or the one under
85/// `CLAUDE_CONFIG_DIR`) registers the lean-ctx server. The standard setup's
86/// Grep/Glob deny is only safe with it: the deny hides native search in
87/// favour of `ctx_search`/`ctx_glob`, which only this entry provides.
88pub fn has_lean_ctx_mcp_entry(home: &std::path::Path) -> bool {
89    lean_ctx_mcp_entry_state(home) == Some(true)
90}
91
92/// Like [`has_lean_ctx_mcp_entry`], but `None` when the file exists and does
93/// not parse (Claude Code may be rewriting it): callers that would remove
94/// something on `false` leave it unchanged instead.
95pub fn lean_ctx_mcp_entry_state(home: &std::path::Path) -> Option<bool> {
96    match std::fs::read_to_string(crate::core::editor_registry::claude_mcp_json_path(home)) {
97        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Some(false),
98        Err(_) => None,
99        Ok(c) if c.trim().is_empty() => Some(false),
100        Ok(c) => crate::core::jsonc::parse_jsonc(&c)
101            .ok()
102            .map(|v| v.pointer("/mcpServers/lean-ctx").is_some()),
103    }
104}
105
106/// `… lean-ctx … statusline [args]` — the binary may be quoted or portable.
107pub fn is_lean_ctx_statusline(cmd: &str) -> bool {
108    statusline_args(cmd).is_some()
109}
110
111/// The part after the `statusline` subcommand (with its leading space), when
112/// `cmd` runs lean-ctx's status line.
113pub fn statusline_args(cmd: &str) -> Option<&str> {
114    let idx = cmd.find(" statusline")?;
115    let (bin, rest) = cmd.split_at(idx);
116    let rest = &rest[" statusline".len()..];
117    let ends_token = rest.is_empty() || rest.starts_with(' ');
118    (bin.contains("lean-ctx") && ends_token).then_some(rest)
119}
120
121pub const REGISTER_TS: &str = include_str!("../templates/claude_mod/register.ts");
122pub const HOOKS_JSON: &str = include_str!("../templates/claude_mod/hooks.json");
123/// The cockpit (sidebar, pulse line, overlays), imported by `register.ts`.
124pub const COCKPIT_TSX: &str = include_str!("../templates/claude_mod/cockpit.tsx");
125/// ctx_patch / ctx_read / ctx_search results drawn as diffs and code,
126/// imported by `register.ts`.
127pub const RENDER_TSX: &str = include_str!("../templates/claude_mod/render.tsx");
128/// The `$.state` contract the manifest names as `types`.
129pub const TYPES_DTS: &str = include_str!("../templates/claude_mod/cockpit.d.ts");
130/// Source manifest; `hooks::agents::claude_mod::plugin_manifest` stamps the engine version into it.
131pub const PLUGIN_JSON: &str = include_str!("../templates/claude_mod/plugin.json");
132
133#[cfg(test)]
134mod tests {
135    use super::settings_have_govern_hook;
136
137    #[test]
138    fn govern_hook_detected_in_shell_and_exec_form() {
139        let shell = serde_json::json!({"hooks": {"PostToolUse": [{"hooks": [
140            {"type": "command", "command": "\"/a b/lean-ctx\" hook govern"}
141        ]}]}});
142        assert!(settings_have_govern_hook(
143            &serde_json::to_string_pretty(&shell).unwrap()
144        ));
145        let exec = serde_json::json!({"hooks": {"PostToolUse": [{"hooks": [
146            {"type": "command", "command": "C:/x/Author Software/lean-ctx.exe", "args": ["hook", "govern"]}
147        ]}]}});
148        assert!(settings_have_govern_hook(
149            &serde_json::to_string_pretty(&exec).unwrap()
150        ));
151        let standard = serde_json::json!({"hooks": {"PreToolUse": [{"hooks": [
152            {"type": "command", "command": "C:/x/Author Software/lean-ctx.exe", "args": ["hook", "rewrite"]}
153        ]}]}});
154        assert!(!settings_have_govern_hook(&standard.to_string()));
155        assert!(!settings_have_govern_hook("{}"));
156    }
157}