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 Aider,
53 AgentsMd,
56}
57
58enum Style {
60 OwnFile,
62 CursorMdc,
64 MarkedBlock,
67}
68
69impl AgentEditor {
70 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 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
112pub 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 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 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
180fn 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
220fn 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 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 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 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 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}