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: "copilot",
46            display_name: "GitHub Copilot",
47            skill_dir: home.join(".copilot/skills/lean-ctx"),
48        },
49        SkillTarget {
50            agent_key: "openclaw",
51            display_name: "OpenClaw",
52            skill_dir: home.join(".openclaw/skills/lean-ctx"),
53        },
54    ]
55}
56
57fn is_skill_agent_detected(agent_key: &str, home: &std::path::Path) -> bool {
58    match agent_key {
59        "claude" => {
60            command_exists("claude")
61                || crate::core::editor_registry::claude_mcp_json_path(home).exists()
62                || crate::core::editor_registry::claude_state_dir(home).exists()
63        }
64        "codebuddy" => {
65            command_exists("codebuddy")
66                || crate::core::editor_registry::codebuddy_mcp_json_path(home).exists()
67                || crate::core::editor_registry::codebuddy_state_dir(home).exists()
68        }
69        "cursor" => home.join(".cursor").exists(),
70        "codex" => {
71            let codex_dir =
72                crate::core::home::resolve_codex_dir().unwrap_or_else(|| home.join(".codex"));
73            codex_dir.exists() || command_exists("codex")
74        }
75        "copilot" => {
76            home.join(".copilot").exists()
77                || home.join(".copilot/mcp-config.json").exists()
78                || command_exists("copilot")
79        }
80        "openclaw" => home.join(".openclaw").exists() || command_exists("openclaw"),
81        _ => false,
82    }
83}
84
85/// Install SKILL.md for a specific agent. Returns the installed path.
86pub fn install_skill_for_agent(home: &std::path::Path, agent_key: &str) -> Result<PathBuf, String> {
87    let targets = build_skill_targets(home);
88    let target = targets
89        .into_iter()
90        .find(|t| t.agent_key == agent_key)
91        .ok_or_else(|| format!("No skill target for agent '{agent_key}'"))?;
92
93    let skill_path = target.skill_dir.join("SKILL.md");
94    std::fs::create_dir_all(&target.skill_dir).map_err(|e| e.to_string())?;
95
96    if skill_path.exists() {
97        let existing = std::fs::read_to_string(&skill_path).unwrap_or_default();
98        if existing == SKILL_TEMPLATE {
99            return Ok(skill_path);
100        }
101    }
102
103    crate::config_io::write_atomic_with_backup(&skill_path, SKILL_TEMPLATE)?;
104    Ok(skill_path)
105}
106
107/// Install SKILL.md for all detected agents.
108/// Returns `Vec<(display_name, was_new_or_updated)>`.
109pub fn install_all_skills(home: &std::path::Path) -> Vec<(String, bool)> {
110    // `rules_injection = off`: the user opted out of lean-ctx-authored steering
111    // entirely (GH #361). The on-demand SKILL.md is part of that surface, so
112    // write none — mirrors `inject_all_rules`'s early return.
113    if crate::core::config::Config::load().rules_injection_effective()
114        == crate::core::config::RulesInjection::Off
115    {
116        return Vec::new();
117    }
118    let targets = build_skill_targets(home);
119    let mut results = Vec::new();
120
121    for target in &targets {
122        if !is_skill_agent_detected(target.agent_key, home) {
123            continue;
124        }
125
126        let skill_path = target.skill_dir.join("SKILL.md");
127        let already_current = skill_path.exists()
128            && std::fs::read_to_string(&skill_path).is_ok_and(|c| c == SKILL_TEMPLATE);
129
130        if already_current {
131            results.push((target.display_name.to_string(), false));
132            continue;
133        }
134
135        if let Err(e) = std::fs::create_dir_all(&target.skill_dir) {
136            tracing::warn!(
137                "Failed to create skill dir for {}: {e}",
138                target.display_name
139            );
140            continue;
141        }
142
143        match crate::config_io::write_atomic_with_backup(&skill_path, SKILL_TEMPLATE) {
144            Ok(()) => results.push((target.display_name.to_string(), true)),
145            Err(e) => {
146                tracing::warn!("Failed to write SKILL.md for {}: {e}", target.display_name);
147            }
148        }
149    }
150
151    results
152}