Skip to main content

recall_echo/
config_cli.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! CLI handlers for `recall-echo config show` and `recall-echo config set`.
6
7use std::path::Path;
8
9use crate::cli_provider::CliSpec;
10use crate::config::{self, Provider};
11use crate::error::RecallError;
12
13const BOLD: &str = "\x1b[1m";
14const DIM: &str = "\x1b[2m";
15const GREEN: &str = "\x1b[32m";
16const RESET: &str = "\x1b[0m";
17
18/// Display current configuration.
19pub fn show(memory_dir: &Path) -> Result<(), RecallError> {
20    let cfg = config::load(memory_dir);
21    let path = config::config_path(memory_dir);
22    let exists = path.exists();
23
24    eprintln!("{BOLD}recall-echo config{RESET}");
25    if exists {
26        eprintln!("{DIM}{}{RESET}\n", path.display());
27    } else {
28        eprintln!("{DIM}(no config file — using defaults){RESET}\n");
29    }
30
31    // Ephemeral
32    eprintln!("{BOLD}[ephemeral]{RESET}");
33    eprintln!("  max_entries = {}", cfg.ephemeral.max_entries);
34
35    // LLM
36    eprintln!("\n{BOLD}[llm]{RESET}");
37    let provider_label = match &cfg.llm.provider {
38        Provider::Openai => "openai (ollama)".to_string(),
39        other => other.to_string(),
40    };
41    eprintln!("  provider = {provider_label}");
42    eprintln!(
43        "  model    = {} {DIM}({}){RESET}",
44        cfg.llm.resolved_model(),
45        if cfg.llm.model.is_empty() {
46            "default"
47        } else {
48            "custom"
49        }
50    );
51    if cfg.llm.provider.is_cli() {
52        show_cli_section(&cfg.llm);
53    } else {
54        eprintln!(
55            "  api_base = {} {DIM}({}){RESET}",
56            cfg.llm.resolved_api_base(),
57            if cfg.llm.api_base.is_empty() {
58                "default"
59            } else {
60                "custom"
61            }
62        );
63    }
64
65    show_capture_section(&cfg.capture);
66    show_extraction_section(&cfg.extraction);
67    show_serve_section(&cfg.serve);
68
69    // Pipeline
70    if let Some(ref pipeline) = cfg.pipeline {
71        eprintln!("\n{BOLD}[pipeline]{RESET}");
72        eprintln!(
73            "  docs_dir  = {}",
74            pipeline
75                .docs_dir
76                .as_deref()
77                .unwrap_or("{DIM}(not set){RESET}")
78        );
79        eprintln!("  auto_sync = {}", pipeline.auto_sync.unwrap_or(false));
80    }
81
82    Ok(())
83}
84
85/// Which agent CLIs the daemon sweeps transcripts from.
86///
87/// Shown even at its defaults: "sessions are being imported from every CLI on
88/// this machine" is a thing a user debugging their setup needs to know, and it
89/// is invisible in a config file that never mentions it.
90fn show_capture_section(capture: &config::CaptureSection) {
91    eprintln!("\n{BOLD}[capture]{RESET}");
92    eprintln!("  enabled     = {}", capture.enabled);
93    let sources = match &capture.sources {
94        Some(sources) if !sources.is_empty() => sources
95            .iter()
96            .map(ToString::to_string)
97            .collect::<Vec<_>>()
98            .join(", "),
99        _ => "(every CLI with sessions on this machine)".to_string(),
100    };
101    eprintln!("  sources     = {sources}");
102    eprintln!(
103        "  settle_secs = {} {DIM}(a transcript must be this quiet to count as finished){RESET}",
104        capture.settle_secs
105    );
106}
107
108/// Background entity extraction inside the graph daemon.
109fn show_extraction_section(extraction: &config::ExtractionSection) {
110    eprintln!("\n{BOLD}[extraction]{RESET}");
111    eprintln!("  background_enabled = {}", extraction.background_enabled);
112    eprintln!(
113        "  idle_after_secs    = {} {DIM}(quiet period before a batch starts){RESET}",
114        extraction.idle_after_secs
115    );
116    eprintln!(
117        "  batch_size         = {} {DIM}(archives per batch){RESET}",
118        extraction.batch_size
119    );
120}
121
122/// Where the graph daemon listens and how long it lives.
123fn show_serve_section(serve: &config::ServeSection) {
124    eprintln!("\n{BOLD}[serve]{RESET}");
125    let socket = serve
126        .socket_path
127        .as_deref()
128        .filter(|path| !path.trim().is_empty());
129    match socket {
130        Some(path) => eprintln!("  socket_path       = {path}"),
131        None => eprintln!("  socket_path       = {DIM}(derived from the memory directory){RESET}"),
132    }
133    let idle = match serve.idle_timeout_secs {
134        0 => "0 (never idle-shuts-down)".to_string(),
135        secs => format!("{secs}"),
136    };
137    eprintln!("  idle_timeout_secs = {idle}");
138}
139
140/// Show the resolved agent-CLI call, so a misconfigured vendor is visible
141/// before it is spawned rather than after it fails.
142fn show_cli_section(llm: &config::LlmSection) {
143    eprintln!("\n{BOLD}[llm.cli]{RESET}");
144    match CliSpec::resolve(&llm.provider, &llm.cli) {
145        Ok(spec) => {
146            let preset = llm
147                .cli
148                .preset
149                .or_else(|| llm.provider.default_cli_preset())
150                .map_or_else(|| "custom".to_string(), |p| p.to_string());
151            eprintln!("  preset   = {preset}");
152            eprintln!("  command  = {}", spec.resolve_command());
153            eprintln!(
154                "  timeout  = {}",
155                spec.timeout
156                    .map_or_else(|| "none".to_string(), |t| format!("{}s", t.as_secs()))
157            );
158            eprintln!("  output   = {}", spec.output_mode);
159            let result = if spec.result_json_paths.is_empty() {
160                "raw stdout".to_string()
161            } else {
162                spec.result_json_paths.to_string()
163            };
164            eprintln!("  result   = {result}");
165            if !spec.ndjson_match.is_empty() {
166                eprintln!("  match    = {}", spec.ndjson_match);
167            }
168            // Whether this CLI's token bill will be measured or estimated —
169            // worth knowing before the number is read, not after.
170            let usage = if spec.usage_input_paths.is_empty() && spec.usage_output_paths.is_empty() {
171                format!("{DIM}estimated (this CLI reports no token counts){RESET}")
172            } else {
173                format!("{} / {}", spec.usage_input_paths, spec.usage_output_paths)
174            };
175            eprintln!("  usage    = {usage}");
176            eprintln!(
177                "  {DIM}{}{RESET}",
178                spec.argv_preview(&spec.resolve_model(&llm.model))
179            );
180        }
181        Err(err) => eprintln!("  {err}"),
182    }
183}
184
185/// Set a config key and save.
186pub fn set(memory_dir: &Path, key: &str, value: &str) -> Result<(), RecallError> {
187    let mut cfg = config::load(memory_dir);
188    cfg.set_key(key, value)?;
189    config::save(memory_dir, &cfg)?;
190
191    eprintln!("{GREEN}✓{RESET} Set {BOLD}{key}{RESET} = {BOLD}{value}{RESET}");
192
193    // Show what the new provider resolves to — a CLI provider has no API base,
194    // and what matters instead is the call it will make.
195    if key == "llm.provider" || key == "provider" {
196        if cfg.llm.provider.is_cli() {
197            match CliSpec::resolve(&cfg.llm.provider, &cfg.llm.cli) {
198                Ok(spec) => eprintln!(
199                    "  command  → {}",
200                    spec.argv_preview(&spec.resolve_model(&cfg.llm.model))
201                ),
202                Err(err) => eprintln!("  {err}"),
203            }
204        } else {
205            eprintln!("  model    → {}", cfg.llm.resolved_model());
206            eprintln!("  api_base → {}", cfg.llm.resolved_api_base());
207        }
208    }
209
210    Ok(())
211}