1use 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
13pub const EMBEDDED_RULES_MD: &str = include_str!("../../.agents/rules/dev-prune.rules.md");
16
17#[derive(clap::ValueEnum, Clone, Copy, Debug)]
22pub enum AgentEditor {
23 Cursor,
25 Windsurf,
27 Antigravity,
29 Cline,
31 Roo,
33 Kilocode,
35 Continue,
37 AmazonQ,
39 Kiro,
41 Trae,
43 Junie,
45 Gemini,
47 Zed,
49 Copilot,
51 AgentsMd,
54}
55
56enum Style {
58 OwnFile,
60 CursorMdc,
62 MarkedBlock,
65}
66
67impl AgentEditor {
68 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
96pub 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 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 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
164fn 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
201fn 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 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 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 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}