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 fn label(self) -> &'static str {
113 match self {
114 AgentEditor::Cursor => "Cursor",
115 AgentEditor::Windsurf => "Windsurf",
116 AgentEditor::Antigravity => "Antigravity",
117 AgentEditor::Cline => "Cline",
118 AgentEditor::Roo => "Roo Code",
119 AgentEditor::Kilocode => "Kilo Code",
120 AgentEditor::Continue => "Continue",
121 AgentEditor::AmazonQ => "Amazon Q Developer",
122 AgentEditor::Kiro => "Kiro",
123 AgentEditor::Trae => "Trae",
124 AgentEditor::Junie => "JetBrains Junie",
125 AgentEditor::Gemini => "Gemini CLI",
126 AgentEditor::Zed => "Zed",
127 AgentEditor::Copilot => "GitHub Copilot",
128 AgentEditor::Aider => "Aider",
129 AgentEditor::AgentsMd => "AGENTS.md readers (Codex, Jules, Amp, OpenCode)",
130 }
131 }
132
133 fn traces(self) -> Traces {
146 const NONE: Traces = Traces {
147 home: &[],
148 repo: &[],
149 extension: None,
150 };
151 match self {
152 AgentEditor::Cursor => Traces {
153 home: &[".cursor"],
154 repo: &[".cursor"],
155 ..NONE
156 },
157 AgentEditor::Windsurf => Traces {
158 home: &[".codeium/windsurf", ".windsurf"],
159 repo: &[".windsurf"],
160 ..NONE
161 },
162 AgentEditor::Antigravity => Traces {
167 home: &[".antigravity-ide", ".gemini/antigravity"],
168 repo: &[".agent"],
169 ..NONE
170 },
171 AgentEditor::Cline => Traces {
172 repo: &[".clinerules"],
173 extension: Some("saoudrizwan.claude-dev"),
174 ..NONE
175 },
176 AgentEditor::Roo => Traces {
179 repo: &[".roo"],
180 extension: Some("rooveterinaryinc.roo"),
181 ..NONE
182 },
183 AgentEditor::Kilocode => Traces {
184 repo: &[".kilocode"],
185 extension: Some("kilocode.kilo-code"),
186 ..NONE
187 },
188 AgentEditor::Continue => Traces {
189 home: &[".continue"],
190 repo: &[".continue"],
191 extension: Some("continue.continue"),
192 },
193 AgentEditor::AmazonQ => Traces {
194 home: &[".aws/amazonq"],
195 repo: &[".amazonq"],
196 extension: Some("amazonwebservices.amazon-q-vscode"),
197 },
198 AgentEditor::Kiro => Traces {
199 home: &[".kiro"],
200 repo: &[".kiro"],
201 ..NONE
202 },
203 AgentEditor::Trae => Traces {
204 home: &[".trae"],
205 repo: &[".trae"],
206 ..NONE
207 },
208 AgentEditor::Junie => Traces {
209 repo: &[".junie"],
210 ..NONE
211 },
212 AgentEditor::Gemini => Traces {
213 home: &[".gemini/settings.json"],
214 repo: &["GEMINI.md"],
215 ..NONE
216 },
217 AgentEditor::Zed => Traces {
218 home: &[".config/zed", "AppData/Roaming/Zed"],
219 repo: &[".rules"],
220 ..NONE
221 },
222 AgentEditor::Copilot => Traces {
225 home: &[".copilot"],
226 repo: &[".github/copilot-instructions.md"],
227 extension: Some("github.copilot"),
228 },
229 AgentEditor::Aider => Traces {
230 home: &[".aider.conf.yml"],
231 repo: &[".aider.conf.yml"],
232 ..NONE
233 },
234 AgentEditor::AgentsMd => Traces {
235 home: &[".codex", ".config/opencode", ".opencode"],
236 repo: &["AGENTS.md"],
237 ..NONE
238 },
239 }
240 }
241
242 fn detected_under(self, home: &std::path::Path, repo: Option<&std::path::Path>) -> bool {
243 let traces = self.traces();
244 traces.home.iter().any(|t| home.join(t).exists())
245 || traces
246 .extension
247 .is_some_and(|prefix| extension_present(home, prefix))
248 || repo.is_some_and(|r| traces.repo.iter().any(|t| r.join(t).exists()))
249 }
250
251 fn rules_state(self, repo: &std::path::Path) -> RulesState {
255 let (relative, style) = self.target();
256 let Ok(existing) = fs::read_to_string(repo.join(relative)) else {
257 return RulesState::Missing;
258 };
259 match style {
260 Style::OwnFile | Style::CursorMdc => {
261 if existing.contains(EMBEDDED_RULES_MD) {
262 RulesState::Current
263 } else {
264 RulesState::Stale
265 }
266 }
267 Style::MarkedBlock => {
268 if !existing.contains(crate::constants::RULES_BLOCK_START) {
269 RulesState::Missing
270 } else if existing.contains(EMBEDDED_RULES_MD) {
271 RulesState::Current
272 } else {
273 RulesState::Stale
274 }
275 }
276 }
277 }
278
279 fn flag_value(self) -> String {
283 use clap::ValueEnum;
284 self.to_possible_value()
285 .expect("no skipped variants")
286 .get_name()
287 .to_string()
288 }
289}
290
291struct Traces {
293 home: &'static [&'static str],
295 repo: &'static [&'static str],
298 extension: Option<&'static str>,
301}
302
303enum RulesState {
305 Current,
306 Stale,
307 Missing,
308}
309
310const EXTENSION_ROOTS: &[&str] = &[
314 ".vscode/extensions",
315 ".vscode-insiders/extensions",
316 ".vscode-oss/extensions",
317 ".antigravity-ide/extensions",
318 ".cursor/extensions",
319 ".windsurf/extensions",
320 ".trae/extensions",
321 ".kiro/extensions",
322];
323
324fn extension_present(home: &std::path::Path, prefix: &str) -> bool {
331 EXTENSION_ROOTS.iter().any(|root| {
332 fs::read_dir(home.join(root)).is_ok_and(|entries| {
333 entries.flatten().any(|entry| {
334 entry
335 .file_name()
336 .to_string_lossy()
337 .to_ascii_lowercase()
338 .starts_with(prefix)
339 })
340 })
341 })
342}
343
344pub fn detect_editors(home: &std::path::Path, repo: Option<&std::path::Path>) -> Vec<AgentEditor> {
348 use clap::ValueEnum;
349 AgentEditor::value_variants()
350 .iter()
351 .copied()
352 .filter(|editor| editor.detected_under(home, repo))
353 .collect()
354}
355
356pub fn run(agent: Option<AgentEditor>, detected: bool) -> Result<()> {
361 if let Some(editor) = agent {
362 return write_agent_rules(editor);
363 }
364 if detected {
365 return write_detected_rules();
366 }
367 output::print_header("dev-prune AI Agent Skill Integration");
368
369 let skill_path = {
372 let config_dir =
373 Registry::config_dir().context("could not resolve the config directory")?;
374 fs::create_dir_all(&config_dir)
375 .with_context(|| format!("could not create {}", output::clean_path(&config_dir)))?;
376 let target = config_dir.join("SKILL.md");
377 fs::write(&target, EMBEDDED_SKILL_MD)
378 .with_context(|| format!("could not write {}", output::clean_path(&target)))?;
379 output::clean_path(&target)
380 };
381
382 output::print_success(&format!("Bundled SKILL.md exported to `{skill_path}`"));
383
384 let agent_roots = crate::setup::agent_skill_roots();
387 match crate::setup::ensure_agent_skills() {
388 crate::setup::Outcome::Installed | crate::setup::Outcome::AlreadyPresent => {
389 for root in &agent_roots {
390 output::print_success(&format!(
391 "Skill installed for your AI agent at `{}`",
392 output::clean_path(root.join("SKILL.md"))
393 ));
394 }
395 }
396 crate::setup::Outcome::Skipped(_) => {
397 output::print_info(
398 "No AI agent skills directory was found — use the prompts below instead.",
399 );
400 }
401 crate::setup::Outcome::Failed(why) => {
402 output::print_warning(&format!(
403 "Could not install into the agent skills directory: {why}"
404 ));
405 }
406 }
407 print_detection_report();
408 println!();
409 output::print_header("🤖 AI Agent Onboarding Prompts (Copy & Paste to your AI Assistant)");
410 println!();
411 output::print_info("Prompt 1: Initial Workspace Discovery & Onboarding");
412 println!("```markdown");
413 println!(
414 "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."
415 );
416 println!("```");
417 println!();
418 output::print_info(
419 "Prompt 2: Universal Skill Import (Antigravity, Claude Code, Cursor, Windsurf, Copilot, OpenClaw)",
420 );
421 println!("```markdown");
422 println!(
423 "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."
424 );
425 println!("```");
426
427 Ok(())
428}
429
430fn require_repo(cwd: &std::path::Path, flag: &str) -> Result<()> {
433 if !crate::scanner::is_git_repo(cwd) {
434 anyhow::bail!(
435 "`{flag}` writes rules into a repository, and the current directory is not \
436 one. Run it from the repository root."
437 );
438 }
439 Ok(())
440}
441
442fn write_agent_rules(editor: AgentEditor) -> Result<()> {
448 let cwd = std::env::current_dir().context("could not read the current directory")?;
449 require_repo(&cwd, "--agent")?;
450 write_rules_for(editor, &cwd)?;
451 print_commit_note();
452 Ok(())
453}
454
455fn write_detected_rules() -> Result<()> {
459 let cwd = std::env::current_dir().context("could not read the current directory")?;
460 require_repo(&cwd, "--detected")?;
461 let home = dirs::home_dir().context("could not resolve the home directory")?;
462 let editors = detect_editors(&home, Some(&cwd));
463 if editors.is_empty() {
464 output::print_info(
465 "No editor left a trace on this machine or in this repository — nothing to \
466 write. `devp skill --agent <editor>` writes rules for one by name; `devp \
467 skill --help` lists them.",
468 );
469 return Ok(());
470 }
471 for editor in editors {
472 write_rules_for(editor, &cwd)?;
473 }
474 print_commit_note();
475 Ok(())
476}
477
478fn write_rules_for(editor: AgentEditor, cwd: &std::path::Path) -> Result<()> {
479 let (relative, style) = editor.target();
480 let target = cwd.join(relative);
481 let content = match style {
482 Style::OwnFile => EMBEDDED_RULES_MD.to_string(),
483 Style::CursorMdc => format!(
484 "---\ndescription: dev-prune (devp) — reclaiming disk space from idle \
485 repositories safely\nalwaysApply: false\n---\n\n{EMBEDDED_RULES_MD}"
486 ),
487 Style::MarkedBlock => {
488 let existing = fs::read_to_string(&target).unwrap_or_default();
489 upsert_marked_block(&existing)
490 }
491 };
492 write_rules_file(&target, &content)?;
493
494 output::print_success(&format!("Rules written: {}", output::clean_path(&target)));
495 if let Some(wiring) = editor.wiring() {
496 output::print_info(wiring);
497 }
498 Ok(())
499}
500
501fn print_commit_note() {
502 output::print_info(
503 "Commit the file if the whole team's agents should have it; it is inert data \
504 and safe to share.",
505 );
506}
507
508fn print_detection_report() {
513 let Some(home) = dirs::home_dir() else {
516 return;
517 };
518 let repo = std::env::current_dir()
519 .ok()
520 .filter(|d| crate::scanner::is_git_repo(d));
521 let editors = detect_editors(&home, repo.as_deref());
522 if editors.is_empty() {
523 return;
524 }
525 println!();
526 output::print_header("Detected editors and agents");
527 let rows: Vec<(String, &'static str, String)> = editors
528 .iter()
529 .map(|editor| {
530 let state = match &repo {
531 Some(r) => match editor.rules_state(r) {
532 RulesState::Current => "rules current here",
533 RulesState::Stale => "rules stale here",
534 RulesState::Missing => "no rules here yet",
535 },
536 None => "",
537 };
538 (
539 editor.label().to_string(),
540 state,
541 format!("devp skill --agent {}", editor.flag_value()),
542 )
543 })
544 .collect();
545 let label_width = rows.iter().map(|(l, _, _)| l.len()).max().unwrap_or(0);
546 let state_width = rows.iter().map(|(_, s, _)| s.len()).max().unwrap_or(0);
547 for (label, state, command) in &rows {
548 println!(" {label:<label_width$} {state:<state_width$} {command}");
549 }
550 if repo.is_some() {
551 output::print_info(
552 "`devp skill --detected` writes rules for all of them into this repository \
553 in one pass.",
554 );
555 } else {
556 output::print_info(
557 "Run `devp skill --detected` from a repository root to write rules for all \
558 of them in one pass.",
559 );
560 }
561}
562
563fn upsert_marked_block(existing: &str) -> String {
566 let block = format!(
567 "{}\n{EMBEDDED_RULES_MD}{}\n",
568 crate::constants::RULES_BLOCK_START,
569 crate::constants::RULES_BLOCK_END
570 );
571 match (
572 existing.find(crate::constants::RULES_BLOCK_START),
573 existing.find(crate::constants::RULES_BLOCK_END),
574 ) {
575 (Some(start), Some(end)) if end > start => {
576 let after = end + crate::constants::RULES_BLOCK_END.len();
577 let after = if existing[after..].starts_with('\n') {
579 after + 1
580 } else {
581 after
582 };
583 format!("{}{block}{}", &existing[..start], &existing[after..])
584 }
585 _ if existing.is_empty() => block,
586 _ => format!("{}\n\n{block}", existing.trim_end_matches('\n')),
587 }
588}
589
590fn write_rules_file(target: &std::path::Path, content: &str) -> Result<()> {
591 if let Some(parent) = target.parent() {
592 fs::create_dir_all(parent)
593 .with_context(|| format!("could not create {}", output::clean_path(parent)))?;
594 }
595 fs::write(target, content)
596 .with_context(|| format!("could not write {}", output::clean_path(target)))
597}
598
599#[cfg(test)]
600mod tests {
601 use super::*;
602 use clap::ValueEnum;
603
604 #[test]
605 fn every_editor_writes_to_its_own_file() {
606 let mut paths: Vec<&str> = AgentEditor::value_variants()
609 .iter()
610 .map(|e| e.target().0)
611 .collect();
612 let total = paths.len();
613 paths.sort_unstable();
614 paths.dedup();
615 assert_eq!(paths.len(), total, "two editors share a path");
616 }
617
618 #[test]
619 fn the_editor_that_has_to_be_told_to_read_its_file_says_so() {
620 for editor in AgentEditor::value_variants() {
625 if let Some(note) = editor.wiring() {
626 let path = editor.target().0;
627 assert_eq!(path, crate::constants::AIDER_CONVENTIONS_FILE);
628 assert!(
629 note.contains(path),
630 "the note does not name the file: {note}"
631 );
632 }
633 }
634 assert!(
635 AgentEditor::Aider.wiring().is_some(),
636 "aider writes a file nothing reads until it is configured"
637 );
638 }
639
640 #[test]
641 fn a_shared_file_is_only_ever_edited_inside_the_markers() {
642 let theirs = "# Our conventions\n\nUse tabs.\n";
645 let once = upsert_marked_block(theirs);
646 let twice = upsert_marked_block(&once);
647 assert_eq!(once, twice, "a second write duplicated the block");
648 assert!(once.starts_with(theirs));
649 assert_eq!(once.matches(crate::constants::RULES_BLOCK_START).count(), 1);
650 }
651
652 #[test]
656 fn a_home_trace_is_enough() {
657 let home = tempfile::tempdir().unwrap();
658 std::fs::create_dir_all(home.path().join(".continue")).unwrap();
659 let found = detect_editors(home.path(), None);
660 assert!(
661 found.iter().any(|e| matches!(e, AgentEditor::Continue)),
662 "a `.continue` home directory did not detect Continue"
663 );
664 }
665
666 #[test]
667 fn a_bare_machine_detects_nothing() {
668 let home = tempfile::tempdir().unwrap();
669 assert!(detect_editors(home.path(), None).is_empty());
670 }
671
672 #[test]
673 fn an_extension_counts_only_by_publisher_prefix() {
674 let home = tempfile::tempdir().unwrap();
677 let ext = home.path().join(".vscode/extensions");
678 std::fs::create_dir_all(ext.join("nvidia.nsight-copilot-2026.1.21-win32-x64")).unwrap();
679 assert!(
680 !detect_editors(home.path(), None)
681 .iter()
682 .any(|e| matches!(e, AgentEditor::Copilot)),
683 "a third-party extension containing 'copilot' read as GitHub Copilot"
684 );
685
686 std::fs::create_dir_all(ext.join("github.copilot-1.350.0")).unwrap();
687 assert!(
688 detect_editors(home.path(), None)
689 .iter()
690 .any(|e| matches!(e, AgentEditor::Copilot)),
691 "the real github.copilot extension was not detected"
692 );
693 }
694
695 #[test]
696 fn repo_traces_apply_only_when_a_repository_is_given() {
697 let home = tempfile::tempdir().unwrap();
698 let repo = tempfile::tempdir().unwrap();
699 std::fs::write(repo.path().join("GEMINI.md"), "# rules\n").unwrap();
700 assert!(
701 detect_editors(home.path(), None).is_empty(),
702 "a repo trace was counted with no repository in play"
703 );
704 assert!(
705 detect_editors(home.path(), Some(repo.path()))
706 .iter()
707 .any(|e| matches!(e, AgentEditor::Gemini)),
708 "GEMINI.md in the repository did not detect the Gemini CLI"
709 );
710 }
711
712 #[test]
713 fn rules_state_tells_missing_from_stale_from_current() {
714 let repo = tempfile::tempdir().unwrap();
715 let editor = AgentEditor::Windsurf;
716 assert!(matches!(
717 editor.rules_state(repo.path()),
718 RulesState::Missing
719 ));
720
721 let target = repo.path().join(editor.target().0);
722 std::fs::create_dir_all(target.parent().unwrap()).unwrap();
723 std::fs::write(&target, "an older release's rules\n").unwrap();
724 assert!(matches!(editor.rules_state(repo.path()), RulesState::Stale));
725
726 std::fs::write(&target, EMBEDDED_RULES_MD).unwrap();
727 assert!(matches!(
728 editor.rules_state(repo.path()),
729 RulesState::Current
730 ));
731 }
732
733 #[test]
734 fn a_shared_file_without_the_markers_counts_as_missing() {
735 let repo = tempfile::tempdir().unwrap();
738 std::fs::write(repo.path().join("AGENTS.md"), "# Their agents file\n").unwrap();
739 assert!(matches!(
740 AgentEditor::AgentsMd.rules_state(repo.path()),
741 RulesState::Missing
742 ));
743 }
744}