lean-ctx 3.5.18

Context Runtime for AI Agents with CCP. 63 MCP tools, 10 read modes, 95+ compression patterns, cross-session memory (CCP), persistent AI knowledge with temporal facts + contradiction detection, multi-agent context sharing + diaries, LITM-aware positioning, AAAK compact format, adaptive compression with Thompson Sampling bandits. Supports 24 AI tools. Reduces LLM token consumption by up to 99%.
Documentation
use std::path::Path;

use super::super::{
    mcp_server_quiet_mode, resolve_binary_path, write_file, HookMode, CLI_REDIRECT_RULES,
    HYBRID_RULES,
};

pub(crate) fn install_qoder_hook_with_mode(mode: HookMode) {
    match mode {
        HookMode::CliRedirect => {
            let home = crate::core::home::resolve_home_dir().unwrap_or_default();
            for path in crate::core::editor_registry::qoder_all_mcp_paths(&home) {
                let target = crate::core::editor_registry::EditorTarget {
                    name: "Qoder",
                    agent_key: "qoder".to_string(),
                    config_path: path,
                    detect_path: home.join(".qoder"),
                    config_type: crate::core::editor_registry::ConfigType::QoderSettings,
                };
                let _ = crate::core::editor_registry::remove_lean_ctx_server(
                    &target,
                    crate::core::editor_registry::WriteOptions {
                        overwrite_invalid: true,
                    },
                );
            }
            install_qoder_cli_redirect_rules(mode);
        }
        HookMode::Hybrid => {
            install_qoder_hook();
            install_qoder_cli_redirect_rules(mode);
        }
        HookMode::Mcp => {
            install_qoder_hook();
        }
    }
}

pub fn install_qoder_hook() {
    let Some(home) = crate::core::home::resolve_home_dir() else {
        tracing::error!("Cannot resolve home directory");
        return;
    };
    let settings_path = home.join(".qoder").join("settings.json");
    install_qoder_hook_config_at("Qoder", &settings_path);
}

fn install_qoder_hook_config_at(name: &str, settings_path: &Path) -> bool {
    let command = format!("{} hook rewrite", resolve_binary_path());
    let mut changed = false;
    let mut root = if settings_path.exists() {
        if let Some(parsed) = std::fs::read_to_string(settings_path)
            .ok()
            .and_then(|content| crate::core::jsonc::parse_jsonc(&content).ok())
        {
            parsed
        } else {
            changed = true;
            serde_json::json!({})
        }
    } else {
        changed = true;
        serde_json::json!({})
    };

    if upsert_qoder_hook_config(&mut root, &command) {
        changed = true;
    }

    if changed {
        write_file(
            settings_path,
            &serde_json::to_string_pretty(&root).unwrap_or_default(),
        );
        if !mcp_server_quiet_mode() {
            eprintln!("Installed {name} hooks at {}", settings_path.display());
        }
    }

    changed
}

fn upsert_qoder_hook_config(root: &mut serde_json::Value, rewrite_cmd: &str) -> bool {
    let original = root.clone();
    if !root.is_object() {
        *root = serde_json::json!({});
    }
    let root_obj = root.as_object_mut().expect("root should be object");
    let hooks_value = root_obj
        .entry("hooks".to_string())
        .or_insert_with(|| serde_json::json!({}));
    if !hooks_value.is_object() {
        *hooks_value = serde_json::json!({});
    }
    let hooks_obj = hooks_value
        .as_object_mut()
        .expect("hooks should be object after normalization");

    let pre_tool_use = hooks_obj
        .entry("PreToolUse".to_string())
        .or_insert_with(|| serde_json::json!([]));
    if !pre_tool_use.is_array() {
        *pre_tool_use = serde_json::json!([]);
    }
    let entries = pre_tool_use
        .as_array_mut()
        .expect("PreToolUse should be array after normalization");

    entries.retain(|entry| !is_lean_ctx_qoder_managed_entry(entry));
    entries.push(serde_json::json!({
        "matcher": "Bash",
        "hooks": [{
            "type": "command",
            "command": rewrite_cmd,
            "timeout": 60
        }]
    }));

    *root != original
}

