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
281pub fn to_bash_compatible_path(path: &str) -> String {
282    let path = match crate::core::pathutil::strip_verbatim_str(path) {
283        Some(stripped) => stripped,
284        None => path.replace('\\', "/"),
285    };
286    if path.len() >= 2 && path.as_bytes()[1] == b':' {
287        let drive = (path.as_bytes()[0] as char).to_ascii_lowercase();
288        format!("/{drive}{}", &path[2..])
289    } else {
290        path
291    }
292}
293
294/// Convert a Unix/MSYS-style path (`/c/Users/...`) back to native Windows
295/// format (`C:/Users/...`). No-op for paths that don't match the pattern.
296pub fn from_bash_to_native_path(path: &str) -> String {
297    crate::core::pathutil::normalize_tool_path(path)
298}
299
300/// Normalize paths from any client format to a consistent OS-native form.
301/// Delegates to `core::pathutil` so `core` crates do not depend on `hooks`.
302pub fn normalize_tool_path(path: &str) -> String {
303    crate::core::pathutil::normalize_tool_path(path)
304}
305
306pub fn generate_rewrite_script(binary: &str) -> String {
307    let case_pattern = crate::rewrite_registry::bash_case_pattern();
308    format!(
309        r#"#!/usr/bin/env bash
310# lean-ctx PreToolUse hook — rewrites bash commands to lean-ctx equivalents
311set -euo pipefail
312
313LEAN_CTX_BIN="{binary}"
314
315INPUT=$(cat)
316TOOL=$(echo "$INPUT" | grep -oE '"tool_name":"([^"\\]|\\.)*"' | head -1 | sed 's/^"tool_name":"//;s/"$//' | sed 's/\\"/"/g;s/\\\\/\\/g')
317
318case "$TOOL" in
319  Bash|bash|PowerShell|powershell) ;;
320  *) exit 0 ;;
321esac
322
323CMD=$(echo "$INPUT" | grep -oE '"command":"([^"\\]|\\.)*"' | head -1 | sed 's/^"command":"//;s/"$//' | sed 's/\\"/"/g;s/\\\\/\\/g')
324
325if [ -z "$CMD" ] || echo "$CMD" | grep -qE "^(lean-ctx |$LEAN_CTX_BIN )"; then
326  exit 0
327fi
328
329case "$CMD" in
330  {case_pattern})
331    # Shell-escape then JSON-escape (two passes)
332    SHELL_ESC=$(printf '%s' "$CMD" | sed 's/\\/\\\\/g;s/"/\\"/g')
333    REWRITE="$LEAN_CTX_BIN -c \"$SHELL_ESC\""
334    JSON_CMD=$(printf '%s' "$REWRITE" | sed 's/\\/\\\\/g;s/"/\\"/g')
335    printf '{{"hookSpecificOutput":{{"hookEventName":"PreToolUse","permissionDecision":"allow","updatedInput":{{"command":"%s"}}}}}}' "$JSON_CMD"
336    ;;
337  *) exit 0 ;;
338esac
339"#
340    )
341}
342
343pub fn generate_compact_rewrite_script(binary: &str) -> String {
344    let case_pattern = crate::rewrite_registry::bash_case_pattern();
345    format!(
346        r#"#!/usr/bin/env bash
347# lean-ctx hook — rewrites shell commands
348set -euo pipefail
349LEAN_CTX_BIN="{binary}"
350INPUT=$(cat)
351CMD=$(echo "$INPUT" | grep -oE '"command":"([^"\\]|\\.)*"' | head -1 | sed 's/^"command":"//;s/"$//' | sed 's/\\"/"/g;s/\\\\/\\/g' 2>/dev/null || echo "")
352if [ -z "$CMD" ] || echo "$CMD" | grep -qE "^(lean-ctx |$LEAN_CTX_BIN )"; then exit 0; fi
353case "$CMD" in
354  {case_pattern})
355    SHELL_ESC=$(printf '%s' "$CMD" | sed 's/\\/\\\\/g;s/"/\\"/g')
356    REWRITE="$LEAN_CTX_BIN -c \"$SHELL_ESC\""
357    JSON_CMD=$(printf '%s' "$REWRITE" | sed 's/\\/\\\\/g;s/"/\\"/g')
358    printf '{{"hookSpecificOutput":{{"hookEventName":"PreToolUse","permissionDecision":"allow","updatedInput":{{"command":"%s"}}}}}}' "$JSON_CMD" ;;
359  *) exit 0 ;;
360esac
361"#
362    )
363}
364
365const REDIRECT_SCRIPT_CLAUDE: &str = r"#!/usr/bin/env bash
366# lean-ctx PreToolUse hook — all native tools pass through
367# Read/Grep/ListFiles are allowed so Edit (which requires native Read) works.
368# The MCP instructions guide the AI to prefer ctx_read/ctx_search/ctx_tree.
369exit 0
370";
371
372const REDIRECT_SCRIPT_GENERIC: &str = r"#!/usr/bin/env bash
373# lean-ctx hook — all native tools pass through
374exit 0
375";
376
377pub fn hybrid_rules_content() -> String {
378    use crate::core::rules_canonical;
379    format!(
380        "{start}\n<!-- version: {version} -->\n\n\
381# lean-ctx \u{2014} Hybrid Mode (MCP reads + CLI commands)\n\n\
382{bullets}\n\n\
383{never}\n\n\
384{end}",
385        start = rules_canonical::START_MARK,
386        version = rules_canonical::RULES_VERSION,
387        bullets = rules_canonical::BULLETS,
388        never = rules_canonical::NEVER,
389        end = rules_canonical::END_MARK,
390    )
391}
392
393pub fn install_project_rules() {
394    install_project_rules_for_agents(&[]);
395}
396
397/// Install project rules, optionally scoped to specific agents.
398/// If `agents` is empty, installs for all agents (legacy behavior).
399pub fn install_project_rules_for_agents(agents: &[&str]) {
400    if crate::core::config::Config::load().rules_scope_effective()
401        == crate::core::config::RulesScope::Global
402    {
403        return;
404    }
405
406    let cwd = std::env::current_dir().unwrap_or_default();
407
408    if !is_inside_git_repo(&cwd) {
409        eprintln!(
410            "  Skipping project files: not inside a git repository.\n  \
411             Run this command from your project root to create CLAUDE.md / AGENTS.md."
412        );
413        return;
414    }
415
416    let home = crate::core::home::resolve_home_dir().unwrap_or_default();
417    if cwd == home {
418        eprintln!(
419            "  Skipping project files: current directory is your home folder.\n  \
420             Run this command from a project directory instead."
421        );
422        return;
423    }
424
425    let all = agents.is_empty();
426    let wants = |name: &str| all || agents.iter().any(|a| a.eq_ignore_ascii_case(name));
427
428    ensure_project_agents_integration(&cwd);
429
430    if wants("cursor") || wants("windsurf") {
431        let cursorrules = cwd.join(".cursorrules");
432        if !cursorrules.exists()
433            || !std::fs::read_to_string(&cursorrules)
434                .unwrap_or_default()
435                .contains("lean-ctx")
436        {
437            let content = cursorrules_content();
438            if cursorrules.exists() {
439                let mut existing = std::fs::read_to_string(&cursorrules).unwrap_or_default();
440                if !existing.ends_with('\n') {
441                    existing.push('\n');
442                }
443                existing.push('\n');
444                existing.push_str(&content);
445                write_file(&cursorrules, &existing);
446            } else {
447                write_file(&cursorrules, &content);
448            }
449            if !mcp_server_quiet_mode() {
450                eprintln!("Created/updated .cursorrules in project root.");
451            }
452        }
453    }
454
455    if wants("claude") {
456        // GL #555: project rules files without `paths:` frontmatter load
457        // unconditionally every session and stacked on top of the global
458        // CLAUDE.md block (12k+ token memory footprints in the field). The
459        // AGENTS.md block + on-demand skill carry the same guidance, so the
460        // lean-ctx-owned copy is removed instead of refreshed.
461        let claude_rules_file = cwd.join(".claude").join("rules").join("lean-ctx.md");
462        if let Ok(existing) = std::fs::read_to_string(&claude_rules_file)
463            && existing.contains(crate::core::rules_canonical::RULES_MARKER_PREFIX)
464            && std::fs::remove_file(&claude_rules_file).is_ok()
465            && !mcp_server_quiet_mode()
466        {
467            eprintln!(
468                "Removed .claude/rules/lean-ctx.md (always-loaded duplicate; AGENTS.md block + skill replace it)."
469            );
470        }
471
472        install_claude_project_hooks(&cwd);
473    }
474
475    if wants("codebuddy") {
476        let codebuddy_rules_file = cwd.join(".codebuddy").join("rules").join("lean-ctx.md");
477        if let Ok(existing) = std::fs::read_to_string(&codebuddy_rules_file)
478            && existing.contains(crate::core::rules_canonical::RULES_MARKER_PREFIX)
479            && std::fs::remove_file(&codebuddy_rules_file).is_ok()
480            && !mcp_server_quiet_mode()
481        {
482            eprintln!(
483                "Removed .codebuddy/rules/lean-ctx.md (always-loaded duplicate; CODEBUDDY.md block + skill replace it)."
484            );
485        }
486
487        install_codebuddy_project_hooks(&cwd);
488    }
489
490    if wants("kiro") {
491        let kiro_dir = cwd.join(".kiro");
492        if kiro_dir.exists() {
493            let steering_dir = kiro_dir.join("steering");
494            let steering_file = steering_dir.join("lean-ctx.md");
495            if !steering_file.exists()
496                || !std::fs::read_to_string(&steering_file)
497                    .unwrap_or_default()
498                    .contains("lean-ctx")
499            {
500                let _ = std::fs::create_dir_all(&steering_dir);
501                write_file(&steering_file, &kiro_steering_content());
502                if !mcp_server_quiet_mode() {
503                    eprintln!("Created .kiro/steering/lean-ctx.md (Kiro steering).");
504                }
505            }
506        }
507    }
508
509    if wants("copilot") || wants("vscode") {
510        ensure_copilot_instructions(&cwd);
511        ensure_vscode_instruction_files_setting(&cwd);
512    }
513}
514
515const PROJECT_LEAN_CTX_MD_MARKER: &str =
516    crate::core::rules_canonical::PROJECT_LEAN_CTX_OWNED_MARKER;
517const PROJECT_LEAN_CTX_MD: &str = "LEAN-CTX.md";
518const PROJECT_AGENTS_MD: &str = "AGENTS.md";
519// The AGENTS.md pointer block keeps its own marker pair, independent of the
520// dedicated rules-file `START_MARK`: pointer-only files must not be counted as
521// duplicate lean-ctx sources (doctor overhead, #684).
522const AGENTS_BLOCK_START: &str = crate::core::rules_canonical::AGENTS_BLOCK_START;
523const AGENTS_BLOCK_END: &str = crate::core::rules_canonical::AGENTS_BLOCK_END;
524
525fn ensure_project_agents_integration(cwd: &std::path::Path) {
526    let lean_ctx_md = cwd.join(PROJECT_LEAN_CTX_MD);
527    // Longform (#578): LEAN-CTX.md is opened on demand via the AGENTS.md
528    // pointer, never auto-loaded, so it carries the verbose teaching profile.
529    let desired = format!(
530        "{PROJECT_LEAN_CTX_MD_MARKER}\n{}\n",
531        crate::rules_inject::rules_longform_markdown()
532    );
533
534    if !lean_ctx_md.exists() {
535        write_file(&lean_ctx_md, &desired);
536    } else if std::fs::read_to_string(&lean_ctx_md)
537        .unwrap_or_default()
538        .contains(PROJECT_LEAN_CTX_MD_MARKER)
539    {
540        let current = std::fs::read_to_string(&lean_ctx_md).unwrap_or_default();
541        let version_str = format!(
542            "<!-- version: {} -->",
543            crate::core::rules_canonical::RULES_VERSION
544        );
545        if !current.contains(&version_str) {
546            write_file(&lean_ctx_md, &desired);
547        }
548    }
549
550    // No `@` import: Claude Code expands `@file` references inline at session
551    // start, so pointing at LEAN-CTX.md re-loaded the full ruleset into every
552    // session on top of this block (GL #555). The block is self-contained;
553    // the full ruleset stays in LEAN-CTX.md for on-demand reading.
554    let block = format!(
555        "{AGENTS_BLOCK_START}\n\
556## lean-ctx\n\n\
557lean-ctx is active — the MCP tools replace native equivalents.\n\
558Full rules: {PROJECT_LEAN_CTX_MD} (open on demand — do not auto-load).\n\
559{AGENTS_BLOCK_END}\n"
560    );
561
562    let agents_md = cwd.join(PROJECT_AGENTS_MD);
563    if !agents_md.exists() {
564        let content = format!("# Agent Instructions\n\n{block}");
565        write_file(&agents_md, &content);
566        if !mcp_server_quiet_mode() {
567            eprintln!("Created AGENTS.md in project root (lean-ctx reference only).");
568        }
569        return;
570    }
571
572    let existing = std::fs::read_to_string(&agents_md).unwrap_or_default();
573
574    // Marker checks are line-based (GL #1158): a prose mention of the marker
575    // (as this repo's own AGENTS.md carries) must not trigger block surgery.
576    let has_block = crate::marked_block::contains_marker_line(&existing, AGENTS_BLOCK_START);
577
578    if existing.contains("CLI-first Token Optimization for Pi") && !has_block {
579        let content = format!("# Agent Instructions\n\n{block}");
580        write_file(&agents_md, &content);
581        return;
582    }
583
584    if has_block {
585        let updated = crate::marked_block::replace_marked_block(
586            &existing,
587            AGENTS_BLOCK_START,
588            AGENTS_BLOCK_END,
589            &block,
590        );
591        if updated != existing {
592            write_file(&agents_md, &updated);
593        }
594        return;
595    }
596
597    if existing.contains("lean-ctx") && existing.contains(PROJECT_LEAN_CTX_MD) {
598        return;
599    }
600
601    let mut out = existing;
602    if !out.ends_with('\n') {
603        out.push('\n');
604    }
605    out.push('\n');
606    out.push_str(&block);
607    write_file(&agents_md, &out);
608    if !mcp_server_quiet_mode() {
609        eprintln!("Updated AGENTS.md (added lean-ctx reference block).");
610    }
611}
612
613/// #555: VS Code Copilot Chat auto-applies `.github/copilot-instructions.md` to
614/// every request, but `init --agent copilot` previously wrote only a weak
615/// AGENTS.md pointer — Claude-family models then ignored the lean-ctx tool
616/// mapping while GPT-5.x mostly followed it. Write the strong dedicated ruleset
617/// into a `<!-- lean-ctx-rules -->` marked block so it merges idempotently and
618/// never clobbers the user's own instructions.
619fn ensure_copilot_instructions(cwd: &std::path::Path) {
620    let path = cwd.join(".github").join("copilot-instructions.md");
621    let block = crate::rules_inject::rules_dedicated_markdown();
622    let start = crate::core::rules_canonical::START_MARK;
623    let end = crate::core::rules_canonical::END_MARK;
624    let owned = format!("{}\n", block.trim_end());
625
626    let existing = std::fs::read_to_string(&path).unwrap_or_default();
627    let desired = if existing.trim().is_empty() {
628        owned
629    } else if existing.contains(start) {
630        // Refresh our block; keep any surrounding user-authored content.
631        let user = crate::marked_block::remove_content(&existing, start, end);
632        if user.trim().is_empty() {
633            owned
634        } else {
635            format!("{}\n\n{}\n", user.trim_end(), block.trim_end())
636        }
637    } else {
638        // User-authored file with no lean-ctx block yet: append ours once.
639        format!("{}\n\n{}\n", existing.trim_end(), block.trim_end())
640    };
641
642    if desired == existing {
643        return;
644    }
645    if let Some(parent) = path.parent()
646        && std::fs::create_dir_all(parent).is_err()
647    {
648        return;
649    }
650    write_file(&path, &desired);
651    if !mcp_server_quiet_mode() {
652        eprintln!("Created/updated .github/copilot-instructions.md (Copilot/VS Code rules).");
653    }
654}
655
656/// #555 safety net: VS Code applies instruction files when
657/// `github.copilot.chat.codeGeneration.useInstructionFiles` is on (the default).
658/// A user or org policy may have disabled it globally, so pin it on for this
659/// project. Set only when the key is absent — an explicit user value is honoured.
660fn ensure_vscode_instruction_files_setting(cwd: &std::path::Path) {
661    const KEY: &str = "github.copilot.chat.codeGeneration.useInstructionFiles";
662    let path = cwd.join(".vscode").join("settings.json");
663
664    let existing = std::fs::read_to_string(&path).unwrap_or_default();
665    let mut json = if existing.trim().is_empty() {
666        serde_json::json!({})
667    } else {
668        match crate::core::jsonc::parse_jsonc(&existing) {
669            Ok(v) if v.is_object() => v,
670            // Never clobber an unparseable or non-object settings file.
671            _ => return,
672        }
673    };
674    let Some(obj) = json.as_object_mut() else {
675        return;
676    };
677    if obj.contains_key(KEY) {
678        return;
679    }
680    obj.insert(KEY.to_string(), serde_json::Value::Bool(true));
681
682    if let Some(parent) = path.parent()
683        && std::fs::create_dir_all(parent).is_err()
684    {
685        return;
686    }
687    let Ok(formatted) = serde_json::to_string_pretty(&json) else {
688        return;
689    };
690    if crate::config_io::write_atomic_with_backup(&path, &formatted).is_ok()
691        && !mcp_server_quiet_mode()
692    {
693        eprintln!("Set {KEY} in .vscode/settings.json.");
694    }
695}
696
697/// Compact pointer only (#578): Cursor already auto-loads the canonical full
698/// ruleset from `~/.cursor/rules/lean-ctx.mdc`, so a project `.cursorrules`
699/// that repeats it bills the same guidance twice in every session.
700pub fn cursorrules_content() -> String {
701    let start = crate::core::rules_canonical::START_MARK;
702    let end = crate::core::rules_canonical::END_MARK;
703    let version = crate::core::rules_canonical::RULES_VERSION;
704    format!(
705        "{start}\n<!-- version: {version} -->\n\n\
706# lean-ctx\n\n\
707{bullets}\n\n\
708{never}\n\
709Full rules: ~/.cursor/rules/lean-ctx.mdc (auto-loaded) \u{2014} do not duplicate here.\n\
710{end}",
711        bullets = crate::core::rules_canonical::BULLETS,
712        never = crate::core::rules_canonical::NEVER,
713    )
714}
715
716pub fn kiro_steering_content() -> String {
717    use crate::core::rules_canonical;
718    format!(
719        "---\n\
720inclusion: always\n\
721---\n\n\
722# Context Engineering Layer\n\n\
723{start}\n\
724<!-- version: {version} -->\n\n\
725The workspace has the `lean-ctx` MCP server installed. \
726You MUST prefer lean-ctx tools over native equivalents for token efficiency and caching.\n\n\
727{bullets}\n\n\
728{never}\n\n\
729## When to use native Kiro tools instead\n\n\
730- `fsWrite` / `fsAppend` \u{2014} always use native (lean-ctx doesn't write files)\n\
731- `strReplace` \u{2014} always use native (precise string replacement)\n\
732- `semanticRename` / `smartRelocate` \u{2014} always use native (IDE integration)\n\
733- `getDiagnostics` \u{2014} always use native (language server diagnostics)\n\
734- `deleteFile` \u{2014} always use native\n\
735- Glob \u{2014} always use native glob\n\n\
736{end}",
737        start = rules_canonical::START_MARK,
738        version = rules_canonical::RULES_VERSION,
739        bullets = rules_canonical::BULLETS,
740        never = rules_canonical::NEVER,
741        end = rules_canonical::END_MARK,
742    )
743}
744/// #281: whether the hooks layer may register the lean-ctx MCP server in an
745/// agent's config. Honors `[setup] auto_update_mcp`. Hooks, rules and skills
746/// still install when this is `false` — only the MCP-server writes are gated, so
747/// MCP-disabled environments stay free of MCP entries. Centralised here so every
748/// per-agent writer shares one source of truth (the shared JSON writer in
749/// `support.rs` enforces the same gate for `mcpServers`-style agents).
750pub(crate) fn should_register_mcp() -> bool {
751    crate::core::config::Config::load()
752        .setup
753        .should_update_mcp()
754}
755
756pub fn install_agent_hook(agent: &str, global: bool) {
757    install_agent_hook_with_mode(agent, global, HookMode::Mcp);
758}
759
760pub fn install_agent_hook_with_mode(agent: &str, global: bool, mode: HookMode) {
761    let home = crate::core::home::resolve_home_dir().unwrap_or_default();
762    match agent {
763        "claude" | "claude-code" => install_claude_hook_with_mode(global, mode),
764        "codebuddy" => install_codebuddy_hook_with_mode(global, mode),
765        "cursor" => install_cursor_hook_with_mode(global, mode),
766        "gemini" => {
767            install_gemini_hook();
768            // Google is transitioning Gemini CLI → Antigravity CLI (`agy`), and
769            // `gemini` setup also configures the Antigravity CLI MCP target. The
770            // hooks must follow: `agy` reads hooks only from its plugin dir
771            // (`~/.gemini/config/plugins/lean-ctx`), never from the legacy
772            // `~/.gemini/settings.json`, so install the plugin too (#284).
773            install_antigravity_cli_hook();
774        }
775        "antigravity" => install_antigravity_hook(),
776        "antigravity-cli" => install_antigravity_cli_hook(),
777        "augment" => install_mcp_json_agent(
778            "Augment CLI",
779            "~/.augment/settings.json",
780            &crate::core::editor_registry::augment_cli_settings_path(&home),
781        ),
782        "codex" => install_codex_hook(),
783        "windsurf" => install_windsurf_rules(global),
784        "cline" | "roo" => install_cline_rules(global),
785        "copilot" | "vscode" => install_copilot_hook(global),
786        // VS Code Insiders needs no hook install of its own: the MCP entry in
787        // its separate `Code - Insiders/User/mcp.json` is written by the
788        // editor-registry writer (GH #694), and the Copilot hook layer is
789        // user-global (`~/.copilot`), already covered by copilot/vscode.
790        "vscode-insiders" => {}
791        "pi" => install_pi_hook_with_mode(global, mode),
792        "qoder" => install_qoder_hook_with_mode(mode),
793        "qoderwork" => install_mcp_json_agent(
794            "QoderWork",
795            "~/.qoderwork/mcp.json",
796            &home.join(".qoderwork/mcp.json"),
797        ),
798        "qwen" => install_mcp_json_agent(
799            "Qwen Code",
800            "~/.qwen/settings.json",
801            &home.join(".qwen/settings.json"),
802        ),
803        "trae" => install_mcp_json_agent("Trae", "~/.trae/mcp.json", &home.join(".trae/mcp.json")),
804        "amazonq" => install_mcp_json_agent(
805            "Amazon Q Developer",
806            "~/.aws/amazonq/default.json",
807            &home.join(".aws/amazonq/default.json"),
808        ),
809        "jetbrains" => install_jetbrains_hook(),
810        "kiro" => install_kiro_hook(),
811        "verdent" => install_mcp_json_agent(
812            "Verdent",
813            "~/.verdent/mcp.json",
814            &home.join(".verdent/mcp.json"),
815        ),
816        "opencode" => install_opencode_hook_with_mode(mode),
817        "amp" => install_amp_hook(),
818        "crush" => install_crush_hook_with_mode(mode),
819        "openclaw" => install_openclaw_hook(),
820        "hermes" => install_hermes_hook_with_mode(global, mode),
821        "zed" => {
822            let zed_path = crate::core::editor_registry::zed_settings_path(&home);
823            let binary = resolve_binary_path();
824            let entry = full_server_entry(&binary);
825            install_named_json_server("Zed", "settings.json", &zed_path, "context_servers", entry);
826        }
827        "aider" => {
828            install_mcp_json_agent("Aider", "~/.aider/mcp.json", &home.join(".aider/mcp.json"));
829        }
830        "continue" => install_mcp_json_agent(
831            "Continue",
832            "~/.continue/mcp.json",
833            &home.join(".continue/mcp.json"),
834        ),
835        "neovim" => install_mcp_json_agent(
836            "Neovim (mcphub.nvim)",
837            "~/.config/mcphub/servers.json",
838            &home.join(".config/mcphub/servers.json"),
839        ),
840        "emacs" => install_mcp_json_agent(
841            "Emacs (mcp.el)",
842            "~/.emacs.d/mcp.json",
843            &home.join(".emacs.d/mcp.json"),
844        ),
845        "sublime" => install_mcp_json_agent(
846            "Sublime Text",
847            "~/.config/sublime-text/mcp.json",
848            &home.join(".config/sublime-text/mcp.json"),
849        ),
850        _ => {
851            eprintln!("Unknown agent: {agent}");
852            eprintln!("  Supported: aider, amazonq, amp, antigravity, antigravity-cli, augment,");
853            eprintln!(
854                "    claude, cline, codebuddy, codex, continue, copilot, crush, cursor, emacs, gemini,"
855            );
856            eprintln!("    hermes, jetbrains, kiro, neovim, openclaw, opencode, pi, qoder,");
857            eprintln!("    qoderwork, qwen, roo, sublime, trae, verdent, vscode, windsurf, zed");
858            std::process::exit(1);
859        }
860    }
861}
862
863pub fn install_agent_project_hooks(agent: &str, cwd: &std::path::Path) {
864    match agent {
865        "claude" | "claude-code" => agents::install_claude_project_hooks(cwd),
866        "codebuddy" => agents::install_codebuddy_project_hooks(cwd),
867        _ => {}
868    }
869}
870
871fn write_file(path: &std::path::Path, content: &str) {
872    // Skip identical rewrites: re-running setup/init must not churn mtimes or
873    // leave .bak files behind for content that did not change (GL #558).
874    if std::fs::read_to_string(path).is_ok_and(|existing| existing == content) {
875        return;
876    }
877    if let Err(e) = crate::config_io::write_atomic_with_backup(path, content) {
878        tracing::error!("Error writing {}: {e}", path.display());
879    }
880}
881
882/// Create a setup directory, surfacing a clear error instead of silently
883/// swallowing it (#596).
884///
885/// A user may symlink `~/.claude` / `~/.codex` (or a child) into a dotfiles
886/// repo; [`crate::config_io::ensure_dir`] follows such a symlink to its real
887/// in-`$HOME` target and tolerates a dangling one. Returns `false` (after
888/// printing the reason) when the directory cannot be prepared, so the caller can
889/// skip the now-impossible writes rather than failing confusingly downstream.
890fn ensure_state_dir(dir: &std::path::Path) -> bool {
891    match crate::config_io::ensure_dir(dir) {
892        Ok(()) => true,
893        Err(e) => {
894            // Always surface — a swallowed dir failure was the #596 footgun.
895            eprintln!("lean-ctx setup: cannot prepare {}: {e}", dir.display());
896            false
897        }
898    }
899}
900
901fn is_inside_git_repo(path: &std::path::Path) -> bool {
902    let mut p = path;
903    loop {
904        if p.join(".git").exists() {
905            return true;
906        }
907        match p.parent() {
908            Some(parent) => p = parent,
909            None => return false,
910        }
911    }
912}
913
914#[cfg(unix)]
915fn make_executable(path: &PathBuf) {
916    use std::os::unix::fs::PermissionsExt;
917    let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755));
918}
919
920#[cfg(not(unix))]
921fn make_executable(_path: &PathBuf) {}
922
923/// Env key/value pairs for the lean-ctx MCP server entry written into agent
924/// configs (Codex TOML + the JSON agents).
925///
926/// Deliberately does NOT pin `LEAN_CTX_DATA_DIR`: lean-ctx auto-detects its
927/// per-category dirs (config/data/state/cache) at runtime, and pinning the data
928/// dir would set that var in the server's environment, forcing single-dir mode
929/// and collapsing config/state/cache onto the data dir — defeating the XDG split
930/// (GH #408). Emits `LEAN_CTX_PROJECT_ROOT` and `LEAN_CTX_EXTRA_ROOTS` when known
931/// (process env first, then config). Without these, a long-lived MCP server
932/// spawned by the agent loses the project / worktree scope captured at `init`,
933/// so an explicit path under a sibling worktree is wrongly rejected as a jail
934/// escape (#403). Single source of truth so every agent installer stays consistent.
935pub(crate) fn mcp_server_env_pairs() -> Vec<(String, String)> {
936    let mut pairs = Vec::new();
937
938    let cfg = crate::core::config::Config::load();
939
940    let project_root = std::env::var("LEAN_CTX_PROJECT_ROOT")
941        .ok()
942        .filter(|v| !v.trim().is_empty())
943        .or_else(|| cfg.project_root.clone().filter(|v| !v.trim().is_empty()));
944    if let Some(root) = project_root {
945        pairs.push(("LEAN_CTX_PROJECT_ROOT".to_string(), root));
946    }
947
948    // Env override is already a platform path-list; config is a Vec we join the
949    // same way `LEAN_CTX_EXTRA_ROOTS` is parsed (`std::env::split_paths`).
950    let extra_roots = std::env::var("LEAN_CTX_EXTRA_ROOTS")
951        .ok()
952        .filter(|v| !v.trim().is_empty())
953        .or_else(|| {
954            let roots: Vec<&str> = cfg
955                .extra_roots
956                .iter()
957                .map(String::as_str)
958                .filter(|s| !s.trim().is_empty())
959                .collect();
960            if roots.is_empty() {
961                return None;
962            }
963            std::env::join_paths(roots)
964                .ok()
965                .map(|s| s.to_string_lossy().to_string())
966        });
967    if let Some(extra) = extra_roots {
968        pairs.push(("LEAN_CTX_EXTRA_ROOTS".to_string(), extra));
969    }
970
971    pairs
972}
973
974/// The MCP server env block as a JSON object, for the JSON-config agents.
975pub(crate) fn mcp_server_env_json() -> serde_json::Value {
976    let map: serde_json::Map<String, serde_json::Value> = mcp_server_env_pairs()
977        .into_iter()
978        .map(|(k, v)| (k, serde_json::Value::String(v)))
979        .collect();
980    serde_json::Value::Object(map)
981}
982
983fn full_server_entry(binary: &str) -> serde_json::Value {
984    // No LEAN_CTX_FULL_TOOLS here: forcing the full toolset (69+ schemas,
985    // ~15k tokens of tool definitions resent every turn) made lean-ctx one of
986    // the biggest token consumers in users' sessions (GitHub #385). The server
987    // defaults to the core toolset + ctx_call/ctx_expand for on-demand access;
988    // power users opt in via `tool_profile = "power"` in config.toml.
989    serde_json::json!({
990        "command": binary,
991        "env": mcp_server_env_json()
992    })
993}
994
995pub(crate) fn install_mcp_json_agent(
996    name: &str,
997    display_path: &str,
998    config_path: &std::path::Path,
999) {
1000    let binary = resolve_binary_path();
1001    let entry = full_server_entry(&binary);
1002    install_named_json_server(name, display_path, config_path, "mcpServers", entry);
1003}
1004
1005#[cfg(test)]
1006mod tests;