1use std::fs;
46use std::io::{self, BufRead, Write as _};
47use std::path::Path;
48
49use crate::agent_cli::{self, AgentCli, McpReport, McpStatus};
50use crate::config::{self, Config, LlmSection, Provider};
51use crate::error::RecallError;
52use crate::paths;
53use crate::transcript::Source;
54
55const GREEN: &str = "\x1b[32m";
57const YELLOW: &str = "\x1b[33m";
58const RED: &str = "\x1b[31m";
59const BOLD: &str = "\x1b[1m";
60const DIM: &str = "\x1b[2m";
61const RESET: &str = "\x1b[0m";
62
63const MEMORY_TEMPLATE: &str = "# Memory\n\n\
64<!-- recall-echo: Curated memory. Distilled facts, preferences, patterns. -->\n\
65<!-- Keep under 200 lines. Only write confirmed, stable information. -->\n";
66
67const ARCHIVE_TEMPLATE: &str = "# Conversation Archive\n\n\
68| # | Date | Session | Topics | Messages | Duration |\n\
69|---|------|---------|--------|----------|----------|\n";
70
71const MODEL_DOWNLOAD_SIZE: &str = "~127 MB";
74
75enum Status {
76 Created,
77 Exists,
78 Error,
79}
80
81fn print_status(status: Status, msg: &str) {
82 match status {
83 Status::Created => eprintln!(" {GREEN}✓{RESET} {msg}"),
84 Status::Exists => eprintln!(" {YELLOW}~{RESET} {msg}"),
85 Status::Error => eprintln!(" {RED}✗{RESET} {msg}"),
86 }
87}
88
89fn ensure_dir(path: &Path) {
90 if !path.exists() {
91 if let Err(e) = fs::create_dir_all(path) {
92 print_status(
93 Status::Error,
94 &format!("Failed to create {}: {e}", path.display()),
95 );
96 }
97 }
98}
99
100fn write_if_not_exists(path: &Path, content: &str, label: &str) {
101 if path.exists() {
102 print_status(
103 Status::Exists,
104 &format!("{label} already exists — preserved"),
105 );
106 } else {
107 match fs::write(path, content) {
108 Ok(()) => print_status(Status::Created, &format!("Created {label}")),
109 Err(e) => print_status(Status::Error, &format!("Failed to create {label}: {e}")),
110 }
111 }
112}
113
114fn select_provider(reader: &mut dyn BufRead, detected: &[AgentCli]) -> Option<Provider> {
121 match detected {
122 [only] => {
124 print_status(
125 Status::Created,
126 &format!("found {only} — using it for extraction"),
127 );
128 Some(only.provider())
129 }
130 [] => {
131 eprintln!(
132 "\n {YELLOW}~{RESET} No agent CLI found. Extraction needs a model provider — \
133 {BOLD}ollama{RESET} is the free, local option."
134 );
135 prompt_any_provider(reader)
136 }
137 several => prompt_installed_cli(reader, several),
138 }
139}
140
141fn default_cli(detected: &[AgentCli]) -> AgentCli {
144 let running_under = agent_cli::current().filter(|cli| detected.contains(cli));
145 running_under
146 .or_else(|| {
147 detected
148 .contains(&AgentCli::ClaudeCode)
149 .then_some(AgentCli::ClaudeCode)
150 })
151 .or_else(|| detected.first().copied())
152 .unwrap_or(AgentCli::ClaudeCode)
153}
154
155fn prompt_installed_cli(reader: &mut dyn BufRead, detected: &[AgentCli]) -> Option<Provider> {
157 let default = default_cli(detected);
158 if !atty_check() {
159 print_status(
160 Status::Created,
161 &format!(
162 "{} agent CLIs found — using {default} for extraction",
163 detected.len()
164 ),
165 );
166 return Some(default.provider());
167 }
168
169 let default_index = detected.iter().position(|cli| *cli == default).unwrap_or(0) + 1;
170
171 eprintln!("\n{BOLD}Which CLI should recall-echo use to extract knowledge?{RESET}");
172 for (index, cli) in detected.iter().enumerate() {
173 let note = if *cli == default {
174 if agent_cli::current() == Some(*cli) {
175 "— you're running under it (default)"
176 } else {
177 "— (default)"
178 }
179 } else {
180 ""
181 };
182 eprintln!(
183 " {BOLD}{}{RESET}) {:<12}{DIM}{note}{RESET}",
184 index + 1,
185 cli.label()
186 );
187 }
188 eprintln!(" {BOLD}o{RESET}) other {DIM}— Claude API, Ollama, or decide later{RESET}");
189 eprint!("\n Choice [{default_index}]: ");
190 io::stderr().flush().ok();
191
192 let mut input = String::new();
193 if reader.read_line(&mut input).is_err() {
194 return Some(default.provider());
195 }
196
197 let answer = input.trim().to_lowercase();
198 if answer.is_empty() {
199 return Some(default.provider());
200 }
201 if answer == "o" || answer == "other" {
202 return prompt_any_provider(reader);
203 }
204 if let Some(cli) = answer
205 .parse::<usize>()
206 .ok()
207 .and_then(|n| detected.get(n.wrapping_sub(1)))
208 {
209 return Some(cli.provider());
210 }
211 if let Some(cli) = detected.iter().find(|cli| cli.label() == answer) {
212 return Some(cli.provider());
213 }
214 eprintln!(" {YELLOW}~{RESET} Unknown choice, defaulting to {default}");
215 Some(default.provider())
216}
217
218fn prompt_any_provider(reader: &mut dyn BufRead) -> Option<Provider> {
223 if !atty_check() {
224 return Some(Provider::Anthropic);
225 }
226
227 eprintln!("\n{BOLD}LLM provider for entity extraction:{RESET}");
228 eprintln!(" {BOLD}1{RESET}) anthropic {DIM}— Claude API (default){RESET}");
229 eprintln!(" {BOLD}2{RESET}) ollama {DIM}— Local models via Ollama, free{RESET}");
230 eprintln!(
231 " {BOLD}3{RESET}) claude-code {DIM}— Spawns your `claude` CLI (subscription){RESET}"
232 );
233 eprintln!(
234 " {BOLD}4{RESET}) gemini {DIM}— Spawns your `gemini` CLI (subscription){RESET}"
235 );
236 eprintln!(" {BOLD}5{RESET}) grok {DIM}— Spawns your `grok` CLI (subscription){RESET}");
237 eprintln!(" {BOLD}6{RESET}) codex {DIM}— Spawns your `codex` CLI (subscription){RESET}");
238 eprintln!(
239 " {BOLD}7{RESET}) skip {DIM}— Configure later with `recall-echo config`{RESET}"
240 );
241 eprint!("\n Choice [1]: ");
242 io::stderr().flush().ok();
243
244 let mut input = String::new();
245 if reader.read_line(&mut input).is_err() {
246 return None;
247 }
248
249 match input.trim() {
250 "" | "1" | "anthropic" => Some(Provider::Anthropic),
251 "2" | "ollama" => Some(Provider::Openai),
252 "3" | "claude-code" => Some(Provider::ClaudeCode),
253 "4" | "gemini" => Some(Provider::Gemini),
254 "5" | "grok" => Some(Provider::Grok),
255 "6" | "codex" => Some(Provider::Codex),
256 "7" | "skip" => None,
257 _ => {
258 eprintln!(" {YELLOW}~{RESET} Unknown choice, defaulting to anthropic");
259 Some(Provider::Anthropic)
260 }
261 }
262}
263
264fn configure_llm(
267 reader: &mut dyn BufRead,
268 memory_dir: &Path,
269 detected: &[AgentCli],
270) -> Option<Provider> {
271 if config::exists(memory_dir) {
272 print_status(
273 Status::Exists,
274 ".recall-echo.toml already exists — preserved",
275 );
276 return Some(config::load(memory_dir).llm.provider);
277 }
278
279 let Some(provider) = select_provider(reader, detected) else {
280 print_status(
281 Status::Exists,
282 "Skipped LLM config — run `recall-echo config set provider <name>` later",
283 );
284 return None;
285 };
286
287 let cfg = Config {
288 llm: LlmSection {
289 provider: provider.clone(),
290 ..LlmSection::default()
291 },
292 ..Config::default()
293 };
294 match config::save(memory_dir, &cfg) {
295 Ok(()) => {
296 print_status(
297 Status::Created,
298 &format!(
299 "Created .recall-echo.toml (provider: {})",
300 label_of(&provider)
301 ),
302 );
303 Some(provider)
304 }
305 Err(e) => {
306 print_status(Status::Error, &format!("Failed to write config: {e}"));
307 None
308 }
309 }
310}
311
312fn label_of(provider: &Provider) -> String {
314 match provider {
315 Provider::Openai => "ollama (openai-compat)".to_string(),
316 other => other.to_string(),
317 }
318}
319
320fn extraction_line(provider: &Provider) -> String {
322 match provider {
323 Provider::Anthropic => "anthropic (Claude API — set ANTHROPIC_API_KEY)".into(),
324 Provider::Openai => "ollama (local models — free)".into(),
325 Provider::Cli => "custom CLI (from `[llm.cli]`)".into(),
326 cli => format!("{cli} (your subscription — no API billing)"),
327 }
328}
329
330fn init_graph(runtime: &tokio::runtime::Runtime, memory_dir: &Path) {
334 let graph_dir = memory_dir.join("graph");
335 if graph_dir.exists() {
336 print_status(Status::Exists, "graph/ already exists — preserved");
337 return;
338 }
339
340 match runtime.block_on(crate::graph::GraphMemory::open(&graph_dir)) {
341 Ok(_) => print_status(Status::Created, "Created graph/ (SurrealDB)"),
342 Err(e) => print_status(Status::Error, &format!("Failed to init graph: {e}")),
343 }
344}
345
346#[derive(Debug, Clone, PartialEq, Eq)]
348enum WarmOutcome {
349 Ready,
350 Skipped(&'static str),
351 Failed(String),
352}
353
354fn warm_embedding_model(memory_dir: &Path) -> WarmOutcome {
364 let exe = recall_binary();
365 if is_build_dir(&exe) {
366 return WarmOutcome::Skipped("running from a build directory");
367 }
368
369 let models_dir = memory_dir.join("graph").join("models");
370 if let Err(e) = fs::create_dir_all(&models_dir) {
371 return WarmOutcome::Failed(format!("could not create {}: {e}", models_dir.display()));
372 }
373
374 let cached = fs::read_dir(&models_dir).is_ok_and(|mut entries| entries.next().is_some());
375 if cached {
376 eprintln!(" {DIM}… loading the embedding model{RESET}");
377 } else {
378 eprintln!(
379 " {DIM}… downloading the embedding model ({MODEL_DOWNLOAD_SIZE}, once) — \
380 everything else is already set up, Ctrl-C is safe{RESET}"
381 );
382 }
383
384 match crate::graph::embed::FastEmbedder::new(&models_dir) {
385 Ok(_) => WarmOutcome::Ready,
386 Err(e) => WarmOutcome::Failed(e.to_string()),
387 }
388}
389
390fn recall_binary() -> String {
394 std::env::current_exe()
395 .ok()
396 .and_then(|p| p.to_str().map(String::from))
397 .unwrap_or_else(|| "recall-echo".into())
398}
399
400fn is_build_dir(exe: &str) -> bool {
405 exe.contains("/target/debug/") || exe.contains("/target/release/")
406}
407
408fn configure_hooks(_entity_root: &Path) -> bool {
412 let claude_dir = match paths::detect_claude_code() {
413 Some(dir) => dir,
414 None => return false,
415 };
416
417 let settings_path = claude_dir.join("settings.json");
418 let recall_bin = recall_binary();
419
420 if is_build_dir(&recall_bin) {
423 print_status(
424 Status::Exists,
425 "Skipped hook install — running from a build directory",
426 );
427 return false;
428 }
429
430 let archive_cmd = format!("{recall_bin} archive-session");
431 let checkpoint_cmd = format!("{recall_bin} checkpoint --trigger precompact");
432 let consume_cmd = format!("{recall_bin} consume");
433
434 let mut settings: serde_json::Value = if settings_path.exists() {
436 fs::read_to_string(&settings_path)
437 .ok()
438 .and_then(|s| serde_json::from_str(&s).ok())
439 .unwrap_or_else(|| serde_json::json!({}))
440 } else {
441 serde_json::json!({})
442 };
443
444 let hooks = settings.as_object_mut().and_then(|o| {
445 o.entry("hooks")
446 .or_insert_with(|| serde_json::json!({}))
447 .as_object_mut()
448 });
449
450 let hooks = match hooks {
451 Some(h) => h,
452 None => {
453 print_status(Status::Error, "Could not parse settings.json hooks");
454 return false;
455 }
456 };
457
458 let mut changed = false;
459
460 if !hook_exists(hooks, "SessionStart", &consume_cmd) {
465 let arr = hooks
466 .entry("SessionStart")
467 .or_insert_with(|| serde_json::json!([]))
468 .as_array_mut();
469 if let Some(arr) = arr {
470 arr.push(serde_json::json!({
471 "matcher": "startup|resume",
472 "hooks": [{"type": "command", "command": consume_cmd}]
473 }));
474 changed = true;
475 }
476 }
477
478 if !hook_exists(hooks, "SessionEnd", &archive_cmd) {
480 let arr = hooks
481 .entry("SessionEnd")
482 .or_insert_with(|| serde_json::json!([]))
483 .as_array_mut();
484 if let Some(arr) = arr {
485 arr.push(serde_json::json!({
486 "hooks": [{"type": "command", "command": archive_cmd}]
487 }));
488 changed = true;
489 }
490 }
491
492 if !hook_exists(hooks, "PreCompact", &checkpoint_cmd) {
494 let arr = hooks
495 .entry("PreCompact")
496 .or_insert_with(|| serde_json::json!([]))
497 .as_array_mut();
498 if let Some(arr) = arr {
499 arr.push(serde_json::json!({
500 "hooks": [{"type": "command", "command": checkpoint_cmd}]
501 }));
502 changed = true;
503 }
504 }
505
506 if changed {
507 match serde_json::to_string_pretty(&settings) {
508 Ok(content) => match fs::write(&settings_path, content) {
509 Ok(()) => {
510 print_status(
511 Status::Created,
512 "Configured SessionStart + SessionEnd + PreCompact hooks in settings.json",
513 );
514 return true;
515 }
516 Err(e) => print_status(
517 Status::Error,
518 &format!("Failed to write settings.json: {e}"),
519 ),
520 },
521 Err(e) => print_status(Status::Error, &format!("Failed to serialize settings: {e}")),
522 }
523 } else {
524 print_status(Status::Exists, "Hooks already configured in settings.json");
525 return true;
526 }
527
528 false
529}
530
531fn hook_exists(
533 hooks: &serde_json::Map<String, serde_json::Value>,
534 event: &str,
535 command: &str,
536) -> bool {
537 if let Some(arr) = hooks.get(event).and_then(|v| v.as_array()) {
538 for group in arr {
539 if let Some(inner) = group.get("hooks").and_then(|h| h.as_array()) {
540 for hook in inner {
541 if let Some(cmd) = hook.get("command").and_then(|c| c.as_str()) {
542 if cmd.contains("recall-echo archive-session")
544 && command.contains("archive-session")
545 {
546 return true;
547 }
548 if cmd.contains("recall-echo checkpoint") && command.contains("checkpoint")
549 {
550 return true;
551 }
552 if cmd.contains("recall-echo consume") && command.contains("consume") {
553 return true;
554 }
555 }
556 }
557 }
558 }
559 }
560 false
561}
562
563fn register_mcp_clients(
572 runtime: &tokio::runtime::Runtime,
573 detected: &[AgentCli],
574 entity_root: &Path,
575) -> Vec<McpReport> {
576 if detected.is_empty() {
577 return Vec::new();
578 }
579
580 let exe = recall_binary();
581 if is_build_dir(&exe) {
582 print_status(
583 Status::Exists,
584 "Skipped MCP registration — running from a build directory",
585 );
586 return Vec::new();
587 }
588
589 let root = fs::canonicalize(entity_root).unwrap_or_else(|_| entity_root.to_path_buf());
590 let reports: Vec<McpReport> = runtime.block_on(async {
591 let mut reports = Vec::with_capacity(detected.len());
592 for cli in detected {
593 reports.push(agent_cli::register_mcp(*cli, &exe, &root).await);
594 }
595 reports
596 });
597
598 for report in &reports {
599 match &report.status {
600 McpStatus::Registered => print_status(
601 Status::Created,
602 &format!("Registered MCP server with {}", report.cli),
603 ),
604 McpStatus::AlreadyRegistered => print_status(
605 Status::Exists,
606 &format!("MCP server already registered with {}", report.cli),
607 ),
608 McpStatus::Failed(detail) => {
609 print_status(
610 Status::Error,
611 &format!("Could not register MCP with {}: {detail}", report.cli),
612 );
613 eprintln!(" {DIM}run it yourself: {}{RESET}", report.command);
614 }
615 }
616 }
617 reports
618}
619
620struct Summary {
624 memory_dir: std::path::PathBuf,
625 provider: Option<Provider>,
626 capture: Vec<Source>,
627 mcp: Vec<McpReport>,
628 embedder: WarmOutcome,
629}
630
631impl Summary {
632 fn mcp_ready(&self) -> Vec<&'static str> {
634 self.mcp
635 .iter()
636 .filter(|report| !matches!(report.status, McpStatus::Failed(_)))
637 .map(|report| report.cli.label())
638 .collect()
639 }
640}
641
642fn print_summary(summary: &Summary) {
644 eprintln!("\n{BOLD}Setup complete.{RESET}\n");
645 print_status(
646 Status::Created,
647 &format!("memory initialised at {}", summary.memory_dir.display()),
648 );
649
650 match &summary.provider {
651 Some(provider) => print_status(
652 Status::Created,
653 &format!("extraction: {}", extraction_line(provider)),
654 ),
655 None => print_status(
656 Status::Exists,
657 "extraction: not configured — `recall-echo config set provider <name>`",
658 ),
659 }
660
661 if summary.capture.is_empty() {
662 print_status(
663 Status::Exists,
664 "capture: no agent CLI has recorded sessions here yet",
665 );
666 } else {
667 let names: Vec<&str> = summary.capture.iter().map(Source::as_str).collect();
668 print_status(Status::Created, &format!("capture: {}", names.join(", ")));
669 }
670
671 let ready = summary.mcp_ready();
672 if !ready.is_empty() {
673 print_status(
674 Status::Created,
675 &format!("MCP registered: {}", ready.join(", ")),
676 );
677 }
678
679 match &summary.embedder {
680 WarmOutcome::Ready => print_status(Status::Created, "embedding model ready"),
681 WarmOutcome::Skipped(reason) => print_status(
682 Status::Exists,
683 &format!("embedding model not warmed ({reason}) — downloads on first use"),
684 ),
685 WarmOutcome::Failed(detail) => print_status(
686 Status::Exists,
687 &format!("embedding model not downloaded ({detail}) — retries on first use"),
688 ),
689 }
690
691 eprintln!("\n {BOLD}Your next session will be remembered.{RESET}\n");
692 eprintln!(" {DIM}recall-echo status — is it healthy, what has it got{RESET}");
693 eprintln!(" {DIM}recall-echo config show — what it decided{RESET}");
694 eprintln!();
695}
696
697fn atty_check() -> bool {
699 use std::io::IsTerminal;
700 std::io::stderr().is_terminal()
701}
702
703pub fn run(entity_root: &Path) -> Result<(), RecallError> {
718 let stdin = io::stdin();
719 let mut reader = stdin.lock();
720 run_with_reader(entity_root, &mut reader)
721}
722
723pub fn run_with_reader(entity_root: &Path, reader: &mut dyn BufRead) -> Result<(), RecallError> {
725 if !entity_root.exists() {
726 return Err(RecallError::NotInitialized(format!(
727 "Directory not found: {}\n Create the directory first, or run from a valid path.",
728 entity_root.display()
729 )));
730 }
731
732 eprintln!("\n{BOLD}recall-echo{RESET} — initializing memory system\n");
733
734 let memory_dir = entity_root.join("memory");
735 let conversations_dir = memory_dir.join("conversations");
736 ensure_dir(&memory_dir);
737 ensure_dir(&conversations_dir);
738
739 write_if_not_exists(&memory_dir.join("MEMORY.md"), MEMORY_TEMPLATE, "MEMORY.md");
741
742 write_if_not_exists(&memory_dir.join("EPHEMERAL.md"), "", "EPHEMERAL.md");
744
745 write_if_not_exists(
747 &memory_dir.join("ARCHIVE.md"),
748 ARCHIVE_TEMPLATE,
749 "ARCHIVE.md",
750 );
751
752 let runtime = tokio::runtime::Builder::new_current_thread()
753 .enable_all()
754 .build();
755 let runtime = match runtime {
756 Ok(runtime) => Some(runtime),
757 Err(e) => {
758 print_status(Status::Error, &format!("Failed to start runtime: {e}"));
759 None
760 }
761 };
762
763 if let Some(runtime) = &runtime {
764 init_graph(runtime, &memory_dir);
765 }
766
767 let detected = agent_cli::installed();
768 let provider = configure_llm(reader, &memory_dir, &detected);
769
770 configure_hooks(entity_root);
775
776 let mcp = match &runtime {
777 Some(runtime) => register_mcp_clients(runtime, &detected, entity_root),
778 None => Vec::new(),
779 };
780
781 let embedder = warm_embedding_model(&memory_dir);
783
784 print_summary(&Summary {
785 memory_dir,
786 provider,
787 capture: agent_cli::capturing(),
788 mcp,
789 embedder,
790 });
791
792 Ok(())
793}
794
795#[cfg(test)]
796mod tests {
797 use super::*;
798 use std::io::Cursor;
799
800 #[test]
805 fn the_test_binary_is_recognised_as_a_build_directory() {
806 assert!(
807 is_build_dir(&recall_binary()),
808 "test binary should be treated as a build directory: {}",
809 recall_binary()
810 );
811 assert!(!is_build_dir("/usr/local/bin/recall-echo"));
812 assert!(!is_build_dir("/home/d/.cargo/bin/recall-echo"));
813 }
814
815 #[test]
816 fn init_creates_directories_and_files() {
817 let tmp = tempfile::tempdir().unwrap();
818 let root = tmp.path().to_path_buf();
819 let mut reader = Cursor::new(b"skip\n" as &[u8]); run_with_reader(&root, &mut reader).unwrap();
822
823 assert!(root.join("memory/MEMORY.md").exists());
824 assert!(root.join("memory/EPHEMERAL.md").exists());
825 assert!(root.join("memory/ARCHIVE.md").exists());
826 assert!(root.join("memory/conversations").exists());
827 }
828
829 #[test]
830 fn init_is_idempotent() {
831 let tmp = tempfile::tempdir().unwrap();
832 let root = tmp.path().to_path_buf();
833 let mut reader = Cursor::new(b"skip\n" as &[u8]);
834
835 run_with_reader(&root, &mut reader).unwrap();
836 fs::write(root.join("memory/MEMORY.md"), "custom content").unwrap();
837
838 let mut reader2 = Cursor::new(b"skip\n" as &[u8]);
839 run_with_reader(&root, &mut reader2).unwrap();
840 let content = fs::read_to_string(root.join("memory/MEMORY.md")).unwrap();
841 assert_eq!(content, "custom content");
842 }
843
844 #[test]
847 fn a_second_init_preserves_the_configured_provider() {
848 let tmp = tempfile::tempdir().unwrap();
849 let root = tmp.path().to_path_buf();
850 let memory_dir = root.join("memory");
851 fs::create_dir_all(&memory_dir).unwrap();
852
853 let chosen = configure_llm(
854 &mut Cursor::new(b"" as &[u8]),
855 &memory_dir,
856 &[AgentCli::Grok],
857 );
858 assert_eq!(chosen, Some(Provider::Grok));
859
860 let again = configure_llm(
862 &mut Cursor::new(b"" as &[u8]),
863 &memory_dir,
864 &[AgentCli::ClaudeCode, AgentCli::Codex],
865 );
866 assert_eq!(again, Some(Provider::Grok));
867 }
868
869 #[test]
870 fn init_fails_if_root_missing() {
871 let mut reader = Cursor::new(b"" as &[u8]);
872 let result = run_with_reader(Path::new("/nonexistent/path"), &mut reader);
873 assert!(result.is_err());
874 }
875
876 #[test]
879 fn a_single_installed_cli_is_chosen_without_asking() {
880 let mut reader = Cursor::new(b"" as &[u8]);
881 assert_eq!(
882 select_provider(&mut reader, &[AgentCli::Codex]),
883 Some(Provider::Codex)
884 );
885 assert_eq!(reader.position(), 0, "nothing should have been read");
886 }
887
888 #[test]
891 fn several_installed_clis_default_without_blocking() {
892 let mut reader = Cursor::new(b"" as &[u8]);
893 let chosen = select_provider(&mut reader, &[AgentCli::Grok, AgentCli::Codex]);
894 assert_eq!(chosen, Some(Provider::Grok));
895 }
896
897 #[test]
898 fn the_default_prefers_claude_code_over_install_order() {
899 assert_eq!(
900 default_cli(&[AgentCli::Codex, AgentCli::ClaudeCode]),
901 AgentCli::ClaudeCode
902 );
903 assert_eq!(
904 default_cli(&[AgentCli::Gemini, AgentCli::Grok]),
905 AgentCli::Gemini
906 );
907 assert_eq!(default_cli(&[]), AgentCli::ClaudeCode);
908 }
909
910 #[test]
911 fn no_installed_cli_falls_back_to_the_full_menu() {
912 let mut reader = Cursor::new(b"" as &[u8]);
913 assert_eq!(select_provider(&mut reader, &[]), Some(Provider::Anthropic));
914 }
915
916 #[test]
917 fn the_summary_names_the_cost_of_each_provider() {
918 assert!(extraction_line(&Provider::Grok).contains("no API billing"));
919 assert!(extraction_line(&Provider::Anthropic).contains("ANTHROPIC_API_KEY"));
920 assert!(extraction_line(&Provider::Openai).contains("free"));
921 }
922
923 #[test]
924 fn the_summary_lists_only_the_clients_that_registered() {
925 let summary = Summary {
926 memory_dir: std::path::PathBuf::from("/tmp/memory"),
927 provider: Some(Provider::Grok),
928 capture: vec![Source::Grok],
929 mcp: vec![
930 McpReport {
931 cli: AgentCli::ClaudeCode,
932 status: McpStatus::Registered,
933 command: String::new(),
934 },
935 McpReport {
936 cli: AgentCli::Grok,
937 status: McpStatus::AlreadyRegistered,
938 command: String::new(),
939 },
940 McpReport {
941 cli: AgentCli::Gemini,
942 status: McpStatus::Failed("no".into()),
943 command: String::new(),
944 },
945 ],
946 embedder: WarmOutcome::Ready,
947 };
948 assert_eq!(summary.mcp_ready(), ["claude-code", "grok"]);
949 }
950
951 #[test]
952 fn hook_exists_recognizes_consume_command() {
953 let hooks_json: serde_json::Value = serde_json::json!({
954 "SessionStart": [{
955 "matcher": "startup|resume",
956 "hooks": [{"type": "command", "command": "/usr/local/bin/recall-echo consume"}]
957 }]
958 });
959 let hooks = hooks_json.as_object().unwrap();
960 assert!(hook_exists(hooks, "SessionStart", "recall-echo consume"));
961 assert!(!hook_exists(hooks, "SessionEnd", "recall-echo consume"));
962 }
963
964 #[test]
965 fn hook_exists_distinguishes_archive_from_consume() {
966 let hooks_json: serde_json::Value = serde_json::json!({
967 "SessionEnd": [{
968 "hooks": [{"type": "command", "command": "recall-echo archive-session"}]
969 }]
970 });
971 let hooks = hooks_json.as_object().unwrap();
972 assert!(hook_exists(
973 hooks,
974 "SessionEnd",
975 "recall-echo archive-session"
976 ));
977 }
978
979 #[test]
980 fn archive_template_has_header() {
981 let tmp = tempfile::tempdir().unwrap();
982 let mut reader = Cursor::new(b"skip\n" as &[u8]);
983 run_with_reader(tmp.path(), &mut reader).unwrap();
984 let content = fs::read_to_string(tmp.path().join("memory/ARCHIVE.md")).unwrap();
985 assert!(content.contains("# Conversation Archive"));
986 assert!(content.contains("| # | Date"));
987 }
988}