1use anyhow::{Context, Result};
9use colored::Colorize;
10use std::process::Command;
11use std::time::Instant;
12
13use crate::providers::ProviderType;
14
15#[derive(Debug, Clone)]
17pub(crate) struct AgentConfig {
18 pub(crate) name: &'static str,
20 pub(crate) provider_type: ProviderType,
22 pub(crate) commands: &'static [&'static str],
24 pub(crate) default_args: &'static [&'static str],
26 pub(crate) harvestable: bool,
28 pub(crate) storage_hint: &'static str,
30}
31
32pub(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
100pub(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
119fn find_agent_binary(config: &AgentConfig) -> Option<String> {
121 for cmd in config.commands {
122 #[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 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
167fn 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
192pub(crate) fn resolve_storage_path(home: &std::path::Path, config: &AgentConfig) -> Option<std::path::PathBuf> {
194 let hint = config.storage_hint.trim_start_matches("~/");
196 let path = home.join(hint);
197 Some(path)
198}
199
200fn 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_files.push(path);
227 }
228 Some(old_time) => {
229 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 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
276pub fn run_agent_cli(
278 agent_alias: Option<&str>,
279 args: &[String],
280 no_save: bool,
281 verbose: bool,
282) -> Result<()> {
283 let alias = agent_alias.unwrap_or("claude"); 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 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 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 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 let mut cmd = Command::new(&binary);
352 cmd.args(config.default_args);
353 if !args.is_empty() {
354 cmd.args(args);
355 }
356
357 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 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 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 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
471pub(crate) fn auto_harvest_sessions(session_files: &[std::path::PathBuf]) -> Result<usize> {
473 use crate::commands::{harvest_init, harvest_run};
474
475 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 harvest_init(None, false)?;
485 }
486
487 harvest_run(None, None, None, true, false, Some("Auto-save from chasm run"))?;
489
490 Ok(session_files.len())
491}
492
493pub 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}