Skip to main content

lean_ctx/rules_inject/
skills.rs

1//! SKILL.md installation for agents with a skills directory.
2
3use std::path::PathBuf;
4
5use super::detect::command_exists;
6
7// ---------------------------------------------------------------------------
8// SKILL.md installation
9// ---------------------------------------------------------------------------
10
11pub(super) const SKILL_TEMPLATE: &str = include_str!("../templates/SKILL.md");
12
13pub(super) struct SkillTarget {
14    agent_key: &'static str,
15    display_name: &'static str,
16    skill_dir: PathBuf,
17}
18
19pub(super) fn build_skill_targets(home: &std::path::Path) -> Vec<SkillTarget> {
20    vec![
21        SkillTarget {
22            agent_key: "claude",
23            display_name: "Claude Code",
24            skill_dir: crate::setup::claude_config_dir(home).join("skills/lean-ctx"),
25        },
26        SkillTarget {
27            agent_key: "codebuddy",
28            display_name: "CodeBuddy",
29            skill_dir: crate::core::editor_registry::codebuddy_state_dir(home)
30                .join("skills/lean-ctx"),
31        },
32        SkillTarget {
33            agent_key: "cursor",
34            display_name: "Cursor",
35            skill_dir: home.join(".cursor/skills/lean-ctx"),
36        },
37        SkillTarget {
38            agent_key: "codex",
39            display_name: "Codex CLI",
40            skill_dir: crate::core::home::resolve_codex_dir()
41                .unwrap_or_else(|| home.join(".codex"))
42                .join("skills/lean-ctx"),
43        },
44        SkillTarget {
45            agent_key: "grok",
46            display_name: "Grok",
47            skill_dir: home.join(".grok/skills/lean-ctx"),
48        },
49        SkillTarget {
50            agent_key: "copilot",
51            display_name: "GitHub Copilot",
52            skill_dir: home.join(".copilot/skills/lean-ctx"),
53        },
54        SkillTarget {
55            agent_key: "openclaw",
56            display_name: "OpenClaw",
57            skill_dir: home.join(".openclaw/skills/lean-ctx"),
58        },
59        SkillTarget {
60            agent_key: "opencode",
61            display_name: "OpenCode",
62            skill_dir: home.join(".config/opencode/skills/lean-ctx"),
63        },
64    ]
65}
66
67fn is_skill_agent_detected(agent_key: &str, home: &std::path::Path) -> bool {
68    match agent_key {
69        "claude" => {
70            command_exists("claude")
71                || crate::core::editor_registry::claude_mcp_json_path(home).exists()
72                || crate::core::editor_registry::claude_state_dir(home).exists()
73        }
74        "codebuddy" => {
75            command_exists("codebuddy")
76                || crate::core::editor_registry::codebuddy_mcp_json_path(home).exists()
77                || crate::core::editor_registry::codebuddy_state_dir(home).exists()
78        }
79        "cursor" => home.join(".cursor").exists(),
80        "codex" => {
81            let codex_dir =
82                crate::core::home::resolve_codex_dir().unwrap_or_else(|| home.join(".codex"));
83            codex_dir.exists() || command_exists("codex")
84        }
85        "grok" => home.join(".grok").exists() || command_exists("grok"),
86        "copilot" => {
87            home.join(".copilot").exists()
88                || home.join(".copilot/mcp-config.json").exists()
89                || command_exists("copilot")
90        }
91        "openclaw" => home.join(".openclaw").exists() || command_exists("openclaw"),
92        "opencode" => home.join(".config/opencode").exists() || command_exists("opencode"),
93        _ => false,
94    }
95}
96
97/// Install SKILL.md for a specific agent. Returns the installed path.
98pub fn install_skill_for_agent(home: &std::path::Path, agent_key: &str) -> Result<PathBuf, String> {
99    let targets = build_skill_targets(home);
100    let target = targets
101        .into_iter()
102        .find(|t| t.agent_key == agent_key)
103        .ok_or_else(|| format!("No skill target for agent '{agent_key}'"))?;
104
105    let skill_path = target.skill_dir.join("SKILL.md");
106    std::fs::create_dir_all(&target.skill_dir).map_err(|e| e.to_string())?;
107
108    if skill_path.exists() {
109        let existing = std::fs::read_to_string(&skill_path).unwrap_or_default();
110        if existing == SKILL_TEMPLATE {
111            return Ok(skill_path);
112        }
113    }
114
115    crate::config_io::write_atomic_with_backup(&skill_path, SKILL_TEMPLATE)?;
116    Ok(skill_path)
117}
118
119/// Install SKILL.md for all detected agents.
120/// Returns `Vec<(display_name, was_new_or_updated)>`.
121pub fn install_all_skills(home: &std::path::Path) -> Vec<(String, bool)> {
122    // `rules_injection = off`: the user opted out of lean-ctx-authored steering
123    // entirely (GH #361). The on-demand SKILL.md is part of that surface, so
124    // write none — mirrors `inject_all_rules`'s early return.
125    if crate::core::config::Config::load().rules_injection_effective()
126        == crate::core::config::RulesInjection::Off
127    {
128        return Vec::new();
129    }
130    let targets = build_skill_targets(home);
131    let mut results = Vec::new();
132
133    for target in &targets {
134        if !is_skill_agent_detected(target.agent_key, home) {
135            continue;
136        }
137
138        let skill_path = target.skill_dir.join("SKILL.md");
139        let already_current = skill_path.exists()
140            && std::fs::read_to_string(&skill_path).is_ok_and(|c| c == SKILL_TEMPLATE);
141
142        if already_current {
143            results.push((target.display_name.to_string(), false));
144            continue;
145        }
146
147        if let Err(e) = std::fs::create_dir_all(&target.skill_dir) {
148            tracing::warn!(
149                "Failed to create skill dir for {}: {e}",
150                target.display_name
151            );
152            continue;
153        }
154
155        match crate::config_io::write_atomic_with_backup(&skill_path, SKILL_TEMPLATE) {
156            Ok(()) => results.push((target.display_name.to_string(), true)),
157            Err(e) => {
158                tracing::warn!("Failed to write SKILL.md for {}: {e}", target.display_name);
159            }
160        }
161    }
162
163    results
164}