Skip to main content

lean_ctx/hooks/
mod.rs

1use std::path::PathBuf;
2
3pub mod agents;
4mod support;
5
6/// Controls how hooks instruct agents to access lean-ctx functionality.
7///
8/// * `Mcp` — MCP server only (extension/plugin-based agents without reliable shell).
9/// * `Hybrid` — MCP server + shell hooks for command compression (best of both).
10#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
11#[serde(rename_all = "snake_case")]
12pub enum HookMode {
13    #[default]
14    Mcp,
15    Hybrid,
16}
17
18impl std::fmt::Display for HookMode {
19    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
20        match self {
21            Self::Mcp => write!(f, "MCP"),
22            Self::Hybrid => write!(f, "Hybrid"),
23        }
24    }
25}
26
27impl HookMode {
28    pub fn from_str_loose(s: &str) -> Option<Self> {
29        match s.to_lowercase().replace('-', "").as_str() {
30            "mcp" => Some(Self::Mcp),
31            "hybrid" => Some(Self::Hybrid),
32            _ => None,
33        }
34    }
35
36    pub fn description(&self) -> &'static str {
37        match self {
38            Self::Mcp => "MCP server only (extension/plugin-based agents without reliable shell)",
39            Self::Hybrid => "MCP server + shell hooks for command compression (best of both)",
40        }
41    }
42}
43
44/// Auto-detect the best hook mode for a given agent key based on its shell capabilities.
45///
46/// Criteria (verified against provider docs May 2026):
47///   Hybrid — MCP server (full Context OS) + shell hooks where available.
48///            Read/Search via MCP (reliable, cached). Shell via hooks (zero overhead).
49///   Mcp    — agent has no reliable direct shell tool (e.g. IDE plugin only)
50/// Agents that get the Hybrid integration (MCP for reads/search + shell hooks
51/// or rules for command compression). Kept as a single data list so it is
52/// testable and so `refresh_installed_hooks` can prove it covers every one of
53/// them (see `refresh_covers_every_hybrid_agent`).
54pub const HYBRID_AGENTS: &[&str] = &[
55    "cursor",
56    "gemini",
57    "codex",
58    "claude",
59    "claude-code",
60    "crush",
61    "hermes",
62    "opencode",
63    "openclaw",
64    "pi",
65    "qoder",
66    "windsurf",
67    "amp",
68    "cline",
69    "roo",
70    "copilot",
71    "kiro",
72    "qwen",
73    "trae",
74    "antigravity",
75    "antigravity-cli",
76    "amazonq",
77    "verdent",
78];
79
80pub fn recommend_hook_mode(agent_key: &str) -> HookMode {
81    if HYBRID_AGENTS.contains(&agent_key) {
82        HookMode::Hybrid
83    } else {
84        // No reliable direct shell tool → MCP only.
85        HookMode::Mcp
86    }
87}
88use agents::{
89    install_amp_hook, install_antigravity_cli_hook, install_antigravity_hook,
90    install_claude_hook_config, install_claude_hook_scripts, install_claude_hook_with_mode,
91    install_claude_project_hooks, install_cline_rules, install_codebuddy_hook_config,
92    install_codebuddy_hook_scripts, install_codebuddy_hook_with_mode,
93    install_codebuddy_project_hooks, install_codex_hook, install_copilot_hook,
94    install_crush_hook_with_mode, install_cursor_hook_config, install_cursor_hook_scripts,
95    install_cursor_hook_with_mode, install_gemini_hook, install_gemini_hook_config,
96    install_gemini_hook_scripts, install_hermes_hook_with_mode, install_jetbrains_hook,
97    install_kiro_hook, install_openclaw_hook, install_opencode_hook_with_mode,
98    install_pi_hook_with_mode, install_qoder_hook, install_qoder_hook_with_mode,
99    install_windsurf_hooks, install_windsurf_rules,
100};
101use support::{
102    ensure_codex_hooks_enabled, install_codex_instruction_docs, install_named_json_server,
103    upsert_lean_ctx_codex_hook_entries,
104};
105
106fn mcp_server_quiet_mode() -> bool {
107    std::env::var_os("LEAN_CTX_MCP_SERVER").is_some()
108        || matches!(std::env::var("LEAN_CTX_QUIET"), Ok(value) if value.trim() == "1")
109}
110
111/// Agents whose global shell-hook artifacts embed the binary path / command
112/// and therefore must be re-rendered after an update or on MCP server start so
113/// they always point at the current binary. Each entry is gated on a detection
114/// marker (see `hooks_installed_for`) so we never install hooks for an agent
115/// the user never configured. The `refresh_covers_every_hybrid_agent` test
116/// proves this list plus `REFRESH_EXEMPT_HYBRID_AGENTS` accounts for every
117/// Hybrid agent, so a newly added agent can never silently regress.
118const REFRESHABLE_HOOK_AGENTS: &[&str] = &[
119    "claude", "cursor", "gemini", "codex", "windsurf", "copilot", "qoder",
120];
121
122/// Hybrid agents intentionally NOT auto-refreshed, with the reason each is safe
123/// to skip. Refresh runs silently (including on every MCP server start), so it
124/// must never spawn subprocesses or write project/cwd-relative files. Used by
125/// the coverage test to prove every Hybrid agent has an explicit decision.
126#[cfg(test)]
127const REFRESH_EXEMPT_HYBRID_AGENTS: &[&str] = &[
128    // Alias of `claude` — same global files, already refreshed via "claude".
129    "claude-code",
130    // Installer shells out to `pi install` (subprocess) — unsafe on every start.
131    "pi",
132    // Write project/cwd-relative rules (.clinerules, .kiro/steering) — a silent
133    // server-start refresh must not create files in the user's working dir.
134    "cline",
135    "roo",
136    "kiro",
137    // MCP-config / rules wiring only (no global binary-embedding shell-hook
138    // script to keep current); refreshed by `setup --fix`, not on start.
139    "antigravity",
140    "antigravity-cli",
141    "amp",
142    "crush",
143    "hermes",
144    "opencode",
145    "openclaw",
146    "qwen",
147    "trae",
148    "amazonq",
149    "verdent",
150];
151
152/// Silently refresh all hook scripts for agents that are already configured.
153/// Called after updates and on MCP server start to ensure hooks match the
154/// current binary version. Registry-driven: every Hybrid agent with a global
155/// shell hook is covered (the rest are explicitly exempted, enforced by test).
156pub fn refresh_installed_hooks() {
157    let Some(home) = crate::core::home::resolve_home_dir() else {
158        return;
159    };
160    for agent in REFRESHABLE_HOOK_AGENTS {
161        if hooks_installed_for(agent, &home) {
162            refresh_agent_hooks(agent, &home);
163        }
164    }
165}
166
167/// True when `agent` already has lean-ctx hook artifacts on disk (global only).
168fn hooks_installed_for(agent: &str, home: &std::path::Path) -> bool {
169    match agent {
170        "claude" => {
171            let dir = crate::setup::claude_config_dir(home);
172            dir.join("hooks/lean-ctx-rewrite.sh").exists()
173                || file_contains_lean_ctx(&dir.join("settings.json"))
174        }
175        "codebuddy" => {
176            let dir = crate::core::editor_registry::codebuddy_state_dir(home);
177            dir.join("hooks/lean-ctx-rewrite.sh").exists()
178                || file_contains_lean_ctx(&dir.join("settings.json"))
179        }
180        "cursor" => {
181            home.join(".cursor/hooks/lean-ctx-rewrite.sh").exists()
182                || file_contains_lean_ctx(&home.join(".cursor/hooks.json"))
183        }
184        "gemini" => {
185            home.join(".gemini/hooks/lean-ctx-rewrite-gemini.sh")
186                .exists()
187                || home.join(".gemini/hooks/lean-ctx-hook-gemini.sh").exists()
188        }
189        "codex" => {
190            let dir = crate::core::home::resolve_codex_dir().unwrap_or_else(|| home.join(".codex"));
191            dir.join("hooks/lean-ctx-rewrite-codex.sh").exists()
192                || file_contains_lean_ctx(&dir.join("hooks.json"))
193        }
194        "windsurf" => file_contains_lean_ctx(&home.join(".codeium/windsurf/hooks.json")),
195        "copilot" => {
196            // User-level Copilot hooks live under ~/.copilot/hooks (#381);
197            // ~/.github/hooks is the pre-#381 legacy location.
198            file_contains_lean_ctx(&home.join(".copilot/hooks/hooks.json"))
199                || file_contains_lean_ctx(&home.join(".github/hooks/hooks.json"))
200        }
201        "qoder" => file_contains_lean_ctx(&home.join(".qoder/settings.json")),
202        _ => false,
203    }
204}
205
206/// Re-render the hook artifacts for an already-configured agent. Only calls
207/// narrow, subprocess-free, global installers (never the full agent setup).
208fn refresh_agent_hooks(agent: &str, home: &std::path::Path) {
209    match agent {
210        "claude" => {
211            install_claude_hook_scripts(home);
212            install_claude_hook_config(home);
213        }
214        "codebuddy" => {
215            install_codebuddy_hook_scripts(home);
216            install_codebuddy_hook_config(home);
217        }
218        "cursor" => {
219            install_cursor_hook_scripts(home);
220            install_cursor_hook_config(home);
221        }
222        "gemini" => {
223            install_gemini_hook_scripts(home);
224            install_gemini_hook_config(home);
225        }
226        "codex" => install_codex_hook(),
227        "windsurf" => install_windsurf_hooks(home),
228        "copilot" => install_copilot_hook(true),
229        "qoder" => install_qoder_hook(),
230        _ => {}
231    }
232}
233
234fn file_contains_lean_ctx(path: &std::path::Path) -> bool {
235    std::fs::read_to_string(path).is_ok_and(|c| c.contains("lean-ctx"))
236}
237
238/// Resolve the lean-ctx binary to an **absolute** path for generated hook
239/// commands and MCP server entries.
240///
241/// Agent hooks (Codex, Cursor, Claude, Gemini, Antigravity, …) are executed by
242/// the host under a plain non-login shell (`sh -c …`) whose `PATH` is not
243/// guaranteed to contain the install dir (e.g. `/usr/local/bin`). A bare
244/// `lean-ctx` therefore fails with exit code 127 (#367). Always emitting the
245/// resolved absolute path makes hook execution deterministic and matches what
246/// MCP setup (`setup/mcp.rs`) and `doctor` already do. Existing configs with a
247/// bare command are rewritten on the next `lean-ctx init` / `doctor` run.
248///
249/// Kept strictly absolute — also used for MCP server `command` fields, which
250/// hosts spawn **directly** (no shell), so `$HOME/...` forms would break
251/// there. Shell-executed hook commands go through
252/// [`resolve_hook_command_binary`], which honors the portable override (#708).
253fn resolve_binary_path() -> String {
254    crate::core::portable_binary::resolve_portable_binary()
255}
256
257/// Binary token for **shell-executed** hook commands (`<binary> hook rewrite`
258/// in Claude/Cursor/Gemini/… hook configs and generated `#!/bin/sh` scripts).
259///
260/// Portable override (#708): `LEAN_CTX_HOOK_BINARY` env, then config
261/// `hook_binary`, is emitted **verbatim** — for settings files synced across
262/// machines with different usernames (`$HOME/.local/bin/lean-ctx`). Hook
263/// hosts run these commands through a shell, so the variable expands at
264/// execution time; `doctor` accepts the override as current, so
265/// `init`/`--fix`/`update` stop rewriting synced files. MCP registrations
266/// and autostart units keep the absolute path (no shell there).
267fn resolve_hook_command_binary() -> String {
268    if let Some(portable) = crate::core::portable_binary::hook_binary_override() {
269        return portable;
270    }
271    resolve_binary_path()
272}
273
274fn resolve_binary_path_for_bash() -> String {
275    if let Some(portable) = crate::core::portable_binary::hook_binary_override() {
276        return portable;
277    }
278    to_bash_compatible_path(&resolve_binary_path())
279}
280
281/// Shell-quotes a binary token for generated `#!/bin/sh` wrappers and
282/// `LEAN_CTX_BIN=` assignments (#719). Double quotes keep `$HOME`-style
283/// portable overrides (#708) expanding at execution time while paths with
284/// spaces survive word splitting (npm installs under
285/// `C:\Users\First Last\AppData\…`). `"` and `` ` `` are escaped; `$` stays
286/// active on purpose — portable forms rely on it.
287pub(crate) fn shell_quoted_binary(binary: &str) -> String {
288    let escaped = binary.replace('"', "\\\"").replace('`', "\\`");
289    format!("\"{escaped}\"")
290}
291
292/// #719: true when an existing generated wrapper references a portable binary
293/// form (`$HOME/…`, `${HOME}/…`, `%USERPROFILE%\…`) that resolves to an
294/// existing binary on THIS machine. Such a wrapper is healthy and must not be
295/// re-stamped with a machine-absolute path: on multi-machine synced setups
296/// (Dropbox'd `~/.claude`, different usernames) a heal on the machine WITHOUT
297/// the portable override would otherwise bake its absolute path into the
298/// wrapper, and every new session on the peer machine dies mid tool call with
299/// no surfaced error.
300fn wrapper_is_portable_and_working(path: &std::path::Path, home: &std::path::Path) -> bool {
301    std::fs::read_to_string(path)
302        .is_ok_and(|content| wrapper_content_is_portable_and_working(&content, home))
303}
304
305pub(crate) fn wrapper_content_is_portable_and_working(
306    content: &str,
307    home: &std::path::Path,
308) -> bool {
309    let Some(token) = wrapper_binary_token(content) else {
310        return false;
311    };
312    if !(token.contains("$HOME") || token.contains("${HOME}") || token.contains("%USERPROFILE%")) {
313        return false;
314    }
315    let home_s = home.to_string_lossy();
316    let expanded = token
317        .replace("${HOME}", &home_s)
318        .replace("$HOME", &home_s)
319        .replace("%USERPROFILE%", &home_s);
320    std::path::Path::new(&from_bash_to_native_path(&expanded)).exists()
321}
322
323/// Extracts the binary token from a generated wrapper script: the
324/// `LEAN_CTX_BIN=` assignment (rewrite scripts) or the `exec <binary> hook …`
325/// line (native wrappers) — quoted or bare.
326pub(crate) fn wrapper_binary_token(content: &str) -> Option<String> {
327    for line in content.lines() {
328        let t = line.trim();
329        if let Some(rest) = t.strip_prefix("LEAN_CTX_BIN=") {
330            let rest = rest.trim();
331            let tok = rest
332                .strip_prefix('"')
333                .map_or(rest, |r| r.split('"').next().unwrap_or_default());
334            if !tok.is_empty() {
335                return Some(tok.to_string());
336            }
337        }
338        if let Some(rest) = t.strip_prefix("exec ") {
339            let rest = rest.trim();
340            let tok = match rest.strip_prefix('"') {
341                Some(r) => r.split('"').next().unwrap_or_default().to_string(),
342                None => rest
343                    .split_whitespace()
344                    .next()
345                    .unwrap_or_default()
346                    .to_string(),
347            };
348            if !tok.is_empty() {
349                return Some(tok);
350            }
351        }
352    }
353    None
354}
355
356/// Writes a generated hook wrapper unless the portable override is unset AND
357/// the existing file already carries a working portable reference (#719) —
358/// healing must never replace a synced portable wrapper with a
359/// machine-absolute path.
360fn write_wrapper_file(path: &std::path::Path, content: &str, home: &std::path::Path) {
361    if crate::core::portable_binary::hook_binary_override().is_none()
362        && wrapper_is_portable_and_working(path, home)
363    {
364        return;
365    }
366    write_file(path, content);
367}
368
369pub fn to_bash_compatible_path(path: &str) -> String {
370    let path = match crate::core::pathutil::strip_verbatim_str(path) {
371        Some(stripped) => stripped,
372        None => path.replace('\\', "/"),
373    };
374    if path.len() >= 2 && path.as_bytes()[1] == b':' {
375        let drive = (path.as_bytes()[0] as char).to_ascii_lowercase();
376        format!("/{drive}{}", &path[2..])
377    } else {
378        path
379    }
380}
381
382/// Convert a Unix/MSYS-style path (`/c/Users/...`) back to native Windows
383/// format (`C:/Users/...`). No-op for paths that don't match the pattern.
384pub fn from_bash_to_native_path(path: &str) -> String {
385    crate::core::pathutil::normalize_tool_path(path)
386}
387
388/// Normalize paths from any client format to a consistent OS-native form.
389/// Delegates to `core::pathutil` so `core` crates do not depend on `hooks`.
390pub fn normalize_tool_path(path: &str) -> String {
391    crate::core::pathutil::normalize_tool_path(path)
392}
393
394pub fn generate_rewrite_script(binary: &str) -> String {
395    let case_pattern = crate::rewrite_registry::bash_case_pattern();
396    // #719: assignment + rewritten command carry the binary quoted, so
397    // portable `$HOME/…` overrides expand at exec time and paths with spaces
398    // survive word splitting.
399    let quoted_binary = shell_quoted_binary(binary);
400    format!(
401        r#"#!/usr/bin/env bash
402# lean-ctx PreToolUse hook — rewrites bash commands to lean-ctx equivalents
403set -euo pipefail
404
405LEAN_CTX_BIN={quoted_binary}
406
407INPUT=$(cat)
408TOOL=$(echo "$INPUT" | grep -oE '"tool_name":"([^"\\]|\\.)*"' | head -1 | sed 's/^"tool_name":"//;s/"$//' | sed 's/\\"/"/g;s/\\\\/\\/g')
409
410case "$TOOL" in
411  Bash|bash|PowerShell|powershell) ;;
412  *) exit 0 ;;
413esac
414
415CMD=$(echo "$INPUT" | grep -oE '"command":"([^"\\]|\\.)*"' | head -1 | sed 's/^"command":"//;s/"$//' | sed 's/\\"/"/g;s/\\\\/\\/g')
416
417if [ -z "$CMD" ] || echo "$CMD" | grep -qE "^(lean-ctx |\"?$LEAN_CTX_BIN\"? )"; then
418  exit 0
419fi
420
421case "$CMD" in
422  {case_pattern})
423    # Shell-escape then JSON-escape (two passes)
424    SHELL_ESC=$(printf '%s' "$CMD" | sed 's/\\/\\\\/g;s/"/\\"/g')
425    REWRITE="\"$LEAN_CTX_BIN\" -c \"$SHELL_ESC\""
426    JSON_CMD=$(printf '%s' "$REWRITE" | sed 's/\\/\\\\/g;s/"/\\"/g')
427    printf '{{"hookSpecificOutput":{{"hookEventName":"PreToolUse","permissionDecision":"allow","updatedInput":{{"command":"%s"}}}}}}' "$JSON_CMD"
428    ;;
429  *) exit 0 ;;
430esac
431"#
432    )
433}
434
435pub fn generate_compact_rewrite_script(binary: &str) -> String {
436    let case_pattern = crate::rewrite_registry::bash_case_pattern();
437    let quoted_binary = shell_quoted_binary(binary);
438    format!(
439        r#"#!/usr/bin/env bash
440# lean-ctx hook — rewrites shell commands
441set -euo pipefail
442LEAN_CTX_BIN={quoted_binary}
443INPUT=$(cat)
444CMD=$(echo "$INPUT" | grep -oE '"command":"([^"\\]|\\.)*"' | head -1 | sed 's/^"command":"//;s/"$//' | sed 's/\\"/"/g;s/\\\\/\\/g' 2>/dev/null || echo "")
445if [ -z "$CMD" ] || echo "$CMD" | grep -qE "^(lean-ctx |\"?$LEAN_CTX_BIN\"? )"; then exit 0; fi
446case "$CMD" in
447  {case_pattern})
448    SHELL_ESC=$(printf '%s' "$CMD" | sed 's/\\/\\\\/g;s/"/\\"/g')
449    REWRITE="\"$LEAN_CTX_BIN\" -c \"$SHELL_ESC\""
450    JSON_CMD=$(printf '%s' "$REWRITE" | sed 's/\\/\\\\/g;s/"/\\"/g')
451    printf '{{"hookSpecificOutput":{{"hookEventName":"PreToolUse","permissionDecision":"allow","updatedInput":{{"command":"%s"}}}}}}' "$JSON_CMD" ;;
452  *) exit 0 ;;
453esac
454"#
455    )
456}
457
458const REDIRECT_SCRIPT_CLAUDE: &str = r"#!/usr/bin/env bash
459# lean-ctx PreToolUse hook — all native tools pass through
460# Read/Grep/ListFiles are allowed so Edit (which requires native Read) works.
461# The MCP instructions guide the AI to prefer ctx_read/ctx_search/ctx_tree.
462exit 0
463";
464
465const REDIRECT_SCRIPT_GENERIC: &str = r"#!/usr/bin/env bash
466# lean-ctx hook — all native tools pass through
467exit 0
468";
469
470pub fn hybrid_rules_content() -> String {
471    use crate::core::rules_canonical;
472    format!(
473        "{start}\n<!-- version: {version} -->\n\n\
474# lean-ctx \u{2014} Hybrid Mode (MCP reads + CLI commands)\n\n\
475{bullets}\n\n\
476{never}\n\n\
477{end}",
478        start = rules_canonical::START_MARK,
479        version = rules_canonical::RULES_VERSION,
480        bullets = rules_canonical::BULLETS,
481        never = rules_canonical::NEVER,
482        end = rules_canonical::END_MARK,
483    )
484}
485
486pub fn install_project_rules() {
487    install_project_rules_for_agents(&[]);
488}
489
490/// Install project rules, optionally scoped to specific agents.
491/// If `agents` is empty, installs for all agents (legacy behavior).
492pub fn install_project_rules_for_agents(agents: &[&str]) {
493    if crate::core::config::Config::load().rules_scope_effective()
494        == crate::core::config::RulesScope::Global
495    {
496        return;
497    }
498
499    let cwd = std::env::current_dir().unwrap_or_default();
500
501    if !is_inside_git_repo(&cwd) {
502        eprintln!(
503            "  Skipping project files: not inside a git repository.\n  \
504             Run this command from your project root to create CLAUDE.md / AGENTS.md."
505        );
506        return;
507    }
508
509    let home = crate::core::home::resolve_home_dir().unwrap_or_default();
510    if cwd == home {
511        eprintln!(
512            "  Skipping project files: current directory is your home folder.\n  \
513             Run this command from a project directory instead."
514        );
515        return;
516    }
517
518    let all = agents.is_empty();
519    let wants = |name: &str| all || agents.iter().any(|a| a.eq_ignore_ascii_case(name));
520
521    ensure_project_agents_integration(&cwd);
522
523    if wants("cursor") || wants("windsurf") {
524        let cursorrules = cwd.join(".cursorrules");
525        if !cursorrules.exists()
526            || !std::fs::read_to_string(&cursorrules)
527                .unwrap_or_default()
528                .contains("lean-ctx")
529        {
530            let content = cursorrules_content();
531            if cursorrules.exists() {
532                let mut existing = std::fs::read_to_string(&cursorrules).unwrap_or_default();
533                if !existing.ends_with('\n') {
534                    existing.push('\n');
535                }
536                existing.push('\n');
537                existing.push_str(&content);
538                write_file(&cursorrules, &existing);
539            } else {
540                write_file(&cursorrules, &content);
541            }
542            if !mcp_server_quiet_mode() {
543                eprintln!("Created/updated .cursorrules in project root.");
544            }
545        }
546    }
547
548    if wants("claude") {
549        // GL #555: project rules files without `paths:` frontmatter load
550        // unconditionally every session and stacked on top of the global
551        // CLAUDE.md block (12k+ token memory footprints in the field). The
552        // AGENTS.md block + on-demand skill carry the same guidance, so the
553        // lean-ctx-owned copy is removed instead of refreshed.
554        let claude_rules_file = cwd.join(".claude").join("rules").join("lean-ctx.md");
555        if let Ok(existing) = std::fs::read_to_string(&claude_rules_file)
556            && existing.contains(crate::core::rules_canonical::RULES_MARKER_PREFIX)
557            && std::fs::remove_file(&claude_rules_file).is_ok()
558            && !mcp_server_quiet_mode()
559        {
560            eprintln!(
561                "Removed .claude/rules/lean-ctx.md (always-loaded duplicate; AGENTS.md block + skill replace it)."
562            );
563        }
564
565        install_claude_project_hooks(&cwd);
566    }
567
568    if wants("codebuddy") {
569        let codebuddy_rules_file = cwd.join(".codebuddy").join("rules").join("lean-ctx.md");
570        if let Ok(existing) = std::fs::read_to_string(&codebuddy_rules_file)
571            && existing.contains(crate::core::rules_canonical::RULES_MARKER_PREFIX)
572            && std::fs::remove_file(&codebuddy_rules_file).is_ok()
573            && !mcp_server_quiet_mode()
574        {
575            eprintln!(
576                "Removed .codebuddy/rules/lean-ctx.md (always-loaded duplicate; CODEBUDDY.md block + skill replace it)."
577            );
578        }
579
580        install_codebuddy_project_hooks(&cwd);
581    }
582
583    if wants("kiro") {
584        let kiro_dir = cwd.join(".kiro");
585        if kiro_dir.exists() {
586            let steering_dir = kiro_dir.join("steering");
587            let steering_file = steering_dir.join("lean-ctx.md");
588            if !steering_file.exists()
589                || !std::fs::read_to_string(&steering_file)
590                    .unwrap_or_default()
591                    .contains("lean-ctx")
592            {
593                let _ = std::fs::create_dir_all(&steering_dir);
594                write_file(&steering_file, &kiro_steering_content());
595                if !mcp_server_quiet_mode() {
596                    eprintln!("Created .kiro/steering/lean-ctx.md (Kiro steering).");
597                }
598            }
599        }
600    }
601
602    if wants("copilot") || wants("vscode") {
603        ensure_copilot_instructions(&cwd);
604        ensure_vscode_instruction_files_setting(&cwd);
605    }
606}
607
608const PROJECT_LEAN_CTX_MD_MARKER: &str =
609    crate::core::rules_canonical::PROJECT_LEAN_CTX_OWNED_MARKER;
610const PROJECT_LEAN_CTX_MD: &str = "LEAN-CTX.md";
611const PROJECT_AGENTS_MD: &str = "AGENTS.md";
612// The AGENTS.md pointer block keeps its own marker pair, independent of the
613// dedicated rules-file `START_MARK`: pointer-only files must not be counted as
614// duplicate lean-ctx sources (doctor overhead, #684).
615const AGENTS_BLOCK_START: &str = crate::core::rules_canonical::AGENTS_BLOCK_START;
616const AGENTS_BLOCK_END: &str = crate::core::rules_canonical::AGENTS_BLOCK_END;
617
618fn ensure_project_agents_integration(cwd: &std::path::Path) {
619    let lean_ctx_md = cwd.join(PROJECT_LEAN_CTX_MD);
620    // Longform (#578): LEAN-CTX.md is opened on demand via the AGENTS.md
621    // pointer, never auto-loaded, so it carries the verbose teaching profile.
622    let desired = format!(
623        "{PROJECT_LEAN_CTX_MD_MARKER}\n{}\n",
624        crate::rules_inject::rules_longform_markdown()
625    );
626
627    if !lean_ctx_md.exists() {
628        write_file(&lean_ctx_md, &desired);
629    } else if std::fs::read_to_string(&lean_ctx_md)
630        .unwrap_or_default()
631        .contains(PROJECT_LEAN_CTX_MD_MARKER)
632    {
633        let current = std::fs::read_to_string(&lean_ctx_md).unwrap_or_default();
634        let version_str = format!(
635            "<!-- version: {} -->",
636            crate::core::rules_canonical::RULES_VERSION
637        );
638        if !current.contains(&version_str) {
639            write_file(&lean_ctx_md, &desired);
640        }
641    }
642
643    // No `@` import: Claude Code expands `@file` references inline at session
644    // start, so pointing at LEAN-CTX.md re-loaded the full ruleset into every
645    // session on top of this block (GL #555). The block is self-contained;
646    // the full ruleset stays in LEAN-CTX.md for on-demand reading.
647    let block = format!(
648        "{AGENTS_BLOCK_START}\n\
649## lean-ctx\n\n\
650lean-ctx is active — the MCP tools replace native equivalents.\n\
651Full rules: {PROJECT_LEAN_CTX_MD} (open on demand — do not auto-load).\n\
652{AGENTS_BLOCK_END}\n"
653    );
654
655    let agents_md = cwd.join(PROJECT_AGENTS_MD);
656    if !agents_md.exists() {
657        let content = format!("# Agent Instructions\n\n{block}");
658        write_file(&agents_md, &content);
659        if !mcp_server_quiet_mode() {
660            eprintln!("Created AGENTS.md in project root (lean-ctx reference only).");
661        }
662        return;
663    }
664
665    let existing = std::fs::read_to_string(&agents_md).unwrap_or_default();
666
667    // Marker checks are line-based (GL #1158): a prose mention of the marker
668    // (as this repo's own AGENTS.md carries) must not trigger block surgery.
669    let has_block = crate::marked_block::contains_marker_line(&existing, AGENTS_BLOCK_START);
670
671    if existing.contains("CLI-first Token Optimization for Pi") && !has_block {
672        let content = format!("# Agent Instructions\n\n{block}");
673        write_file(&agents_md, &content);
674        return;
675    }
676
677    if has_block {
678        let updated = crate::marked_block::replace_marked_block(
679            &existing,
680            AGENTS_BLOCK_START,
681            AGENTS_BLOCK_END,
682            &block,
683        );
684        if updated != existing {
685            write_file(&agents_md, &updated);
686        }
687        return;
688    }
689
690    if existing.contains("lean-ctx") && existing.contains(PROJECT_LEAN_CTX_MD) {
691        return;
692    }
693
694    let mut out = existing;
695    if !out.ends_with('\n') {
696        out.push('\n');
697    }
698    out.push('\n');
699    out.push_str(&block);
700    write_file(&agents_md, &out);
701    if !mcp_server_quiet_mode() {
702        eprintln!("Updated AGENTS.md (added lean-ctx reference block).");
703    }
704}
705
706/// #555: VS Code Copilot Chat auto-applies `.github/copilot-instructions.md` to
707/// every request, but `init --agent copilot` previously wrote only a weak
708/// AGENTS.md pointer — Claude-family models then ignored the lean-ctx tool
709/// mapping while GPT-5.x mostly followed it. Write the strong dedicated ruleset
710/// into a `<!-- lean-ctx-rules -->` marked block so it merges idempotently and
711/// never clobbers the user's own instructions.
712fn ensure_copilot_instructions(cwd: &std::path::Path) {
713    let path = cwd.join(".github").join("copilot-instructions.md");
714    let block = crate::rules_inject::rules_dedicated_markdown();
715    let start = crate::core::rules_canonical::START_MARK;
716    let end = crate::core::rules_canonical::END_MARK;
717    let owned = format!("{}\n", block.trim_end());
718
719    let existing = std::fs::read_to_string(&path).unwrap_or_default();
720    let desired = if existing.trim().is_empty() {
721        owned
722    } else if existing.contains(start) {
723        // Refresh our block; keep any surrounding user-authored content.
724        let user = crate::marked_block::remove_content(&existing, start, end);
725        if user.trim().is_empty() {
726            owned
727        } else {
728            format!("{}\n\n{}\n", user.trim_end(), block.trim_end())
729        }
730    } else {
731        // User-authored file with no lean-ctx block yet: append ours once.
732        format!("{}\n\n{}\n", existing.trim_end(), block.trim_end())
733    };
734
735    if desired == existing {
736        return;
737    }
738    if let Some(parent) = path.parent()
739        && std::fs::create_dir_all(parent).is_err()
740    {
741        return;
742    }
743    write_file(&path, &desired);
744    if !mcp_server_quiet_mode() {
745        eprintln!("Created/updated .github/copilot-instructions.md (Copilot/VS Code rules).");
746    }
747}
748
749/// #555 safety net: VS Code applies instruction files when
750/// `github.copilot.chat.codeGeneration.useInstructionFiles` is on (the default).
751/// A user or org policy may have disabled it globally, so pin it on for this
752/// project. Set only when the key is absent — an explicit user value is honoured.
753fn ensure_vscode_instruction_files_setting(cwd: &std::path::Path) {
754    const KEY: &str = "github.copilot.chat.codeGeneration.useInstructionFiles";
755    let path = cwd.join(".vscode").join("settings.json");
756
757    let existing = std::fs::read_to_string(&path).unwrap_or_default();
758    let mut json = if existing.trim().is_empty() {
759        serde_json::json!({})
760    } else {
761        match crate::core::jsonc::parse_jsonc(&existing) {
762            Ok(v) if v.is_object() => v,
763            // Never clobber an unparseable or non-object settings file.
764            _ => return,
765        }
766    };
767    let Some(obj) = json.as_object_mut() else {
768        return;
769    };
770    if obj.contains_key(KEY) {
771        return;
772    }
773    obj.insert(KEY.to_string(), serde_json::Value::Bool(true));
774
775    if let Some(parent) = path.parent()
776        && std::fs::create_dir_all(parent).is_err()
777    {
778        return;
779    }
780    let Ok(formatted) = serde_json::to_string_pretty(&json) else {
781        return;
782    };
783    if crate::config_io::write_atomic_with_backup(&path, &formatted).is_ok()
784        && !mcp_server_quiet_mode()
785    {
786        eprintln!("Set {KEY} in .vscode/settings.json.");
787    }
788}
789
790/// Compact pointer only (#578): Cursor already auto-loads the canonical full
791/// ruleset from `~/.cursor/rules/lean-ctx.mdc`, so a project `.cursorrules`
792/// that repeats it bills the same guidance twice in every session.
793pub fn cursorrules_content() -> String {
794    let start = crate::core::rules_canonical::START_MARK;
795    let end = crate::core::rules_canonical::END_MARK;
796    let version = crate::core::rules_canonical::RULES_VERSION;
797    format!(
798        "{start}\n<!-- version: {version} -->\n\n\
799# lean-ctx\n\n\
800{bullets}\n\n\
801{never}\n\
802Full rules: ~/.cursor/rules/lean-ctx.mdc (auto-loaded) \u{2014} do not duplicate here.\n\
803{end}",
804        bullets = crate::core::rules_canonical::BULLETS,
805        never = crate::core::rules_canonical::NEVER,
806    )
807}
808
809pub fn kiro_steering_content() -> String {
810    use crate::core::rules_canonical;
811    format!(
812        "---\n\
813inclusion: always\n\
814---\n\n\
815# Context Engineering Layer\n\n\
816{start}\n\
817<!-- version: {version} -->\n\n\
818The workspace has the `lean-ctx` MCP server installed. \
819You MUST prefer lean-ctx tools over native equivalents for token efficiency and caching.\n\n\
820{bullets}\n\n\
821{never}\n\n\
822## When to use native Kiro tools instead\n\n\
823- `fsWrite` / `fsAppend` \u{2014} always use native (lean-ctx doesn't write files)\n\
824- `strReplace` \u{2014} always use native (precise string replacement)\n\
825- `semanticRename` / `smartRelocate` \u{2014} always use native (IDE integration)\n\
826- `getDiagnostics` \u{2014} always use native (language server diagnostics)\n\
827- `deleteFile` \u{2014} always use native\n\
828- Glob \u{2014} always use native glob\n\n\
829{end}",
830        start = rules_canonical::START_MARK,
831        version = rules_canonical::RULES_VERSION,
832        bullets = rules_canonical::BULLETS,
833        never = rules_canonical::NEVER,
834        end = rules_canonical::END_MARK,
835    )
836}
837/// #281: whether the hooks layer may register the lean-ctx MCP server in an
838/// agent's config. Honors `[setup] auto_update_mcp`. Hooks, rules and skills
839/// still install when this is `false` — only the MCP-server writes are gated, so
840/// MCP-disabled environments stay free of MCP entries. Centralised here so every
841/// per-agent writer shares one source of truth (the shared JSON writer in
842/// `support.rs` enforces the same gate for `mcpServers`-style agents).
843pub(crate) fn should_register_mcp() -> bool {
844    crate::core::config::Config::load()
845        .setup
846        .should_update_mcp()
847}
848
849pub fn install_agent_hook(agent: &str, global: bool) {
850    install_agent_hook_with_mode(agent, global, HookMode::Mcp);
851}
852
853pub fn install_agent_hook_with_mode(agent: &str, global: bool, mode: HookMode) {
854    let home = crate::core::home::resolve_home_dir().unwrap_or_default();
855    match agent {
856        "claude" | "claude-code" => install_claude_hook_with_mode(global, mode),
857        "codebuddy" => install_codebuddy_hook_with_mode(global, mode),
858        "cursor" => install_cursor_hook_with_mode(global, mode),
859        "gemini" => {
860            install_gemini_hook();
861            // Google is transitioning Gemini CLI → Antigravity CLI (`agy`), and
862            // `gemini` setup also configures the Antigravity CLI MCP target. The
863            // hooks must follow: `agy` reads hooks only from its plugin dir
864            // (`~/.gemini/config/plugins/lean-ctx`), never from the legacy
865            // `~/.gemini/settings.json`, so install the plugin too (#284).
866            install_antigravity_cli_hook();
867        }
868        "antigravity" => install_antigravity_hook(),
869        "antigravity-cli" => install_antigravity_cli_hook(),
870        "augment" => install_mcp_json_agent(
871            "Augment CLI",
872            "~/.augment/settings.json",
873            &crate::core::editor_registry::augment_cli_settings_path(&home),
874        ),
875        "codex" => install_codex_hook(),
876        "windsurf" => install_windsurf_rules(global),
877        "cline" | "roo" => install_cline_rules(global),
878        "copilot" | "vscode" => install_copilot_hook(global),
879        // VS Code Insiders needs no hook install of its own: the MCP entry in
880        // its separate `Code - Insiders/User/mcp.json` is written by the
881        // editor-registry writer (GH #694), and the Copilot hook layer is
882        // user-global (`~/.copilot`), already covered by copilot/vscode.
883        "vscode-insiders" => {}
884        "pi" => install_pi_hook_with_mode(global, mode),
885        "qoder" => install_qoder_hook_with_mode(mode),
886        "qoderwork" => install_mcp_json_agent(
887            "QoderWork",
888            "~/.qoderwork/mcp.json",
889            &home.join(".qoderwork/mcp.json"),
890        ),
891        "qwen" => install_mcp_json_agent(
892            "Qwen Code",
893            "~/.qwen/settings.json",
894            &home.join(".qwen/settings.json"),
895        ),
896        "trae" => install_mcp_json_agent("Trae", "~/.trae/mcp.json", &home.join(".trae/mcp.json")),
897        "amazonq" => install_mcp_json_agent(
898            "Amazon Q Developer",
899            "~/.aws/amazonq/default.json",
900            &home.join(".aws/amazonq/default.json"),
901        ),
902        "jetbrains" => install_jetbrains_hook(),
903        "kiro" => install_kiro_hook(),
904        "verdent" => install_mcp_json_agent(
905            "Verdent",
906            "~/.verdent/mcp.json",
907            &home.join(".verdent/mcp.json"),
908        ),
909        "opencode" => install_opencode_hook_with_mode(mode),
910        "amp" => install_amp_hook(),
911        "crush" => install_crush_hook_with_mode(mode),
912        "openclaw" => install_openclaw_hook(),
913        "hermes" => install_hermes_hook_with_mode(global, mode),
914        "zed" => {
915            let zed_path = crate::core::editor_registry::zed_settings_path(&home);
916            let binary = resolve_binary_path();
917            let entry = full_server_entry(&binary);
918            install_named_json_server("Zed", "settings.json", &zed_path, "context_servers", entry);
919        }
920        "aider" => {
921            install_mcp_json_agent("Aider", "~/.aider/mcp.json", &home.join(".aider/mcp.json"));
922        }
923        "continue" => install_mcp_json_agent(
924            "Continue",
925            "~/.continue/mcp.json",
926            &home.join(".continue/mcp.json"),
927        ),
928        "neovim" => install_mcp_json_agent(
929            "Neovim (mcphub.nvim)",
930            "~/.config/mcphub/servers.json",
931            &home.join(".config/mcphub/servers.json"),
932        ),
933        "emacs" => install_mcp_json_agent(
934            "Emacs (mcp.el)",
935            "~/.emacs.d/mcp.json",
936            &home.join(".emacs.d/mcp.json"),
937        ),
938        "sublime" => install_mcp_json_agent(
939            "Sublime Text",
940            "~/.config/sublime-text/mcp.json",
941            &home.join(".config/sublime-text/mcp.json"),
942        ),
943        _ => {
944            eprintln!("Unknown agent: {agent}");
945            eprintln!("  Supported: aider, amazonq, amp, antigravity, antigravity-cli, augment,");
946            eprintln!(
947                "    claude, cline, codebuddy, codex, continue, copilot, crush, cursor, emacs, gemini,"
948            );
949            eprintln!("    hermes, jetbrains, kiro, neovim, openclaw, opencode, pi, qoder,");
950            eprintln!("    qoderwork, qwen, roo, sublime, trae, verdent, vscode, windsurf, zed");
951            std::process::exit(1);
952        }
953    }
954}
955
956pub fn install_agent_project_hooks(agent: &str, cwd: &std::path::Path) {
957    match agent {
958        "claude" | "claude-code" => agents::install_claude_project_hooks(cwd),
959        "codebuddy" => agents::install_codebuddy_project_hooks(cwd),
960        _ => {}
961    }
962}
963
964fn write_file(path: &std::path::Path, content: &str) {
965    // Skip identical rewrites: re-running setup/init must not churn mtimes or
966    // leave .bak files behind for content that did not change (GL #558).
967    if std::fs::read_to_string(path).is_ok_and(|existing| existing == content) {
968        return;
969    }
970    if let Err(e) = crate::config_io::write_atomic_with_backup(path, content) {
971        tracing::error!("Error writing {}: {e}", path.display());
972    }
973}
974
975/// Create a setup directory, surfacing a clear error instead of silently
976/// swallowing it (#596).
977///
978/// A user may symlink `~/.claude` / `~/.codex` (or a child) into a dotfiles
979/// repo; [`crate::config_io::ensure_dir`] follows such a symlink to its real
980/// in-`$HOME` target and tolerates a dangling one. Returns `false` (after
981/// printing the reason) when the directory cannot be prepared, so the caller can
982/// skip the now-impossible writes rather than failing confusingly downstream.
983fn ensure_state_dir(dir: &std::path::Path) -> bool {
984    match crate::config_io::ensure_dir(dir) {
985        Ok(()) => true,
986        Err(e) => {
987            // Always surface — a swallowed dir failure was the #596 footgun.
988            eprintln!("lean-ctx setup: cannot prepare {}: {e}", dir.display());
989            false
990        }
991    }
992}
993
994fn is_inside_git_repo(path: &std::path::Path) -> bool {
995    let mut p = path;
996    loop {
997        if p.join(".git").exists() {
998            return true;
999        }
1000        match p.parent() {
1001            Some(parent) => p = parent,
1002            None => return false,
1003        }
1004    }
1005}
1006
1007#[cfg(unix)]
1008fn make_executable(path: &PathBuf) {
1009    use std::os::unix::fs::PermissionsExt;
1010    let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755));
1011}
1012
1013#[cfg(not(unix))]
1014fn make_executable(_path: &PathBuf) {}
1015
1016/// Env key/value pairs for the lean-ctx MCP server entry written into agent
1017/// configs (Codex TOML + the JSON agents).
1018///
1019/// Deliberately does NOT pin `LEAN_CTX_DATA_DIR`: lean-ctx auto-detects its
1020/// per-category dirs (config/data/state/cache) at runtime, and pinning the data
1021/// dir would set that var in the server's environment, forcing single-dir mode
1022/// and collapsing config/state/cache onto the data dir — defeating the XDG split
1023/// (GH #408). Emits `LEAN_CTX_PROJECT_ROOT` and `LEAN_CTX_EXTRA_ROOTS` when known
1024/// (process env first, then config). Without these, a long-lived MCP server
1025/// spawned by the agent loses the project / worktree scope captured at `init`,
1026/// so an explicit path under a sibling worktree is wrongly rejected as a jail
1027/// escape (#403). Single source of truth so every agent installer stays consistent.
1028pub(crate) fn mcp_server_env_pairs() -> Vec<(String, String)> {
1029    let mut pairs = Vec::new();
1030
1031    let cfg = crate::core::config::Config::load();
1032
1033    let project_root = std::env::var("LEAN_CTX_PROJECT_ROOT")
1034        .ok()
1035        .filter(|v| !v.trim().is_empty())
1036        .or_else(|| cfg.project_root.clone().filter(|v| !v.trim().is_empty()));
1037    if let Some(root) = project_root {
1038        pairs.push(("LEAN_CTX_PROJECT_ROOT".to_string(), root));
1039    }
1040
1041    // Env override is already a platform path-list; config is a Vec we join the
1042    // same way `LEAN_CTX_EXTRA_ROOTS` is parsed (`std::env::split_paths`).
1043    let extra_roots = std::env::var("LEAN_CTX_EXTRA_ROOTS")
1044        .ok()
1045        .filter(|v| !v.trim().is_empty())
1046        .or_else(|| {
1047            let roots: Vec<&str> = cfg
1048                .extra_roots
1049                .iter()
1050                .map(String::as_str)
1051                .filter(|s| !s.trim().is_empty())
1052                .collect();
1053            if roots.is_empty() {
1054                return None;
1055            }
1056            std::env::join_paths(roots)
1057                .ok()
1058                .map(|s| s.to_string_lossy().to_string())
1059        });
1060    if let Some(extra) = extra_roots {
1061        pairs.push(("LEAN_CTX_EXTRA_ROOTS".to_string(), extra));
1062    }
1063
1064    pairs
1065}
1066
1067/// The MCP server env block as a JSON object, for the JSON-config agents.
1068pub(crate) fn mcp_server_env_json() -> serde_json::Value {
1069    let map: serde_json::Map<String, serde_json::Value> = mcp_server_env_pairs()
1070        .into_iter()
1071        .map(|(k, v)| (k, serde_json::Value::String(v)))
1072        .collect();
1073    serde_json::Value::Object(map)
1074}
1075
1076fn full_server_entry(binary: &str) -> serde_json::Value {
1077    // No LEAN_CTX_FULL_TOOLS here: forcing the full toolset (69+ schemas,
1078    // ~15k tokens of tool definitions resent every turn) made lean-ctx one of
1079    // the biggest token consumers in users' sessions (GitHub #385). The server
1080    // defaults to the core toolset + ctx_call/ctx_expand for on-demand access;
1081    // power users opt in via `tool_profile = "power"` in config.toml.
1082    serde_json::json!({
1083        "command": binary,
1084        "env": mcp_server_env_json()
1085    })
1086}
1087
1088pub(crate) fn install_mcp_json_agent(
1089    name: &str,
1090    display_path: &str,
1091    config_path: &std::path::Path,
1092) {
1093    let binary = resolve_binary_path();
1094    let entry = full_server_entry(&binary);
1095    install_named_json_server(name, display_path, config_path, "mcpServers", entry);
1096}
1097
1098#[cfg(test)]
1099mod tests;