Skip to main content

cortex_runtime/cli/
plug.rs

1// Copyright 2026 Cortex Contributors
2// SPDX-License-Identifier: Apache-2.0
3
4//! `cortex plug` — auto-discover and inject into AI agents.
5//!
6//! Scans the machine for known AI agent configurations and injects
7//! Cortex as an MCP server into each one. Supports `--list`, `--remove`,
8//! `--status`, and `--agent <name>` for fine-grained control.
9
10use anyhow::Result;
11use serde_json::json;
12use std::path::{Path, PathBuf};
13
14/// Run the plug command.
15pub 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        // Filter to specific agent if requested
47        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        // Inject
148        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    // Summary
208    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
251// ── Agent Probes ────────────────────────────────────────────────
252
253/// An agent probe knows how to detect and locate an agent's config.
254struct 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
312/// Build probes that point to a test config directory instead of real agent paths.
313fn build_test_probes(config_dir: &str) -> Vec<AgentProbe> {
314    let base = PathBuf::from(config_dir);
315    // Leak the PathBuf into 'static lifetime for the fn pointers.
316    // This is fine — test mode only, process exits soon.
317    let base: &'static Path = Box::leak(base.into_boxed_path());
318
319    // We can't use fn pointers with captures, so use a global approach:
320    // create test config files for each known agent pattern.
321    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                        // Store the path in a leaked static so fn pointer can reference it.
343                        let p: &'static Path = Box::leak(config_path.into_boxed_path());
344                        // We need unique fn pointers per agent. Use a static array approach.
345                        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    // Return path even if file doesn't exist yet — parent dir must exist
394    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        // Check if `claude` is in PATH
411        if which::which("claude").is_ok() {
412            // Create the directory if it doesn't exist
413            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    // Cline stores config in VS Code's globalStorage
450    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
463// ── MCP Config Injection ────────────────────────────────────────
464
465/// The MCP server config that gets injected into agent configs.
466fn 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
477/// Result of an injection attempt.
478enum InjectionResult {
479    Injected,
480    AlreadyPresent,
481}
482
483/// Result of a removal attempt.
484enum RemovalResult {
485    Removed,
486    NotPresent,
487}
488
489/// Inject the Cortex MCP server entry into an agent's JSON config.
490fn 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    // Ensure parent directory exists
514    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
522/// Remove the Cortex MCP server entry from an agent's JSON config.
523fn 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
541/// Check if Cortex is already present in an agent's config.
542fn 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}