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 clap::ValueEnum;
7use std::fs;
8use std::path::{Path, PathBuf};
9
10use crate::config::Registry;
11use crate::output;
12
13pub const EMBEDDED_SKILL_MD: &str = include_str!("../../.agents/skills/dev-prune/SKILL.md");
14
15/// The condensed rules `--agent` writes: what the tool is, the non-negotiables, and a
16/// pointer at the full SKILL.md — short enough that an editor loads it on every turn.
17pub const EMBEDDED_RULES_MD: &str = include_str!("../../.agents/rules/dev-prune.rules.md");
18
19/// Editors whose agents read per-repository rule files.
20///
21/// Claude Code is deliberately absent: its skill installs globally (`devp skill`,
22/// `devp setup`), so there is nothing to write into individual repositories.
23///
24/// This list is frozen by design: a coding agent it does not name yet, one that has
25/// renamed its convention, or one that has been discontinued is not a reason to keep
26/// adding and removing variants here forever. `--rules-file <path>` writes the same
27/// rules into any path the user names, so nothing is blocked on this enum changing.
28#[derive(clap::ValueEnum, Clone, Copy, Debug)]
29pub enum AgentEditor {
30    /// `.cursor/rules/dev-prune.mdc`
31    Cursor,
32    /// `.windsurf/rules/dev-prune.md`
33    Windsurf,
34    /// `.agent/rules/dev-prune.md` (Antigravity)
35    Antigravity,
36    /// `.clinerules/dev-prune.md`
37    Cline,
38    /// `.roo/rules/dev-prune.md` (Roo Code)
39    Roo,
40    /// `.kilocode/rules/dev-prune.md` (Kilo Code)
41    Kilocode,
42    /// `.continue/rules/dev-prune.md` (Continue)
43    Continue,
44    /// `.amazonq/rules/dev-prune.md` (Amazon Q Developer)
45    AmazonQ,
46    /// `.kiro/steering/dev-prune.md` (Kiro)
47    Kiro,
48    /// `.trae/rules/dev-prune.md` (Trae)
49    Trae,
50    /// `.junie/guidelines.md`, as a marked block (JetBrains Junie)
51    Junie,
52    /// `GEMINI.md`, as a marked block (Gemini CLI)
53    Gemini,
54    /// `.rules`, as a marked block (Zed — read ahead of every other convention)
55    Zed,
56    /// `.github/copilot-instructions.md`, as a marked block
57    Copilot,
58    /// `CONVENTIONS.md`, as a marked block (Aider — which has to be told to read it)
59    Aider,
60    /// `AGENTS.md`, as a marked block — the cross-tool convention (Codex, Jules,
61    /// Amp, OpenCode, Antigravity and others read it)
62    AgentsMd,
63}
64
65/// How the rules go into the file.
66enum Style {
67    /// dev-prune owns the whole file.
68    OwnFile,
69    /// Cursor's `.mdc` format, which needs frontmatter above the rules.
70    CursorMdc,
71    /// The file belongs to somebody else, so dev-prune owns a marked block inside it
72    /// and leaves every byte outside the markers exactly as found.
73    MarkedBlock,
74}
75
76impl AgentEditor {
77    /// The repository-relative file this editor's agent actually reads, and how to
78    /// write into it.
79    ///
80    /// One table rather than one match arm each: an editor whose agent reads a
81    /// directory of rule files is the same three lines every time, and the only thing
82    /// a contributor should have to establish is the path.
83    fn target(self) -> (&'static str, Style) {
84        use crate::constants as c;
85        match self {
86            AgentEditor::Cursor => (c::CURSOR_RULES_FILE, Style::CursorMdc),
87            AgentEditor::Windsurf => (c::WINDSURF_RULES_FILE, Style::OwnFile),
88            AgentEditor::Antigravity => (c::ANTIGRAVITY_RULES_FILE, Style::OwnFile),
89            AgentEditor::Cline => (c::CLINE_RULES_FILE, Style::OwnFile),
90            AgentEditor::Roo => (c::ROO_RULES_FILE, Style::OwnFile),
91            AgentEditor::Kilocode => (c::KILOCODE_RULES_FILE, Style::OwnFile),
92            AgentEditor::Continue => (c::CONTINUE_RULES_FILE, Style::OwnFile),
93            AgentEditor::AmazonQ => (c::AMAZON_Q_RULES_FILE, Style::OwnFile),
94            AgentEditor::Kiro => (c::KIRO_STEERING_FILE, Style::OwnFile),
95            AgentEditor::Trae => (c::TRAE_RULES_FILE, Style::OwnFile),
96            AgentEditor::Junie => (c::JUNIE_GUIDELINES_FILE, Style::MarkedBlock),
97            AgentEditor::Gemini => (c::GEMINI_MD_FILE, Style::MarkedBlock),
98            AgentEditor::Zed => (c::ZED_RULES_FILE, Style::MarkedBlock),
99            AgentEditor::Copilot => (c::COPILOT_INSTRUCTIONS_FILE, Style::MarkedBlock),
100            AgentEditor::Aider => (c::AIDER_CONVENTIONS_FILE, Style::MarkedBlock),
101            AgentEditor::AgentsMd => (c::AGENTS_MD_FILE, Style::MarkedBlock),
102        }
103    }
104
105    /// What the user still has to do, for the one editor that does not read its
106    /// file unprompted. Aider loads `CONVENTIONS.md` only when told to, so writing
107    /// the file and saying nothing would leave rules an agent never sees.
108    fn wiring(self) -> Option<&'static str> {
109        match self {
110            AgentEditor::Aider => Some(
111                "Aider does not read this file on its own. Add `read: CONVENTIONS.md` \
112                 to `.aider.conf.yml`, or start it with `aider --read CONVENTIONS.md`.",
113            ),
114            _ => None,
115        }
116    }
117
118    /// The editor's name as a person knows it, for the detection report.
119    fn label(self) -> &'static str {
120        match self {
121            AgentEditor::Cursor => "Cursor",
122            AgentEditor::Windsurf => "Windsurf",
123            AgentEditor::Antigravity => "Antigravity",
124            AgentEditor::Cline => "Cline",
125            AgentEditor::Roo => "Roo Code",
126            AgentEditor::Kilocode => "Kilo Code",
127            AgentEditor::Continue => "Continue",
128            AgentEditor::AmazonQ => "Amazon Q Developer",
129            AgentEditor::Kiro => "Kiro",
130            AgentEditor::Trae => "Trae",
131            AgentEditor::Junie => "JetBrains Junie",
132            AgentEditor::Gemini => "Gemini CLI",
133            AgentEditor::Zed => "Zed",
134            AgentEditor::Copilot => "GitHub Copilot",
135            AgentEditor::Aider => "Aider",
136            AgentEditor::AgentsMd => "AGENTS.md readers (Codex, Jules, Amp, OpenCode)",
137        }
138    }
139
140    /// The on-disk traces that mark this editor as present, for the detection
141    /// report and `--detected`.
142    ///
143    /// Presence on disk, never process probing: every editor here leaves a
144    /// well-known directory or file behind once it has run, so an `exists()`
145    /// check finds it without spawning anything — which is also what lets the
146    /// tests fabricate a whole machine out of a temp directory. The strings live
147    /// inline rather than in `constants.rs` for the same reason as
148    /// `detect_vscode_editors`'s candidate list: each one means something only as
149    /// this table's row. A row may be empty on every axis — the editor is then
150    /// reachable by name but never claimed as detected — so a contributor adding
151    /// a target owes this table nothing.
152    fn traces(self) -> Traces {
153        const NONE: Traces = Traces {
154            home: &[],
155            repo: &[],
156            extension: None,
157        };
158        match self {
159            AgentEditor::Cursor => Traces {
160                home: &[".cursor"],
161                repo: &[".cursor"],
162                ..NONE
163            },
164            AgentEditor::Windsurf => Traces {
165                home: &[".codeium/windsurf", ".windsurf"],
166                repo: &[".windsurf"],
167                ..NONE
168            },
169            // `.gemini` alone is not evidence of the Gemini CLI: Antigravity keeps
170            // its own state under `.gemini/antigravity*` on a machine that has
171            // never run the CLI, which is why Antigravity claims that subtree and
172            // Gemini claims only the CLI's own settings file.
173            AgentEditor::Antigravity => Traces {
174                home: &[".antigravity-ide", ".gemini/antigravity"],
175                repo: &[".agent"],
176                ..NONE
177            },
178            AgentEditor::Cline => Traces {
179                repo: &[".clinerules"],
180                extension: Some("saoudrizwan.claude-dev"),
181                ..NONE
182            },
183            // Roo Code kept its original extension ID when it renamed, so the
184            // prefix stops before the `-cline` suffix to survive another rename.
185            AgentEditor::Roo => Traces {
186                repo: &[".roo"],
187                extension: Some("rooveterinaryinc.roo"),
188                ..NONE
189            },
190            AgentEditor::Kilocode => Traces {
191                repo: &[".kilocode"],
192                extension: Some("kilocode.kilo-code"),
193                ..NONE
194            },
195            AgentEditor::Continue => Traces {
196                home: &[".continue"],
197                repo: &[".continue"],
198                extension: Some("continue.continue"),
199            },
200            AgentEditor::AmazonQ => Traces {
201                home: &[".aws/amazonq"],
202                repo: &[".amazonq"],
203                extension: Some("amazonwebservices.amazon-q-vscode"),
204            },
205            AgentEditor::Kiro => Traces {
206                home: &[".kiro"],
207                repo: &[".kiro"],
208                ..NONE
209            },
210            AgentEditor::Trae => Traces {
211                home: &[".trae"],
212                repo: &[".trae"],
213                ..NONE
214            },
215            AgentEditor::Junie => Traces {
216                repo: &[".junie"],
217                ..NONE
218            },
219            AgentEditor::Gemini => Traces {
220                home: &[".gemini/settings.json"],
221                repo: &["GEMINI.md"],
222                ..NONE
223            },
224            AgentEditor::Zed => Traces {
225                home: &[".config/zed", "AppData/Roaming/Zed"],
226                repo: &[".rules"],
227                ..NONE
228            },
229            // `.copilot` is the Copilot CLI's home directory; the extension covers
230            // the editor-hosted install.
231            AgentEditor::Copilot => Traces {
232                home: &[".copilot"],
233                repo: &[".github/copilot-instructions.md"],
234                extension: Some("github.copilot"),
235            },
236            AgentEditor::Aider => Traces {
237                home: &[".aider.conf.yml"],
238                repo: &[".aider.conf.yml"],
239                ..NONE
240            },
241            AgentEditor::AgentsMd => Traces {
242                home: &[".codex", ".config/opencode", ".opencode"],
243                repo: &["AGENTS.md"],
244                ..NONE
245            },
246        }
247    }
248
249    fn detected_under(self, home: &std::path::Path, repo: Option<&std::path::Path>) -> bool {
250        let traces = self.traces();
251        traces.home.iter().any(|t| home.join(t).exists())
252            || traces
253                .extension
254                .is_some_and(|prefix| extension_present(home, prefix))
255            || repo.is_some_and(|r| traces.repo.iter().any(|t| r.join(t).exists()))
256    }
257
258    /// Whether `repo` already carries this editor's rules, and whether they match
259    /// the ones this binary would write — the same current-or-stale distinction
260    /// `stale_skill_copies` draws for the global SKILL.md installs.
261    fn rules_state(self, repo: &std::path::Path) -> RulesState {
262        let (relative, style) = self.target();
263        let Ok(existing) = fs::read_to_string(repo.join(relative)) else {
264            return RulesState::Missing;
265        };
266        match style {
267            Style::OwnFile | Style::CursorMdc => {
268                if existing.contains(EMBEDDED_RULES_MD) {
269                    RulesState::Current
270                } else {
271                    RulesState::Stale
272                }
273            }
274            Style::MarkedBlock => {
275                if !existing.contains(crate::constants::RULES_BLOCK_START) {
276                    RulesState::Missing
277                } else if existing.contains(EMBEDDED_RULES_MD) {
278                    RulesState::Current
279                } else {
280                    RulesState::Stale
281                }
282            }
283        }
284    }
285
286    /// The value `--agent` takes for this editor, exactly as clap will parse it —
287    /// derived rather than restated, so the report cannot suggest a spelling the
288    /// parser refuses.
289    fn flag_value(self) -> String {
290        use clap::ValueEnum;
291        self.to_possible_value()
292            .expect("no skipped variants")
293            .get_name()
294            .to_string()
295    }
296}
297
298/// Which onboarding prompt `--prompt` prints on its own, with no header or fence — so
299/// it is pipeable straight into a clipboard tool or a file.
300#[derive(clap::ValueEnum, Clone, Copy, Debug)]
301pub enum PromptKind {
302    /// Onboard Claude Code: read the skill, then scan and register the workspace.
303    Workspace,
304    /// Onboard any other coding agent, via `--agent` or `--rules-file`.
305    Agent,
306}
307
308impl PromptKind {
309    /// The exact value `--prompt` takes for this kind, so a hint that names the flag
310    /// cannot drift from what clap actually accepts.
311    fn flag_value(self) -> String {
312        self.to_possible_value()
313            .expect("PromptKind has no skipped variants")
314            .get_name()
315            .to_string()
316    }
317}
318
319/// See [`AgentEditor::traces`].
320struct Traces {
321    /// Paths relative to the home directory; a file or a directory, either counts.
322    home: &'static [&'static str],
323    /// Paths relative to the repository root — evidence the *team* uses the editor
324    /// even when this machine has never run it.
325    repo: &'static [&'static str],
326    /// An extension ID prefix to look for under every VS Code-family editor's
327    /// extension directory.
328    extension: Option<&'static str>,
329}
330
331/// Whether a repository's rules file is the one this binary would write.
332enum RulesState {
333    Current,
334    Stale,
335    Missing,
336}
337
338/// Where every VS Code-family editor unpacks its installed extensions, relative to
339/// the home directory. A miss costs one failed directory read, so listing a fork
340/// nobody has is free — the same reasoning as `detect_vscode_editors`.
341const EXTENSION_ROOTS: &[&str] = &[
342    ".vscode/extensions",
343    ".vscode-insiders/extensions",
344    ".vscode-oss/extensions",
345    ".antigravity-ide/extensions",
346    ".cursor/extensions",
347    ".windsurf/extensions",
348    ".trae/extensions",
349    ".kiro/extensions",
350];
351
352/// Whether any installed extension's directory name starts with `prefix`.
353///
354/// Anchored at the publisher, not a substring match: `nvidia.nsight-copilot` must
355/// not read as GitHub Copilot. Extension directories are named
356/// `<publisher>.<name>-<version>`, lowercased by the editor, so the prefix is
357/// compared against the lowercased name.
358fn extension_present(home: &std::path::Path, prefix: &str) -> bool {
359    EXTENSION_ROOTS.iter().any(|root| {
360        fs::read_dir(home.join(root)).is_ok_and(|entries| {
361            entries.flatten().any(|entry| {
362                entry
363                    .file_name()
364                    .to_string_lossy()
365                    .to_ascii_lowercase()
366                    .starts_with(prefix)
367            })
368        })
369    })
370}
371
372/// Every editor whose traces are on this machine — or, when `repo` is given, in
373/// that repository. Both roots come in as parameters so tests can hand in a
374/// fabricated machine instead of reading the real one.
375pub fn detect_editors(home: &std::path::Path, repo: Option<&std::path::Path>) -> Vec<AgentEditor> {
376    use clap::ValueEnum;
377    AgentEditor::value_variants()
378        .iter()
379        .copied()
380        .filter(|editor| editor.detected_under(home, repo))
381        .collect()
382}
383
384/// The prompt that onboards Claude Code itself: read the skill, then scan and
385/// register the workspace. Dry-run first — an agent that skips straight to `--auto`
386/// would register directories the user never meant to hand it.
387fn workspace_prompt(skill_path: &str) -> String {
388    format!(
389        "Read the dev-prune AI skill at file://{skill_path}. Then run \
390         `devp init --auto --dry-run`, tell me what it found, and run `devp init --auto` \
391         once I confirm it, to register every Git repository in my workspace."
392    )
393}
394
395/// The prompt that onboards any other coding agent. Deliberately does not enumerate
396/// which ones by name — that list is exactly what goes stale.
397fn agent_prompt(skill_path: &str) -> String {
398    format!(
399        "I have installed `dev-prune` on my machine. Read the skill at file://{skill_path}. \
400         If you are Claude Code, it is already installed globally, from the export above. \
401         Any other coding agent should instead run `devp skill --agent <name>` in each \
402         repository — `devp skill --help` lists every `<name>`, `agents-md` covers anything \
403         that reads the shared `AGENTS.md` convention, and `devp skill --rules-file <path>` \
404         covers anything else, writing the same rules into whatever file you name."
405    )
406}
407
408/// Run `devp skill` to export SKILL.md, report the detected editors and display AI
409/// Agent onboarding prompts; `devp skill --agent <editor>` writes per-repository
410/// rules for one editor; `devp skill --rules-file <path>` writes them into an exact
411/// path instead; `devp skill --prompt <kind> [--copy]` prints one prompt on its own;
412/// and `devp skill --detected` writes them for every editor the report would list.
413pub fn run(
414    agent: Option<AgentEditor>,
415    rules_file: Option<PathBuf>,
416    prompt: Option<PromptKind>,
417    copy: bool,
418    detected: bool,
419) -> Result<()> {
420    if let Some(editor) = agent {
421        return write_agent_rules(editor);
422    }
423    if let Some(path) = rules_file {
424        return write_rules_to_path(&path);
425    }
426    if let Some(kind) = prompt {
427        let skill_path = output::clean_path(&skill_md_path()?);
428        let text = match kind {
429            PromptKind::Workspace => workspace_prompt(&skill_path),
430            PromptKind::Agent => agent_prompt(&skill_path),
431        };
432        if copy {
433            match copy_to_clipboard(&text) {
434                Ok(tool) => eprintln!("Copied to the clipboard, via {tool}."),
435                Err(()) => eprintln!(
436                    "No clipboard tool found on PATH — pipe it yourself: `devp skill \
437                     --prompt {} | <your clipboard command>`.",
438                    kind.flag_value()
439                ),
440            }
441        }
442        println!("{text}");
443        return Ok(());
444    }
445    if detected {
446        return write_detected_rules();
447    }
448
449    output::print_header("dev-prune AI Agent Skill Integration");
450    let skill_path = export_skill_md()?;
451
452    output::print_success(&format!("Bundled SKILL.md exported to `{skill_path}`"));
453
454    // Agents with an on-disk skill format get the file put where they read it, so the
455    // prompts below are only needed for the ones without one.
456    let agent_roots = crate::setup::agent_skill_roots();
457    match crate::setup::ensure_agent_skills() {
458        crate::setup::Outcome::Installed | crate::setup::Outcome::AlreadyPresent => {
459            for root in &agent_roots {
460                output::print_success(&format!(
461                    "Skill installed for your AI agent at `{}`",
462                    output::clean_path(root.join("SKILL.md"))
463                ));
464            }
465        }
466        crate::setup::Outcome::Skipped(_) => {
467            output::print_info(
468                "No AI agent skills directory was found — use the prompts below instead.",
469            );
470        }
471        crate::setup::Outcome::Failed(why) => {
472            output::print_warning(&format!(
473                "Could not install into the agent skills directory: {why}"
474            ));
475        }
476    }
477    print_detection_report();
478    println!();
479    output::print_header("🤖 AI Agent Onboarding Prompts (Copy & Paste to your AI Assistant)");
480    println!();
481    output::print_info("Prompt 1: Initial Workspace Discovery & Onboarding");
482    println!("```markdown");
483    println!("{}", workspace_prompt(&skill_path));
484    println!("```");
485    println!();
486    output::print_info("Prompt 2: Onboard Any Other Coding Agent");
487    println!("```markdown");
488    println!("{}", agent_prompt(&skill_path));
489    println!("```");
490    println!();
491    output::print_info(
492        "`devp skill --prompt workspace` or `--prompt agent` prints either one on its \
493         own; add `--copy` to send it straight to the clipboard.",
494    );
495
496    Ok(())
497}
498
499/// Where SKILL.md lives, without writing it — `--prompt` needs the path but must not
500/// have side effects of its own.
501fn skill_md_path() -> Result<PathBuf> {
502    Ok(Registry::config_dir()
503        .context("could not resolve the config directory")?
504        .join("SKILL.md"))
505}
506
507/// Writes SKILL.md into the config directory and returns its cleaned display path.
508///
509/// The export is the command's one job — claiming success over a swallowed write
510/// error would leave the user pointing an agent at a file that is not there.
511fn export_skill_md() -> Result<String> {
512    let target = skill_md_path()?;
513    if let Some(parent) = target.parent() {
514        fs::create_dir_all(parent)
515            .with_context(|| format!("could not create {}", output::clean_path(parent)))?;
516    }
517    fs::write(&target, EMBEDDED_SKILL_MD)
518        .with_context(|| format!("could not write {}", output::clean_path(&target)))?;
519    Ok(output::clean_path(&target))
520}
521
522/// Both writing flags need a repository to write into; the message names the flag
523/// the user actually typed.
524fn require_repo(cwd: &std::path::Path, flag: &str) -> Result<()> {
525    if !crate::scanner::is_git_repo(cwd) {
526        anyhow::bail!(
527            "`{flag}` writes rules into a repository, and the current directory is not \
528             one. Run it from the repository root."
529        );
530    }
531    Ok(())
532}
533
534/// Write the condensed rules into the current repository, in `editor`'s format.
535///
536/// Per-repository by design: these files are meant to be committed so the whole team's
537/// agents pick them up, which is exactly why nothing here is written unasked — this
538/// runs only when the user types the flag.
539fn write_agent_rules(editor: AgentEditor) -> Result<()> {
540    let cwd = std::env::current_dir().context("could not read the current directory")?;
541    require_repo(&cwd, "--agent")?;
542    write_rules_for(editor, &cwd)?;
543    print_commit_note();
544    Ok(())
545}
546
547/// Write rules for every editor detected on this machine or in this repository —
548/// the ones plain `devp skill` reports. Same consent shape as `--agent`: the report
549/// only ever prints, and typing the flag is what authorises the writes.
550fn write_detected_rules() -> Result<()> {
551    let cwd = std::env::current_dir().context("could not read the current directory")?;
552    require_repo(&cwd, "--detected")?;
553    let home = dirs::home_dir().context("could not resolve the home directory")?;
554    let editors = detect_editors(&home, Some(&cwd));
555    if editors.is_empty() {
556        output::print_info(
557            "No editor left a trace on this machine or in this repository — nothing to \
558             write. `devp skill --agent <editor>` writes rules for one by name; `devp \
559             skill --help` lists them.",
560        );
561        return Ok(());
562    }
563    for editor in editors {
564        write_rules_for(editor, &cwd)?;
565    }
566    print_commit_note();
567    Ok(())
568}
569
570fn write_rules_for(editor: AgentEditor, cwd: &std::path::Path) -> Result<()> {
571    let (relative, style) = editor.target();
572    let target = cwd.join(relative);
573    let content = match style {
574        Style::OwnFile => EMBEDDED_RULES_MD.to_string(),
575        Style::CursorMdc => format!(
576            "---\ndescription: dev-prune (devp) — reclaiming disk space from idle \
577             repositories safely\nalwaysApply: false\n---\n\n{EMBEDDED_RULES_MD}"
578        ),
579        Style::MarkedBlock => {
580            let existing = fs::read_to_string(&target).unwrap_or_default();
581            upsert_marked_block(&existing)
582        }
583    };
584    write_rules_file(&target, &content)?;
585
586    output::print_success(&format!("Rules written: {}", output::clean_path(&target)));
587    if let Some(wiring) = editor.wiring() {
588        output::print_info(wiring);
589    }
590    Ok(())
591}
592
593fn print_commit_note() {
594    output::print_info(
595        "Commit the file if the whole team's agents should have it; it is inert data \
596         and safe to share.",
597    );
598}
599
600/// The read-only half of detection: which editors this machine or repository shows
601/// traces of, whether their rules are already written here, and the command that
602/// writes them. Printed by the bare `devp skill` run; `--detected` acts on the
603/// same list.
604fn print_detection_report() {
605    // No home directory means no baseline to detect against; the bare run has other
606    // jobs, so the report just stays silent rather than failing them.
607    let Some(home) = dirs::home_dir() else {
608        return;
609    };
610    let repo = std::env::current_dir()
611        .ok()
612        .filter(|d| crate::scanner::is_git_repo(d));
613    let editors = detect_editors(&home, repo.as_deref());
614    if editors.is_empty() {
615        return;
616    }
617    println!();
618    output::print_header("Detected editors and agents");
619    let rows: Vec<(String, &'static str, String)> = editors
620        .iter()
621        .map(|editor| {
622            let state = match &repo {
623                Some(r) => match editor.rules_state(r) {
624                    RulesState::Current => "rules current here",
625                    RulesState::Stale => "rules stale here",
626                    RulesState::Missing => "no rules here yet",
627                },
628                None => "",
629            };
630            (
631                editor.label().to_string(),
632                state,
633                format!("devp skill --agent {}", editor.flag_value()),
634            )
635        })
636        .collect();
637    let label_width = rows.iter().map(|(l, _, _)| l.len()).max().unwrap_or(0);
638    let state_width = rows.iter().map(|(_, s, _)| s.len()).max().unwrap_or(0);
639    for (label, state, command) in &rows {
640        println!("  {label:<label_width$}  {state:<state_width$}  {command}");
641    }
642    if repo.is_some() {
643        output::print_info(
644            "`devp skill --detected` writes rules for all of them into this repository \
645             in one pass.",
646        );
647    } else {
648        output::print_info(
649            "Run `devp skill --detected` from a repository root to write rules for all \
650             of them in one pass.",
651        );
652    }
653}
654
655/// Write the condensed rules into `path`, at an exact location rather than one of the
656/// editors [`AgentEditor`] already knows — the escape hatch for a coding agent this
657/// list does not name yet, one that renamed its convention, or one that has been
658/// discontinued, none of which should have to wait on a new dev-prune release.
659///
660/// Always a marked block: an arbitrary path is as likely to belong to the user as to
661/// dev-prune, so this makes the same safe assumption [`Style::MarkedBlock`] makes for
662/// every editor above that shares its file with other tools.
663fn write_rules_to_path(path: &Path) -> Result<()> {
664    if path.is_absolute()
665        || path
666            .components()
667            .any(|c| c == std::path::Component::ParentDir)
668    {
669        anyhow::bail!(
670            "`--rules-file` takes a path inside the repository — `{}` would write \
671             outside it.",
672            path.display()
673        );
674    }
675
676    let cwd = std::env::current_dir().context("could not read the current directory")?;
677    if !crate::scanner::is_git_repo(&cwd) {
678        anyhow::bail!(
679            "`--rules-file` writes rules into a repository, and the current directory \
680             is not one. Run it from the repository root."
681        );
682    }
683
684    let target = cwd.join(path);
685    let existing = fs::read_to_string(&target).unwrap_or_default();
686    let content = upsert_marked_block(&existing);
687    write_rules_file(&target, &content)?;
688
689    output::print_success(&format!("Rules written: {}", output::clean_path(&target)));
690    output::print_info(
691        "Commit the file if the whole team's agents should have it; it is inert data \
692         and safe to share.",
693    );
694    Ok(())
695}
696
697/// Replace dev-prune's marked block in `existing`, or append one — leaving every
698/// byte outside the markers exactly as found.
699fn upsert_marked_block(existing: &str) -> String {
700    let block = format!(
701        "{}\n{EMBEDDED_RULES_MD}{}\n",
702        crate::constants::RULES_BLOCK_START,
703        crate::constants::RULES_BLOCK_END
704    );
705    match (
706        existing.find(crate::constants::RULES_BLOCK_START),
707        existing.find(crate::constants::RULES_BLOCK_END),
708    ) {
709        (Some(start), Some(end)) if end > start => {
710            let after = end + crate::constants::RULES_BLOCK_END.len();
711            // The trailing newline of the old block belongs to it.
712            let after = if existing[after..].starts_with('\n') {
713                after + 1
714            } else {
715                after
716            };
717            format!("{}{block}{}", &existing[..start], &existing[after..])
718        }
719        _ if existing.is_empty() => block,
720        _ => format!("{}\n\n{block}", existing.trim_end_matches('\n')),
721    }
722}
723
724fn write_rules_file(target: &Path, content: &str) -> Result<()> {
725    if let Some(parent) = target.parent() {
726        fs::create_dir_all(parent)
727            .with_context(|| format!("could not create {}", output::clean_path(parent)))?;
728    }
729    fs::write(target, content)
730        .with_context(|| format!("could not write {}", output::clean_path(target)))
731}
732
733/// The clipboard commands to try, in order, on this platform.
734fn clipboard_candidates() -> Vec<(&'static str, &'static [&'static str])> {
735    use crate::constants as c;
736    match std::env::consts::OS {
737        "windows" => vec![(c::CLIPBOARD_COMMAND_WINDOWS, [].as_slice())],
738        "macos" => vec![(c::CLIPBOARD_COMMAND_MACOS, [].as_slice())],
739        // Anything else is assumed Unix-like; clip.exe is the WSL fallback, tried last.
740        _ => c::CLIPBOARD_COMMANDS_LINUX
741            .iter()
742            .copied()
743            .chain(std::iter::once((
744                c::CLIPBOARD_COMMAND_WINDOWS,
745                [].as_slice(),
746            )))
747            .collect(),
748    }
749}
750
751/// Shells out to whatever clipboard tool the platform has, returning its name on
752/// success. `Err(())` means none was found on PATH — the caller still has `text` to
753/// print itself, so nothing is lost, only the copy.
754///
755/// A crate like `arboard` would do this without a subprocess, but dev-prune ships
756/// musl-static Linux binaries and its test suite runs on headless CI runners with no
757/// X11 or Wayland session — a native clipboard dependency risks breaking both for a
758/// feature that is not essential. Shelling out costs nothing when the tool is missing:
759/// the attempt just fails and the next candidate is tried.
760fn copy_to_clipboard(text: &str) -> Result<&'static str, ()> {
761    for (cmd, args) in clipboard_candidates() {
762        if try_clipboard_command(cmd, args, text) {
763            return Ok(cmd);
764        }
765    }
766    Err(())
767}
768
769fn try_clipboard_command(cmd: &str, args: &[&str], text: &str) -> bool {
770    use std::io::Write;
771    use std::process::{Command, Stdio};
772
773    let Ok(mut child) = Command::new(cmd)
774        .args(args)
775        .stdin(Stdio::piped())
776        .stdout(Stdio::null())
777        .stderr(Stdio::null())
778        .spawn()
779    else {
780        return false;
781    };
782    let Some(mut stdin) = child.stdin.take() else {
783        return false;
784    };
785    if stdin.write_all(text.as_bytes()).is_err() {
786        return false;
787    }
788    drop(stdin);
789    child.wait().map(|status| status.success()).unwrap_or(false)
790}
791
792#[cfg(test)]
793mod tests {
794    use super::*;
795    use clap::ValueEnum;
796
797    #[test]
798    fn every_editor_writes_to_its_own_file() {
799        // A copy-pasted path would make one editor silently overwrite another's rules,
800        // and nothing else in the program would notice.
801        let mut paths: Vec<&str> = AgentEditor::value_variants()
802            .iter()
803            .map(|e| e.target().0)
804            .collect();
805        let total = paths.len();
806        paths.sort_unstable();
807        paths.dedup();
808        assert_eq!(paths.len(), total, "two editors share a path");
809    }
810
811    #[test]
812    fn the_editor_that_has_to_be_told_to_read_its_file_says_so() {
813        // Rules an agent never loads are worse than no rules at all: the repository
814        // looks configured and nothing is. Aider is the only target whose file is not
815        // picked up by being there, so it is the only one that carries a note — and the
816        // note has to name the file, because that name is what goes in the config.
817        for editor in AgentEditor::value_variants() {
818            if let Some(note) = editor.wiring() {
819                let path = editor.target().0;
820                assert_eq!(path, crate::constants::AIDER_CONVENTIONS_FILE);
821                assert!(
822                    note.contains(path),
823                    "the note does not name the file: {note}"
824                );
825            }
826        }
827        assert!(
828            AgentEditor::Aider.wiring().is_some(),
829            "aider writes a file nothing reads until it is configured"
830        );
831    }
832
833    #[test]
834    fn a_shared_file_is_only_ever_edited_inside_the_markers() {
835        // The whole reason `MarkedBlock` exists: these files belong to the user, and a
836        // second run must not stack a second copy of the rules on top of the first.
837        let theirs = "# Our conventions\n\nUse tabs.\n";
838        let once = upsert_marked_block(theirs);
839        let twice = upsert_marked_block(&once);
840        assert_eq!(once, twice, "a second write duplicated the block");
841        assert!(once.starts_with(theirs));
842        assert_eq!(once.matches(crate::constants::RULES_BLOCK_START).count(), 1);
843    }
844
845    #[test]
846    fn every_agent_value_is_named_in_the_ide_integration_doc() {
847        // Keeps the enum and the doc table from drifting apart. It cannot and does not
848        // claim to verify the paths against each vendor's own docs — that part is a
849        // manual policy, described in docs/IDE_INTEGRATION.md itself.
850        let doc = include_str!("../../docs/IDE_INTEGRATION.md");
851        for editor in AgentEditor::value_variants() {
852            let name = editor.to_possible_value().expect("no skipped variants");
853            let needle = format!("`{}`", name.get_name());
854            assert!(
855                doc.contains(&needle),
856                "docs/IDE_INTEGRATION.md's table does not mention `--agent {}`",
857                name.get_name()
858            );
859        }
860    }
861
862    #[test]
863    fn clipboard_has_a_command_to_try_on_every_platform() {
864        let candidates = clipboard_candidates();
865        assert!(!candidates.is_empty(), "no clipboard command to try");
866        assert!(candidates.iter().all(|(cmd, _)| !cmd.is_empty()));
867    }
868
869    // Detection takes its roots as parameters precisely so these tests can build a
870    // machine out of a temp directory — none of them reads the real home directory.
871
872    #[test]
873    fn a_home_trace_is_enough() {
874        let home = tempfile::tempdir().unwrap();
875        std::fs::create_dir_all(home.path().join(".continue")).unwrap();
876        let found = detect_editors(home.path(), None);
877        assert!(
878            found.iter().any(|e| matches!(e, AgentEditor::Continue)),
879            "a `.continue` home directory did not detect Continue"
880        );
881    }
882
883    #[test]
884    fn a_bare_machine_detects_nothing() {
885        let home = tempfile::tempdir().unwrap();
886        assert!(detect_editors(home.path(), None).is_empty());
887    }
888
889    #[test]
890    fn an_extension_counts_only_by_publisher_prefix() {
891        // The one real near-miss on record: `nvidia.nsight-copilot` sits in the same
892        // extensions directory and contains "copilot", but it is not GitHub Copilot.
893        let home = tempfile::tempdir().unwrap();
894        let ext = home.path().join(".vscode/extensions");
895        std::fs::create_dir_all(ext.join("nvidia.nsight-copilot-2026.1.21-win32-x64")).unwrap();
896        assert!(
897            !detect_editors(home.path(), None)
898                .iter()
899                .any(|e| matches!(e, AgentEditor::Copilot)),
900            "a third-party extension containing 'copilot' read as GitHub Copilot"
901        );
902
903        std::fs::create_dir_all(ext.join("github.copilot-1.350.0")).unwrap();
904        assert!(
905            detect_editors(home.path(), None)
906                .iter()
907                .any(|e| matches!(e, AgentEditor::Copilot)),
908            "the real github.copilot extension was not detected"
909        );
910    }
911
912    #[test]
913    fn repo_traces_apply_only_when_a_repository_is_given() {
914        let home = tempfile::tempdir().unwrap();
915        let repo = tempfile::tempdir().unwrap();
916        std::fs::write(repo.path().join("GEMINI.md"), "# rules\n").unwrap();
917        assert!(
918            detect_editors(home.path(), None).is_empty(),
919            "a repo trace was counted with no repository in play"
920        );
921        assert!(
922            detect_editors(home.path(), Some(repo.path()))
923                .iter()
924                .any(|e| matches!(e, AgentEditor::Gemini)),
925            "GEMINI.md in the repository did not detect the Gemini CLI"
926        );
927    }
928
929    #[test]
930    fn rules_state_tells_missing_from_stale_from_current() {
931        let repo = tempfile::tempdir().unwrap();
932        let editor = AgentEditor::Windsurf;
933        assert!(matches!(
934            editor.rules_state(repo.path()),
935            RulesState::Missing
936        ));
937
938        let target = repo.path().join(editor.target().0);
939        std::fs::create_dir_all(target.parent().unwrap()).unwrap();
940        std::fs::write(&target, "an older release's rules\n").unwrap();
941        assert!(matches!(editor.rules_state(repo.path()), RulesState::Stale));
942
943        std::fs::write(&target, EMBEDDED_RULES_MD).unwrap();
944        assert!(matches!(
945            editor.rules_state(repo.path()),
946            RulesState::Current
947        ));
948    }
949
950    #[test]
951    fn a_shared_file_without_the_markers_counts_as_missing() {
952        // AGENTS.md full of the user's own text is not dev-prune rules going stale;
953        // it is a file dev-prune has never written into.
954        let repo = tempfile::tempdir().unwrap();
955        std::fs::write(repo.path().join("AGENTS.md"), "# Their agents file\n").unwrap();
956        assert!(matches!(
957            AgentEditor::AgentsMd.rules_state(repo.path()),
958            RulesState::Missing
959        ));
960    }
961}