Skip to main content

cortex_runtime/cli/
repl.rs

1// Copyright 2026 Cortex Contributors
2// SPDX-License-Identifier: Apache-2.0
3
4//! Interactive REPL for Cortex — Claude Code-style slash command interface.
5//!
6//! Launch with `cortex` (no subcommand) to enter the interactive mode.
7//! Type `/help` for available commands, Tab for completion.
8
9use crate::cli::doctor::cortex_home;
10use crate::cli::output::Styled;
11use crate::cli::repl_commands;
12use crate::cli::repl_complete;
13use anyhow::Result;
14use rustyline::config::CompletionType;
15use rustyline::error::ReadlineError;
16use rustyline::{Config, Editor};
17
18/// History file location.
19fn history_path() -> std::path::PathBuf {
20    cortex_home().join("repl_history")
21}
22
23/// Print the welcome banner with runtime status summary.
24async fn print_banner() {
25    let s = Styled::new();
26
27    eprintln!();
28    eprintln!(
29        "  {} {} {}",
30        s.green("\u{25c9}"),
31        s.bold(&format!("Cortex v{}", env!("CARGO_PKG_VERSION"))),
32        s.dim("— Web Cartographer for AI Agents")
33    );
34
35    // Quick status check
36    let daemon_status = check_daemon_status().await;
37    let cache_count = count_cached_maps();
38
39    eprintln!("    {} | Cached maps: {}", daemon_status, cache_count,);
40
41    eprintln!();
42    eprintln!(
43        "    Press {} to browse commands, {} to complete, {} to quit.",
44        s.cyan("/"),
45        s.dim("Tab"),
46        s.dim("/exit")
47    );
48    eprintln!();
49}
50
51/// Check if daemon is running and return a status string.
52async fn check_daemon_status() -> String {
53    let s = Styled::new();
54    let socket_path = "/tmp/cortex.sock";
55
56    if !std::path::Path::new(socket_path).exists() {
57        return format!("Daemon: {}", s.yellow("not running"));
58    }
59
60    // Try to connect
61    match tokio::net::UnixStream::connect(socket_path).await {
62        Ok(_) => {
63            // Read PID
64            let pid = std::fs::read_to_string(cortex_home().join("cortex.pid"))
65                .ok()
66                .and_then(|p| p.trim().parse::<i32>().ok());
67
68            match pid {
69                Some(p) => format!("Daemon: {} (pid {})", s.green("running"), p),
70                None => format!("Daemon: {}", s.green("running")),
71            }
72        }
73        Err(_) => format!("Daemon: {}", s.yellow("socket exists but unresponsive")),
74    }
75}
76
77/// Count cached .ctx files.
78fn count_cached_maps() -> usize {
79    let maps_dir = cortex_home().join("maps");
80    std::fs::read_dir(&maps_dir)
81        .map(|d| {
82            d.flatten()
83                .filter(|e| e.path().extension().is_some_and(|x| x == "ctx"))
84                .count()
85        })
86        .unwrap_or(0)
87}
88
89/// Run the interactive REPL.
90pub async fn run() -> Result<()> {
91    // Print welcome banner
92    print_banner().await;
93
94    // Configure rustyline with List completion (shows all matches like Bash)
95    let config = Config::builder()
96        .history_ignore_space(true)
97        .auto_add_history(true)
98        .completion_type(CompletionType::List)
99        .completion_prompt_limit(20)
100        .build();
101
102    let helper = repl_complete::CortexHelper::new();
103    let mut rl: Editor<repl_complete::CortexHelper, rustyline::history::DefaultHistory> =
104        Editor::with_config(config)?;
105    rl.set_helper(Some(helper));
106
107    // Bind custom keys for smart Tab completion and command picker
108    repl_complete::bind_keys(&mut rl);
109
110    // Load history
111    let hist_path = history_path();
112    if hist_path.exists() {
113        let _ = rl.load_history(&hist_path);
114    }
115
116    // Session state
117    let mut state = repl_commands::ReplState::new();
118
119    // Main REPL loop
120    let prompt = format!(
121        " {} ",
122        if Styled::new().ok_sym() == "OK" {
123            "cortex>"
124        } else {
125            "\x1b[36mcortex>\x1b[0m"
126        }
127    );
128
129    loop {
130        match rl.readline(&prompt) {
131            Ok(line) => {
132                let line = line.trim();
133                if line.is_empty() {
134                    continue;
135                }
136
137                // Dispatch command
138                match repl_commands::execute(line, &mut state).await {
139                    Ok(true) => {
140                        // /exit was called
141                        let s = Styled::new();
142                        eprintln!("  {} Goodbye!", s.dim("\u{2728}"));
143                        break;
144                    }
145                    Ok(false) => {
146                        // Continue REPL
147                    }
148                    Err(e) => {
149                        let s = Styled::new();
150                        eprintln!("  {} {e:#}", s.fail_sym());
151                    }
152                }
153            }
154            Err(ReadlineError::Interrupted) => {
155                // Ctrl+C — don't exit, just show hint
156                let s = Styled::new();
157                eprintln!("  {} Type {} to quit.", s.dim("(Ctrl+C)"), s.bold("/exit"));
158            }
159            Err(ReadlineError::Eof) => {
160                // Ctrl+D — exit
161                let s = Styled::new();
162                eprintln!("  {} Goodbye!", s.dim("\u{2728}"));
163                break;
164            }
165            Err(err) => {
166                eprintln!("  Error: {err}");
167                break;
168            }
169        }
170    }
171
172    // Save history
173    let _ = std::fs::create_dir_all(hist_path.parent().unwrap_or(std::path::Path::new(".")));
174    let _ = rl.save_history(&hist_path);
175
176    Ok(())
177}