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    /// Run `config doctor --json` for the full list of accepted keys with their
75    /// defaults. That listing is derived from the registry, so it cannot drift
76    /// from what the binary accepts — a static list here can, and did
77    /// (GAP-SG-203). The sample below is kept short for that reason; the
78    /// authoritative answer is always `config doctor`.
79    ///
80    /// The value is validated against the key's domain, so a bad timezone or a
81    /// non-numeric size is rejected here rather than misbehaving later.
82    ///
83    /// Sample of accepted keys: `db.path`, `display.tz`, `embedding.dim`,
84    /// `embedding.model`, `embedding.backend`, `llm.backend`, `log.level`,
85    /// `log.format`, `namespace.default`,
86    /// `network.openrouter.chat_url` (alias `network.chat_url`),
87    /// `network.openrouter.embeddings_url` (alias `network.embed_url`).
88    Set {
89        /// Dotted key name, e.g. `display.tz`.
90        key: String,
91        /// Value as string (parsed by consumers).
92        value: String,
93        /// Emit machine-readable JSON on stdout.
94        #[arg(long, hide = true)]
95        json: bool,
96        /// GAP-SG-139: accepted as a no-op for agent uniformity (XDG settings; no graph I/O).
97        #[arg(long, value_name = "PATH", help = DB_NOOP_HELP)]
98        db: Option<String>,
99    },
100    /// Get an operational setting from XDG config.
101    Get {
102        /// Key.
103        key: String,
104        /// Emit machine-readable JSON on stdout.
105        #[arg(long, hide = true)]
106        json: bool,
107        /// GAP-SG-139: accepted as a no-op for agent uniformity (XDG settings; no graph I/O).
108        #[arg(long, value_name = "PATH", help = DB_NOOP_HELP)]
109        db: Option<String>,
110    },
111    /// List all operational settings (no secrets).
112    List {
113        /// Include well-known defaults even when not stored in XDG.
114        #[arg(long, default_value_t = false)]
115        effective: bool,
116        /// Emit machine-readable JSON on stdout.
117        #[arg(long, hide = true)]
118        json: bool,
119        /// Emit the JSON Schema for `config list` stdout and exit 0
120        /// without reading settings (agent-native R-AN-01).
121        #[arg(
122            long,
123            default_value_t = false,
124            help = "Print JSON Schema for config list output and exit"
125        )]
126        print_schema: bool,
127        /// GAP-SG-139: accepted as a no-op for agent uniformity (XDG settings; no graph I/O).
128        #[arg(long, value_name = "PATH", help = DB_NOOP_HELP)]
129        db: Option<String>,
130    },
131    /// Unset an operational setting.
132    Unset {
133        /// Key.
134        key: String,
135        /// Emit machine-readable JSON on stdout.
136        #[arg(long, hide = true)]
137        json: bool,
138        /// GAP-SG-139: accepted as a no-op for agent uniformity (XDG settings; no graph I/O).
139        #[arg(long, value_name = "PATH", help = DB_NOOP_HELP)]
140        db: Option<String>,
141    },
142}
143
144/// Run.
145pub fn run(args: ConfigArgs) -> Result<(), AppError> {
146    match args.action {
147        ConfigAction::AddKey {
148            provider,
149            from_stdin,
150            json: _,
151            db: _,
152        } => {
153            let key = if from_stdin {
154                // Declarative refusal (`--no-input`): never reach for the key.
155                if crate::stdin_helper::no_input() {
156                    return Err(AppError::Validation(
157                        crate::i18n::validation::no_input_blocks_stdin(),
158                    ));
159                }
160                let mut buf = String::new();
161                io::stdin().read_to_string(&mut buf).map_err(AppError::Io)?;
162                buf.trim().to_string()
163            } else {
164                return Err(AppError::Validation(
165                    "--from-stdin is required to avoid shell history exposure".into(),
166                ));
167            };
168            if key.is_empty() {
169                return Err(AppError::Validation(
170                    crate::i18n::validation::api_key_cannot_be_empty(),
171                ));
172            }
173            let fingerprint = compute_fingerprint(&key);
174            let entry = ApiKeyEntry {
175                provider: provider.clone(),
176                value: key,
177                added_at: chrono::Utc::now().to_rfc3339(),
178                fingerprint: fingerprint.clone(),
179            };
180            let mut cfg = config::load_config()?;
181            cfg.keys.retain(|k| k.provider != provider);
182            cfg.keys.push(entry);
183            config::save_config(&cfg)?;
184            let output = json!({
185                "action": "key_added",
186                "provider": provider,
187                "fingerprint": fingerprint,
188            });
189            crate::output::emit_json_compact(&output)?;
190            Ok(())
191        }
192        ConfigAction::ListKeys { json: _, db: _ } => {
193            let cfg = config::load_config()?;
194            let keys: Vec<_> = cfg
195                .keys
196                .iter()
197                .map(|k| {
198                    json!({
199                        "provider": k.provider,
200                        "fingerprint": k.fingerprint,
201                        "masked_value": mask_key(&k.value),
202                        "added_at": k.added_at,
203                    })
204                })
205                .collect();
206            let output = json!({ "keys": keys });
207            crate::output::emit_json(&output)?;
208            Ok(())
209        }
210        ConfigAction::RemoveKey {
211            fingerprint,
212            json: _,
213            db: _,
214        } => {
215            let mut cfg = config::load_config()?;
216            let before = cfg.keys.len();
217            cfg.keys.retain(|k| k.fingerprint != fingerprint);
218            if cfg.keys.len() == before {
219                return Err(AppError::NotFound(
220                    crate::i18n::validation::api_key_fingerprint_not_found(&fingerprint),
221                ));
222            }
223            config::save_config(&cfg)?;
224            let output = json!({
225                "action": "key_removed",
226                "fingerprint": fingerprint,
227            });
228            crate::output::emit_json_compact(&output)?;
229            Ok(())
230        }
231        ConfigAction::Doctor { json: _, db: _ } => {
232            let config_path = config::config_file_path()
233                .map(|p| p.display().to_string())
234                .unwrap_or_else(|_| "unavailable".to_string());
235            let config_exists = std::path::Path::new(&config_path).exists();
236            let providers = ["openrouter"];
237            let mut results = vec![];
238            for provider in &providers {
239                let resolved = config::resolve_api_key(provider, None);
240                results.push(json!({
241                    "provider": provider,
242                    "resolved": resolved.is_some(),
243                    "source": resolved.as_ref().map(|r| r.source),
244                    "masked_value": resolved.as_ref().map(|r| {
245                        use secrecy::ExposeSecret;
246                        mask_key(r.value.expose_secret())
247                    }),
248                }));
249            }
250            // Operational knobs with source layer (flag|xdg|default|derived).
251            // Product env is never a source.
252            //
253            // GAP-SG-85: this listing used to be a hand-written table of 14
254            // entries next to a 44-key registry, and `db.path` — the key that
255            // redirects the whole database — was one of the missing ones. The
256            // list is now DERIVED from `config::SETTING_KEYS`, so a key cannot
257            // exist without being discoverable here.
258            let rt = crate::runtime_config::get();
259
260            // Only these keys can be overridden by a CLI flag today. Mapping
261            // them explicitly keeps the `flag` source honest instead of
262            // claiming a flag layer that does not exist for the other keys.
263            let flag_for = |key: &str| -> Option<&str> {
264                match key {
265                    "display.tz" => rt.display_tz.as_deref(),
266                    "i18n.lang" => rt.lang.as_deref(),
267                    "log.level" => rt.log_level.as_deref(),
268                    "log.format" => rt.log_format.as_deref(),
269                    "llm.model" => rt.llm_model.as_deref(),
270                    "llm.fallback" => rt.llm_fallback.as_deref(),
271                    "db.path" => rt.db_path.as_deref(),
272                    _ => None,
273                }
274            };
275
276            let knobs: Vec<_> = config::SETTING_KEYS
277                .iter()
278                .map(|entry| {
279                    let runtime_flag = flag_for(entry.key).filter(|v| !v.is_empty());
280                    let xdg_value = config::get_setting(entry.key)
281                        .ok()
282                        .flatten()
283                        .filter(|v| !v.is_empty());
284                    let (source, value) = match (runtime_flag, xdg_value) {
285                        (Some(v), _) => ("flag", Some(v.to_string())),
286                        (None, Some(v)) => ("xdg", Some(v)),
287                        // A key whose default is derived from the host has no
288                        // literal to report; `derived` says so instead of
289                        // printing an empty string that reads like "unset".
290                        (None, None) => match entry.default {
291                            Some(d) => ("default", Some(d.to_string())),
292                            None => ("derived", None),
293                        },
294                    };
295                    json!({ "key": entry.key, "value": value, "source": source })
296                })
297                .collect();
298            let output = json!({
299                "config_path": config_path,
300                "config_exists": config_exists,
301                "providers": results,
302                "knobs": knobs,
303                "product_env_reads": false,
304                "note": "Precedence: CLI flag > XDG config set > named default. No SQLITE_GRAPHRAG_* product env.",
305            });
306            crate::output::emit_json(&output)?;
307            Ok(())
308        }
309        ConfigAction::Path { json: _, db: _ } => {
310            let path = config::config_file_path()?;
311            let output = json!({
312                "config_path": path.display().to_string(),
313                "exists": path.exists(),
314            });
315            crate::output::emit_json_compact(&output)?;
316            Ok(())
317        }
318        ConfigAction::Set {
319            key,
320            value,
321            json: _,
322            db: _,
323        } => {
324            config::set_setting(&key, &value)?;
325            let output = json!({
326                "action": "setting_set",
327                "key": key,
328                "value": value,
329            });
330            crate::output::emit_json_compact(&output)?;
331            Ok(())
332        }
333        ConfigAction::Get {
334            key,
335            json: _,
336            db: _,
337        } => {
338            let value = config::get_setting(&key)?;
339            // GAP-SG-202: `found: false` alone conflated two states an operator
340            // must tell apart — a real key that is simply unset, and a key that
341            // does not exist at all. `config set` has always answered exit 1
342            // with a did-you-mean for the second, so the two verbs disagreed
343            // about the same string, and a typo read as "exists, empty".
344            //
345            // The exit code stays 0 on purpose: scripts probe presence with
346            // this command, and turning a probe into a failure would break them
347            // to say something the envelope can carry instead.
348            let known = config::is_known_setting(&key);
349            let output = json!({
350                "key": key,
351                "value": value,
352                "found": value.is_some(),
353                "known": known,
354                "suggestion": if known { None } else { config::nearest_setting_key(&key) },
355            });
356            crate::output::emit_json_compact(&output)?;
357            Ok(())
358        }
359        ConfigAction::List {
360            effective,
361            json: _,
362            print_schema,
363            db: _,
364        } => {
365            if print_schema {
366                return crate::print_schema::emit(crate::print_schema::SchemaId::ConfigList);
367            }
368            let mut settings = config::list_settings()?;
369            if effective {
370                // GAP-SG-93 established that defaults must come from constants
371                // rather than literals. GAP-SG-209 finishes the job: this used
372                // to be a hand-maintained list of SEVEN pairs while
373                // `SETTING_KEYS` declares a default for roughly fifty keys, so
374                // `--effective` promised "well-known defaults" and delivered a
375                // seventh of them. Worse, it was a SECOND copy — `display.tz`
376                // was spelled `"UTC"` here and `Some("UTC")` in the registry,
377                // free to drift apart in silence.
378                //
379                // The registry is the single source of truth for which keys
380                // exist AND what they fall back to, so read it. A key whose
381                // default is `None` is one the host derives at runtime (an XDG
382                // directory, the CPU count, a probe); inventing a literal for
383                // those would print a number the process never uses, which is
384                // the drift this loop exists to prevent.
385                for entry in config::SETTING_KEYS {
386                    let Some(default) = entry.default else {
387                        continue;
388                    };
389                    settings
390                        .entry(entry.key.to_string())
391                        .or_insert_with(|| default.to_string());
392                }
393            }
394            let output = json!({
395                "settings": settings,
396                "effective": effective,
397            });
398            crate::output::emit_json(&output)?;
399            Ok(())
400        }
401        ConfigAction::Unset {
402            key,
403            json: _,
404            db: _,
405        } => {
406            let removed = config::unset_setting(&key)?;
407            let output = json!({
408                "action": "setting_unset",
409                "key": key,
410                "removed": removed,
411            });
412            crate::output::emit_json_compact(&output)?;
413            Ok(())
414        }
415    }
416}
417
418#[cfg(test)]
419mod tests {
420    use clap::Parser;
421
422    #[test]
423    fn config_doctor_accepts_db_as_noop() {
424        let cli = crate::cli::Cli::try_parse_from([
425            "sqlite-graphrag",
426            "config",
427            "doctor",
428            "--db",
429            "/tmp/gap-sg-139-sentinel.sqlite",
430        ])
431        .expect("config doctor must accept --db as a no-op (GAP-SG-139)");
432
433        match cli.command {
434            Some(crate::cli::Commands::Config(args)) => match args.action {
435                super::ConfigAction::Doctor { db, .. } => {
436                    assert_eq!(db.as_deref(), Some("/tmp/gap-sg-139-sentinel.sqlite"));
437                }
438                other => panic!("expected Doctor, got {other:?}"),
439            },
440            other => panic!("expected Config, got {other:?}"),
441        }
442    }
443
444    #[test]
445    fn config_list_accepts_db_as_noop() {
446        let cli = crate::cli::Cli::try_parse_from([
447            "sqlite-graphrag",
448            "config",
449            "list",
450            "--db",
451            "/tmp/gap-sg-139-sentinel.sqlite",
452        ])
453        .expect("config list must accept --db as a no-op (GAP-SG-139)");
454
455        match cli.command {
456            Some(crate::cli::Commands::Config(args)) => match args.action {
457                super::ConfigAction::List { db, .. } => {
458                    assert_eq!(db.as_deref(), Some("/tmp/gap-sg-139-sentinel.sqlite"));
459                }
460                other => panic!("expected List, got {other:?}"),
461            },
462            other => panic!("expected Config, got {other:?}"),
463        }
464    }
465
466    #[test]
467    fn config_add_key_accepts_db_as_noop() {
468        let cli = crate::cli::Cli::try_parse_from([
469            "sqlite-graphrag",
470            "config",
471            "add-key",
472            "--provider",
473            "openrouter",
474            "--db",
475            "/tmp/gap-sg-139-sentinel.sqlite",
476        ])
477        .expect("config add-key must accept --db as a no-op (GAP-SG-139)");
478
479        match cli.command {
480            Some(crate::cli::Commands::Config(args)) => match args.action {
481                super::ConfigAction::AddKey { db, .. } => {
482                    assert_eq!(db.as_deref(), Some("/tmp/gap-sg-139-sentinel.sqlite"));
483                }
484                other => panic!("expected AddKey, got {other:?}"),
485            },
486            other => panic!("expected Config, got {other:?}"),
487        }
488    }
489}