Skip to main content

rgx/
skill.rs

1//! `rgx --agent install|uninstall|list|skill`: wire rgx into AI coding agents.
2//!
3//! An install only writes where rgx owns the namespace (Claude skill dir, Gemini extension), or, for
4//! shared files (Codex AGENTS.md, Cursor/VS Code config), edits idempotently — a removable marked
5//! block or a merged JSON key — never a blind append. MCP registration that belongs to a host's own
6//! CLI is printed, not run. The skill text is version-controlled in `assets/skill.md` and embedded at
7//! build time so the installed copy can't drift from the binary (see `CLAUDE.md`).
8
9use std::io::{IsTerminal, Write as _};
10use std::path::{Path, PathBuf};
11
12use anyhow::{Context, Result, bail};
13use serde_json::{Map, Value, json};
14
15const SKILL_MD: &str = include_str!("../assets/skill.md");
16const VERSION: &str = env!("CARGO_PKG_VERSION");
17
18const BLOCK_BEGIN: &str = "<!-- >>> rgx (managed) >>> -->";
19const BLOCK_END: &str = "<!-- <<< rgx (managed) <<< -->";
20
21const CURSOR_DESC: &str = "Prefer rgx over rg/grep/find/fd when searching this repo";
22
23#[derive(Clone, Copy, PartialEq, Eq)]
24pub enum Target {
25    Claude,
26    Codex,
27    Cursor,
28    Gemini,
29    VsCode,
30}
31
32#[derive(Clone, Copy, PartialEq, Eq)]
33pub enum Scope {
34    User,
35    Project,
36}
37
38impl Target {
39    const ALL: [Target; 5] = [
40        Target::Claude,
41        Target::Codex,
42        Target::Cursor,
43        Target::Gemini,
44        Target::VsCode,
45    ];
46
47    fn parse(s: &str) -> Option<Target> {
48        match s.to_ascii_lowercase().as_str() {
49            "claude" | "claude-code" | "claudecode" => Some(Target::Claude),
50            "codex" => Some(Target::Codex),
51            "cursor" => Some(Target::Cursor),
52            "gemini" | "gemini-cli" => Some(Target::Gemini),
53            "vscode" | "vs-code" | "code" | "copilot" => Some(Target::VsCode),
54            _ => None,
55        }
56    }
57
58    fn label(self) -> &'static str {
59        match self {
60            Target::Claude => "Claude Code",
61            Target::Codex => "Codex",
62            Target::Cursor => "Cursor",
63            Target::Gemini => "Gemini CLI",
64            Target::VsCode => "VS Code",
65        }
66    }
67
68    fn default_scope(self) -> Scope {
69        match self {
70            Target::Claude | Target::Codex | Target::Gemini => Scope::User,
71            Target::Cursor | Target::VsCode => Scope::Project,
72        }
73    }
74
75    fn supports(self, scope: Scope) -> bool {
76        !(self == Target::Cursor && scope == Scope::User)
77    }
78}
79
80impl Scope {
81    fn label(self) -> &'static str {
82        match self {
83            Scope::User => "user",
84            Scope::Project => "project",
85        }
86    }
87}
88
89/// Filesystem roots, injected so the installer is testable without touching a real `$HOME`.
90pub struct Env {
91    home: PathBuf,
92    cwd: PathBuf,
93}
94
95impl Env {
96    fn from_system() -> Result<Env> {
97        let home = std::env::var_os("HOME")
98            .map(PathBuf::from)
99            .context("HOME is not set")?;
100        let cwd = std::env::current_dir().context("current directory")?;
101        Ok(Env { home, cwd })
102    }
103
104    fn base(&self, scope: Scope) -> &Path {
105        match scope {
106            Scope::User => &self.home,
107            Scope::Project => &self.cwd,
108        }
109    }
110}
111
112/// A single filesystem change an install will make. Built up front so the plan can be previewed and
113/// confirmed before anything is written; `Note` is a manual step printed for the user (e.g. an
114/// `mcp add` command rgx won't run itself).
115enum Action {
116    Write {
117        path: PathBuf,
118        contents: String,
119    },
120    MergeJson {
121        path: PathBuf,
122        root_key: &'static str,
123    },
124    Block {
125        path: PathBuf,
126        body: String,
127    },
128    Note(String),
129}
130
131struct Opts {
132    targets: Vec<Target>,
133    scope: Option<Scope>,
134    yes: bool,
135    dry_run: bool,
136}
137
138/// `rgx --agent skill`: print the skill document (no side effects).
139pub fn print_skill() {
140    print!("{SKILL_MD}");
141}
142
143/// `rgx --agent install [targets] [--user|--project] [--dry-run|--yes]`.
144pub fn install_cli(args: &[String]) -> Result<()> {
145    let opts = parse_args(args)?;
146    let env = Env::from_system()?;
147    let targets = resolve_targets(&opts.targets, &env)?;
148    let mut plan = Vec::new();
149    for t in targets {
150        let sc = resolve_scope(t, opts.scope)?;
151        plan.push((t, sc, plan_target(&env, t, sc)));
152    }
153
154    println!("rgx --agent install will make these changes:");
155    for (t, sc, actions) in &plan {
156        println!("\n{} ({}):", t.label(), sc.label());
157        for a in actions {
158            println!("  {}", describe(&env, a));
159        }
160    }
161
162    if opts.dry_run {
163        println!("\n(dry run — nothing written)");
164        return Ok(());
165    }
166    if !opts.yes && !confirm_proceed("\nApply these changes?")? {
167        println!("aborted; nothing written");
168        return Ok(());
169    }
170
171    println!();
172    for (t, sc, actions) in plan {
173        println!("{} ({}):", t.label(), sc.label());
174        for a in actions {
175            match apply(a)? {
176                Done::Wrote(p) => println!("  wrote   {}", p.display()),
177                Done::Manual(n) => println!("  {n}"),
178            }
179        }
180    }
181    Ok(())
182}
183
184/// `rgx --agent uninstall [targets] [--user|--project] [--dry-run|--yes]`.
185pub fn uninstall_cli(args: &[String]) -> Result<()> {
186    let opts = parse_args(args)?;
187    let env = Env::from_system()?;
188    let targets = if opts.targets.is_empty() {
189        Target::ALL.to_vec()
190    } else {
191        opts.targets
192    };
193    let mut plan = Vec::new();
194    for t in targets {
195        let sc = resolve_scope(t, opts.scope)?;
196        plan.push((t, sc, pending_removals(&env, t, sc)));
197    }
198    if plan.iter().all(|(_, _, items)| items.is_empty()) {
199        println!("nothing installed for the selected agents");
200        return Ok(());
201    }
202
203    println!("rgx --agent uninstall will remove:");
204    for (t, sc, items) in &plan {
205        if items.is_empty() {
206            continue;
207        }
208        println!("\n{} ({}):", t.label(), sc.label());
209        for item in items {
210            println!("  {item}");
211        }
212    }
213
214    if opts.dry_run {
215        println!("\n(dry run — nothing removed)");
216        return Ok(());
217    }
218    if !opts.yes && !confirm_proceed("\nRemove these?")? {
219        println!("aborted; nothing removed");
220        return Ok(());
221    }
222
223    println!();
224    for (t, sc, items) in plan {
225        if items.is_empty() {
226            continue;
227        }
228        let removed = uninstall_target(&env, t, sc)?;
229        println!("{} ({}):", t.label(), sc.label());
230        for line in removed {
231            println!("  removed {line}");
232        }
233    }
234    Ok(())
235}
236
237/// `rgx --agent list`: show each target, whether it's detected, and whether rgx is installed.
238pub fn list() -> Result<()> {
239    let env = Env::from_system()?;
240    for t in Target::ALL {
241        let detected = if detect(&env, t) { "detected" } else { "-" };
242        let sc = t.default_scope();
243        let installed = if is_installed(&env, t, sc) {
244            "installed"
245        } else {
246            "-"
247        };
248        println!("  {:<12} {:<10} {}", t.label(), detected, installed);
249    }
250    Ok(())
251}
252
253fn parse_args(args: &[String]) -> Result<Opts> {
254    let mut opts = Opts {
255        targets: Vec::new(),
256        scope: None,
257        yes: false,
258        dry_run: false,
259    };
260    for a in args {
261        match a.as_str() {
262            "--user" => opts.scope = Some(Scope::User),
263            "--project" | "--repo" => opts.scope = Some(Scope::Project),
264            "--yes" | "-y" => opts.yes = true,
265            "--dry-run" | "-n" => opts.dry_run = true,
266            s if s.starts_with('-') => bail!("unknown flag {s:?}"),
267            s => opts.targets.push(Target::parse(s).with_context(|| {
268                format!("unknown target {s:?} (use: claude, codex, cursor, gemini, vscode)")
269            })?),
270        }
271    }
272    Ok(opts)
273}
274
275fn resolve_scope(t: Target, scope: Option<Scope>) -> Result<Scope> {
276    let sc = scope.unwrap_or_else(|| t.default_scope());
277    if !t.supports(sc) {
278        bail!("{} supports project scope only", t.label());
279    }
280    Ok(sc)
281}
282
283fn resolve_targets(requested: &[Target], env: &Env) -> Result<Vec<Target>> {
284    if !requested.is_empty() {
285        return Ok(requested.to_vec());
286    }
287    let found: Vec<Target> = Target::ALL
288        .into_iter()
289        .filter(|t| detect(env, *t))
290        .collect();
291    if found.is_empty() {
292        bail!(
293            "no agents detected; name one explicitly, e.g. `rgx --agent install claude`\n\
294             targets: claude, codex, cursor, gemini, vscode"
295        );
296    }
297    Ok(found)
298}
299
300fn detect(env: &Env, t: Target) -> bool {
301    match t {
302        Target::Claude => env.home.join(".claude").is_dir(),
303        Target::Codex => env.home.join(".codex").is_dir(),
304        Target::Gemini => env.home.join(".gemini").is_dir(),
305        Target::Cursor => env.cwd.join(".cursor").is_dir() || env.home.join(".cursor").is_dir(),
306        Target::VsCode => env.cwd.join(".vscode").is_dir() || on_path("code"),
307    }
308}
309
310fn is_installed(env: &Env, t: Target, scope: Scope) -> bool {
311    match t {
312        Target::Claude => claude_skill(env, scope).is_file(),
313        Target::Gemini => gemini_dir(env, scope)
314            .join("gemini-extension.json")
315            .is_file(),
316        Target::Cursor => env.cwd.join(".cursor/rules/rgx.mdc").is_file(),
317        Target::Codex => has_block(&codex_agents(env, scope)),
318        Target::VsCode => json_has_rgx(&env.cwd.join(".vscode/mcp.json"), "servers"),
319    }
320}
321
322fn plan_target(env: &Env, t: Target, scope: Scope) -> Vec<Action> {
323    match t {
324        Target::Claude => {
325            let cmd = match scope {
326                Scope::User => "claude mcp add rgx -- rgx --agent mcp",
327                Scope::Project => "claude mcp add --scope project rgx -- rgx --agent mcp",
328            };
329            vec![
330                Action::Write {
331                    path: claude_skill(env, scope),
332                    contents: SKILL_MD.to_string(),
333                },
334                Action::Note(format!("register MCP: {cmd}")),
335            ]
336        }
337        Target::Codex => vec![
338            Action::Block {
339                path: codex_agents(env, scope),
340                body: skill_body().to_string(),
341            },
342            Action::Note("register MCP: codex mcp add rgx -- rgx --agent mcp".to_string()),
343        ],
344        Target::Cursor => vec![
345            Action::Write {
346                path: env.cwd.join(".cursor/rules/rgx.mdc"),
347                contents: format!(
348                    "---\ndescription: {CURSOR_DESC}\nalwaysApply: true\n---\n\n{}",
349                    skill_body()
350                ),
351            },
352            Action::MergeJson {
353                path: env.cwd.join(".cursor/mcp.json"),
354                root_key: "mcpServers",
355            },
356        ],
357        Target::Gemini => {
358            let dir = gemini_dir(env, scope);
359            let manifest = json!({
360                "name": "rgx",
361                "version": VERSION,
362                "mcpServers": { "rgx": rgx_server() },
363                "contextFileName": "GEMINI.md",
364            });
365            vec![
366                Action::Write {
367                    path: dir.join("gemini-extension.json"),
368                    contents: format!("{}\n", to_pretty(&manifest).unwrap_or_default()),
369                },
370                Action::Write {
371                    path: dir.join("GEMINI.md"),
372                    contents: skill_body().to_string(),
373                },
374            ]
375        }
376        Target::VsCode => match scope {
377            Scope::Project => vec![
378                Action::MergeJson {
379                    path: env.cwd.join(".vscode/mcp.json"),
380                    root_key: "servers",
381                },
382                Action::Block {
383                    path: env.cwd.join(".github/copilot-instructions.md"),
384                    body: skill_body().to_string(),
385                },
386            ],
387            Scope::User => vec![
388                Action::Note(
389                    "register MCP: code --add-mcp \
390                     '{\"name\":\"rgx\",\"command\":\"rgx\",\"args\":[\"--agent\",\"mcp\"]}'"
391                        .to_string(),
392                ),
393                Action::Note(
394                    "add the skill to your user copilot-instructions in VS Code settings"
395                        .to_string(),
396                ),
397            ],
398        },
399    }
400}
401
402enum Done {
403    Wrote(PathBuf),
404    Manual(String),
405}
406
407fn apply(action: Action) -> Result<Done> {
408    match action {
409        Action::Write { path, contents } => {
410            write_file(&path, &contents)?;
411            Ok(Done::Wrote(path))
412        }
413        Action::MergeJson { path, root_key } => {
414            merge_mcp_json(&path, root_key)?;
415            Ok(Done::Wrote(path))
416        }
417        Action::Block { path, body } => {
418            upsert_block(&path, &body)?;
419            Ok(Done::Wrote(path))
420        }
421        Action::Note(n) => Ok(Done::Manual(n)),
422    }
423}
424
425fn describe(env: &Env, action: &Action) -> String {
426    let _ = env;
427    match action {
428        Action::Write { path, .. } => {
429            let verb = if path.is_file() {
430                "overwrite"
431            } else {
432                "create"
433            };
434            format!("{verb} {}", path.display())
435        }
436        Action::MergeJson { path, root_key } => {
437            if path.exists() {
438                format!("add \"rgx\" to {} ({root_key})", path.display())
439            } else {
440                format!("create {} with the \"rgx\" server", path.display())
441            }
442        }
443        Action::Block { path, .. } => {
444            if has_block(path) {
445                format!("update the rgx block in {}", path.display())
446            } else if path.exists() {
447                format!("add an rgx block to {}", path.display())
448            } else {
449                format!("create {}", path.display())
450            }
451        }
452        Action::Note(n) => format!("you then run: {n}"),
453    }
454}
455
456fn pending_removals(env: &Env, t: Target, scope: Scope) -> Vec<String> {
457    let mut items = Vec::new();
458    let file = |p: PathBuf, items: &mut Vec<String>| {
459        if p.is_file() {
460            items.push(p.display().to_string());
461        }
462    };
463    match t {
464        Target::Claude => file(claude_skill(env, scope), &mut items),
465        Target::Gemini => {
466            let dir = gemini_dir(env, scope);
467            if dir.is_dir() {
468                items.push(dir.display().to_string());
469            }
470        }
471        Target::Cursor => {
472            file(env.cwd.join(".cursor/rules/rgx.mdc"), &mut items);
473            if json_has_rgx(&env.cwd.join(".cursor/mcp.json"), "mcpServers") {
474                items.push(format!(
475                    "{} (rgx key)",
476                    env.cwd.join(".cursor/mcp.json").display()
477                ));
478            }
479        }
480        Target::Codex => {
481            let p = codex_agents(env, scope);
482            if has_block(&p) {
483                items.push(format!("{} (rgx block)", p.display()));
484            }
485        }
486        Target::VsCode => {
487            if json_has_rgx(&env.cwd.join(".vscode/mcp.json"), "servers") {
488                items.push(format!(
489                    "{} (rgx key)",
490                    env.cwd.join(".vscode/mcp.json").display()
491                ));
492            }
493            let instr = env.cwd.join(".github/copilot-instructions.md");
494            if has_block(&instr) {
495                items.push(format!("{} (rgx block)", instr.display()));
496            }
497        }
498    }
499    items
500}
501
502fn confirm_proceed(prompt: &str) -> Result<bool> {
503    if !std::io::stdin().is_terminal() {
504        bail!("not a terminal; re-run with --yes to apply, or --dry-run to preview");
505    }
506    print!("{prompt} [y/N] ");
507    std::io::stdout().flush().ok();
508    let mut line = String::new();
509    std::io::stdin().read_line(&mut line)?;
510    Ok(matches!(
511        line.trim().to_ascii_lowercase().as_str(),
512        "y" | "yes"
513    ))
514}
515
516#[cfg(test)]
517fn install_target(env: &Env, t: Target, scope: Scope) -> Result<()> {
518    for action in plan_target(env, t, scope) {
519        apply(action)?;
520    }
521    Ok(())
522}
523
524fn uninstall_target(env: &Env, t: Target, scope: Scope) -> Result<Vec<String>> {
525    let mut removed = Vec::new();
526    match t {
527        Target::Claude => remove_file_into(&claude_skill(env, scope), &mut removed),
528        Target::Gemini => {
529            let dir = gemini_dir(env, scope);
530            if dir.is_dir() {
531                std::fs::remove_dir_all(&dir)
532                    .with_context(|| format!("remove {}", dir.display()))?;
533                removed.push(dir.display().to_string());
534            }
535        }
536        Target::Cursor => {
537            remove_file_into(&env.cwd.join(".cursor/rules/rgx.mdc"), &mut removed);
538            remove_mcp_json(
539                &env.cwd.join(".cursor/mcp.json"),
540                "mcpServers",
541                &mut removed,
542            )?;
543        }
544        Target::Codex => remove_block_into(&codex_agents(env, scope), &mut removed)?,
545        Target::VsCode => {
546            remove_mcp_json(&env.cwd.join(".vscode/mcp.json"), "servers", &mut removed)?;
547            remove_block_into(
548                &env.cwd.join(".github/copilot-instructions.md"),
549                &mut removed,
550            )?;
551        }
552    }
553    Ok(removed)
554}
555
556fn claude_skill(env: &Env, scope: Scope) -> PathBuf {
557    env.base(scope).join(".claude/skills/rgx/SKILL.md")
558}
559
560fn codex_agents(env: &Env, scope: Scope) -> PathBuf {
561    match scope {
562        Scope::User => env.home.join(".codex/AGENTS.md"),
563        Scope::Project => env.cwd.join("AGENTS.md"),
564    }
565}
566
567fn gemini_dir(env: &Env, scope: Scope) -> PathBuf {
568    env.base(scope).join(".gemini/extensions/rgx")
569}
570
571fn rgx_server() -> Value {
572    json!({ "command": "rgx", "args": ["--agent", "mcp"] })
573}
574
575fn skill_body() -> &'static str {
576    if let Some(rest) = SKILL_MD.strip_prefix("---\n")
577        && let Some(idx) = rest.find("\n---\n")
578    {
579        return rest[idx + 5..].trim_start_matches('\n');
580    }
581    SKILL_MD
582}
583
584fn write_file(path: &Path, contents: &str) -> Result<()> {
585    if let Some(dir) = path.parent() {
586        std::fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?;
587    }
588    std::fs::write(path, contents).with_context(|| format!("write {}", path.display()))
589}
590
591fn remove_file_into(path: &Path, removed: &mut Vec<String>) {
592    if path.is_file() && std::fs::remove_file(path).is_ok() {
593        removed.push(path.display().to_string());
594    }
595}
596
597fn to_pretty(v: &Value) -> Result<String> {
598    serde_json::to_string_pretty(v).context("serialize JSON")
599}
600
601fn merge_mcp_json(path: &Path, root_key: &str) -> Result<()> {
602    let mut root = read_json(path)?;
603    let obj = root
604        .as_object_mut()
605        .with_context(|| format!("{} is not a JSON object", path.display()))?;
606    let servers = obj
607        .entry(root_key)
608        .or_insert_with(|| Value::Object(Map::new()))
609        .as_object_mut()
610        .with_context(|| format!("{root_key} in {} is not an object", path.display()))?;
611    servers.insert("rgx".to_string(), rgx_server());
612    write_file(path, &format!("{}\n", to_pretty(&root)?))
613}
614
615fn remove_mcp_json(path: &Path, root_key: &str, removed: &mut Vec<String>) -> Result<()> {
616    if !path.exists() {
617        return Ok(());
618    }
619    let mut root = read_json(path)?;
620    let gone = root
621        .as_object_mut()
622        .and_then(|o| o.get_mut(root_key))
623        .and_then(|s| s.as_object_mut())
624        .map(|s| s.remove("rgx").is_some())
625        .unwrap_or(false);
626    if gone {
627        write_file(path, &format!("{}\n", to_pretty(&root)?))?;
628        removed.push(format!("{} (rgx key)", path.display()));
629    }
630    Ok(())
631}
632
633fn read_json(path: &Path) -> Result<Value> {
634    if !path.exists() {
635        return Ok(Value::Object(Map::new()));
636    }
637    let text = std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
638    if text.trim().is_empty() {
639        return Ok(Value::Object(Map::new()));
640    }
641    serde_json::from_str(&text).with_context(|| format!("parse {}", path.display()))
642}
643
644fn json_has_rgx(path: &Path, root_key: &str) -> bool {
645    read_json(path)
646        .ok()
647        .and_then(|v| v.get(root_key).and_then(|s| s.get("rgx")).map(|_| ()))
648        .is_some()
649}
650
651fn block_text(body: &str) -> String {
652    format!("{BLOCK_BEGIN}\n{}\n{BLOCK_END}\n", body.trim())
653}
654
655fn upsert_block(path: &Path, body: &str) -> Result<()> {
656    let existing = if path.exists() {
657        std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?
658    } else {
659        String::new()
660    };
661    let block = block_text(body);
662    let new = match find_block(&existing) {
663        Some((s, e)) => format!("{}{}{}", &existing[..s], block, &existing[e..]),
664        None if existing.trim().is_empty() => block,
665        None => format!("{}\n\n{}", existing.trim_end(), block),
666    };
667    write_file(path, &new)
668}
669
670fn remove_block_into(path: &Path, removed: &mut Vec<String>) -> Result<()> {
671    if !path.exists() {
672        return Ok(());
673    }
674    let existing =
675        std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
676    if let Some((s, e)) = find_block(&existing) {
677        let trimmed = format!("{}{}", &existing[..s], &existing[e..]);
678        let trimmed = trimmed.trim();
679        if trimmed.is_empty() {
680            std::fs::remove_file(path).with_context(|| format!("remove {}", path.display()))?;
681        } else {
682            write_file(path, &format!("{trimmed}\n"))?;
683        }
684        removed.push(format!("{} (rgx block)", path.display()));
685    }
686    Ok(())
687}
688
689fn has_block(path: &Path) -> bool {
690    std::fs::read_to_string(path)
691        .map(|s| find_block(&s).is_some())
692        .unwrap_or(false)
693}
694
695fn find_block(s: &str) -> Option<(usize, usize)> {
696    let start = s.find(BLOCK_BEGIN)?;
697    let end_marker = s[start..].find(BLOCK_END)? + start + BLOCK_END.len();
698    let end = s[end_marker..]
699        .find('\n')
700        .map(|n| end_marker + n + 1)
701        .unwrap_or(end_marker);
702    Some((start, end))
703}
704
705fn on_path(bin: &str) -> bool {
706    std::env::var_os("PATH")
707        .map(|paths| {
708            std::env::split_paths(&paths)
709                .any(|dir| dir.join(bin).is_file() || dir.join(format!("{bin}.exe")).is_file())
710        })
711        .unwrap_or(false)
712}
713
714#[cfg(test)]
715mod tests {
716    use super::*;
717
718    fn temp_env() -> (tempfile::TempDir, Env) {
719        let dir = tempfile::tempdir().unwrap();
720        let home = dir.path().join("home");
721        let cwd = dir.path().join("repo");
722        std::fs::create_dir_all(&home).unwrap();
723        std::fs::create_dir_all(&cwd).unwrap();
724        let env = Env { home, cwd };
725        (dir, env)
726    }
727
728    #[test]
729    fn installs_every_target_into_its_own_namespace() {
730        let (_d, env) = temp_env();
731        for t in Target::ALL {
732            let scope = t.default_scope();
733            install_target(&env, t, scope).unwrap();
734        }
735        assert!(env.home.join(".claude/skills/rgx/SKILL.md").is_file());
736        assert!(
737            env.home
738                .join(".gemini/extensions/rgx/gemini-extension.json")
739                .is_file()
740        );
741        assert!(env.home.join(".gemini/extensions/rgx/GEMINI.md").is_file());
742        assert!(env.cwd.join(".cursor/rules/rgx.mdc").is_file());
743        assert!(env.cwd.join(".cursor/mcp.json").is_file());
744        assert!(has_block(&env.home.join(".codex/AGENTS.md")));
745        assert!(env.cwd.join(".vscode/mcp.json").is_file());
746        assert!(has_block(&env.cwd.join(".github/copilot-instructions.md")));
747
748        let mdc = std::fs::read_to_string(env.cwd.join(".cursor/rules/rgx.mdc")).unwrap();
749        assert!(mdc.starts_with("---\n"));
750        assert!(mdc.contains("alwaysApply: true"));
751    }
752
753    #[test]
754    fn merge_preserves_existing_servers_and_is_idempotent() {
755        let (_d, env) = temp_env();
756        let mcp = env.cwd.join(".vscode/mcp.json");
757        write_file(
758            &mcp,
759            "{\n  \"servers\": { \"other\": { \"command\": \"x\" } }\n}\n",
760        )
761        .unwrap();
762        merge_mcp_json(&mcp, "servers").unwrap();
763        merge_mcp_json(&mcp, "servers").unwrap();
764        let v = read_json(&mcp).unwrap();
765        assert!(v["servers"]["other"].is_object());
766        assert_eq!(v["servers"]["rgx"]["command"], "rgx");
767    }
768
769    #[test]
770    fn block_upsert_is_idempotent_and_preserves_surrounding_text() {
771        let (_d, env) = temp_env();
772        let path = env.cwd.join("AGENTS.md");
773        write_file(&path, "# Project\n\nHand-written notes.\n").unwrap();
774        upsert_block(&path, "first").unwrap();
775        upsert_block(&path, "second").unwrap();
776        let text = std::fs::read_to_string(&path).unwrap();
777        assert_eq!(text.matches(BLOCK_BEGIN).count(), 1);
778        assert!(text.contains("Hand-written notes."));
779        assert!(text.contains("second"));
780        assert!(!text.contains("first"));
781    }
782
783    #[test]
784    fn uninstall_removes_block_and_json_key_but_keeps_user_content() {
785        let (_d, env) = temp_env();
786        install_target(&env, Target::Codex, Scope::User).unwrap();
787        let agents = codex_agents(&env, Scope::User);
788        std::fs::write(
789            &agents,
790            format!("# Mine\n\n{}", std::fs::read_to_string(&agents).unwrap()),
791        )
792        .unwrap();
793        let removed = uninstall_target(&env, Target::Codex, Scope::User).unwrap();
794        assert!(!removed.is_empty());
795        let text = std::fs::read_to_string(&agents).unwrap();
796        assert!(text.contains("# Mine"));
797        assert!(!text.contains(BLOCK_BEGIN));
798
799        install_target(&env, Target::VsCode, Scope::Project).unwrap();
800        uninstall_target(&env, Target::VsCode, Scope::Project).unwrap();
801        assert!(!json_has_rgx(&env.cwd.join(".vscode/mcp.json"), "servers"));
802    }
803
804    #[test]
805    fn cursor_rejects_user_scope() {
806        assert!(resolve_scope(Target::Cursor, Some(Scope::User)).is_err());
807        assert!(resolve_scope(Target::Cursor, None).is_ok());
808    }
809}