Skip to main content

chasm/commands/
run.rs

1// Copyright (c) 2024-2026 Nervosys LLC
2// SPDX-License-Identifier: AGPL-3.0-only
3//! Run commands for launching AI coding agents with auto-save
4//!
5//! Launches terminal CLI agents (Claude Code, Codex CLI, Cursor, etc.)
6//! and automatically harvests the resulting session into the chasm database.
7
8use anyhow::{Context, Result};
9use colored::Colorize;
10use std::process::Command;
11use std::time::Instant;
12
13use crate::providers::ProviderType;
14
15/// Agent binary configuration
16#[derive(Debug, Clone)]
17pub(crate) struct AgentConfig {
18    /// Display name
19    pub(crate) name: &'static str,
20    /// Provider type for harvest
21    pub(crate) provider_type: ProviderType,
22    /// Command/binary names to try (in order of preference)
23    pub(crate) commands: &'static [&'static str],
24    /// Default arguments when launching interactively
25    pub(crate) default_args: &'static [&'static str],
26    /// Whether this agent creates files we can auto-harvest
27    pub(crate) harvestable: bool,
28    /// Session storage description
29    pub(crate) storage_hint: &'static str,
30}
31
32/// All supported agent configurations
33pub(crate) const AGENTS: &[AgentConfig] = &[
34    AgentConfig {
35        name: "Claude Code",
36        provider_type: ProviderType::ClaudeCode,
37        commands: &["claude"],
38        default_args: &[],
39        harvestable: true,
40        storage_hint: "~/.claude/projects/",
41    },
42    AgentConfig {
43        name: "OpenCode",
44        provider_type: ProviderType::OpenCode,
45        commands: &["opencode"],
46        default_args: &[],
47        harvestable: true,
48        storage_hint: "~/.opencode/conversations/",
49    },
50    AgentConfig {
51        name: "OpenClaw",
52        provider_type: ProviderType::OpenClaw,
53        commands: &["openclaw", "clawdbot"],
54        default_args: &[],
55        harvestable: true,
56        storage_hint: "~/.openclaw/chat-history/",
57    },
58    AgentConfig {
59        name: "Antigravity",
60        provider_type: ProviderType::Antigravity,
61        commands: &["antigravity", "ag"],
62        default_args: &[],
63        harvestable: true,
64        storage_hint: "~/.antigravity/sessions/",
65    },
66    AgentConfig {
67        name: "Cursor CLI",
68        provider_type: ProviderType::Cursor,
69        commands: &["cursor"],
70        default_args: &[],
71        harvestable: true,
72        storage_hint: "~/.cursor/chats/",
73    },
74    AgentConfig {
75        name: "Codex CLI",
76        provider_type: ProviderType::CodexCli,
77        commands: &["codex"],
78        default_args: &[],
79        harvestable: true,
80        storage_hint: "~/.codex/sessions/",
81    },
82    AgentConfig {
83        name: "Droid CLI",
84        provider_type: ProviderType::DroidCli,
85        commands: &["droid", "factory"],
86        default_args: &[],
87        harvestable: true,
88        storage_hint: "~/.factory/sessions/",
89    },
90    AgentConfig {
91        name: "Gemini CLI",
92        provider_type: ProviderType::GeminiCli,
93        commands: &["gemini"],
94        default_args: &[],
95        harvestable: true,
96        storage_hint: "~/.gemini/tmp/",
97    },
98];
99
100/// Resolve an agent alias to its configuration
101pub(crate) fn resolve_agent(alias: &str) -> Option<&'static AgentConfig> {
102    let alias_lower = alias.to_lowercase();
103    match alias_lower.as_str() {
104        "claude" | "claude-code" | "claudecode" => AGENTS.iter().find(|a| a.name == "Claude Code"),
105        "open" | "opencode" | "open-code" => AGENTS.iter().find(|a| a.name == "OpenCode"),
106        "claw" | "openclaw" | "clawdbot" | "open-claw" => {
107            AGENTS.iter().find(|a| a.name == "OpenClaw")
108        }
109        "cursor" | "cursor-cli" => AGENTS.iter().find(|a| a.name == "Cursor CLI"),
110        "codex" | "codex-cli" | "codexcli" => AGENTS.iter().find(|a| a.name == "Codex CLI"),
111        "droid" | "droid-cli" | "droidcli" | "factory" => {
112            AGENTS.iter().find(|a| a.name == "Droid CLI")
113        }
114        "gemini" | "gemini-cli" | "geminicli" => AGENTS.iter().find(|a| a.name == "Gemini CLI"),
115        _ => None,
116    }
117}
118
119/// Find the executable path for an agent
120fn find_agent_binary(config: &AgentConfig) -> Option<String> {
121    for cmd in config.commands {
122        // Try `which`/`where` to find the binary
123        #[cfg(target_os = "windows")]
124        {
125            if let Ok(output) = Command::new("where").arg(cmd).output() {
126                if output.status.success() {
127                    if let Ok(path) = String::from_utf8(output.stdout) {
128                        let path = path.lines().next().unwrap_or("").trim();
129                        if !path.is_empty() {
130                            return Some(path.to_string());
131                        }
132                    }
133                }
134            }
135            // Also check common npm global paths
136            if let Ok(output) = Command::new("where")
137                .arg(format!("{}.cmd", cmd))
138                .output()
139            {
140                if output.status.success() {
141                    if let Ok(path) = String::from_utf8(output.stdout) {
142                        let path = path.lines().next().unwrap_or("").trim();
143                        if !path.is_empty() {
144                            return Some(path.to_string());
145                        }
146                    }
147                }
148            }
149        }
150        #[cfg(not(target_os = "windows"))]
151        {
152            if let Ok(output) = Command::new("which").arg(cmd).output() {
153                if output.status.success() {
154                    if let Ok(path) = String::from_utf8(output.stdout) {
155                        let path = path.trim();
156                        if !path.is_empty() {
157                            return Some(path.to_string());
158                        }
159                    }
160                }
161            }
162        }
163    }
164    None
165}
166
167/// Snapshot current session files before agent launch (for diff-based auto-save)
168fn snapshot_session_dir(config: &AgentConfig) -> Option<std::collections::HashMap<std::path::PathBuf, std::time::SystemTime>> {
169    let home = dirs::home_dir()?;
170    let storage_path = resolve_storage_path(&home, config)?;
171
172    if !storage_path.exists() {
173        return Some(std::collections::HashMap::new());
174    }
175
176    let mut snapshot = std::collections::HashMap::new();
177    if let Ok(entries) = std::fs::read_dir(&storage_path) {
178        for entry in entries.flatten() {
179            let path = entry.path();
180            if path.is_file() {
181                if let Ok(metadata) = path.metadata() {
182                    if let Ok(modified) = metadata.modified() {
183                        snapshot.insert(path, modified);
184                    }
185                }
186            }
187        }
188    }
189    Some(snapshot)
190}
191
192/// Resolve the storage path for an agent from its config
193pub(crate) fn resolve_storage_path(home: &std::path::Path, config: &AgentConfig) -> Option<std::path::PathBuf> {
194    // Parse storage_hint like "~/.claude/projects/" into actual path
195    let hint = config.storage_hint.trim_start_matches("~/");
196    let path = home.join(hint);
197    Some(path)
198}
199
200/// Detect new/modified session files after agent exits
201fn detect_new_sessions(
202    config: &AgentConfig,
203    before: &std::collections::HashMap<std::path::PathBuf, std::time::SystemTime>,
204) -> Vec<std::path::PathBuf> {
205    let home = match dirs::home_dir() {
206        Some(h) => h,
207        None => return vec![],
208    };
209    let storage_path = match resolve_storage_path(&home, config) {
210        Some(p) => p,
211        None => return vec![],
212    };
213
214    if !storage_path.exists() {
215        return vec![];
216    }
217
218    let mut new_files = Vec::new();
219    if let Ok(entries) = std::fs::read_dir(&storage_path) {
220        for entry in entries.flatten() {
221            let path = entry.path();
222            if path.is_file() {
223                match before.get(&path) {
224                    None => {
225                        // New file
226                        new_files.push(path);
227                    }
228                    Some(old_time) => {
229                        // Check if modified
230                        if let Ok(metadata) = path.metadata() {
231                            if let Ok(modified) = metadata.modified() {
232                                if modified > *old_time {
233                                    new_files.push(path);
234                                }
235                            }
236                        }
237                    }
238                }
239            }
240        }
241    }
242
243    // Also recurse one level for agents that use per-project subdirs (Claude Code)
244    if config.storage_hint.contains("projects") {
245        if let Ok(entries) = std::fs::read_dir(&storage_path) {
246            for entry in entries.flatten() {
247                let subdir = entry.path();
248                if subdir.is_dir() {
249                    if let Ok(sub_entries) = std::fs::read_dir(&subdir) {
250                        for sub_entry in sub_entries.flatten() {
251                            let path = sub_entry.path();
252                            if path.is_file() {
253                                match before.get(&path) {
254                                    None => new_files.push(path),
255                                    Some(old_time) => {
256                                        if let Ok(metadata) = path.metadata() {
257                                            if let Ok(modified) = metadata.modified() {
258                                                if modified > *old_time {
259                                                    new_files.push(path);
260                                                }
261                                            }
262                                        }
263                                    }
264                                }
265                            }
266                        }
267                    }
268                }
269            }
270        }
271    }
272
273    new_files
274}
275
276/// Run an agent by alias with auto-save
277pub fn run_agent_cli(
278    agent_alias: Option<&str>,
279    args: &[String],
280    no_save: bool,
281    verbose: bool,
282) -> Result<()> {
283    // Resolve agent
284    let alias = agent_alias.unwrap_or("claude"); // Default to Claude Code
285    let config = resolve_agent(alias).ok_or_else(|| {
286        anyhow::anyhow!(
287            "Unknown agent '{}'. Supported agents:\n\
288             \n  {}  claude    → Claude Code\
289             \n  {}  open      → OpenCode\
290             \n  {}  claw      → OpenClaw (ClawdBot)\
291             \n  {}  cursor    → Cursor CLI\
292             \n  {}  codex     → Codex CLI (OpenAI)\
293             \n  {}  droid     → Droid CLI (Factory)\
294             \n  {}  gemini    → Gemini CLI (Google)\
295             \n\nRun 'chasm list agents' to see all agents.",
296            alias,
297            "*".cyan(),
298            "*".cyan(),
299            "*".cyan(),
300            "*".cyan(),
301            "*".cyan(),
302            "*".cyan(),
303            "*".cyan(),
304        )
305    })?;
306
307    // Find binary
308    let binary = find_agent_binary(config).ok_or_else(|| {
309        anyhow::anyhow!(
310            "{} not found. Tried: {}\n\
311             \nInstall it first:\n\
312             \n  {} npm install -g {} (if available)\
313             \n  {} Or visit the agent's official website",
314            config.name,
315            config.commands.join(", "),
316            "→".cyan(),
317            config.commands[0],
318            "→".cyan(),
319        )
320    })?;
321
322    // Header
323    println!("{}", "=".repeat(70).cyan());
324    println!(
325        "{} Launching {} with auto-save",
326        "[>]".green().bold(),
327        config.name.bold()
328    );
329    println!("{}", "=".repeat(70).cyan());
330    println!();
331    println!("  {} {}", "Binary:".dimmed(), binary);
332    println!("  {} {}", "Storage:".dimmed(), config.storage_hint);
333    if !no_save {
334        println!(
335            "  {} Sessions will be auto-saved on exit",
336            "Auto-save:".dimmed()
337        );
338    }
339    println!();
340
341    // Snapshot session files before launch
342    let before_snapshot = if !no_save && config.harvestable {
343        snapshot_session_dir(config)
344    } else {
345        None
346    };
347
348    let timer = Instant::now();
349
350    // Build command
351    let mut cmd = Command::new(&binary);
352    cmd.args(config.default_args);
353    if !args.is_empty() {
354        cmd.args(args);
355    }
356
357    // Inherit stdio for interactive use
358    cmd.stdin(std::process::Stdio::inherit())
359        .stdout(std::process::Stdio::inherit())
360        .stderr(std::process::Stdio::inherit());
361
362    if verbose {
363        println!(
364            "{} Running: {} {}",
365            "[*]".blue(),
366            binary,
367            args.join(" ")
368        );
369    }
370
371    // Launch agent (blocking — waits for user to finish)
372    let status = cmd
373        .status()
374        .with_context(|| format!("Failed to launch {}", config.name))?;
375
376    let elapsed = timer.elapsed();
377    let elapsed_str = if elapsed.as_secs() >= 3600 {
378        format!(
379            "{}h {}m {}s",
380            elapsed.as_secs() / 3600,
381            (elapsed.as_secs() % 3600) / 60,
382            elapsed.as_secs() % 60
383        )
384    } else if elapsed.as_secs() >= 60 {
385        format!(
386            "{}m {}s",
387            elapsed.as_secs() / 60,
388            elapsed.as_secs() % 60
389        )
390    } else {
391        format!("{}s", elapsed.as_secs())
392    };
393
394    println!();
395    println!("{}", "=".repeat(70).cyan());
396
397    if status.success() {
398        println!(
399            "{} {} exited successfully ({})",
400            "[+]".green().bold(),
401            config.name,
402            elapsed_str.dimmed()
403        );
404    } else {
405        println!(
406            "{} {} exited with code {} ({})",
407            "[!]".yellow().bold(),
408            config.name,
409            status.code().unwrap_or(-1),
410            elapsed_str.dimmed()
411        );
412    }
413
414    // Auto-save: detect new sessions and harvest them
415    if !no_save && config.harvestable {
416        if let Some(ref before) = before_snapshot {
417            let new_sessions = detect_new_sessions(config, before);
418            if new_sessions.is_empty() {
419                println!(
420                    "{} No new session files detected",
421                    "[i]".blue()
422                );
423            } else {
424                println!(
425                    "{} Detected {} new/modified session file(s)",
426                    "[+]".green().bold(),
427                    new_sessions.len()
428                );
429                for f in &new_sessions {
430                    if let Some(name) = f.file_name() {
431                        println!("   {} {}", "+".green(), name.to_string_lossy().dimmed());
432                    }
433                }
434
435                // Auto-harvest into the database
436                println!();
437                println!(
438                    "{} Auto-saving to harvest database...",
439                    "[*]".blue()
440                );
441
442                match auto_harvest_sessions(&new_sessions) {
443                    Ok(count) => {
444                        println!(
445                            "{} Saved {} session(s) to harvest database",
446                            "[+]".green().bold(),
447                            count
448                        );
449                    }
450                    Err(e) => {
451                        println!(
452                            "{} Auto-save failed: {}",
453                            "[!]".yellow(),
454                            e
455                        );
456                        println!(
457                            "{} Run 'chasm harvest run' manually to save sessions",
458                            "[i]".blue()
459                        );
460                    }
461                }
462            }
463        }
464    }
465
466    println!("{}", "=".repeat(70).cyan());
467
468    Ok(())
469}
470
471/// Auto-harvest detected session files into the harvest database
472pub(crate) fn auto_harvest_sessions(session_files: &[std::path::PathBuf]) -> Result<usize> {
473    use crate::commands::{harvest_init, harvest_run};
474
475    // Check if harvest DB exists, init if needed
476    let db_path = if let Ok(p) = std::env::var("CSM_HARVEST_DB") {
477        std::path::PathBuf::from(p)
478    } else {
479        std::env::current_dir()?.join("chat_sessions.db")
480    };
481
482    if !db_path.exists() {
483        // Auto-init the harvest database
484        harvest_init(None, false)?;
485    }
486
487    // Run harvest to pick up new session files from all providers
488    harvest_run(None, None, None, true, false, Some("Auto-save from chasm run"))?;
489
490    Ok(session_files.len())
491}
492
493/// List all available agents and their status
494pub fn list_agents_cli() -> Result<()> {
495    println!("{}", "=".repeat(70).cyan());
496    println!(
497        "{} Available Agents",
498        "[*]".bold()
499    );
500    println!("{}", "=".repeat(70).cyan());
501    println!();
502
503    println!(
504        "  {:<14} {:<18} {:<12} {}",
505        "Alias".bold(),
506        "Agent".bold(),
507        "Status".bold(),
508        "Storage".bold()
509    );
510    println!("  {}", "-".repeat(64));
511
512    let aliases = [
513        ("claude", "Claude Code"),
514        ("open", "OpenCode"),
515        ("claw", "OpenClaw"),
516        ("cursor", "Cursor CLI"),
517        ("codex", "Codex CLI"),
518        ("droid", "Droid CLI"),
519        ("gemini", "Gemini CLI"),
520    ];
521
522    for (alias, _name) in &aliases {
523        if let Some(config) = resolve_agent(alias) {
524            let status = if find_agent_binary(config).is_some() {
525                "installed".green().to_string()
526            } else {
527                "not found".red().to_string()
528            };
529
530            println!(
531                "  {:<14} {:<18} {:<12} {}",
532                alias.cyan(),
533                config.name,
534                status,
535                config.storage_hint.dimmed()
536            );
537        }
538    }
539
540    println!();
541    println!(
542        "{} Usage: {} <agent> [-- <agent-args>...]",
543        "[i]".blue(),
544        "chasm run".bold()
545    );
546    println!(
547        "{} Default agent: {} (Claude Code)",
548        "[i]".blue(),
549        "claude".cyan()
550    );
551    println!();
552
553    Ok(())
554}