1use anyhow::Result;
11use serde_json::json;
12use std::path::{Path, PathBuf};
13
14pub async fn run(
16 list_only: bool,
17 remove: bool,
18 status_only: bool,
19 agent: Option<&str>,
20 config_dir: Option<&str>,
21) -> Result<()> {
22 let quiet = crate::cli::output::is_quiet();
23 let json_mode = crate::cli::output::is_json();
24
25 if !quiet && !json_mode {
26 println!();
27 println!(" Cortex Plug \u{2014} Connect web cartography tools to your AI agents.");
28 println!();
29 }
30
31 let probes = if let Some(dir) = config_dir {
32 build_test_probes(dir)
33 } else {
34 build_probes()
35 };
36 let mut connected = 0u32;
37 let mut needs_restart: Vec<&str> = Vec::new();
38 let mut json_results: Vec<serde_json::Value> = Vec::new();
39
40 if !quiet && !json_mode && !list_only && !status_only {
41 println!(" Scanning for agents...");
42 println!();
43 }
44
45 for probe in &probes {
46 if let Some(target) = agent {
48 if !probe.name.eq_ignore_ascii_case(target)
49 && !probe.short_name.eq_ignore_ascii_case(target)
50 {
51 continue;
52 }
53 }
54
55 let config_path = match probe.detect() {
56 Some(p) => p,
57 None => {
58 if list_only || status_only {
59 if json_mode {
60 json_results.push(json!({
61 "agent": probe.name,
62 "detected": false,
63 }));
64 } else if !quiet {
65 println!(" \u{2717} {:<20} not found", probe.name);
66 }
67 }
68 continue;
69 }
70 };
71
72 if list_only {
73 if json_mode {
74 json_results.push(json!({
75 "agent": probe.name,
76 "detected": true,
77 "config_path": config_path.display().to_string(),
78 }));
79 } else if !quiet {
80 println!(
81 " \u{2713} {:<20} found at {}",
82 probe.name,
83 config_path.display()
84 );
85 }
86 connected += 1;
87 continue;
88 }
89
90 if status_only {
91 let has_cortex = check_cortex_present(&config_path);
92 if json_mode {
93 json_results.push(json!({
94 "agent": probe.name,
95 "detected": true,
96 "config_path": config_path.display().to_string(),
97 "cortex_connected": has_cortex,
98 }));
99 } else if !quiet {
100 let symbol = if has_cortex { "\u{2713}" } else { "\u{25cb}" };
101 let status = if has_cortex {
102 "connected"
103 } else {
104 "not connected"
105 };
106 println!(" {} {:<20} {}", symbol, probe.name, status);
107 }
108 if has_cortex {
109 connected += 1;
110 }
111 continue;
112 }
113
114 if remove {
115 match remove_mcp_server(&config_path) {
116 Ok(RemovalResult::Removed) => {
117 if json_mode {
118 json_results.push(json!({
119 "agent": probe.name,
120 "action": "removed",
121 }));
122 } else if !quiet {
123 println!(
124 " \u{2713} {:<20} \u{2192} Removed from {}",
125 probe.name,
126 config_path
127 .file_name()
128 .unwrap_or_default()
129 .to_string_lossy()
130 );
131 }
132 }
133 Ok(RemovalResult::NotPresent) => {
134 if !quiet && !json_mode {
135 println!(" \u{25cb} {:<20} was not connected", probe.name);
136 }
137 }
138 Err(e) => {
139 if !quiet && !json_mode {
140 println!(" \u{26a0} {:<20} removal failed: {}", probe.name, e);
141 }
142 }
143 }
144 continue;
145 }
146
147 match inject_mcp_server(&config_path) {
149 Ok(InjectionResult::Injected) => {
150 connected += 1;
151 if json_mode {
152 json_results.push(json!({
153 "agent": probe.name,
154 "action": "injected",
155 "config_path": config_path.display().to_string(),
156 "needs_restart": probe.needs_restart,
157 }));
158 } else if !quiet {
159 println!(" \u{2713} {:<20} found", probe.name);
160 println!(
161 " \u{2192} Added 9 Cortex tools to {}",
162 config_path
163 .file_name()
164 .unwrap_or_default()
165 .to_string_lossy()
166 );
167 println!(
168 " \u{2192} Tools: map, query, pathfind, act, perceive, compare, auth, compile, wql"
169 );
170 if probe.needs_restart {
171 println!(" \u{2192} Restart {} to activate", probe.name);
172 needs_restart.push(probe.name);
173 } else {
174 println!(" \u{2192} Active immediately");
175 }
176 }
177 }
178 Ok(InjectionResult::AlreadyPresent) => {
179 connected += 1;
180 if json_mode {
181 json_results.push(json!({
182 "agent": probe.name,
183 "action": "already_present",
184 }));
185 } else if !quiet {
186 println!(" \u{2713} {:<20} already connected", probe.name);
187 }
188 }
189 Err(e) => {
190 if json_mode {
191 json_results.push(json!({
192 "agent": probe.name,
193 "action": "error",
194 "error": e.to_string(),
195 }));
196 } else if !quiet {
197 println!(" \u{26a0} {:<20} injection failed: {}", probe.name, e);
198 }
199 }
200 }
201
202 if !quiet && !json_mode {
203 println!();
204 }
205 }
206
207 if json_mode {
209 crate::cli::output::print_json(&json!({
210 "agents": json_results,
211 "connected": connected,
212 }));
213 } else if !quiet {
214 if !list_only && !status_only && !remove {
215 println!(" \u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501}");
216 println!(
217 " \u{2713} {} agent(s) connected. Cortex is ready.",
218 connected
219 );
220 println!();
221 println!(" What happened:");
222 println!(" Your agent(s) now have 9 web cartography tools.");
223 println!(" Cortex maps websites into graphs \u{2014} your agent queries");
224 println!(" them in microseconds instead of browsing page by page.");
225 println!();
226 if !needs_restart.is_empty() {
227 println!(" Restart to activate: {}", needs_restart.join(", "));
228 println!();
229 }
230 println!(" Try it:");
231 println!(" Claude: \"Map amazon.com and find headphones under $300\"");
232 println!(" Terminal: cortex map amazon.com");
233 println!(" Python: from cortex_client import map");
234 println!();
235 println!(" Manage:");
236 println!(" cortex plug --status See which agents are connected");
237 println!(" cortex plug --remove Disconnect from all agents");
238 println!(" cortex status Check if the runtime is running");
239 println!();
240 } else if remove {
241 println!();
242 println!(" Done. Cortex disconnected from agents.");
243 println!(" Runtime still running. Stop with: cortex stop");
244 println!();
245 }
246 }
247
248 Ok(())
249}
250
251struct AgentProbe {
255 name: &'static str,
256 short_name: &'static str,
257 needs_restart: bool,
258 detect_fn: fn() -> Option<PathBuf>,
259}
260
261impl AgentProbe {
262 fn detect(&self) -> Option<PathBuf> {
263 (self.detect_fn)()
264 }
265}
266
267fn home_dir() -> PathBuf {
268 dirs::home_dir().expect("cannot determine home directory")
269}
270
271fn build_probes() -> Vec<AgentProbe> {
272 vec![
273 AgentProbe {
274 name: "Claude Desktop",
275 short_name: "claude-desktop",
276 needs_restart: true,
277 detect_fn: detect_claude_desktop,
278 },
279 AgentProbe {
280 name: "Claude Code",
281 short_name: "claude-code",
282 needs_restart: false,
283 detect_fn: detect_claude_code,
284 },
285 AgentProbe {
286 name: "Cursor",
287 short_name: "cursor",
288 needs_restart: true,
289 detect_fn: detect_cursor,
290 },
291 AgentProbe {
292 name: "Windsurf",
293 short_name: "windsurf",
294 needs_restart: true,
295 detect_fn: detect_windsurf,
296 },
297 AgentProbe {
298 name: "Continue",
299 short_name: "continue",
300 needs_restart: false,
301 detect_fn: detect_continue,
302 },
303 AgentProbe {
304 name: "Cline",
305 short_name: "cline",
306 needs_restart: false,
307 detect_fn: detect_cline,
308 },
309 ]
310}
311
312fn build_test_probes(config_dir: &str) -> Vec<AgentProbe> {
314 let base = PathBuf::from(config_dir);
315 let base: &'static Path = Box::leak(base.into_boxed_path());
318
319 let pairs: Vec<(&'static str, &'static str, &'static str, bool)> = vec![
322 (
323 "Claude Desktop",
324 "claude-desktop",
325 "claude/claude_desktop_config.json",
326 true,
327 ),
328 ("Cursor", "cursor", "cursor/mcp.json", true),
329 ("Continue", "continue", "continue/config.json", false),
330 ];
331
332 pairs
333 .into_iter()
334 .filter_map(|(name, short, rel_path, needs_restart)| {
335 let config_path = base.join(rel_path);
336 if config_path.parent()?.exists() {
337 Some(AgentProbe {
338 name,
339 short_name: short,
340 needs_restart,
341 detect_fn: {
342 let p: &'static Path = Box::leak(config_path.into_boxed_path());
344 match short {
346 "claude-desktop" => {
347 static mut TEST_PATH_CLAUDE: Option<&'static Path> = None;
348 unsafe {
349 TEST_PATH_CLAUDE = Some(p);
350 }
351 fn detect() -> Option<PathBuf> {
352 unsafe { TEST_PATH_CLAUDE.map(|p| p.to_path_buf()) }
353 }
354 detect
355 }
356 "cursor" => {
357 static mut TEST_PATH_CURSOR: Option<&'static Path> = None;
358 unsafe {
359 TEST_PATH_CURSOR = Some(p);
360 }
361 fn detect() -> Option<PathBuf> {
362 unsafe { TEST_PATH_CURSOR.map(|p| p.to_path_buf()) }
363 }
364 detect
365 }
366 "continue" => {
367 static mut TEST_PATH_CONTINUE: Option<&'static Path> = None;
368 unsafe {
369 TEST_PATH_CONTINUE = Some(p);
370 }
371 fn detect() -> Option<PathBuf> {
372 unsafe { TEST_PATH_CONTINUE.map(|p| p.to_path_buf()) }
373 }
374 detect
375 }
376 _ => return None,
377 }
378 },
379 })
380 } else {
381 None
382 }
383 })
384 .collect()
385}
386
387fn detect_claude_desktop() -> Option<PathBuf> {
388 let candidates = if cfg!(target_os = "macos") {
389 vec![home_dir().join("Library/Application Support/Claude/claude_desktop_config.json")]
390 } else {
391 vec![home_dir().join(".config/claude/claude_desktop_config.json")]
392 };
393 for p in candidates {
395 if let Some(parent) = p.parent() {
396 if parent.exists() {
397 return Some(p);
398 }
399 }
400 }
401 None
402}
403
404fn detect_claude_code() -> Option<PathBuf> {
405 let settings = home_dir().join(".claude/settings.json");
406 let parent = settings.parent()?;
407 if parent.exists() {
408 Some(settings)
409 } else {
410 if which::which("claude").is_ok() {
412 let _ = std::fs::create_dir_all(parent);
414 Some(settings)
415 } else {
416 None
417 }
418 }
419}
420
421fn detect_cursor() -> Option<PathBuf> {
422 let config = home_dir().join(".cursor/mcp.json");
423 if home_dir().join(".cursor").exists() {
424 Some(config)
425 } else {
426 None
427 }
428}
429
430fn detect_windsurf() -> Option<PathBuf> {
431 let config = home_dir().join(".codeium/windsurf/mcp_config.json");
432 if home_dir().join(".codeium").exists() {
433 Some(config)
434 } else {
435 None
436 }
437}
438
439fn detect_continue() -> Option<PathBuf> {
440 let config = home_dir().join(".continue/config.json");
441 if home_dir().join(".continue").exists() {
442 Some(config)
443 } else {
444 None
445 }
446}
447
448fn detect_cline() -> Option<PathBuf> {
449 let base = if cfg!(target_os = "macos") {
451 home_dir()
452 .join("Library/Application Support/Code/User/globalStorage/saoudrizwan.claude-dev")
453 } else {
454 home_dir().join(".config/Code/User/globalStorage/saoudrizwan.claude-dev")
455 };
456 if base.exists() {
457 Some(base.join("settings/cline_mcp_settings.json"))
458 } else {
459 None
460 }
461}
462
463fn cortex_mcp_entry() -> serde_json::Value {
467 json!({
468 "command": "npx",
469 "args": ["-y", "@cortex/mcp-server"],
470 "env": {
471 "CORTEX_HOST": "127.0.0.1",
472 "CORTEX_PORT": "7700"
473 }
474 })
475}
476
477enum InjectionResult {
479 Injected,
480 AlreadyPresent,
481}
482
483enum RemovalResult {
485 Removed,
486 NotPresent,
487}
488
489fn inject_mcp_server(config_path: &Path) -> Result<InjectionResult> {
491 let mut config: serde_json::Value = if config_path.exists() {
492 let content = std::fs::read_to_string(config_path)?;
493 serde_json::from_str(&content).unwrap_or(json!({}))
494 } else {
495 json!({})
496 };
497
498 let obj = config
499 .as_object_mut()
500 .ok_or_else(|| anyhow::anyhow!("config is not a JSON object"))?;
501
502 let servers = obj.entry("mcpServers").or_insert(json!({}));
503
504 if servers.get("cortex").is_some() {
505 return Ok(InjectionResult::AlreadyPresent);
506 }
507
508 servers
509 .as_object_mut()
510 .ok_or_else(|| anyhow::anyhow!("mcpServers is not a JSON object"))?
511 .insert("cortex".to_string(), cortex_mcp_entry());
512
513 if let Some(parent) = config_path.parent() {
515 std::fs::create_dir_all(parent)?;
516 }
517
518 std::fs::write(config_path, serde_json::to_string_pretty(&config)?)?;
519 Ok(InjectionResult::Injected)
520}
521
522fn remove_mcp_server(config_path: &Path) -> Result<RemovalResult> {
524 if !config_path.exists() {
525 return Ok(RemovalResult::NotPresent);
526 }
527
528 let content = std::fs::read_to_string(config_path)?;
529 let mut config: serde_json::Value = serde_json::from_str(&content)?;
530
531 if let Some(servers) = config.get_mut("mcpServers").and_then(|v| v.as_object_mut()) {
532 if servers.remove("cortex").is_some() {
533 std::fs::write(config_path, serde_json::to_string_pretty(&config)?)?;
534 return Ok(RemovalResult::Removed);
535 }
536 }
537
538 Ok(RemovalResult::NotPresent)
539}
540
541fn check_cortex_present(config_path: &Path) -> bool {
543 if !config_path.exists() {
544 return false;
545 }
546 let Ok(content) = std::fs::read_to_string(config_path) else {
547 return false;
548 };
549 let Ok(config) = serde_json::from_str::<serde_json::Value>(&content) else {
550 return false;
551 };
552 config
553 .get("mcpServers")
554 .and_then(|v| v.get("cortex"))
555 .is_some()
556}
557
558#[cfg(test)]
559mod tests {
560 use super::*;
561 use std::io::Write;
562 use tempfile::NamedTempFile;
563
564 #[test]
565 fn test_inject_into_empty_file() {
566 let mut tmp = NamedTempFile::new().unwrap();
567 write!(tmp, "{{}}").unwrap();
568 let path = tmp.path().to_path_buf();
569
570 let result = inject_mcp_server(&path).unwrap();
571 assert!(matches!(result, InjectionResult::Injected));
572
573 let content: serde_json::Value =
574 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
575 assert!(content["mcpServers"]["cortex"]["command"]
576 .as_str()
577 .is_some());
578 }
579
580 #[test]
581 fn test_inject_idempotent() {
582 let mut tmp = NamedTempFile::new().unwrap();
583 write!(tmp, "{{}}").unwrap();
584 let path = tmp.path().to_path_buf();
585
586 inject_mcp_server(&path).unwrap();
587 let result = inject_mcp_server(&path).unwrap();
588 assert!(matches!(result, InjectionResult::AlreadyPresent));
589 }
590
591 #[test]
592 fn test_inject_preserves_existing_servers() {
593 let mut tmp = NamedTempFile::new().unwrap();
594 write!(
595 tmp,
596 r#"{{"mcpServers": {{"other": {{"command": "other-server"}}}}}}"#
597 )
598 .unwrap();
599 let path = tmp.path().to_path_buf();
600
601 inject_mcp_server(&path).unwrap();
602
603 let content: serde_json::Value =
604 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
605 assert!(content["mcpServers"]["other"]["command"].as_str().is_some());
606 assert!(content["mcpServers"]["cortex"]["command"]
607 .as_str()
608 .is_some());
609 }
610
611 #[test]
612 fn test_remove_mcp_server() {
613 let mut tmp = NamedTempFile::new().unwrap();
614 write!(tmp, "{{}}").unwrap();
615 let path = tmp.path().to_path_buf();
616
617 inject_mcp_server(&path).unwrap();
618 let result = remove_mcp_server(&path).unwrap();
619 assert!(matches!(result, RemovalResult::Removed));
620
621 let content: serde_json::Value =
622 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
623 assert!(content["mcpServers"]["cortex"].is_null());
624 }
625
626 #[test]
627 fn test_remove_not_present() {
628 let mut tmp = NamedTempFile::new().unwrap();
629 write!(tmp, "{{}}").unwrap();
630 let path = tmp.path().to_path_buf();
631
632 let result = remove_mcp_server(&path).unwrap();
633 assert!(matches!(result, RemovalResult::NotPresent));
634 }
635
636 #[test]
637 fn test_check_cortex_present() {
638 let mut tmp = NamedTempFile::new().unwrap();
639 write!(tmp, "{{}}").unwrap();
640 let path = tmp.path().to_path_buf();
641
642 assert!(!check_cortex_present(&path));
643 inject_mcp_server(&path).unwrap();
644 assert!(check_cortex_present(&path));
645 }
646}