use anyhow::{Context, Result};
use clap::ValueEnum;
use std::fs;
use std::path::{Path, PathBuf};
use crate::config::Registry;
use crate::output;
pub const EMBEDDED_SKILL_MD: &str = include_str!("../../.agents/skills/dev-prune/SKILL.md");
pub const EMBEDDED_RULES_MD: &str = include_str!("../../.agents/rules/dev-prune.rules.md");
#[derive(clap::ValueEnum, Clone, Copy, Debug)]
pub enum AgentEditor {
Cursor,
Windsurf,
Antigravity,
Cline,
Roo,
Kilocode,
Continue,
AmazonQ,
Kiro,
Trae,
Junie,
Gemini,
Zed,
Copilot,
Aider,
AgentsMd,
}
enum Style {
OwnFile,
CursorMdc,
MarkedBlock,
}
impl AgentEditor {
fn target(self) -> (&'static str, Style) {
use crate::constants as c;
match self {
AgentEditor::Cursor => (c::CURSOR_RULES_FILE, Style::CursorMdc),
AgentEditor::Windsurf => (c::WINDSURF_RULES_FILE, Style::OwnFile),
AgentEditor::Antigravity => (c::ANTIGRAVITY_RULES_FILE, Style::OwnFile),
AgentEditor::Cline => (c::CLINE_RULES_FILE, Style::OwnFile),
AgentEditor::Roo => (c::ROO_RULES_FILE, Style::OwnFile),
AgentEditor::Kilocode => (c::KILOCODE_RULES_FILE, Style::OwnFile),
AgentEditor::Continue => (c::CONTINUE_RULES_FILE, Style::OwnFile),
AgentEditor::AmazonQ => (c::AMAZON_Q_RULES_FILE, Style::OwnFile),
AgentEditor::Kiro => (c::KIRO_STEERING_FILE, Style::OwnFile),
AgentEditor::Trae => (c::TRAE_RULES_FILE, Style::OwnFile),
AgentEditor::Junie => (c::JUNIE_GUIDELINES_FILE, Style::MarkedBlock),
AgentEditor::Gemini => (c::GEMINI_MD_FILE, Style::MarkedBlock),
AgentEditor::Zed => (c::ZED_RULES_FILE, Style::MarkedBlock),
AgentEditor::Copilot => (c::COPILOT_INSTRUCTIONS_FILE, Style::MarkedBlock),
AgentEditor::Aider => (c::AIDER_CONVENTIONS_FILE, Style::MarkedBlock),
AgentEditor::AgentsMd => (c::AGENTS_MD_FILE, Style::MarkedBlock),
}
}
fn wiring(self) -> Option<&'static str> {
match self {
AgentEditor::Aider => Some(
"Aider does not read this file on its own. Add `read: CONVENTIONS.md` \
to `.aider.conf.yml`, or start it with `aider --read CONVENTIONS.md`.",
),
_ => None,
}
}
fn label(self) -> &'static str {
match self {
AgentEditor::Cursor => "Cursor",
AgentEditor::Windsurf => "Windsurf",
AgentEditor::Antigravity => "Antigravity",
AgentEditor::Cline => "Cline",
AgentEditor::Roo => "Roo Code",
AgentEditor::Kilocode => "Kilo Code",
AgentEditor::Continue => "Continue",
AgentEditor::AmazonQ => "Amazon Q Developer",
AgentEditor::Kiro => "Kiro",
AgentEditor::Trae => "Trae",
AgentEditor::Junie => "JetBrains Junie",
AgentEditor::Gemini => "Gemini CLI",
AgentEditor::Zed => "Zed",
AgentEditor::Copilot => "GitHub Copilot",
AgentEditor::Aider => "Aider",
AgentEditor::AgentsMd => "AGENTS.md readers (Codex, Jules, Amp, OpenCode)",
}
}
fn traces(self) -> Traces {
const NONE: Traces = Traces {
home: &[],
repo: &[],
extension: None,
};
match self {
AgentEditor::Cursor => Traces {
home: &[".cursor"],
repo: &[".cursor"],
..NONE
},
AgentEditor::Windsurf => Traces {
home: &[".codeium/windsurf", ".windsurf"],
repo: &[".windsurf"],
..NONE
},
AgentEditor::Antigravity => Traces {
home: &[".antigravity-ide", ".gemini/antigravity"],
repo: &[".agent"],
..NONE
},
AgentEditor::Cline => Traces {
repo: &[".clinerules"],
extension: Some("saoudrizwan.claude-dev"),
..NONE
},
AgentEditor::Roo => Traces {
repo: &[".roo"],
extension: Some("rooveterinaryinc.roo"),
..NONE
},
AgentEditor::Kilocode => Traces {
repo: &[".kilocode"],
extension: Some("kilocode.kilo-code"),
..NONE
},
AgentEditor::Continue => Traces {
home: &[".continue"],
repo: &[".continue"],
extension: Some("continue.continue"),
},
AgentEditor::AmazonQ => Traces {
home: &[".aws/amazonq"],
repo: &[".amazonq"],
extension: Some("amazonwebservices.amazon-q-vscode"),
},
AgentEditor::Kiro => Traces {
home: &[".kiro"],
repo: &[".kiro"],
..NONE
},
AgentEditor::Trae => Traces {
home: &[".trae"],
repo: &[".trae"],
..NONE
},
AgentEditor::Junie => Traces {
repo: &[".junie"],
..NONE
},
AgentEditor::Gemini => Traces {
home: &[".gemini/settings.json"],
repo: &["GEMINI.md"],
..NONE
},
AgentEditor::Zed => Traces {
home: &[".config/zed", "AppData/Roaming/Zed"],
repo: &[".rules"],
..NONE
},
AgentEditor::Copilot => Traces {
home: &[".copilot"],
repo: &[".github/copilot-instructions.md"],
extension: Some("github.copilot"),
},
AgentEditor::Aider => Traces {
home: &[".aider.conf.yml"],
repo: &[".aider.conf.yml"],
..NONE
},
AgentEditor::AgentsMd => Traces {
home: &[".codex", ".config/opencode", ".opencode"],
repo: &["AGENTS.md"],
..NONE
},
}
}
fn detected_under(self, home: &std::path::Path, repo: Option<&std::path::Path>) -> bool {
let traces = self.traces();
traces.home.iter().any(|t| home.join(t).exists())
|| traces
.extension
.is_some_and(|prefix| extension_present(home, prefix))
|| repo.is_some_and(|r| traces.repo.iter().any(|t| r.join(t).exists()))
}
fn rules_state(self, repo: &std::path::Path) -> RulesState {
let (relative, style) = self.target();
let Ok(existing) = fs::read_to_string(repo.join(relative)) else {
return RulesState::Missing;
};
match style {
Style::OwnFile | Style::CursorMdc => {
if existing.contains(EMBEDDED_RULES_MD) {
RulesState::Current
} else {
RulesState::Stale
}
}
Style::MarkedBlock => {
if !existing.contains(crate::constants::RULES_BLOCK_START) {
RulesState::Missing
} else if existing.contains(EMBEDDED_RULES_MD) {
RulesState::Current
} else {
RulesState::Stale
}
}
}
}
fn flag_value(self) -> String {
use clap::ValueEnum;
self.to_possible_value()
.expect("no skipped variants")
.get_name()
.to_string()
}
}
#[derive(clap::ValueEnum, Clone, Copy, Debug)]
pub enum PromptKind {
Workspace,
Agent,
}
impl PromptKind {
fn flag_value(self) -> String {
self.to_possible_value()
.expect("PromptKind has no skipped variants")
.get_name()
.to_string()
}
}
struct Traces {
home: &'static [&'static str],
repo: &'static [&'static str],
extension: Option<&'static str>,
}
enum RulesState {
Current,
Stale,
Missing,
}
const EXTENSION_ROOTS: &[&str] = &[
".vscode/extensions",
".vscode-insiders/extensions",
".vscode-oss/extensions",
".antigravity-ide/extensions",
".cursor/extensions",
".windsurf/extensions",
".trae/extensions",
".kiro/extensions",
];
fn extension_present(home: &std::path::Path, prefix: &str) -> bool {
EXTENSION_ROOTS.iter().any(|root| {
fs::read_dir(home.join(root)).is_ok_and(|entries| {
entries.flatten().any(|entry| {
entry
.file_name()
.to_string_lossy()
.to_ascii_lowercase()
.starts_with(prefix)
})
})
})
}
pub fn detect_editors(home: &std::path::Path, repo: Option<&std::path::Path>) -> Vec<AgentEditor> {
use clap::ValueEnum;
AgentEditor::value_variants()
.iter()
.copied()
.filter(|editor| editor.detected_under(home, repo))
.collect()
}
fn workspace_prompt(skill_path: &str) -> String {
format!(
"Read the dev-prune AI skill at file://{skill_path}. Then run \
`devp init --auto --dry-run`, tell me what it found, and run `devp init --auto` \
once I confirm it, to register every Git repository in my workspace."
)
}
fn agent_prompt(skill_path: &str) -> String {
format!(
"I have installed `dev-prune` on my machine. Read the skill at file://{skill_path}. \
If you are Claude Code, it is already installed globally, from the export above. \
Any other coding agent should instead run `devp skill --agent <name>` in each \
repository — `devp skill --help` lists every `<name>`, `agents-md` covers anything \
that reads the shared `AGENTS.md` convention, and `devp skill --rules-file <path>` \
covers anything else, writing the same rules into whatever file you name."
)
}
pub fn run(
agent: Option<AgentEditor>,
rules_file: Option<PathBuf>,
prompt: Option<PromptKind>,
copy: bool,
detected: bool,
) -> Result<()> {
if let Some(editor) = agent {
return write_agent_rules(editor);
}
if let Some(path) = rules_file {
return write_rules_to_path(&path);
}
if let Some(kind) = prompt {
let skill_path = output::clean_path(&skill_md_path()?);
let text = match kind {
PromptKind::Workspace => workspace_prompt(&skill_path),
PromptKind::Agent => agent_prompt(&skill_path),
};
if copy {
match copy_to_clipboard(&text) {
Ok(tool) => eprintln!("Copied to the clipboard, via {tool}."),
Err(()) => eprintln!(
"No clipboard tool found on PATH — pipe it yourself: `devp skill \
--prompt {} | <your clipboard command>`.",
kind.flag_value()
),
}
}
println!("{text}");
return Ok(());
}
if detected {
return write_detected_rules();
}
output::print_header("dev-prune AI Agent Skill Integration");
let skill_path = export_skill_md()?;
output::print_success(&format!("Bundled SKILL.md exported to `{skill_path}`"));
let agent_roots = crate::setup::agent_skill_roots();
match crate::setup::ensure_agent_skills() {
crate::setup::Outcome::Installed | crate::setup::Outcome::AlreadyPresent => {
for root in &agent_roots {
output::print_success(&format!(
"Skill installed for your AI agent at `{}`",
output::clean_path(root.join("SKILL.md"))
));
}
}
crate::setup::Outcome::Skipped(_) => {
output::print_info(
"No AI agent skills directory was found — use the prompts below instead.",
);
}
crate::setup::Outcome::Failed(why) => {
output::print_warning(&format!(
"Could not install into the agent skills directory: {why}"
));
}
}
print_detection_report();
println!();
output::print_header("🤖 AI Agent Onboarding Prompts (Copy & Paste to your AI Assistant)");
println!();
output::print_info("Prompt 1: Initial Workspace Discovery & Onboarding");
println!("```markdown");
println!("{}", workspace_prompt(&skill_path));
println!("```");
println!();
output::print_info("Prompt 2: Onboard Any Other Coding Agent");
println!("```markdown");
println!("{}", agent_prompt(&skill_path));
println!("```");
println!();
output::print_info(
"`devp skill --prompt workspace` or `--prompt agent` prints either one on its \
own; add `--copy` to send it straight to the clipboard.",
);
Ok(())
}
fn skill_md_path() -> Result<PathBuf> {
Ok(Registry::config_dir()
.context("could not resolve the config directory")?
.join("SKILL.md"))
}
fn export_skill_md() -> Result<String> {
let target = skill_md_path()?;
if let Some(parent) = target.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("could not create {}", output::clean_path(parent)))?;
}
fs::write(&target, EMBEDDED_SKILL_MD)
.with_context(|| format!("could not write {}", output::clean_path(&target)))?;
Ok(output::clean_path(&target))
}
fn require_repo(cwd: &std::path::Path, flag: &str) -> Result<()> {
if !crate::scanner::is_git_repo(cwd) {
anyhow::bail!(
"`{flag}` writes rules into a repository, and the current directory is not \
one. Run it from the repository root."
);
}
Ok(())
}
fn write_agent_rules(editor: AgentEditor) -> Result<()> {
let cwd = std::env::current_dir().context("could not read the current directory")?;
require_repo(&cwd, "--agent")?;
write_rules_for(editor, &cwd)?;
print_commit_note();
Ok(())
}
fn write_detected_rules() -> Result<()> {
let cwd = std::env::current_dir().context("could not read the current directory")?;
require_repo(&cwd, "--detected")?;
let home = dirs::home_dir().context("could not resolve the home directory")?;
let editors = detect_editors(&home, Some(&cwd));
if editors.is_empty() {
output::print_info(
"No editor left a trace on this machine or in this repository — nothing to \
write. `devp skill --agent <editor>` writes rules for one by name; `devp \
skill --help` lists them.",
);
return Ok(());
}
for editor in editors {
write_rules_for(editor, &cwd)?;
}
print_commit_note();
Ok(())
}
fn write_rules_for(editor: AgentEditor, cwd: &std::path::Path) -> Result<()> {
let (relative, style) = editor.target();
let target = cwd.join(relative);
let content = match style {
Style::OwnFile => EMBEDDED_RULES_MD.to_string(),
Style::CursorMdc => format!(
"---\ndescription: dev-prune (devp) — reclaiming disk space from idle \
repositories safely\nalwaysApply: false\n---\n\n{EMBEDDED_RULES_MD}"
),
Style::MarkedBlock => {
let existing = fs::read_to_string(&target).unwrap_or_default();
upsert_marked_block(&existing)
}
};
write_rules_file(&target, &content)?;
output::print_success(&format!("Rules written: {}", output::clean_path(&target)));
if let Some(wiring) = editor.wiring() {
output::print_info(wiring);
}
Ok(())
}
fn print_commit_note() {
output::print_info(
"Commit the file if the whole team's agents should have it; it is inert data \
and safe to share.",
);
}
fn print_detection_report() {
let Some(home) = dirs::home_dir() else {
return;
};
let repo = std::env::current_dir()
.ok()
.filter(|d| crate::scanner::is_git_repo(d));
let editors = detect_editors(&home, repo.as_deref());
if editors.is_empty() {
return;
}
println!();
output::print_header("Detected editors and agents");
let rows: Vec<(String, &'static str, String)> = editors
.iter()
.map(|editor| {
let state = match &repo {
Some(r) => match editor.rules_state(r) {
RulesState::Current => "rules current here",
RulesState::Stale => "rules stale here",
RulesState::Missing => "no rules here yet",
},
None => "",
};
(
editor.label().to_string(),
state,
format!("devp skill --agent {}", editor.flag_value()),
)
})
.collect();
let label_width = rows.iter().map(|(l, _, _)| l.len()).max().unwrap_or(0);
let state_width = rows.iter().map(|(_, s, _)| s.len()).max().unwrap_or(0);
for (label, state, command) in &rows {
println!(" {label:<label_width$} {state:<state_width$} {command}");
}
if repo.is_some() {
output::print_info(
"`devp skill --detected` writes rules for all of them into this repository \
in one pass.",
);
} else {
output::print_info(
"Run `devp skill --detected` from a repository root to write rules for all \
of them in one pass.",
);
}
}
fn write_rules_to_path(path: &Path) -> Result<()> {
if path.is_absolute()
|| path
.components()
.any(|c| c == std::path::Component::ParentDir)
{
anyhow::bail!(
"`--rules-file` takes a path inside the repository — `{}` would write \
outside it.",
path.display()
);
}
let cwd = std::env::current_dir().context("could not read the current directory")?;
if !crate::scanner::is_git_repo(&cwd) {
anyhow::bail!(
"`--rules-file` writes rules into a repository, and the current directory \
is not one. Run it from the repository root."
);
}
let target = cwd.join(path);
let existing = fs::read_to_string(&target).unwrap_or_default();
let content = upsert_marked_block(&existing);
write_rules_file(&target, &content)?;
output::print_success(&format!("Rules written: {}", output::clean_path(&target)));
output::print_info(
"Commit the file if the whole team's agents should have it; it is inert data \
and safe to share.",
);
Ok(())
}
fn upsert_marked_block(existing: &str) -> String {
let block = format!(
"{}\n{EMBEDDED_RULES_MD}{}\n",
crate::constants::RULES_BLOCK_START,
crate::constants::RULES_BLOCK_END
);
match (
existing.find(crate::constants::RULES_BLOCK_START),
existing.find(crate::constants::RULES_BLOCK_END),
) {
(Some(start), Some(end)) if end > start => {
let after = end + crate::constants::RULES_BLOCK_END.len();
let after = if existing[after..].starts_with('\n') {
after + 1
} else {
after
};
format!("{}{block}{}", &existing[..start], &existing[after..])
}
_ if existing.is_empty() => block,
_ => format!("{}\n\n{block}", existing.trim_end_matches('\n')),
}
}
fn write_rules_file(target: &Path, content: &str) -> Result<()> {
if let Some(parent) = target.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("could not create {}", output::clean_path(parent)))?;
}
fs::write(target, content)
.with_context(|| format!("could not write {}", output::clean_path(target)))
}
fn clipboard_candidates() -> Vec<(&'static str, &'static [&'static str])> {
use crate::constants as c;
match std::env::consts::OS {
"windows" => vec![(c::CLIPBOARD_COMMAND_WINDOWS, [].as_slice())],
"macos" => vec![(c::CLIPBOARD_COMMAND_MACOS, [].as_slice())],
_ => c::CLIPBOARD_COMMANDS_LINUX
.iter()
.copied()
.chain(std::iter::once((
c::CLIPBOARD_COMMAND_WINDOWS,
[].as_slice(),
)))
.collect(),
}
}
fn copy_to_clipboard(text: &str) -> Result<&'static str, ()> {
for (cmd, args) in clipboard_candidates() {
if try_clipboard_command(cmd, args, text) {
return Ok(cmd);
}
}
Err(())
}
fn try_clipboard_command(cmd: &str, args: &[&str], text: &str) -> bool {
use std::io::Write;
use std::process::{Command, Stdio};
let Ok(mut child) = Command::new(cmd)
.args(args)
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
else {
return false;
};
let Some(mut stdin) = child.stdin.take() else {
return false;
};
if stdin.write_all(text.as_bytes()).is_err() {
return false;
}
drop(stdin);
child.wait().map(|status| status.success()).unwrap_or(false)
}
#[cfg(test)]
mod tests {
use super::*;
use clap::ValueEnum;
#[test]
fn every_editor_writes_to_its_own_file() {
let mut paths: Vec<&str> = AgentEditor::value_variants()
.iter()
.map(|e| e.target().0)
.collect();
let total = paths.len();
paths.sort_unstable();
paths.dedup();
assert_eq!(paths.len(), total, "two editors share a path");
}
#[test]
fn the_editor_that_has_to_be_told_to_read_its_file_says_so() {
for editor in AgentEditor::value_variants() {
if let Some(note) = editor.wiring() {
let path = editor.target().0;
assert_eq!(path, crate::constants::AIDER_CONVENTIONS_FILE);
assert!(
note.contains(path),
"the note does not name the file: {note}"
);
}
}
assert!(
AgentEditor::Aider.wiring().is_some(),
"aider writes a file nothing reads until it is configured"
);
}
#[test]
fn a_shared_file_is_only_ever_edited_inside_the_markers() {
let theirs = "# Our conventions\n\nUse tabs.\n";
let once = upsert_marked_block(theirs);
let twice = upsert_marked_block(&once);
assert_eq!(once, twice, "a second write duplicated the block");
assert!(once.starts_with(theirs));
assert_eq!(once.matches(crate::constants::RULES_BLOCK_START).count(), 1);
}
#[test]
fn every_agent_value_is_named_in_the_ide_integration_doc() {
let doc = include_str!("../../docs/IDE_INTEGRATION.md");
for editor in AgentEditor::value_variants() {
let name = editor.to_possible_value().expect("no skipped variants");
let needle = format!("`{}`", name.get_name());
assert!(
doc.contains(&needle),
"docs/IDE_INTEGRATION.md's table does not mention `--agent {}`",
name.get_name()
);
}
}
#[test]
fn clipboard_has_a_command_to_try_on_every_platform() {
let candidates = clipboard_candidates();
assert!(!candidates.is_empty(), "no clipboard command to try");
assert!(candidates.iter().all(|(cmd, _)| !cmd.is_empty()));
}
#[test]
fn a_home_trace_is_enough() {
let home = tempfile::tempdir().unwrap();
std::fs::create_dir_all(home.path().join(".continue")).unwrap();
let found = detect_editors(home.path(), None);
assert!(
found.iter().any(|e| matches!(e, AgentEditor::Continue)),
"a `.continue` home directory did not detect Continue"
);
}
#[test]
fn a_bare_machine_detects_nothing() {
let home = tempfile::tempdir().unwrap();
assert!(detect_editors(home.path(), None).is_empty());
}
#[test]
fn an_extension_counts_only_by_publisher_prefix() {
let home = tempfile::tempdir().unwrap();
let ext = home.path().join(".vscode/extensions");
std::fs::create_dir_all(ext.join("nvidia.nsight-copilot-2026.1.21-win32-x64")).unwrap();
assert!(
!detect_editors(home.path(), None)
.iter()
.any(|e| matches!(e, AgentEditor::Copilot)),
"a third-party extension containing 'copilot' read as GitHub Copilot"
);
std::fs::create_dir_all(ext.join("github.copilot-1.350.0")).unwrap();
assert!(
detect_editors(home.path(), None)
.iter()
.any(|e| matches!(e, AgentEditor::Copilot)),
"the real github.copilot extension was not detected"
);
}
#[test]
fn repo_traces_apply_only_when_a_repository_is_given() {
let home = tempfile::tempdir().unwrap();
let repo = tempfile::tempdir().unwrap();
std::fs::write(repo.path().join("GEMINI.md"), "# rules\n").unwrap();
assert!(
detect_editors(home.path(), None).is_empty(),
"a repo trace was counted with no repository in play"
);
assert!(
detect_editors(home.path(), Some(repo.path()))
.iter()
.any(|e| matches!(e, AgentEditor::Gemini)),
"GEMINI.md in the repository did not detect the Gemini CLI"
);
}
#[test]
fn rules_state_tells_missing_from_stale_from_current() {
let repo = tempfile::tempdir().unwrap();
let editor = AgentEditor::Windsurf;
assert!(matches!(
editor.rules_state(repo.path()),
RulesState::Missing
));
let target = repo.path().join(editor.target().0);
std::fs::create_dir_all(target.parent().unwrap()).unwrap();
std::fs::write(&target, "an older release's rules\n").unwrap();
assert!(matches!(editor.rules_state(repo.path()), RulesState::Stale));
std::fs::write(&target, EMBEDDED_RULES_MD).unwrap();
assert!(matches!(
editor.rules_state(repo.path()),
RulesState::Current
));
}
#[test]
fn a_shared_file_without_the_markers_counts_as_missing() {
let repo = tempfile::tempdir().unwrap();
std::fs::write(repo.path().join("AGENTS.md"), "# Their agents file\n").unwrap();
assert!(matches!(
AgentEditor::AgentsMd.rules_state(repo.path()),
RulesState::Missing
));
}
}