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    /// `CONVENTIONS.md`, as a marked block (Aider — which has to be told to read it)
52    Aider,
53    /// `AGENTS.md`, as a marked block — the cross-tool convention (Codex, Jules,
54    /// Amp, OpenCode, Antigravity and others read it)
55    AgentsMd,
56}
57
58/// How the rules go into the file.
59enum Style {
60    /// dev-prune owns the whole file.
61    OwnFile,
62    /// Cursor's `.mdc` format, which needs frontmatter above the rules.
63    CursorMdc,
64    /// The file belongs to somebody else, so dev-prune owns a marked block inside it
65    /// and leaves every byte outside the markers exactly as found.
66    MarkedBlock,
67}
68
69impl AgentEditor {
70    /// The repository-relative file this editor's agent actually reads, and how to
71    /// write into it.
72    ///
73    /// One table rather than one match arm each: an editor whose agent reads a
74    /// directory of rule files is the same three lines every time, and the only thing
75    /// a contributor should have to establish is the path.
76    fn target(self) -> (&'static str, Style) {
77        use crate::constants as c;
78        match self {
79            AgentEditor::Cursor => (c::CURSOR_RULES_FILE, Style::CursorMdc),
80            AgentEditor::Windsurf => (c::WINDSURF_RULES_FILE, Style::OwnFile),
81            AgentEditor::Antigravity => (c::ANTIGRAVITY_RULES_FILE, Style::OwnFile),
82            AgentEditor::Cline => (c::CLINE_RULES_FILE, Style::OwnFile),
83            AgentEditor::Roo => (c::ROO_RULES_FILE, Style::OwnFile),
84            AgentEditor::Kilocode => (c::KILOCODE_RULES_FILE, Style::OwnFile),
85            AgentEditor::Continue => (c::CONTINUE_RULES_FILE, Style::OwnFile),
86            AgentEditor::AmazonQ => (c::AMAZON_Q_RULES_FILE, Style::OwnFile),
87            AgentEditor::Kiro => (c::KIRO_STEERING_FILE, Style::OwnFile),
88            AgentEditor::Trae => (c::TRAE_RULES_FILE, Style::OwnFile),
89            AgentEditor::Junie => (c::JUNIE_GUIDELINES_FILE, Style::MarkedBlock),
90            AgentEditor::Gemini => (c::GEMINI_MD_FILE, Style::MarkedBlock),
91            AgentEditor::Zed => (c::ZED_RULES_FILE, Style::MarkedBlock),
92            AgentEditor::Copilot => (c::COPILOT_INSTRUCTIONS_FILE, Style::MarkedBlock),
93            AgentEditor::Aider => (c::AIDER_CONVENTIONS_FILE, Style::MarkedBlock),
94            AgentEditor::AgentsMd => (c::AGENTS_MD_FILE, Style::MarkedBlock),
95        }
96    }
97
98    /// What the user still has to do, for the one editor that does not read its
99    /// file unprompted. Aider loads `CONVENTIONS.md` only when told to, so writing
100    /// the file and saying nothing would leave rules an agent never sees.
101    fn wiring(self) -> Option<&'static str> {
102        match self {
103            AgentEditor::Aider => Some(
104                "Aider does not read this file on its own. Add `read: CONVENTIONS.md` \
105                 to `.aider.conf.yml`, or start it with `aider --read CONVENTIONS.md`.",
106            ),
107            _ => None,
108        }
109    }
110}
111
112/// Run `devp skill` to export SKILL.md and display AI Agent onboarding prompts, or
113/// `devp skill --agent <editor>` to write per-repository rules for one editor.
114pub fn run(agent: Option<AgentEditor>) -> Result<()> {
115    if let Some(editor) = agent {
116        return write_agent_rules(editor);
117    }
118    output::print_header("dev-prune AI Agent Skill Integration");
119
120    // The export is the command's one job — claiming success over a swallowed write
121    // error would leave the user pointing an agent at a file that is not there.
122    let skill_path = {
123        let config_dir =
124            Registry::config_dir().context("could not resolve the config directory")?;
125        fs::create_dir_all(&config_dir)
126            .with_context(|| format!("could not create {}", output::clean_path(&config_dir)))?;
127        let target = config_dir.join("SKILL.md");
128        fs::write(&target, EMBEDDED_SKILL_MD)
129            .with_context(|| format!("could not write {}", output::clean_path(&target)))?;
130        output::clean_path(&target)
131    };
132
133    output::print_success(&format!("Bundled SKILL.md exported to `{skill_path}`"));
134
135    // Agents with an on-disk skill format get the file put where they read it, so the
136    // prompts below are only needed for the ones without one.
137    let agent_roots = crate::setup::agent_skill_roots();
138    match crate::setup::ensure_agent_skills() {
139        crate::setup::Outcome::Installed | crate::setup::Outcome::AlreadyPresent => {
140            for root in &agent_roots {
141                output::print_success(&format!(
142                    "Skill installed for your AI agent at `{}`",
143                    output::clean_path(root.join("SKILL.md"))
144                ));
145            }
146        }
147        crate::setup::Outcome::Skipped(_) => {
148            output::print_info(
149                "No AI agent skills directory was found — use the prompts below instead.",
150            );
151        }
152        crate::setup::Outcome::Failed(why) => {
153            output::print_warning(&format!(
154                "Could not install into the agent skills directory: {why}"
155            ));
156        }
157    }
158    println!();
159    output::print_header("🤖 AI Agent Onboarding Prompts (Copy & Paste to your AI Assistant)");
160    println!();
161    output::print_info("Prompt 1: Initial Workspace Discovery & Onboarding");
162    println!("```markdown");
163    println!(
164        "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."
165    );
166    println!("```");
167    println!();
168    output::print_info(
169        "Prompt 2: Universal Skill Import (Antigravity, Claude Code, Cursor, Windsurf, Copilot, OpenClaw)",
170    );
171    println!("```markdown");
172    println!(
173        "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."
174    );
175    println!("```");
176
177    Ok(())
178}
179
180/// Write the condensed rules into the current repository, in `editor`'s format.
181///
182/// Per-repository by design: these files are meant to be committed so the whole team's
183/// agents pick them up, which is exactly why nothing here is written unasked — this
184/// runs only when the user types the flag.
185fn write_agent_rules(editor: AgentEditor) -> Result<()> {
186    let cwd = std::env::current_dir().context("could not read the current directory")?;
187    if !crate::scanner::is_git_repo(&cwd) {
188        anyhow::bail!(
189            "`--agent` writes rules into a repository, and the current directory is not \
190             one. Run it from the repository root."
191        );
192    }
193
194    let (relative, style) = editor.target();
195    let target = cwd.join(relative);
196    let content = match style {
197        Style::OwnFile => EMBEDDED_RULES_MD.to_string(),
198        Style::CursorMdc => format!(
199            "---\ndescription: dev-prune (devp) — reclaiming disk space from idle \
200             repositories safely\nalwaysApply: false\n---\n\n{EMBEDDED_RULES_MD}"
201        ),
202        Style::MarkedBlock => {
203            let existing = fs::read_to_string(&target).unwrap_or_default();
204            upsert_marked_block(&existing)
205        }
206    };
207    write_rules_file(&target, &content)?;
208
209    output::print_success(&format!("Rules written: {}", output::clean_path(&target)));
210    if let Some(wiring) = editor.wiring() {
211        output::print_info(wiring);
212    }
213    output::print_info(
214        "Commit the file if the whole team's agents should have it; it is inert data \
215         and safe to share.",
216    );
217    Ok(())
218}
219
220/// Replace dev-prune's marked block in `existing`, or append one — leaving every
221/// byte outside the markers exactly as found.
222fn upsert_marked_block(existing: &str) -> String {
223    let block = format!(
224        "{}\n{EMBEDDED_RULES_MD}{}\n",
225        crate::constants::RULES_BLOCK_START,
226        crate::constants::RULES_BLOCK_END
227    );
228    match (
229        existing.find(crate::constants::RULES_BLOCK_START),
230        existing.find(crate::constants::RULES_BLOCK_END),
231    ) {
232        (Some(start), Some(end)) if end > start => {
233            let after = end + crate::constants::RULES_BLOCK_END.len();
234            // The trailing newline of the old block belongs to it.
235            let after = if existing[after..].starts_with('\n') {
236                after + 1
237            } else {
238                after
239            };
240            format!("{}{block}{}", &existing[..start], &existing[after..])
241        }
242        _ if existing.is_empty() => block,
243        _ => format!("{}\n\n{block}", existing.trim_end_matches('\n')),
244    }
245}
246
247fn write_rules_file(target: &std::path::Path, content: &str) -> Result<()> {
248    if let Some(parent) = target.parent() {
249        fs::create_dir_all(parent)
250            .with_context(|| format!("could not create {}", output::clean_path(parent)))?;
251    }
252    fs::write(target, content)
253        .with_context(|| format!("could not write {}", output::clean_path(target)))
254}
255
256#[cfg(test)]
257mod tests {
258    use super::*;
259    use clap::ValueEnum;
260
261    #[test]
262    fn every_editor_writes_to_its_own_file() {
263        // A copy-pasted path would make one editor silently overwrite another's rules,
264        // and nothing else in the program would notice.
265        let mut paths: Vec<&str> = AgentEditor::value_variants()
266            .iter()
267            .map(|e| e.target().0)
268            .collect();
269        let total = paths.len();
270        paths.sort_unstable();
271        paths.dedup();
272        assert_eq!(paths.len(), total, "two editors share a path");
273    }
274
275    #[test]
276    fn the_editor_that_has_to_be_told_to_read_its_file_says_so() {
277        // Rules an agent never loads are worse than no rules at all: the repository
278        // looks configured and nothing is. Aider is the only target whose file is not
279        // picked up by being there, so it is the only one that carries a note — and the
280        // note has to name the file, because that name is what goes in the config.
281        for editor in AgentEditor::value_variants() {
282            if let Some(note) = editor.wiring() {
283                let path = editor.target().0;
284                assert_eq!(path, crate::constants::AIDER_CONVENTIONS_FILE);
285                assert!(
286                    note.contains(path),
287                    "the note does not name the file: {note}"
288                );
289            }
290        }
291        assert!(
292            AgentEditor::Aider.wiring().is_some(),
293            "aider writes a file nothing reads until it is configured"
294        );
295    }
296
297    #[test]
298    fn a_shared_file_is_only_ever_edited_inside_the_markers() {
299        // The whole reason `MarkedBlock` exists: these files belong to the user, and a
300        // second run must not stack a second copy of the rules on top of the first.
301        let theirs = "# Our conventions\n\nUse tabs.\n";
302        let once = upsert_marked_block(theirs);
303        let twice = upsert_marked_block(&once);
304        assert_eq!(once, twice, "a second write duplicated the block");
305        assert!(once.starts_with(theirs));
306        assert_eq!(once.matches(crate::constants::RULES_BLOCK_START).count(), 1);
307    }
308}