Skip to main content

lean_ctx/rules_inject/
mod.rs

1//! Rules + SKILL.md injection for every supported agent, split by concern
2//! (GL#440): `content` (payloads), `targets` (agent catalog), `detect`
3//! (installation checks), `write` (atomic file surgery), `skills` (SKILL.md).
4//! This hub owns the shared types and the orchestration entry points.
5//!
6//! Content is delegated to `core::rules_canonical` — all rule text lives
7//! there as `pub const` sections.  Markers (`START_MARK`, `END_MARK`,
8//! `RULES_VERSION`) are also re-exported from canonical.
9
10use std::path::PathBuf;
11
12use serde::{Deserialize, Serialize};
13
14use crate::core::rules_canonical::RulesFile;
15pub use crate::core::rules_canonical::{END_MARK, RULES_VERSION, START_MARK};
16
17mod content;
18mod detect;
19mod skills;
20mod targets;
21#[cfg(test)]
22mod tests;
23mod write;
24
25pub use content::cursor_mdc_document;
26pub use content::{
27    GEMINI_DEDICATED_CONTEXT_FILENAME, gemini_dedicated_rules_path, opencode_dedicated_rules_path,
28};
29pub use skills::{
30    install_all_skills, install_skill_for_agent, install_skills_where, planned_skill_files,
31};
32
33/// The tool set client-neutral rule files may name: what `tools/list` offers a
34/// client without quirks (the lean core when no profile is pinned). These files
35/// are shared by every agent in a home or project, so no client is known.
36fn shared_surface_profile(
37    cfg: &crate::core::config::Config,
38) -> crate::core::tool_profiles::ToolProfile {
39    crate::core::tool_visibility::guidance_profile(cfg, "")
40}
41
42/// Forwarding functions — content is delegated to `core::rules_canonical`.
43pub fn canonical_rules_block() -> String {
44    let cfg = crate::core::config::Config::load();
45    let shadow = cfg.shadow_mode;
46    let level = crate::core::config::CompressionLevel::effective(&cfg);
47    let profile = shared_surface_profile(&cfg);
48    crate::core::rules_canonical::render(
49        shadow,
50        crate::core::rules_canonical::Wrapper::Shared,
51        level,
52        &profile,
53    )
54}
55pub fn rules_shared_content() -> String {
56    canonical_rules_block()
57}
58pub fn rules_dedicated_markdown() -> String {
59    let cfg = crate::core::config::Config::load();
60    let shadow = cfg.shadow_mode;
61    let level = crate::core::config::CompressionLevel::effective(&cfg);
62    let profile = shared_surface_profile(&cfg);
63    crate::core::rules_canonical::render(
64        shadow,
65        crate::core::rules_canonical::Wrapper::Dedicated,
66        level,
67        &profile,
68    )
69}
70
71/// Long-form rules for the on-demand project `LEAN-CTX.md` (#578). Same
72/// config-driven shadow/compression handling as the dedicated render, but the
73/// LONGFORM profile with the verbose teaching sections.
74pub fn rules_longform_markdown() -> String {
75    let cfg = crate::core::config::Config::load();
76    let shadow = cfg.shadow_mode;
77    let level = crate::core::config::CompressionLevel::effective(&cfg);
78    let profile = shared_surface_profile(&cfg);
79    crate::core::rules_canonical::render(
80        shadow,
81        crate::core::rules_canonical::Wrapper::Longform,
82        level,
83        &profile,
84    )
85}
86
87/// The canonical rules block lean-ctx would write for each target, keyed by the
88/// target's display name.
89///
90/// Drift detection compares against this instead of guessing shared-vs-dedicated
91/// from a target's on-disk contents: a freshly synced `SharedMarkdown` file (e.g.
92/// Copilot CLI, Codex CLI) carries no user text, which a content heuristic
93/// mistook for the dedicated layout and then flagged as drifted on every sync.
94/// Keying by the real `RulesFormat` keeps `sync` and `diff` in agreement (#548).
95pub fn expected_blocks_by_target(
96    home: &std::path::Path,
97) -> std::collections::HashMap<String, String> {
98    let injection = crate::core::config::Config::load().rules_injection_effective();
99    let cfg = crate::core::config::Config::load();
100    let shadow = cfg.shadow_mode;
101    let level = crate::core::config::CompressionLevel::effective(&cfg);
102    build_rules_targets(home, injection)
103        .into_iter()
104        .map(|target| {
105            // The profile `inject_rules` writes with, so a freshly synced
106            // file never reads as drifted (#1008, #1849).
107            let profile = write::profile_for_target(&cfg, target.name);
108            let wrapper = match target.format {
109                RulesFormat::SharedMarkdown => crate::core::rules_canonical::Wrapper::Shared,
110                RulesFormat::DedicatedMarkdown => crate::core::rules_canonical::Wrapper::Dedicated,
111                // CursorMdc embeds the render verbatim between the markers
112                // (frontmatter lives outside them); the wrapper is dynamic —
113                // HookCovered on hook-covered installs (GL #1153).
114                RulesFormat::CursorMdc => content::cursor_wrapper_for_mdc(&target.path),
115            };
116            let expected = crate::core::rules_canonical::render(shadow, wrapper, level, &profile);
117            (target.name.to_string(), expected)
118        })
119        .collect()
120}
121
122use detect::is_tool_detected;
123use targets::build_rules_targets;
124use write::inject_rules;
125
126struct RulesTarget {
127    name: &'static str,
128    path: PathBuf,
129    format: RulesFormat,
130}
131
132enum RulesFormat {
133    SharedMarkdown,
134    DedicatedMarkdown,
135    CursorMdc,
136}
137
138#[derive(Debug, Default)]
139pub struct InjectResult {
140    pub injected: Vec<String>,
141    pub updated: Vec<String>,
142    pub already: Vec<String>,
143    pub errors: Vec<String>,
144    pub backed_up: Vec<String>,
145}
146
147#[derive(Debug, Clone, Serialize, Deserialize)]
148pub struct RulesTargetStatus {
149    pub name: String,
150    pub detected: bool,
151    pub path: String,
152    pub state: String,
153    pub note: Option<String>,
154}
155
156enum RulesResult {
157    Updated,
158    AlreadyPresent,
159}
160
161pub fn inject_all_rules(home: &std::path::Path) -> InjectResult {
162    inject_rules_where(home, &|_| true)
163}
164
165/// Agent key (as `init --agent` / `setup --agents` spell it) that owns a
166/// rules target, so a setup agent selection can filter rules files too.
167fn rules_agent_key(target_name: &str) -> Option<&'static str> {
168    Some(match target_name {
169        "Gemini CLI" => "gemini",
170        "VS Code" => "vscode",
171        "Copilot CLI" => "copilot",
172        "Cursor" => "cursor",
173        "Windsurf" => "windsurf",
174        "Zed" => "zed",
175        "Cline" => "cline",
176        "Roo Code" => "roo",
177        "OpenCode" => "opencode",
178        "Continue" => "continue",
179        "Amp" => "amp",
180        "Qwen Code" => "qwen",
181        "Trae" => "trae",
182        "Amazon Q Developer" => "amazonq",
183        "JetBrains IDEs" => "jetbrains",
184        "Antigravity" => "antigravity",
185        "Pi Coding Agent" => "pi",
186        "AWS Kiro" => "kiro",
187        "Verdent" => "verdent",
188        "Crush" => "crush",
189        "Augment" => "augment",
190        "OpenClaw" => "openclaw",
191        "Codex CLI" => "codex",
192        "Grok" => "grok",
193        "Oh My Pi" => "omp",
194        "Hermes Agent" => "hermes",
195        "Qoder" => "qoder",
196        _ => return None,
197    })
198}
199
200/// Rules files a setup run would write for the agents `allows` admits: the
201/// installed tools' targets plus the Claude Code pointer block. Writes nothing.
202/// The real run additionally skips tools whose MCP entry is missing (GH #1435).
203#[must_use]
204pub fn planned_rules_files(
205    home: &std::path::Path,
206    allows: &dyn Fn(&str) -> bool,
207) -> Vec<(String, PathBuf)> {
208    let cfg = crate::core::config::Config::load();
209    let injection = cfg.rules_injection_effective();
210    if cfg.rules_scope_effective() == crate::core::config::RulesScope::Project
211        || injection == crate::core::config::RulesInjection::Off
212    {
213        return Vec::new();
214    }
215    let mut files = Vec::new();
216    if injection == crate::core::config::RulesInjection::Shared
217        && allows("claude")
218        && detect::is_named_agent_ready("Claude Code", home)
219    {
220        files.push((
221            "Claude Code".to_string(),
222            crate::core::editor_registry::claude_state_dir(home).join("CLAUDE.md"),
223        ));
224    }
225    for target in build_rules_targets(home, injection) {
226        if rules_agent_key(target.name).is_some_and(allows) && is_tool_detected(&target, home) {
227            files.push((target.name.to_string(), target.path));
228        }
229    }
230    files
231}
232
233/// [`inject_all_rules`] restricted to the agents `allows` admits (by agent key).
234pub fn inject_rules_where(home: &std::path::Path, allows: &dyn Fn(&str) -> bool) -> InjectResult {
235    let cfg = crate::core::config::Config::load();
236    if cfg.rules_scope_effective() == crate::core::config::RulesScope::Project {
237        return InjectResult::default();
238    }
239    if cfg.rules_injection_effective() == crate::core::config::RulesInjection::Off {
240        return InjectResult::default();
241    }
242
243    let targets = build_rules_targets(home, cfg.rules_injection_effective());
244
245    let mut result = InjectResult::default();
246
247    if cfg.rules_injection_effective() == crate::core::config::RulesInjection::Shared
248        && allows("claude")
249        && detect::is_named_agent_ready("Claude Code", home)
250    {
251        sync_claude_pointer_block(home, &mut result);
252    }
253
254    for target in &targets {
255        if !rules_agent_key(target.name).is_some_and(allows) {
256            continue;
257        }
258        if !is_tool_detected(target, home) {
259            continue;
260        }
261        if rules_agent_key(target.name)
262            .is_some_and(|key| crate::core::governor_hosts::is_installed(key, home))
263        {
264            continue;
265        }
266        // GH #1435: skip rules injection when the tool's MCP server is not
267        // configured — a rules file that mandates ctx_* tools for an agent
268        // that can't reach them is a self-contradiction.
269        if !detect::is_mcp_configured(target, home) {
270            continue;
271        }
272
273        let bak_path = target.path.with_extension(format!(
274            "{}.bak",
275            target
276                .path
277                .extension()
278                .and_then(|e| e.to_str())
279                .unwrap_or("")
280        ));
281        let bak_existed_before = bak_path.exists();
282        let bak_mtime_before = bak_existed_before
283            .then(|| {
284                std::fs::metadata(&bak_path)
285                    .ok()
286                    .and_then(|m| m.modified().ok())
287            })
288            .flatten();
289
290        match inject_rules(target) {
291            Ok(RulesResult::Updated) => {
292                result.updated.push(target.name.to_string());
293                let bak_is_new = if bak_existed_before {
294                    std::fs::metadata(&bak_path)
295                        .ok()
296                        .and_then(|m| m.modified().ok())
297                        != bak_mtime_before
298                } else {
299                    bak_path.exists()
300                };
301                if bak_is_new {
302                    result
303                        .backed_up
304                        .push(bak_path.to_string_lossy().to_string());
305                }
306            }
307            Ok(RulesResult::AlreadyPresent) => result.already.push(target.name.to_string()),
308            Err(e) => result.errors.push(format!("{}: {e}", target.name)),
309        }
310    }
311
312    result
313}
314
315/// Inject global rules for a single agent (by CLI key like "opencode", "cursor", etc.).
316pub fn inject_rules_for_agent(home: &std::path::Path, agent_key: &str) -> InjectResult {
317    let cfg = crate::core::config::Config::load();
318    if cfg.rules_scope_effective() == crate::core::config::RulesScope::Project {
319        return InjectResult::default();
320    }
321    if cfg.rules_injection_effective() == crate::core::config::RulesInjection::Off {
322        return InjectResult::default();
323    }
324
325    // The governor setup owns the host's rules channel (its short block).
326    if crate::core::governor_hosts::is_installed(agent_key, home) {
327        return InjectResult::default();
328    }
329    let targets = build_rules_targets(home, cfg.rules_injection_effective());
330    let mut result = InjectResult::default();
331
332    if cfg.rules_injection_effective() == crate::core::config::RulesInjection::Shared
333        && is_claude_agent_key(agent_key)
334    {
335        sync_claude_pointer_block(home, &mut result);
336        return result;
337    }
338
339    for target in &targets {
340        if !match_agent_name(agent_key, target.name) {
341            continue;
342        }
343
344        let bak_path = target.path.with_extension(format!(
345            "{}.bak",
346            target
347                .path
348                .extension()
349                .and_then(|e| e.to_str())
350                .unwrap_or("")
351        ));
352        let bak_existed_before = bak_path.exists();
353
354        match inject_rules(target) {
355            Ok(RulesResult::Updated) => {
356                result.updated.push(target.name.to_string());
357                if !bak_existed_before && bak_path.exists() {
358                    result
359                        .backed_up
360                        .push(bak_path.to_string_lossy().to_string());
361                }
362            }
363            Ok(RulesResult::AlreadyPresent) => result.already.push(target.name.to_string()),
364            Err(e) => result.errors.push(format!("{}: {e}", target.name)),
365        }
366    }
367
368    result
369}
370
371fn is_claude_agent_key(agent_key: &str) -> bool {
372    matches!(
373        agent_key.trim().to_ascii_lowercase().as_str(),
374        "claude" | "claude-code" | "claude_code" | "claude code"
375    )
376}
377
378fn sync_claude_pointer_block(home: &std::path::Path, result: &mut InjectResult) {
379    let mode = crate::core::hook_mode::recommend_hook_mode("claude");
380    match crate::core::claude_md::sync_claude_global_rules_block(home, mode) {
381        Ok(true) => result.updated.push("Claude Code".to_string()),
382        Ok(false) => result.already.push("Claude Code".to_string()),
383        Err(error) => result.errors.push(format!("Claude Code: {error}")),
384    }
385}
386
387/// Returns `true` if a lean-ctx rules marker is present in *any* supported
388/// agent's rules file.
389#[must_use]
390pub fn any_rules_marker_present(home: &std::path::Path) -> bool {
391    use crate::core::config::RulesInjection;
392    let mut seen = std::collections::HashSet::new();
393    for injection in [RulesInjection::Shared, RulesInjection::Dedicated] {
394        for target in build_rules_targets(home, injection) {
395            if !seen.insert(target.path.clone()) {
396                continue;
397            }
398            if let Ok(content) = std::fs::read_to_string(&target.path)
399                && RulesFile::parse(&content).has_content()
400            {
401                return true;
402            }
403        }
404    }
405    false
406}
407
408/// Whether `lean-ctx setup` has left guidance for any host: a rules target,
409/// or the instruction-file block of a host that has no rules target (Claude
410/// Code's `CLAUDE.md`, CodeBuddy's `CODEBUDDY.md`, #555). The "run setup" tip
411/// needs this wider check: [`any_rules_marker_present`] alone never sees a
412/// Claude-only installation.
413#[must_use]
414pub fn host_guidance_present(home: &std::path::Path) -> bool {
415    use crate::core::editor_registry::{claude_state_dir, codebuddy_state_dir};
416    any_rules_marker_present(home)
417        || pointer_block_present(&[
418            claude_state_dir(home).join("CLAUDE.md"),
419            codebuddy_state_dir(home).join("CODEBUDDY.md"),
420        ])
421}
422
423fn pointer_block_present(files: &[std::path::PathBuf]) -> bool {
424    files.iter().any(|path| {
425        // These are pointer blocks, delimited by AGENTS_BLOCK_START (#549).
426        std::fs::read_to_string(path)
427            .is_ok_and(|c| c.contains(crate::core::rules_canonical::AGENTS_BLOCK_START))
428    })
429}
430
431fn match_agent_name(cli_key: &str, target_name: &str) -> bool {
432    let needle = cli_key.to_lowercase();
433    let tn = target_name.to_lowercase();
434    if needle == "pi" {
435        return tn.contains("pi coding");
436    }
437    if needle == "omp" {
438        return tn.contains("oh my pi");
439    }
440    needle.contains(&tn)
441        || tn.contains(&needle)
442        || (needle.contains("cursor") && tn.contains("cursor"))
443        || (needle.contains("claude") && tn.contains("claude"))
444        || (needle.contains("codebuddy") && tn.contains("codebuddy"))
445        || (needle.contains("windsurf") && tn.contains("windsurf"))
446        || (needle.contains("codex") && tn.contains("claude"))
447        || (needle.contains("zed") && tn.contains("zed"))
448        || (needle.contains("copilot") && tn.contains("copilot"))
449        || (needle.contains("jetbrains") && tn.contains("jetbrains"))
450        || (needle.contains("kiro") && tn.contains("kiro"))
451        || (needle.contains("gemini") && tn.contains("gemini"))
452        || (needle == "opencode" && tn.contains("opencode"))
453        || (needle == "cline" && tn.contains("cline"))
454        || (needle == "roo" && tn.contains("roo"))
455        || (needle == "amp" && tn.contains("amp"))
456        || (needle == "trae" && tn.contains("trae"))
457        || (needle == "amazonq" && tn.contains("amazon"))
458        || (needle == "crush" && tn.contains("crush"))
459        || (needle == "verdent" && tn.contains("verdent"))
460        || (needle == "continue" && tn.contains("continue"))
461        || (needle == "qwen" && tn.contains("qwen"))
462        || (needle == "antigravity" && tn.contains("antigravity"))
463        || (needle == "augment" && tn.contains("augment"))
464        || (needle == "openclaw" && tn.contains("openclaw"))
465        || (needle == "grok" && tn.contains("grok"))
466        || (needle == "vscode" && (tn.contains("vs code") || tn.contains("vscode")))
467}
468
469/// Check if the rules file for a given MCP client is up-to-date.
470pub fn check_rules_freshness(client_name: &str) -> Option<String> {
471    let home = dirs::home_dir()?;
472    check_rules_freshness_at_home(client_name, &home)
473}
474
475fn check_rules_freshness_at_home(client_name: &str, home: &std::path::Path) -> Option<String> {
476    let cfg = crate::core::config::Config::load();
477    if cfg.declines_rule_steering()
478        || cfg.rules_scope_effective() == crate::core::config::RulesScope::Project
479    {
480        return None;
481    }
482    let injection = cfg.rules_injection_effective();
483    let targets = build_rules_targets(home, injection);
484
485    let matched: Vec<&RulesTarget> = targets
486        .iter()
487        .filter(|t| match_agent_name(client_name, t.name))
488        .collect();
489
490    if matched.is_empty() {
491        return None;
492    }
493
494    for target in &matched {
495        // Keep freshness eligibility identical to inject_all_rules(): never
496        // advertise an automatic repair for a target the writer would skip.
497        if !is_tool_detected(target, home) || !detect::is_mcp_configured(target, home) {
498            continue;
499        }
500        if !target.path.exists() {
501            continue;
502        }
503        let content = std::fs::read_to_string(&target.path).ok()?;
504        let file = RulesFile::parse(&content);
505        if file.has_content() && !file.is_current() {
506            return Some(format!(
507                "[RULES OUTDATED] Your {} rules were written by an older lean-ctx version. \
508                 Re-read your rules file ({}) or run `lean-ctx setup` to update, \
509                 then start a new session for full compatibility.",
510                target.name,
511                target.path.display()
512            ));
513        }
514    }
515
516    None
517}
518
519pub fn collect_rules_status(home: &std::path::Path) -> Vec<RulesTargetStatus> {
520    let injection = crate::core::config::Config::load().rules_injection_effective();
521    let targets = build_rules_targets(home, injection);
522    let mut out = Vec::new();
523
524    for target in &targets {
525        let detected = is_tool_detected(target, home);
526        let path = target.path.to_string_lossy().to_string();
527
528        let state = if !detected {
529            "not_detected".to_string()
530        } else if !target.path.exists() {
531            "missing".to_string()
532        } else {
533            match std::fs::read_to_string(&target.path) {
534                Ok(content) => {
535                    let file = RulesFile::parse(&content);
536                    if file.has_content() {
537                        if file.is_current() {
538                            "up_to_date".to_string()
539                        } else {
540                            "outdated".to_string()
541                        }
542                    } else {
543                        "present_without_marker".to_string()
544                    }
545                }
546                Err(_) => "read_error".to_string(),
547            }
548        };
549
550        out.push(RulesTargetStatus {
551            name: target.name.to_string(),
552            detected,
553            path,
554            state,
555            note: None,
556        });
557    }
558
559    out
560}