fn install_qoder_cli_redirect_rules(mode: HookMode) {
    let home = crate::core::home::resolve_home_dir().unwrap_or_default();
    let rules_dir = home.join(".qoder").join("rules");
    let _ = std::fs::create_dir_all(&rules_dir);
    let rules_path = rules_dir.join("lean-ctx.md");

    let content = match mode {
        HookMode::CliRedirect => CLI_REDIRECT_RULES,
        HookMode::Hybrid => HYBRID_RULES,
        HookMode::Mcp => return,
    };

    write_file(&rules_path, content);

    let mode_name = match mode {
        HookMode::CliRedirect => "cli-redirect",
        HookMode::Hybrid => "hybrid",
        HookMode::Mcp => "mcp",
    };
    eprintln!(
        "  \x1b[32m✓\x1b[0m Qoder rules installed in {mode_name} mode at {}",
        rules_path.display()
    );
}

fn is_lean_ctx_qoder_managed_entry(entry: &serde_json::Value) -> bool {
    let Some(entry_obj) = entry.as_object() else {
        return false;
    };
    let matcher = entry_obj
        .get("matcher")
        .and_then(|value| value.as_str())
        .unwrap_or("*");
    let is_shell_matcher = matcher
        .split('|')
        .map(str::trim)
        .any(|part| matches!(part, "Bash" | "run_in_terminal"));
    if !is_shell_matcher {
        return false;
    }
    entry_obj
        .get("hooks")
        .and_then(|value| value.as_array())
        .is_some_and(|hooks| {
            hooks.iter().any(|hook| {
                hook.get("command")
                    .and_then(|value| value.as_str())
                    .is_some_and(|command| {
                        command.contains("lean-ctx") && command.contains("hook rewrite")
                    })
            })
        })
}

#[cfg(test)]
mod tests {
    use serde_json::json;

    #[test]
    fn qoder_hook_config_preserves_custom_hooks_and_upserts_rewrite() {
        let mut root = json!({
            "hooks": {
                "PreToolUse": [
                    {
                        "matcher": "Bash",
                        "hooks": [{ "type": "command", "command": "echo keep-me", "timeout": 5 }]
                    },
                    {
                        "matcher": "Bash",
                        "hooks": [{ "type": "command", "command": "lean-ctx hook rewrite", "timeout": 60 }]
                    }
                ],
                "Stop": [
                    {
                        "hooks": [{ "type": "command", "command": "echo stop", "timeout": 5 }]
                    }
                ]
            }
        });

        let changed = super::upsert_qoder_hook_config(&mut root, "/c/bin/lean-ctx hook rewrite");
        assert!(changed);

        let pre_tool_use = root["hooks"]["PreToolUse"].as_array().unwrap();
        assert_eq!(pre_tool_use.len(), 2);
        assert_eq!(pre_tool_use[0]["hooks"][0]["command"], "echo keep-me");
        assert_eq!(
            pre_tool_use[1]["hooks"][0]["command"],
            "/c/bin/lean-ctx hook rewrite"
        );
        assert_eq!(root["hooks"]["Stop"][0]["hooks"][0]["command"], "echo stop");
    }

    #[test]
    fn qoder_hook_config_creates_fresh_pretooluse_group() {
        let mut root = json!({});
        let changed = super::upsert_qoder_hook_config(&mut root, "lean-ctx hook rewrite");
        assert!(changed);

        assert_eq!(root["hooks"]["PreToolUse"][0]["matcher"], "Bash");
        assert_eq!(
            root["hooks"]["PreToolUse"][0]["hooks"][0],
            json!({ "type": "command", "command": "lean-ctx hook rewrite", "timeout": 60 })
        );
    }
}