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(
117            long,
118            default_value_t = false,
119            help = "Print JSON Schema for config list output and exit"
120        )]
121        print_schema: bool,
122        /// GAP-SG-139: accepted as a no-op for agent uniformity (XDG settings; no graph I/O).
123        #[arg(long, value_name = "PATH", help = DB_NOOP_HELP)]
124        db: Option<String>,
125    },
126    /// Unset an operational setting.
127    Unset {
128        /// Key.
129        key: String,
130        /// Emit machine-readable JSON on stdout.
131        #[arg(long, hide = true)]
132        json: bool,
133        /// GAP-SG-139: accepted as a no-op for agent uniformity (XDG settings; no graph I/O).
134        #[arg(long, value_name = "PATH", help = DB_NOOP_HELP)]
135        db: Option<String>,
136    },
137}
138
139/// Run.
140pub fn run(args: ConfigArgs) -> Result<(), AppError> {
141    match args.action {
142        ConfigAction::AddKey {
143            provider,
144            from_stdin,
145            json: _,
146            db: _,
147        } => {
148            let key = if from_stdin {
149                let mut buf = String::new();
150                io::stdin().read_to_string(&mut buf).map_err(AppError::Io)?;
151                buf.trim().to_string()
152            } else {
153                return Err(AppError::Validation(
154                    "--from-stdin is required to avoid shell history exposure".into(),
155                ));
156            };
157            if key.is_empty() {
158                return Err(AppError::Validation(
159                    crate::i18n::validation::api_key_cannot_be_empty(),
160                ));
161            }
162            let fingerprint = compute_fingerprint(&key);
163            let entry = ApiKeyEntry {
164                provider: provider.clone(),
165                value: key,
166                added_at: chrono::Utc::now().to_rfc3339(),
167                fingerprint: fingerprint.clone(),
168            };
169            let mut cfg = config::load_config()?;
170            cfg.keys.retain(|k| k.provider != provider);
171            cfg.keys.push(entry);
172            config::save_config(&cfg)?;
173            let output = json!({
174                "action": "key_added",
175                "provider": provider,
176                "fingerprint": fingerprint,
177            });
178            println!("{}", serde_json::to_string(&output)?);
179            Ok(())
180        }
181        ConfigAction::ListKeys { json: _, db: _ } => {
182            let cfg = config::load_config()?;
183            let keys: Vec<_> = cfg
184                .keys
185                .iter()
186                .map(|k| {
187                    json!({
188                        "provider": k.provider,
189                        "fingerprint": k.fingerprint,
190                        "masked_value": mask_key(&k.value),
191                        "added_at": k.added_at,
192                    })
193                })
194                .collect();
195            let output = json!({ "keys": keys });
196            println!("{}", serde_json::to_string_pretty(&output)?);
197            Ok(())
198        }
199        ConfigAction::RemoveKey {
200            fingerprint,
201            json: _,
202            db: _,
203        } => {
204            let mut cfg = config::load_config()?;
205            let before = cfg.keys.len();
206            cfg.keys.retain(|k| k.fingerprint != fingerprint);
207            if cfg.keys.len() == before {
208                return Err(AppError::NotFound(format!(
209                    "no key with fingerprint {fingerprint}"
210                )));
211            }
212            config::save_config(&cfg)?;
213            let output = json!({
214                "action": "key_removed",
215                "fingerprint": fingerprint,
216            });
217            println!("{}", serde_json::to_string(&output)?);
218            Ok(())
219        }
220        ConfigAction::Doctor { json: _, db: _ } => {
221            let config_path = config::config_file_path()
222                .map(|p| p.display().to_string())
223                .unwrap_or_else(|_| "unavailable".to_string());
224            let config_exists = std::path::Path::new(&config_path).exists();
225            let providers = ["openrouter"];
226            let mut results = vec![];
227            for provider in &providers {
228                let resolved = config::resolve_api_key(provider, None);
229                results.push(json!({
230                    "provider": provider,
231                    "resolved": resolved.is_some(),
232                    "source": resolved.as_ref().map(|r| r.source),
233                    "masked_value": resolved.as_ref().map(|r| {
234                        use secrecy::ExposeSecret;
235                        mask_key(r.value.expose_secret())
236                    }),
237                }));
238            }
239            // Operational knobs with source layer (flag|xdg|default|derived).
240            // Product env is never a source.
241            //
242            // GAP-SG-85: this listing used to be a hand-written table of 14
243            // entries next to a 44-key registry, and `db.path` — the key that
244            // redirects the whole database — was one of the missing ones. The
245            // list is now DERIVED from `config::SETTING_KEYS`, so a key cannot
246            // exist without being discoverable here.
247            let rt = crate::runtime_config::get();
248
249            // Only these keys can be overridden by a CLI flag today. Mapping
250            // them explicitly keeps the `flag` source honest instead of
251            // claiming a flag layer that does not exist for the other keys.
252            let flag_for = |key: &str| -> Option<&str> {
253                match key {
254                    "display.tz" => rt.display_tz.as_deref(),
255                    "i18n.lang" => rt.lang.as_deref(),
256                    "log.level" => rt.log_level.as_deref(),
257                    "log.format" => rt.log_format.as_deref(),
258                    "llm.claude_binary" => rt.claude_binary.as_deref(),
259                    "llm.codex_binary" => rt.codex_binary.as_deref(),
260                    "llm.opencode_binary" => rt.opencode_binary.as_deref(),
261                    "llm.model" => rt.llm_model.as_deref(),
262                    "llm.fallback" => rt.llm_fallback.as_deref(),
263                    "db.path" => rt.db_path.as_deref(),
264                    _ => None,
265                }
266            };
267
268            let knobs: Vec<_> = config::SETTING_KEYS
269                .iter()
270                .map(|entry| {
271                    let runtime_flag = flag_for(entry.key).filter(|v| !v.is_empty());
272                    let xdg_value = config::get_setting(entry.key)
273                        .ok()
274                        .flatten()
275                        .filter(|v| !v.is_empty());
276                    let (source, value) = match (runtime_flag, xdg_value) {
277                        (Some(v), _) => ("flag", Some(v.to_string())),
278                        (None, Some(v)) => ("xdg", Some(v)),
279                        // A key whose default is derived from the host has no
280                        // literal to report; `derived` says so instead of
281                        // printing an empty string that reads like "unset".
282                        (None, None) => match entry.default {
283                            Some(d) => ("default", Some(d.to_string())),
284                            None => ("derived", None),
285                        },
286                    };
287                    json!({ "key": entry.key, "value": value, "source": source })
288                })
289                .collect();
290            let output = json!({
291                "config_path": config_path,
292                "config_exists": config_exists,
293                "providers": results,
294                "knobs": knobs,
295                "product_env_reads": false,
296                "note": "Precedence: CLI flag > XDG config set > named default. No SQLITE_GRAPHRAG_* product env.",
297            });
298            println!("{}", serde_json::to_string_pretty(&output)?);
299            Ok(())
300        }
301        ConfigAction::Path { json: _, db: _ } => {
302            let path = config::config_file_path()?;
303            let output = json!({
304                "config_path": path.display().to_string(),
305                "exists": path.exists(),
306            });
307            println!("{}", serde_json::to_string(&output)?);
308            Ok(())
309        }
310        ConfigAction::Set {
311            key,
312            value,
313            json: _,
314            db: _,
315        } => {
316            config::set_setting(&key, &value)?;
317            let output = json!({
318                "action": "setting_set",
319                "key": key,
320                "value": value,
321            });
322            println!("{}", serde_json::to_string(&output)?);
323            Ok(())
324        }
325        ConfigAction::Get {
326            key,
327            json: _,
328            db: _,
329        } => {
330            let value = config::get_setting(&key)?;
331            let output = json!({
332                "key": key,
333                "value": value,
334                "found": value.is_some(),
335            });
336            println!("{}", serde_json::to_string(&output)?);
337            Ok(())
338        }
339        ConfigAction::List {
340            effective,
341            json: _,
342            print_schema,
343            db: _,
344        } => {
345            if print_schema {
346                return crate::print_schema::emit(crate::print_schema::SchemaId::ConfigList);
347            }
348            let mut settings = config::list_settings()?;
349            if effective {
350                // GAP-SG-93: defaults MUST come from constants / named defaults,
351                // never hard-coded literals that can drift from DEFAULT_EMBEDDING_DIM.
352                let dim_default = crate::constants::DEFAULT_EMBEDDING_DIM.to_string();
353                let probe_default = crate::constants::DEFAULT_LLM_PROBE_TIMEOUT_MS.to_string();
354                let defaults: &[(&str, &str)] = &[
355                    (
356                        "network.openrouter.chat_url",
357                        crate::constants::DEFAULT_OPENROUTER_CHAT_URL,
358                    ),
359                    (
360                        "network.openrouter.embeddings_url",
361                        crate::constants::DEFAULT_OPENROUTER_EMBEDDINGS_URL,
362                    ),
363                    ("llm.probe_timeout_ms", probe_default.as_str()),
364                    ("llm.fallback", "codex,claude,none"),
365                    ("embedding.dim", dim_default.as_str()),
366                    ("log.level", crate::constants::DEFAULT_LOG_LEVEL),
367                    ("display.tz", "UTC"),
368                ];
369                for (k, v) in defaults {
370                    settings
371                        .entry(k.to_string())
372                        .or_insert_with(|| v.to_string());
373                }
374            }
375            let output = json!({
376                "settings": settings,
377                "effective": effective,
378            });
379            println!("{}", serde_json::to_string_pretty(&output)?);
380            Ok(())
381        }
382        ConfigAction::Unset {
383            key,
384            json: _,
385            db: _,
386        } => {
387            let removed = config::unset_setting(&key)?;
388            let output = json!({
389                "action": "setting_unset",
390                "key": key,
391                "removed": removed,
392            });
393            println!("{}", serde_json::to_string(&output)?);
394            Ok(())
395        }
396    }
397}
398
399#[cfg(test)]
400mod tests {
401    use clap::Parser;
402
403    #[test]
404    fn config_doctor_accepts_db_as_noop() {
405        let cli = crate::cli::Cli::try_parse_from([
406            "sqlite-graphrag",
407            "config",
408            "doctor",
409            "--db",
410            "/tmp/gap-sg-139-sentinel.sqlite",
411        ])
412        .expect("config doctor must accept --db as a no-op (GAP-SG-139)");
413
414        match cli.command {
415            Some(crate::cli::Commands::Config(args)) => match args.action {
416                super::ConfigAction::Doctor { db, .. } => {
417                    assert_eq!(db.as_deref(), Some("/tmp/gap-sg-139-sentinel.sqlite"));
418                }
419                other => panic!("expected Doctor, got {other:?}"),
420            },
421            other => panic!("expected Config, got {other:?}"),
422        }
423    }
424
425    #[test]
426    fn config_list_accepts_db_as_noop() {
427        let cli = crate::cli::Cli::try_parse_from([
428            "sqlite-graphrag",
429            "config",
430            "list",
431            "--db",
432            "/tmp/gap-sg-139-sentinel.sqlite",
433        ])
434        .expect("config list must accept --db as a no-op (GAP-SG-139)");
435
436        match cli.command {
437            Some(crate::cli::Commands::Config(args)) => match args.action {
438                super::ConfigAction::List { db, .. } => {
439                    assert_eq!(db.as_deref(), Some("/tmp/gap-sg-139-sentinel.sqlite"));
440                }
441                other => panic!("expected List, got {other:?}"),
442            },
443            other => panic!("expected Config, got {other:?}"),
444        }
445    }
446
447    #[test]
448    fn config_add_key_accepts_db_as_noop() {
449        let cli = crate::cli::Cli::try_parse_from([
450            "sqlite-graphrag",
451            "config",
452            "add-key",
453            "--provider",
454            "openrouter",
455            "--db",
456            "/tmp/gap-sg-139-sentinel.sqlite",
457        ])
458        .expect("config add-key must accept --db as a no-op (GAP-SG-139)");
459
460        match cli.command {
461            Some(crate::cli::Commands::Config(args)) => match args.action {
462                super::ConfigAction::AddKey { db, .. } => {
463                    assert_eq!(db.as_deref(), Some("/tmp/gap-sg-139-sentinel.sqlite"));
464                }
465                other => panic!("expected AddKey, got {other:?}"),
466            },
467            other => panic!("expected Config, got {other:?}"),
468        }
469    }
470}