1use 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
89pub 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
112enum 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
138pub fn print_skill() {
140 print!("{SKILL_MD}");
141}
142
143pub 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
184pub 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
237pub 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}