Skip to main content

dev_prune/commands/
skill.rs

1// Copyright 2026 VKrishna04
2// SPDX-License-Identifier: Apache-2.0
3
4/// AI Agent Skill exporter & onboarding prompt generator.
5use anyhow::{Context, Result};
6use std::fs;
7
8use crate::config::Registry;
9use crate::output;
10
11pub const EMBEDDED_SKILL_MD: &str = include_str!("../../.agents/skills/dev-prune/SKILL.md");
12
13/// The condensed rules `--agent` writes: what the tool is, the non-negotiables, and a
14/// pointer at the full SKILL.md — short enough that an editor loads it on every turn.
15pub const EMBEDDED_RULES_MD: &str = include_str!("../../.agents/rules/dev-prune.rules.md");
16
17/// Editors whose agents read per-repository rule files.
18///
19/// Claude Code is deliberately absent: its skill installs globally (`devp skill`,
20/// `devp setup`), so there is nothing to write into individual repositories.
21#[derive(clap::ValueEnum, Clone, Copy, Debug)]
22pub enum AgentEditor {
23    /// `.cursor/rules/dev-prune.mdc`
24    Cursor,
25    /// `.windsurf/rules/dev-prune.md`
26    Windsurf,
27    /// `.agent/rules/dev-prune.md` (Antigravity)
28    Antigravity,
29    /// `.clinerules/dev-prune.md`
30    Cline,
31    /// `.roo/rules/dev-prune.md` (Roo Code)
32    Roo,
33    /// `.kilocode/rules/dev-prune.md` (Kilo Code)
34    Kilocode,
35    /// `.continue/rules/dev-prune.md` (Continue)
36    Continue,
37    /// `.amazonq/rules/dev-prune.md` (Amazon Q Developer)
38    AmazonQ,
39    /// `.kiro/steering/dev-prune.md` (Kiro)
40    Kiro,
41    /// `.trae/rules/dev-prune.md` (Trae)
42    Trae,
43    /// `.junie/guidelines.md`, as a marked block (JetBrains Junie)
44    Junie,
45    /// `GEMINI.md`, as a marked block (Gemini CLI)
46    Gemini,
47    /// `.rules`, as a marked block (Zed — read ahead of every other convention)
48    Zed,
49    /// `.github/copilot-instructions.md`, as a marked block
50    Copilot,
51    /// `AGENTS.md`, as a marked block — the cross-tool convention (Codex, Jules,
52    /// Amp, OpenCode, Antigravity and others read it)
53    AgentsMd,
54}
55
56/// How the rules go into the file.
57enum Style {
58    /// dev-prune owns the whole file.
59    OwnFile,
60    /// Cursor's `.mdc` format, which needs frontmatter above the rules.
61    CursorMdc,
62    /// The file belongs to somebody else, so dev-prune owns a marked block inside it
63    /// and leaves every byte outside the markers exactly as found.
64    MarkedBlock,
65}
66
67impl AgentEditor {
68    /// The repository-relative file this editor's agent actually reads, and how to
69    /// write into it.
70    ///
71    /// One table rather than one match arm each: an editor whose agent reads a
72    /// directory of rule files is the same three lines every time, and the only thing
73    /// a contributor should have to establish is the path.
74    fn target(self) -> (&'static str, Style) {
75        use crate::constants as c;
76        match self {
77            AgentEditor::Cursor => (c::CURSOR_RULES_FILE, Style::CursorMdc),
78            AgentEditor::Windsurf => (c::WINDSURF_RULES_FILE, Style::OwnFile),
79            AgentEditor::Antigravity => (c::ANTIGRAVITY_RULES_FILE, Style::OwnFile),
80            AgentEditor::Cline => (c::CLINE_RULES_FILE, Style::OwnFile),
81            AgentEditor::Roo => (c::ROO_RULES_FILE, Style::OwnFile),
82            AgentEditor::Kilocode => (c::KILOCODE_RULES_FILE, Style::OwnFile),
83            AgentEditor::Continue => (c::CONTINUE_RULES_FILE, Style::OwnFile),
84            AgentEditor::AmazonQ => (c::AMAZON_Q_RULES_FILE, Style::OwnFile),
85            AgentEditor::Kiro => (c::KIRO_STEERING_FILE, Style::OwnFile),
86            AgentEditor::Trae => (c::TRAE_RULES_FILE, Style::OwnFile),
87            AgentEditor::Junie => (c::JUNIE_GUIDELINES_FILE, Style::MarkedBlock),
88            AgentEditor::Gemini => (c::GEMINI_MD_FILE, Style::MarkedBlock),
89            AgentEditor::Zed => (c::ZED_RULES_FILE, Style::MarkedBlock),
90            AgentEditor::Copilot => (c::COPILOT_INSTRUCTIONS_FILE, Style::MarkedBlock),
91            AgentEditor::AgentsMd => (c::AGENTS_MD_FILE, Style::MarkedBlock),
92        }
93    }
94}
95
96/// Run `devp skill` to export SKILL.md and display AI Agent onboarding prompts, or
97/// `devp skill --agent <editor>` to write per-repository rules for one editor.
98pub fn run(agent: Option<AgentEditor>) -> Result<()> {
99    if let Some(editor) = agent {
100        return write_agent_rules(editor);
101    }
102    output::print_header("dev-prune AI Agent Skill Integration");
103
104    // The export is the command's one job — claiming success over a swallowed write
105    // error would leave the user pointing an agent at a file that is not there.
106    let skill_path = {
107        let config_dir =
108            Registry::config_dir().context("could not resolve the config directory")?;
109        fs::create_dir_all(&config_dir)
110            .with_context(|| format!("could not create {}", output::clean_path(&config_dir)))?;
111        let target = config_dir.join("SKILL.md");
112        fs::write(&target, EMBEDDED_SKILL_MD)
113            .with_context(|| format!("could not write {}", output::clean_path(&target)))?;
114        output::clean_path(&target)
115    };
116
117    output::print_success(&format!("Bundled SKILL.md exported to `{skill_path}`"));
118
119    // Agents with an on-disk skill format get the file put where they read it, so the
120    // prompts below are only needed for the ones without one.
121    let agent_roots = crate::setup::agent_skill_roots();
122    match crate::setup::ensure_agent_skills() {
123        crate::setup::Outcome::Installed | crate::setup::Outcome::AlreadyPresent => {
124            for root in &agent_roots {
125                output::print_success(&format!(
126                    "Skill installed for your AI agent at `{}`",
127                    output::clean_path(root.join("SKILL.md"))
128                ));
129            }
130        }
131        crate::setup::Outcome::Skipped(_) => {
132            output::print_info(
133                "No AI agent skills directory was found — use the prompts below instead.",
134            );
135        }
136        crate::setup::Outcome::Failed(why) => {
137            output::print_warning(&format!(
138                "Could not install into the agent skills directory: {why}"
139            ));
140        }
141    }
142    println!();
143    output::print_header("🤖 AI Agent Onboarding Prompts (Copy & Paste to your AI Assistant)");
144    println!();
145    output::print_info("Prompt 1: Initial Workspace Discovery & Onboarding");
146    println!("```markdown");
147    println!(
148        "Read the dev-prune AI skill at file://{skill_path} and run `devp init` to scan, register, and onboard all Git repositories in my workspace."
149    );
150    println!("```");
151    println!();
152    output::print_info(
153        "Prompt 2: Universal Skill Import (Antigravity, Claude Code, Cursor, Windsurf, Copilot, OpenClaw)",
154    );
155    println!("```markdown");
156    println!(
157        "I have installed `dev-prune` on my machine. Read the skill at file://{skill_path} and import it into your agent skills folder so you can autonomously maintain lockfiles and prune bloat directories."
158    );
159    println!("```");
160
161    Ok(())
162}
163
164/// Write the condensed rules into the current repository, in `editor`'s format.
165///
166/// Per-repository by design: these files are meant to be committed so the whole team's
167/// agents pick them up, which is exactly why nothing here is written unasked — this
168/// runs only when the user types the flag.
169fn write_agent_rules(editor: AgentEditor) -> Result<()> {
170    let cwd = std::env::current_dir().context("could not read the current directory")?;
171    if !crate::scanner::is_git_repo(&cwd) {
172        anyhow::bail!(
173            "`--agent` writes rules into a repository, and the current directory is not \
174             one. Run it from the repository root."
175        );
176    }
177
178    let (relative, style) = editor.target();
179    let target = cwd.join(relative);
180    let content = match style {
181        Style::OwnFile => EMBEDDED_RULES_MD.to_string(),
182        Style::CursorMdc => format!(
183            "---\ndescription: dev-prune (devp) — reclaiming disk space from idle \
184             repositories safely\nalwaysApply: false\n---\n\n{EMBEDDED_RULES_MD}"
185        ),
186        Style::MarkedBlock => {
187            let existing = fs::read_to_string(&target).unwrap_or_default();
188            upsert_marked_block(&existing)
189        }
190    };
191    write_rules_file(&target, &content)?;
192
193    output::print_success(&format!("Rules written: {}", output::clean_path(&target)));
194    output::print_info(
195        "Commit the file if the whole team's agents should have it; it is inert data \
196         and safe to share.",
197    );
198    Ok(())
199}
200
201/// Replace dev-prune's marked block in `existing`, or append one — leaving every
202/// byte outside the markers exactly as found.
203fn upsert_marked_block(existing: &str) -> String {
204    let block = format!(
205        "{}\n{EMBEDDED_RULES_MD}{}\n",
206        crate::constants::RULES_BLOCK_START,
207        crate::constants::RULES_BLOCK_END
208    );
209    match (
210        existing.find(crate::constants::RULES_BLOCK_START),
211        existing.find(crate::constants::RULES_BLOCK_END),
212    ) {
213        (Some(start), Some(end)) if end > start => {
214            let after = end + crate::constants::RULES_BLOCK_END.len();
215            // The trailing newline of the old block belongs to it.
216            let after = if existing[after..].starts_with('\n') {
217                after + 1
218            } else {
219                after
220            };
221            format!("{}{block}{}", &existing[..start], &existing[after..])
222        }
223        _ if existing.is_empty() => block,
224        _ => format!("{}\n\n{block}", existing.trim_end_matches('\n')),
225    }
226}
227
228fn write_rules_file(target: &std::path::Path, content: &str) -> Result<()> {
229    if let Some(parent) = target.parent() {
230        fs::create_dir_all(parent)
231            .with_context(|| format!("could not create {}", output::clean_path(parent)))?;
232    }
233    fs::write(target, content)
234        .with_context(|| format!("could not write {}", output::clean_path(target)))
235}
236
237#[cfg(test)]
238mod tests {
239    use super::*;
240    use clap::ValueEnum;
241
242    #[test]
243    fn every_editor_writes_to_its_own_file() {
244        // A copy-pasted path would make one editor silently overwrite another's rules,
245        // and nothing else in the program would notice.
246        let mut paths: Vec<&str> = AgentEditor::value_variants()
247            .iter()
248            .map(|e| e.target().0)
249            .collect();
250        let total = paths.len();
251        paths.sort_unstable();
252        paths.dedup();
253        assert_eq!(paths.len(), total, "two editors share a path");
254    }
255
256    #[test]
257    fn a_shared_file_is_only_ever_edited_inside_the_markers() {
258        // The whole reason `MarkedBlock` exists: these files belong to the user, and a
259        // second run must not stack a second copy of the rules on top of the first.
260        let theirs = "# Our conventions\n\nUse tabs.\n";
261        let once = upsert_marked_block(theirs);
262        let twice = upsert_marked_block(&once);
263        assert_eq!(once, twice, "a second write duplicated the block");
264        assert!(once.starts_with(theirs));
265        assert_eq!(once.matches(crate::constants::RULES_BLOCK_START).count(), 1);
266    }
267}