Skip to main content

cortex_runtime/cli/
repl_commands.rs

1// Copyright 2026 Cortex Contributors
2// SPDX-License-Identifier: Apache-2.0
3
4//! Slash command parsing and dispatch for the Cortex REPL.
5//!
6//! Each slash command maps to functionality from the existing CLI commands,
7//! adapted for the interactive session context (e.g., active domain tracking).
8
9use crate::cli::doctor::cortex_home;
10use crate::cli::output::{self, format_duration, format_size, Styled};
11use crate::cli::repl_complete::COMMANDS;
12use crate::cli::repl_progress;
13use crate::intelligence::cache::MapCache;
14use crate::map::types::{
15    FeatureRange, NodeQuery, PageType, PathConstraints, FEAT_PRICE, FEAT_RATING,
16};
17use anyhow::Result;
18use std::time::Instant;
19
20/// Session state preserved across commands.
21pub struct ReplState {
22    /// Currently active domain for queries/pathfind.
23    pub active_domain: Option<String>,
24}
25
26impl ReplState {
27    pub fn new() -> Self {
28        Self {
29            active_domain: None,
30        }
31    }
32}
33
34/// Parse and execute a slash command. Returns `true` if the REPL should exit.
35pub async fn execute(input: &str, state: &mut ReplState) -> Result<bool> {
36    let input = input.trim();
37    if input.is_empty() {
38        return Ok(false);
39    }
40
41    // Strip leading / if present
42    let input = input.strip_prefix('/').unwrap_or(input);
43
44    // Bare `/` with nothing else → show help
45    if input.is_empty() {
46        cmd_help();
47        return Ok(false);
48    }
49
50    // Split into command and arguments
51    let mut parts = input.splitn(2, ' ');
52    let cmd = parts.next().unwrap_or("");
53    let args = parts.next().unwrap_or("").trim();
54
55    match cmd {
56        "exit" | "quit" | "q" => return Ok(true),
57        "help" | "h" | "?" => cmd_help(),
58        "clear" | "cls" => cmd_clear(),
59        "status" => cmd_status().await?,
60        "doctor" => cmd_doctor().await?,
61        "maps" | "ls" => cmd_maps()?,
62        "use" => cmd_use(args, state)?,
63        "map" => cmd_map(args, state).await?,
64        "query" => cmd_query(args, state)?,
65        "pathfind" | "path" => cmd_pathfind(args, state)?,
66        "perceive" => cmd_perceive(args).await?,
67        "settings" | "config" => cmd_settings()?,
68        "cache" => cmd_cache(args)?,
69        "plug" => cmd_plug().await?,
70        _ => {
71            let s = Styled::new();
72            if let Some(suggestion) = crate::cli::repl_complete::suggest_command(cmd) {
73                eprintln!(
74                    "  {} Unknown command '/{cmd}'. Did you mean {}?",
75                    s.warn_sym(),
76                    s.bold(suggestion)
77                );
78            } else {
79                eprintln!(
80                    "  {} Unknown command '/{cmd}'. Type {} or press {} for commands.",
81                    s.warn_sym(),
82                    s.bold("/help"),
83                    s.bold("/")
84                );
85            }
86        }
87    }
88
89    Ok(false)
90}
91
92/// /help — Show available commands.
93fn cmd_help() {
94    let s = Styled::new();
95    eprintln!();
96    eprintln!("  {}", s.bold("Commands:"));
97    eprintln!();
98    for (cmd, desc) in COMMANDS {
99        eprintln!("    {:<22} {}", s.cyan(cmd), s.dim(desc));
100    }
101    eprintln!();
102    eprintln!(
103        "  {}",
104        s.dim("Tip: Tab completion works for commands and domain names.")
105    );
106    eprintln!();
107}
108
109/// /clear — Clear the terminal.
110fn cmd_clear() {
111    // ANSI escape to clear screen and move cursor to top-left
112    eprint!("\x1b[2J\x1b[H");
113}
114
115/// /status — Show runtime status.
116async fn cmd_status() -> Result<()> {
117    crate::cli::status::run().await
118}
119
120/// /doctor — Environment diagnostics.
121async fn cmd_doctor() -> Result<()> {
122    crate::cli::doctor::run().await
123}
124
125/// /maps — List cached maps.
126fn cmd_maps() -> Result<()> {
127    let s = Styled::new();
128    let maps_dir = cortex_home().join("maps");
129
130    let mut entries: Vec<(String, u64, std::time::SystemTime)> = Vec::new();
131    if let Ok(dir) = std::fs::read_dir(&maps_dir) {
132        for entry in dir.flatten() {
133            let path = entry.path();
134            if path.extension().is_some_and(|e| e == "ctx") {
135                if let (Some(stem), Ok(meta)) =
136                    (path.file_stem().and_then(|s| s.to_str()), path.metadata())
137                {
138                    let modified = meta.modified().unwrap_or(std::time::SystemTime::UNIX_EPOCH);
139                    entries.push((stem.to_string(), meta.len(), modified));
140                }
141            }
142        }
143    }
144
145    entries.sort_by(|a, b| b.2.cmp(&a.2));
146
147    if entries.is_empty() {
148        eprintln!();
149        eprintln!(
150            "  {} No cached maps. Map a site with: {}",
151            s.info_sym(),
152            s.bold("/map example.com")
153        );
154        eprintln!();
155        return Ok(());
156    }
157
158    eprintln!();
159    eprintln!("  {} cached map(s):", entries.len());
160    eprintln!();
161
162    let mut total_size = 0u64;
163    for (name, size, modified) in &entries {
164        total_size += size;
165        let ago = modified
166            .elapsed()
167            .map(|d| format_duration(d.as_secs()) + " ago")
168            .unwrap_or_else(|_| "unknown".to_string());
169        eprintln!(
170            "    {:<25} {:>10}   {}",
171            s.bold(name),
172            format_size(*size),
173            s.dim(&ago)
174        );
175    }
176    eprintln!();
177    eprintln!("    {:<25} {}", "Total:", format_size(total_size));
178    eprintln!();
179
180    Ok(())
181}
182
183/// /use <domain> — Switch active domain.
184fn cmd_use(args: &str, state: &mut ReplState) -> Result<()> {
185    let s = Styled::new();
186
187    if args.is_empty() {
188        if let Some(ref domain) = state.active_domain {
189            eprintln!("  Active domain: {}", s.bold(domain));
190        } else {
191            eprintln!(
192                "  {} No active domain. Usage: {}",
193                s.info_sym(),
194                s.bold("/use example.com")
195            );
196        }
197        return Ok(());
198    }
199
200    let domain = args.split_whitespace().next().unwrap_or(args);
201
202    // Check if map exists in cache
203    let mut cache = MapCache::default_cache()?;
204    if cache.load_map(domain)?.is_some() {
205        state.active_domain = Some(domain.to_string());
206        eprintln!("  {} Active domain set to: {}", s.ok_sym(), s.bold(domain));
207    } else {
208        eprintln!(
209            "  {} No cached map for '{}'. Map it first with: /map {domain}",
210            s.warn_sym(),
211            domain
212        );
213        // Still set it — they might map it next
214        state.active_domain = Some(domain.to_string());
215    }
216
217    Ok(())
218}
219
220/// /map <domain> — Map a website with progress display.
221async fn cmd_map(args: &str, state: &mut ReplState) -> Result<()> {
222    let s = Styled::new();
223
224    if args.is_empty() {
225        eprintln!("  {} Usage: {}", s.info_sym(), s.bold("/map <domain>"));
226        return Ok(());
227    }
228
229    let domain = args.split_whitespace().next().unwrap_or(args);
230    let start = Instant::now();
231
232    eprintln!();
233    eprintln!("  Mapping {}...", s.bold(domain));
234    eprintln!();
235
236    // Create progress bars for each acquisition layer
237    let (mp, bars) = repl_progress::create_mapping_progress();
238
239    // Auto-start daemon if needed
240    let socket_path = "/tmp/cortex.sock";
241    if !std::path::Path::new(socket_path).exists() {
242        repl_progress::set_layer_active(&bars[0], "Starting daemon", "auto-start...");
243        let _ = crate::cli::start::run().await;
244        tokio::time::sleep(std::time::Duration::from_millis(300)).await;
245    }
246
247    // Mark first layer as active
248    repl_progress::set_layer_active(&bars[0], "Sitemap discovery", "scanning...");
249
250    // Connect and send MAP request
251    use tokio::io::{AsyncReadExt, AsyncWriteExt};
252    use tokio::net::UnixStream;
253
254    let mut stream = match UnixStream::connect(socket_path).await {
255        Ok(s) => s,
256        Err(_) => {
257            for bar in &bars {
258                repl_progress::set_layer_skipped(bar, "", "failed");
259            }
260            mp.clear()?;
261            eprintln!(
262                "  {} Cannot connect to daemon. Run /status to check.",
263                s.fail_sym()
264            );
265            return Ok(());
266        }
267    };
268
269    let req = serde_json::json!({
270        "id": format!("repl-map-{}", std::process::id()),
271        "method": "map",
272        "params": {
273            "domain": domain,
274            "max_nodes": 50000_u32,
275            "max_render": 200_u32,
276            "max_time_ms": 10000_u64,
277            "respect_robots": true,
278        }
279    });
280    let req_str = format!("{}\n", req);
281    stream.write_all(req_str.as_bytes()).await?;
282
283    // Simulate layer progression while waiting for response
284    // (The real mapper runs all layers server-side; we animate to show activity)
285    let read_handle = tokio::spawn(async move {
286        let mut buf = vec![0u8; 1024 * 1024];
287        let timeout = std::time::Duration::from_millis(40000);
288        match tokio::time::timeout(timeout, stream.read(&mut buf)).await {
289            Ok(Ok(n)) if n > 0 => {
290                let response: serde_json::Value =
291                    serde_json::from_slice(&buf[..n]).unwrap_or_default();
292                Some(response)
293            }
294            _ => None,
295        }
296    });
297
298    // Animate layers while waiting
299    tokio::time::sleep(std::time::Duration::from_millis(500)).await;
300    repl_progress::set_layer_done(&bars[0], "Sitemap discovery", "URLs discovered");
301
302    repl_progress::set_layer_active(&bars[1], "HTTP extraction", "fetching pages...");
303    tokio::time::sleep(std::time::Duration::from_millis(500)).await;
304    repl_progress::set_layer_done(&bars[1], "HTTP extraction", "pages fetched");
305
306    repl_progress::set_layer_active(&bars[2], "Pattern engine", "extracting data...");
307    tokio::time::sleep(std::time::Duration::from_millis(300)).await;
308    repl_progress::set_layer_done(&bars[2], "Pattern engine", "data extracted");
309
310    repl_progress::set_layer_active(&bars[3], "API discovery", "scanning endpoints...");
311    tokio::time::sleep(std::time::Duration::from_millis(200)).await;
312    repl_progress::set_layer_done(&bars[3], "API discovery", "endpoints found");
313
314    // Wait for actual response
315    let response = read_handle.await.ok().flatten();
316
317    repl_progress::set_layer_skipped(&bars[4], "Browser fallback", "skipped (HTTP sufficient)");
318
319    // Clear progress display
320    let _ = mp.clear();
321
322    eprintln!();
323
324    match response {
325        Some(resp) => {
326            if let Some(error) = resp.get("error") {
327                let msg = error
328                    .get("message")
329                    .and_then(|v| v.as_str())
330                    .unwrap_or("unknown error");
331                eprintln!("  {} Mapping failed: {msg}", s.fail_sym());
332            } else {
333                let result = resp.get("result").cloned().unwrap_or_default();
334                let node_count = result
335                    .get("node_count")
336                    .and_then(|v| v.as_u64())
337                    .unwrap_or(0);
338                let edge_count = result
339                    .get("edge_count")
340                    .and_then(|v| v.as_u64())
341                    .unwrap_or(0);
342                let elapsed = start.elapsed();
343
344                eprintln!(
345                    "  {} Map complete: {} nodes, {} edges ({:.1}s)",
346                    s.ok_sym(),
347                    s.bold(&node_count.to_string()),
348                    edge_count,
349                    elapsed.as_secs_f64()
350                );
351
352                // Auto-set active domain
353                state.active_domain = Some(domain.to_string());
354                eprintln!();
355                eprintln!(
356                    "  Active domain set to {}. Try: {}",
357                    s.bold(domain),
358                    s.cyan("/query --type product_detail")
359                );
360            }
361        }
362        None => {
363            eprintln!("  {} Mapping timed out or connection lost.", s.fail_sym());
364        }
365    }
366
367    eprintln!();
368    Ok(())
369}
370
371/// /query [filters] — Search the active domain's map.
372fn cmd_query(args: &str, state: &mut ReplState) -> Result<()> {
373    let s = Styled::new();
374
375    // Determine domain
376    let mut domain = state.active_domain.clone();
377    let mut page_type_str: Option<String> = None;
378    let mut price_lt: Option<f32> = None;
379    let mut rating_gt: Option<f32> = None;
380    let mut limit: u32 = 20;
381    let mut feature_filters: Vec<String> = Vec::new();
382
383    // Simple arg parser
384    let tokens: Vec<&str> = args.split_whitespace().collect();
385    let mut i = 0;
386    while i < tokens.len() {
387        match tokens[i] {
388            "--type" if i + 1 < tokens.len() => {
389                page_type_str = Some(tokens[i + 1].to_string());
390                i += 2;
391            }
392            "--price-lt" if i + 1 < tokens.len() => {
393                price_lt = tokens[i + 1].parse().ok();
394                i += 2;
395            }
396            "--rating-gt" if i + 1 < tokens.len() => {
397                rating_gt = tokens[i + 1].parse().ok();
398                i += 2;
399            }
400            "--limit" if i + 1 < tokens.len() => {
401                limit = tokens[i + 1].parse().unwrap_or(20);
402                i += 2;
403            }
404            "--feature" if i + 1 < tokens.len() => {
405                feature_filters.push(tokens[i + 1].to_string());
406                i += 2;
407            }
408            s if !s.starts_with('-') && domain.is_none() => {
409                domain = Some(s.to_string());
410                i += 1;
411            }
412            _ => {
413                i += 1;
414            }
415        }
416    }
417
418    let domain = match domain {
419        Some(d) => d,
420        None => {
421            eprintln!(
422                "  {} No active domain. Use: {} or {}",
423                s.info_sym(),
424                s.bold("/use example.com"),
425                s.bold("/query example.com --type article")
426            );
427            return Ok(());
428        }
429    };
430
431    // Delegate to existing query logic
432    let rt = tokio::runtime::Handle::current();
433    rt.block_on(async {
434        crate::cli::query_cmd::run(
435            &domain,
436            page_type_str.as_deref(),
437            price_lt,
438            rating_gt,
439            limit,
440            &feature_filters,
441        )
442        .await
443    })
444}
445
446/// /pathfind <from> <to> — Find shortest path.
447fn cmd_pathfind(args: &str, state: &mut ReplState) -> Result<()> {
448    let s = Styled::new();
449
450    let tokens: Vec<&str> = args.split_whitespace().collect();
451    let mut domain = state.active_domain.clone();
452    let mut from: Option<u32> = None;
453    let mut to: Option<u32> = None;
454
455    let mut i = 0;
456    while i < tokens.len() {
457        match tokens[i] {
458            "--from" if i + 1 < tokens.len() => {
459                from = tokens[i + 1].parse().ok();
460                i += 2;
461            }
462            "--to" if i + 1 < tokens.len() => {
463                to = tokens[i + 1].parse().ok();
464                i += 2;
465            }
466            s if !s.starts_with('-') && domain.is_none() => {
467                domain = Some(s.to_string());
468                i += 1;
469            }
470            s if !s.starts_with('-') => {
471                // Positional: first is from, second is to
472                if from.is_none() {
473                    from = s.parse().ok();
474                } else if to.is_none() {
475                    to = s.parse().ok();
476                }
477                i += 1;
478            }
479            _ => {
480                i += 1;
481            }
482        }
483    }
484
485    let domain = match domain {
486        Some(d) => d,
487        None => {
488            eprintln!(
489                "  {} No active domain. Use: {}",
490                s.info_sym(),
491                s.bold("/pathfind --from 0 --to 10")
492            );
493            return Ok(());
494        }
495    };
496
497    let from = match from {
498        Some(f) => f,
499        None => {
500            eprintln!(
501                "  {} Usage: {}",
502                s.info_sym(),
503                s.bold("/pathfind --from 0 --to 10")
504            );
505            return Ok(());
506        }
507    };
508
509    let to = match to {
510        Some(t) => t,
511        None => {
512            eprintln!(
513                "  {} Usage: {}",
514                s.info_sym(),
515                s.bold("/pathfind --from 0 --to 10")
516            );
517            return Ok(());
518        }
519    };
520
521    let rt = tokio::runtime::Handle::current();
522    rt.block_on(async { crate::cli::pathfind_cmd::run(&domain, from, to).await })
523}
524
525/// /perceive <url> — Analyze a single page.
526async fn cmd_perceive(args: &str) -> Result<()> {
527    let s = Styled::new();
528
529    if args.is_empty() {
530        eprintln!(
531            "  {} Usage: {}",
532            s.info_sym(),
533            s.bold("/perceive https://example.com/page")
534        );
535        return Ok(());
536    }
537
538    let url = args.split_whitespace().next().unwrap_or(args);
539    crate::cli::perceive_cmd::run(url, "pretty").await
540}
541
542/// /settings — Show current configuration.
543fn cmd_settings() -> Result<()> {
544    let s = Styled::new();
545
546    eprintln!();
547    eprintln!("  {}", s.bold("Configuration"));
548    eprintln!();
549    eprintln!("    {:<22} {}", "CORTEX_HOME", cortex_home().display());
550    let socket = "/tmp/cortex.sock";
551    eprintln!("    {:<22} {}", "Socket", socket);
552    eprintln!(
553        "    {:<22} {}",
554        "Chromium",
555        crate::cli::doctor::find_chromium()
556            .map(|p| p.display().to_string())
557            .unwrap_or_else(|| s.dim("not found").to_string())
558    );
559
560    // Show relevant env vars
561    eprintln!();
562    eprintln!("  {}", s.bold("Environment Variables"));
563    eprintln!();
564    let env_vars = [
565        "CORTEX_HOME",
566        "CORTEX_CHROMIUM_PATH",
567        "CORTEX_CHROMIUM_NO_SANDBOX",
568        "CORTEX_JSON",
569        "CORTEX_QUIET",
570        "CORTEX_VERBOSE",
571        "CORTEX_NO_COLOR",
572    ];
573    for var in &env_vars {
574        let val = std::env::var(var).unwrap_or_else(|_| s.dim("(not set)").to_string());
575        eprintln!("    {var:<32} {val}");
576    }
577
578    // Cache stats
579    let maps_dir = cortex_home().join("maps");
580    let cache_count = std::fs::read_dir(&maps_dir)
581        .map(|d| {
582            d.flatten()
583                .filter(|e| e.path().extension().is_some_and(|x| x == "ctx"))
584                .count()
585        })
586        .unwrap_or(0);
587    eprintln!();
588    eprintln!("  {}", s.bold("Cache"));
589    eprintln!("    {:<22} {}", "Cached maps", cache_count);
590    eprintln!("    {:<22} {}", "Location", maps_dir.display());
591    eprintln!();
592
593    Ok(())
594}
595
596/// /cache clear [domain] — Clear cached maps.
597fn cmd_cache(args: &str) -> Result<()> {
598    let s = Styled::new();
599
600    let parts: Vec<&str> = args.split_whitespace().collect();
601    if parts.is_empty() || parts[0] != "clear" {
602        eprintln!(
603            "  {} Usage: {}",
604            s.info_sym(),
605            s.bold("/cache clear [domain]")
606        );
607        return Ok(());
608    }
609
610    let domain = parts.get(1).copied();
611    let rt = tokio::runtime::Handle::current();
612    rt.block_on(async { crate::cli::cache_cmd::run_clear(domain).await })
613}
614
615/// /plug — Show agent connections.
616async fn cmd_plug() -> Result<()> {
617    crate::cli::plug::run(false, false, true, None, None).await
618}