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    /// `.github/copilot-instructions.md`, as a marked block
32    Copilot,
33    /// `AGENTS.md`, as a marked block — the cross-tool convention (Codex, Jules,
34    /// Amp, OpenCode, Antigravity and others read it)
35    AgentsMd,
36}
37
38/// Run `devp skill` to export SKILL.md and display AI Agent onboarding prompts, or
39/// `devp skill --agent <editor>` to write per-repository rules for one editor.
40pub fn run(agent: Option<AgentEditor>) -> Result<()> {
41    if let Some(editor) = agent {
42        return write_agent_rules(editor);
43    }
44    output::print_header("dev-prune AI Agent Skill Integration");
45
46    // The export is the command's one job — claiming success over a swallowed write
47    // error would leave the user pointing an agent at a file that is not there.
48    let skill_path = {
49        let config_dir =
50            Registry::config_dir().context("could not resolve the config directory")?;
51        fs::create_dir_all(&config_dir)
52            .with_context(|| format!("could not create {}", output::clean_path(&config_dir)))?;
53        let target = config_dir.join("SKILL.md");
54        fs::write(&target, EMBEDDED_SKILL_MD)
55            .with_context(|| format!("could not write {}", output::clean_path(&target)))?;
56        output::clean_path(&target)
57    };
58
59    output::print_success(&format!("Bundled SKILL.md exported to `{skill_path}`"));
60
61    // Agents with an on-disk skill format get the file put where they read it, so the
62    // prompts below are only needed for the ones without one.
63    let agent_roots = crate::setup::agent_skill_roots();
64    match crate::setup::ensure_agent_skills() {
65        crate::setup::Outcome::Installed | crate::setup::Outcome::AlreadyPresent => {
66            for root in &agent_roots {
67                output::print_success(&format!(
68                    "Skill installed for your AI agent at `{}`",
69                    output::clean_path(root.join("SKILL.md"))
70                ));
71            }
72        }
73        crate::setup::Outcome::Skipped(_) => {
74            output::print_info(
75                "No AI agent skills directory was found — use the prompts below instead.",
76            );
77        }
78        crate::setup::Outcome::Failed(why) => {
79            output::print_warning(&format!(
80                "Could not install into the agent skills directory: {why}"
81            ));
82        }
83    }
84    println!();
85    output::print_header("🤖 AI Agent Onboarding Prompts (Copy & Paste to your AI Assistant)");
86    println!();
87    output::print_info("Prompt 1: Initial Workspace Discovery & Onboarding");
88    println!("```markdown");
89    println!(
90        "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."
91    );
92    println!("```");
93    println!();
94    output::print_info(
95        "Prompt 2: Universal Skill Import (Antigravity, Claude Code, Cursor, Windsurf, Copilot, OpenClaw)",
96    );
97    println!("```markdown");
98    println!(
99        "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."
100    );
101    println!("```");
102
103    Ok(())
104}
105
106/// Write the condensed rules into the current repository, in `editor`'s format.
107///
108/// Per-repository by design: these files are meant to be committed so the whole team's
109/// agents pick them up, which is exactly why nothing here is written unasked — this
110/// runs only when the user types the flag.
111fn write_agent_rules(editor: AgentEditor) -> Result<()> {
112    let cwd = std::env::current_dir().context("could not read the current directory")?;
113    if !crate::scanner::is_git_repo(&cwd) {
114        anyhow::bail!(
115            "`--agent` writes rules into a repository, and the current directory is not \
116             one. Run it from the repository root."
117        );
118    }
119
120    let written = match editor {
121        AgentEditor::Cursor => {
122            let target = cwd.join(crate::constants::CURSOR_RULES_FILE);
123            let content = format!(
124                "---\ndescription: dev-prune (devp) — reclaiming disk space from idle \
125                 repositories safely\nalwaysApply: false\n---\n\n{EMBEDDED_RULES_MD}"
126            );
127            write_rules_file(&target, &content)?;
128            target
129        }
130        AgentEditor::Windsurf => {
131            let target = cwd.join(crate::constants::WINDSURF_RULES_FILE);
132            write_rules_file(&target, EMBEDDED_RULES_MD)?;
133            target
134        }
135        AgentEditor::Antigravity => {
136            let target = cwd.join(crate::constants::ANTIGRAVITY_RULES_FILE);
137            write_rules_file(&target, EMBEDDED_RULES_MD)?;
138            target
139        }
140        AgentEditor::Cline => {
141            let target = cwd.join(crate::constants::CLINE_RULES_FILE);
142            write_rules_file(&target, EMBEDDED_RULES_MD)?;
143            target
144        }
145        // These two formats are one shared file, so dev-prune owns a marked block
146        // inside it rather than the file: replace the block if a previous run left
147        // one, append it otherwise, and touch nothing outside the markers.
148        AgentEditor::Copilot => {
149            let target = cwd.join(crate::constants::COPILOT_INSTRUCTIONS_FILE);
150            let existing = fs::read_to_string(&target).unwrap_or_default();
151            write_rules_file(&target, &upsert_marked_block(&existing))?;
152            target
153        }
154        AgentEditor::AgentsMd => {
155            let target = cwd.join(crate::constants::AGENTS_MD_FILE);
156            let existing = fs::read_to_string(&target).unwrap_or_default();
157            write_rules_file(&target, &upsert_marked_block(&existing))?;
158            target
159        }
160    };
161
162    output::print_success(&format!("Rules written: {}", output::clean_path(&written)));
163    output::print_info(
164        "Commit the file if the whole team's agents should have it; it is inert data \
165         and safe to share.",
166    );
167    Ok(())
168}
169
170/// Replace dev-prune's marked block in `existing`, or append one — leaving every
171/// byte outside the markers exactly as found.
172fn upsert_marked_block(existing: &str) -> String {
173    let block = format!(
174        "{}\n{EMBEDDED_RULES_MD}{}\n",
175        crate::constants::RULES_BLOCK_START,
176        crate::constants::RULES_BLOCK_END
177    );
178    match (
179        existing.find(crate::constants::RULES_BLOCK_START),
180        existing.find(crate::constants::RULES_BLOCK_END),
181    ) {
182        (Some(start), Some(end)) if end > start => {
183            let after = end + crate::constants::RULES_BLOCK_END.len();
184            // The trailing newline of the old block belongs to it.
185            let after = if existing[after..].starts_with('\n') {
186                after + 1
187            } else {
188                after
189            };
190            format!("{}{block}{}", &existing[..start], &existing[after..])
191        }
192        _ if existing.is_empty() => block,
193        _ => format!("{}\n\n{block}", existing.trim_end_matches('\n')),
194    }
195}
196
197fn write_rules_file(target: &std::path::Path, content: &str) -> Result<()> {
198    if let Some(parent) = target.parent() {
199        fs::create_dir_all(parent)
200            .with_context(|| format!("could not create {}", output::clean_path(parent)))?;
201    }
202    fs::write(target, content)
203        .with_context(|| format!("could not write {}", output::clean_path(target)))
204}