Skip to main content

sqlite_graphrag/commands/
config_cmd.rs

1use crate::cli_db_noop::DB_NOOP_HELP;
2use crate::config::{self, compute_fingerprint, mask_key, ApiKeyEntry};
3use crate::errors::AppError;
4use clap::{Args, Subcommand};
5use serde_json::json;
6use std::io::{self, Read};
7
8/// Config args.
9#[derive(Debug, Args)]
10pub struct ConfigArgs {
11    /// Action.
12    #[command(subcommand)]
13    pub action: ConfigAction,
14}
15
16/// Config action.
17#[derive(Debug, Subcommand)]
18pub enum ConfigAction {
19    /// Add an API key for a provider (reads from stdin to avoid shell history).
20    AddKey {
21        /// Provider name.
22        #[arg(long)]
23        provider: String,
24        /// From stdin.
25        #[arg(long, default_value_t = true)]
26        from_stdin: bool,
27        /// GAP-SG-34: no-op; JSON is always emitted on stdout.
28        #[arg(long, hide = true)]
29        json: bool,
30        /// GAP-SG-139: accepted as a no-op for agent uniformity (XDG keys; no graph I/O).
31        #[arg(long, value_name = "PATH", help = DB_NOOP_HELP)]
32        db: Option<String>,
33    },
34    /// List stored API keys (masked) with fingerprints.
35    ListKeys {
36        /// GAP-SG-34: no-op; JSON is always emitted on stdout.
37        #[arg(long, hide = true)]
38        json: bool,
39        /// GAP-SG-139: accepted as a no-op for agent uniformity (XDG keys; no graph I/O).
40        #[arg(long, value_name = "PATH", help = DB_NOOP_HELP)]
41        db: Option<String>,
42    },
43    /// Remove an API key by its fingerprint.
44    RemoveKey {
45        /// Fingerprint.
46        fingerprint: String,
47        /// GAP-SG-34: no-op; JSON is always emitted on stdout.
48        #[arg(long, hide = true)]
49        json: bool,
50        /// GAP-SG-139: accepted as a no-op for agent uniformity (XDG keys; no graph I/O).
51        #[arg(long, value_name = "PATH", help = DB_NOOP_HELP)]
52        db: Option<String>,
53    },
54    /// Diagnose key resolution layers (flag/cli and XDG config; product env deprecated).
55    Doctor {
56        /// GAP-SG-34: no-op; JSON is always emitted on stdout.
57        #[arg(long, hide = true)]
58        json: bool,
59        /// GAP-SG-139: accepted as a no-op for agent uniformity (XDG keys; no graph I/O).
60        #[arg(long, value_name = "PATH", help = DB_NOOP_HELP)]
61        db: Option<String>,
62    },
63    /// Print the resolved XDG config file path.
64    Path {
65        /// GAP-SG-34: no-op; JSON is always emitted on stdout.
66        #[arg(long, hide = true)]
67        json: bool,
68        /// GAP-SG-139: accepted as a no-op for agent uniformity (XDG keys; no graph I/O).
69        #[arg(long, value_name = "PATH", help = DB_NOOP_HELP)]
70        db: Option<String>,
71    },
72    /// Set an operational setting in XDG config (G-T-XDG-01).
73    ///
74    /// Known keys (non-exhaustive):
75    /// `enrich.preserve_threshold`, `enrich.entity_description.domain`,
76    /// `enrich.entity_description.grounding_threshold`,
77    /// `enrich.entity_connect.default_limit`,
78    /// `enrich.entity_connect.max_runtime_secs`,
79    /// `network.openrouter.chat_url` (alias `network.chat_url`),
80    /// `network.openrouter.embeddings_url` (alias `network.embed_url`),
81    /// `log.level`, `log.format`, `display.tz`,
82    /// `embedding.dim`, `llm.concurrency`.
83    Set {
84        /// Dotted key name, e.g. `enrich.preserve_threshold`.
85        key: String,
86        /// Value as string (parsed by consumers).
87        value: String,
88        /// Emit machine-readable JSON on stdout.
89        #[arg(long, hide = true)]
90        json: bool,
91        /// GAP-SG-139: accepted as a no-op for agent uniformity (XDG settings; no graph I/O).
92        #[arg(long, value_name = "PATH", help = DB_NOOP_HELP)]
93        db: Option<String>,
94    },
95    /// Get an operational setting from XDG config.
96    Get {
97        /// Key.
98        key: String,
99        /// Emit machine-readable JSON on stdout.
100        #[arg(long, hide = true)]
101        json: bool,
102        /// GAP-SG-139: accepted as a no-op for agent uniformity (XDG settings; no graph I/O).
103        #[arg(long, value_name = "PATH", help = DB_NOOP_HELP)]
104        db: Option<String>,
105    },
106    /// List all operational settings (no secrets).
107    List {
108        /// Include well-known defaults even when not stored in XDG.
109        #[arg(long, default_value_t = false)]
110        effective: bool,
111        /// Emit machine-readable JSON on stdout.
112        #[arg(long, hide = true)]
113        json: bool,
114        /// Emit the JSON Schema for `config list` stdout and exit 0
115        /// without reading settings (agent-native R-AN-01).
116        #[arg(long, default_value_t = false, help = "Print JSON Schema for config list output and exit")]
117        print_schema: bool,
118        /// GAP-SG-139: accepted as a no-op for agent uniformity (XDG settings; no graph I/O).
119        #[arg(long, value_name = "PATH", help = DB_NOOP_HELP)]
120        db: Option<String>,
121    },
122    /// Unset an operational setting.
123    Unset {
124        /// Key.
125        key: String,
126        /// Emit machine-readable JSON on stdout.
127        #[arg(long, hide = true)]
128        json: bool,
129        /// GAP-SG-139: accepted as a no-op for agent uniformity (XDG settings; no graph I/O).
130        #[arg(long, value_name = "PATH", help = DB_NOOP_HELP)]
131        db: Option<String>,
132    },
133}
134
135/// Run.
136pub fn run(args: ConfigArgs) -> Result<(), AppError> {
137    match args.action {
138        ConfigAction::AddKey {
139            provider,
140            from_stdin,
141            json: _,
142            db: _,
143        } => {
144            let key = if from_stdin {
145                let mut buf = String::new();
146                io::stdin().read_to_string(&mut buf).map_err(AppError::Io)?;
147                buf.trim().to_string()
148            } else {
149                return Err(AppError::Validation(
150                    "--from-stdin is required to avoid shell history exposure".into(),
151                ));
152            };
153            if key.is_empty() {
154                return Err(AppError::Validation(crate::i18n::validation::api_key_cannot_be_empty()));
155            }
156            let fingerprint = compute_fingerprint(&key);
157            let entry = ApiKeyEntry {
158                provider: provider.clone(),
159                value: key,
160                added_at: chrono::Utc::now().to_rfc3339(),
161                fingerprint: fingerprint.clone(),
162            };
163            let mut cfg = config::load_config()?;
164            cfg.keys.retain(|k| k.provider != provider);
165            cfg.keys.push(entry);
166            config::save_config(&cfg)?;
167            let output = json!({
168                "action": "key_added",
169                "provider": provider,
170                "fingerprint": fingerprint,
171            });
172            println!("{}", serde_json::to_string(&output)?);
173            Ok(())
174        }
175        ConfigAction::ListKeys { json: _, db: _ } => {
176            let cfg = config::load_config()?;
177            let keys: Vec<_> = cfg
178                .keys
179                .iter()
180                .map(|k| {
181                    json!({
182                        "provider": k.provider,
183                        "fingerprint": k.fingerprint,
184                        "masked_value": mask_key(&k.value),
185                        "added_at": k.added_at,
186                    })
187                })
188                .collect();
189            let output = json!({ "keys": keys });
190            println!("{}", serde_json::to_string_pretty(&output)?);
191            Ok(())
192        }
193        ConfigAction::RemoveKey {
194            fingerprint,
195            json: _,
196            db: _,
197        } => {
198            let mut cfg = config::load_config()?;
199            let before = cfg.keys.len();
200            cfg.keys.retain(|k| k.fingerprint != fingerprint);
201            if cfg.keys.len() == before {
202                return Err(AppError::NotFound(format!(
203                    "no key with fingerprint {fingerprint}"
204                )));
205            }
206            config::save_config(&cfg)?;
207            let output = json!({
208                "action": "key_removed",
209                "fingerprint": fingerprint,
210            });
211            println!("{}", serde_json::to_string(&output)?);
212            Ok(())
213        }
214        ConfigAction::Doctor { json: _, db: _ } => {
215            let config_path = config::config_file_path()
216                .map(|p| p.display().to_string())
217                .unwrap_or_else(|_| "unavailable".to_string());
218            let config_exists = std::path::Path::new(&config_path).exists();
219            let providers = ["openrouter"];
220            let mut results = vec![];
221            for provider in &providers {
222                let resolved = config::resolve_api_key(provider, None);
223                results.push(json!({
224                    "provider": provider,
225                    "resolved": resolved.is_some(),
226                    "source": resolved.as_ref().map(|r| r.source),
227                    "masked_value": resolved.as_ref().map(|r| {
228                        use secrecy::ExposeSecret;
229                        mask_key(r.value.expose_secret())
230                    }),
231                }));
232            }
233            // Operational knobs with source layer (flag|xdg|default|derived).
234            // Product env is never a source.
235            //
236            // GAP-SG-85: this listing used to be a hand-written table of 14
237            // entries next to a 44-key registry, and `db.path` — the key that
238            // redirects the whole database — was one of the missing ones. The
239            // list is now DERIVED from `config::SETTING_KEYS`, so a key cannot
240            // exist without being discoverable here.
241            let rt = crate::runtime_config::get();
242
243            // Only these keys can be overridden by a CLI flag today. Mapping
244            // them explicitly keeps the `flag` source honest instead of
245            // claiming a flag layer that does not exist for the other keys.
246            let flag_for = |key: &str| -> Option<&str> {
247                match key {
248                    "display.tz" => rt.display_tz.as_deref(),
249                    "i18n.lang" => rt.lang.as_deref(),
250                    "log.level" => rt.log_level.as_deref(),
251                    "log.format" => rt.log_format.as_deref(),
252                    "llm.claude_binary" => rt.claude_binary.as_deref(),
253                    "llm.codex_binary" => rt.codex_binary.as_deref(),
254                    "llm.opencode_binary" => rt.opencode_binary.as_deref(),
255                    "llm.model" => rt.llm_model.as_deref(),
256                    "llm.fallback" => rt.llm_fallback.as_deref(),
257                    "db.path" => rt.db_path.as_deref(),
258                    _ => None,
259                }
260            };
261
262            let knobs: Vec<_> = config::SETTING_KEYS
263                .iter()
264                .map(|entry| {
265                    let runtime_flag = flag_for(entry.key).filter(|v| !v.is_empty());
266                    let xdg_value = config::get_setting(entry.key)
267                        .ok()
268                        .flatten()
269                        .filter(|v| !v.is_empty());
270                    let (source, value) = match (runtime_flag, xdg_value) {
271                        (Some(v), _) => ("flag", Some(v.to_string())),
272                        (None, Some(v)) => ("xdg", Some(v)),
273                        // A key whose default is derived from the host has no
274                        // literal to report; `derived` says so instead of
275                        // printing an empty string that reads like "unset".
276                        (None, None) => match entry.default {
277                            Some(d) => ("default", Some(d.to_string())),
278                            None => ("derived", None),
279                        },
280                    };
281                    json!({ "key": entry.key, "value": value, "source": source })
282                })
283                .collect();
284            let output = json!({
285                "config_path": config_path,
286                "config_exists": config_exists,
287                "providers": results,
288                "knobs": knobs,
289                "product_env_reads": false,
290                "note": "Precedence: CLI flag > XDG config set > named default. No SQLITE_GRAPHRAG_* product env.",
291            });
292            println!("{}", serde_json::to_string_pretty(&output)?);
293            Ok(())
294        }
295        ConfigAction::Path { json: _, db: _ } => {
296            let path = config::config_file_path()?;
297            let output = json!({
298                "config_path": path.display().to_string(),
299                "exists": path.exists(),
300            });
301            println!("{}", serde_json::to_string(&output)?);
302            Ok(())
303        }
304        ConfigAction::Set {
305            key,
306            value,
307            json: _,
308            db: _,
309        } => {
310            config::set_setting(&key, &value)?;
311            let output = json!({
312                "action": "setting_set",
313                "key": key,
314                "value": value,
315            });
316            println!("{}", serde_json::to_string(&output)?);
317            Ok(())
318        }
319        ConfigAction::Get {
320            key,
321            json: _,
322            db: _,
323        } => {
324            let value = config::get_setting(&key)?;
325            let output = json!({
326                "key": key,
327                "value": value,
328                "found": value.is_some(),
329            });
330            println!("{}", serde_json::to_string(&output)?);
331            Ok(())
332        }
333        ConfigAction::List {
334            effective,
335            json: _,
336            print_schema,
337            db: _,
338        } => {
339            if print_schema {
340                return crate::print_schema::emit(crate::print_schema::SchemaId::ConfigList);
341            }
342            let mut settings = config::list_settings()?;
343            if effective {
344                // GAP-SG-93: defaults MUST come from constants / named defaults,
345                // never hard-coded literals that can drift from DEFAULT_EMBEDDING_DIM.
346                let dim_default = crate::constants::DEFAULT_EMBEDDING_DIM.to_string();
347                let probe_default = crate::constants::DEFAULT_LLM_PROBE_TIMEOUT_MS.to_string();
348                let defaults: &[(&str, &str)] = &[
349                    (
350                        "network.openrouter.chat_url",
351                        crate::constants::DEFAULT_OPENROUTER_CHAT_URL,
352                    ),
353                    (
354                        "network.openrouter.embeddings_url",
355                        crate::constants::DEFAULT_OPENROUTER_EMBEDDINGS_URL,
356                    ),
357                    ("llm.probe_timeout_ms", probe_default.as_str()),
358                    ("llm.fallback", "codex,claude,none"),
359                    ("embedding.dim", dim_default.as_str()),
360                    ("log.level", crate::constants::DEFAULT_LOG_LEVEL),
361                    ("display.tz", "UTC"),
362                ];
363                for (k, v) in defaults {
364                    settings.entry(k.to_string()).or_insert_with(|| v.to_string());
365                }
366            }
367            let output = json!({
368                "settings": settings,
369                "effective": effective,
370            });
371            println!("{}", serde_json::to_string_pretty(&output)?);
372            Ok(())
373        }
374        ConfigAction::Unset {
375            key,
376            json: _,
377            db: _,
378        } => {
379            let removed = config::unset_setting(&key)?;
380            let output = json!({
381                "action": "setting_unset",
382                "key": key,
383                "removed": removed,
384            });
385            println!("{}", serde_json::to_string(&output)?);
386            Ok(())
387        }
388    }
389}
390
391#[cfg(test)]
392mod tests {
393    use clap::Parser;
394
395    #[test]
396    fn config_doctor_accepts_db_as_noop() {
397        let cli = crate::cli::Cli::try_parse_from([
398            "sqlite-graphrag",
399            "config",
400            "doctor",
401            "--db",
402            "/tmp/gap-sg-139-sentinel.sqlite",
403        ])
404        .expect("config doctor must accept --db as a no-op (GAP-SG-139)");
405
406        match cli.command {
407            Some(crate::cli::Commands::Config(args)) => match args.action {
408                super::ConfigAction::Doctor { db, .. } => {
409                    assert_eq!(db.as_deref(), Some("/tmp/gap-sg-139-sentinel.sqlite"));
410                }
411                other => panic!("expected Doctor, got {other:?}"),
412            },
413            other => panic!("expected Config, got {other:?}"),
414        }
415    }
416
417    #[test]
418    fn config_list_accepts_db_as_noop() {
419        let cli = crate::cli::Cli::try_parse_from([
420            "sqlite-graphrag",
421            "config",
422            "list",
423            "--db",
424            "/tmp/gap-sg-139-sentinel.sqlite",
425        ])
426        .expect("config list must accept --db as a no-op (GAP-SG-139)");
427
428        match cli.command {
429            Some(crate::cli::Commands::Config(args)) => match args.action {
430                super::ConfigAction::List { db, .. } => {
431                    assert_eq!(db.as_deref(), Some("/tmp/gap-sg-139-sentinel.sqlite"));
432                }
433                other => panic!("expected List, got {other:?}"),
434            },
435            other => panic!("expected Config, got {other:?}"),
436        }
437    }
438
439    #[test]
440    fn config_add_key_accepts_db_as_noop() {
441        let cli = crate::cli::Cli::try_parse_from([
442            "sqlite-graphrag",
443            "config",
444            "add-key",
445            "--provider",
446            "openrouter",
447            "--db",
448            "/tmp/gap-sg-139-sentinel.sqlite",
449        ])
450        .expect("config add-key must accept --db as a no-op (GAP-SG-139)");
451
452        match cli.command {
453            Some(crate::cli::Commands::Config(args)) => match args.action {
454                super::ConfigAction::AddKey { db, .. } => {
455                    assert_eq!(db.as_deref(), Some("/tmp/gap-sg-139-sentinel.sqlite"));
456                }
457                other => panic!("expected AddKey, got {other:?}"),
458            },
459            other => panic!("expected Config, got {other:?}"),
460        }
461    }
462}