Skip to main content

sqlite_graphrag/commands/
config_cmd.rs

1use crate::config::{self, compute_fingerprint, mask_key, ApiKeyEntry};
2use crate::errors::AppError;
3use clap::{Args, Subcommand};
4use serde_json::json;
5use std::io::{self, Read};
6
7#[derive(Debug, Args)]
8pub struct ConfigArgs {
9    #[command(subcommand)]
10    pub action: ConfigAction,
11}
12
13#[derive(Debug, Subcommand)]
14pub enum ConfigAction {
15    /// Add an API key for a provider (reads from stdin to avoid shell history).
16    AddKey {
17        #[arg(long)]
18        provider: String,
19        #[arg(long, default_value_t = true)]
20        from_stdin: bool,
21        /// GAP-SG-34: no-op; JSON is always emitted on stdout.
22        #[arg(long, hide = true)]
23        json: bool,
24    },
25    /// List stored API keys (masked) with fingerprints.
26    ListKeys {
27        /// GAP-SG-34: no-op; JSON is always emitted on stdout.
28        #[arg(long, hide = true)]
29        json: bool,
30    },
31    /// Remove an API key by its fingerprint.
32    RemoveKey {
33        fingerprint: String,
34        /// GAP-SG-34: no-op; JSON is always emitted on stdout.
35        #[arg(long, hide = true)]
36        json: bool,
37    },
38    /// Diagnose key resolution layers (flag/cli and XDG config; product env deprecated).
39    Doctor {
40        /// GAP-SG-34: no-op; JSON is always emitted on stdout.
41        #[arg(long, hide = true)]
42        json: bool,
43    },
44    /// Print the resolved XDG config file path.
45    Path {
46        /// GAP-SG-34: no-op; JSON is always emitted on stdout.
47        #[arg(long, hide = true)]
48        json: bool,
49    },
50    /// Set an operational setting in XDG config (G-T-XDG-01).
51    ///
52    /// Known keys (non-exhaustive):
53    /// `enrich.preserve_threshold`, `enrich.entity_description.domain`,
54    /// `enrich.entity_description.grounding_threshold`,
55    /// `enrich.entity_connect.default_limit`,
56    /// `enrich.entity_connect.max_runtime_secs`,
57    /// `network.openrouter.chat_url` (alias `network.chat_url`),
58    /// `network.openrouter.embeddings_url` (alias `network.embed_url`),
59    /// `log.level`, `log.format`, `display.tz`,
60    /// `embedding.dim`, `llm.concurrency`.
61    Set {
62        /// Dotted key name, e.g. `enrich.preserve_threshold`.
63        key: String,
64        /// Value as string (parsed by consumers).
65        value: String,
66        #[arg(long, hide = true)]
67        json: bool,
68    },
69    /// Get an operational setting from XDG config.
70    Get {
71        key: String,
72        #[arg(long, hide = true)]
73        json: bool,
74    },
75    /// List all operational settings (no secrets).
76    List {
77        /// Include well-known defaults even when not stored in XDG.
78        #[arg(long, default_value_t = false)]
79        effective: bool,
80        #[arg(long, hide = true)]
81        json: bool,
82    },
83    /// Unset an operational setting.
84    Unset {
85        key: String,
86        #[arg(long, hide = true)]
87        json: bool,
88    },
89}
90
91pub fn run(args: ConfigArgs) -> Result<(), AppError> {
92    match args.action {
93        ConfigAction::AddKey {
94            provider,
95            from_stdin,
96            json: _,
97        } => {
98            let key = if from_stdin {
99                let mut buf = String::new();
100                io::stdin().read_to_string(&mut buf).map_err(AppError::Io)?;
101                buf.trim().to_string()
102            } else {
103                return Err(AppError::Validation(
104                    "--from-stdin is required to avoid shell history exposure".into(),
105                ));
106            };
107            if key.is_empty() {
108                return Err(AppError::Validation("API key cannot be empty".into()));
109            }
110            let fingerprint = compute_fingerprint(&key);
111            let entry = ApiKeyEntry {
112                provider: provider.clone(),
113                value: key,
114                added_at: chrono::Utc::now().to_rfc3339(),
115                fingerprint: fingerprint.clone(),
116            };
117            let mut cfg = config::load_config()?;
118            cfg.keys.retain(|k| k.provider != provider);
119            cfg.keys.push(entry);
120            config::save_config(&cfg)?;
121            let output = json!({
122                "action": "key_added",
123                "provider": provider,
124                "fingerprint": fingerprint,
125            });
126            println!("{}", serde_json::to_string(&output)?);
127            Ok(())
128        }
129        ConfigAction::ListKeys { json: _ } => {
130            let cfg = config::load_config()?;
131            let keys: Vec<_> = cfg
132                .keys
133                .iter()
134                .map(|k| {
135                    json!({
136                        "provider": k.provider,
137                        "fingerprint": k.fingerprint,
138                        "masked_value": mask_key(&k.value),
139                        "added_at": k.added_at,
140                    })
141                })
142                .collect();
143            let output = json!({ "keys": keys });
144            println!("{}", serde_json::to_string_pretty(&output)?);
145            Ok(())
146        }
147        ConfigAction::RemoveKey {
148            fingerprint,
149            json: _,
150        } => {
151            let mut cfg = config::load_config()?;
152            let before = cfg.keys.len();
153            cfg.keys.retain(|k| k.fingerprint != fingerprint);
154            if cfg.keys.len() == before {
155                return Err(AppError::NotFound(format!(
156                    "no key with fingerprint {fingerprint}"
157                )));
158            }
159            config::save_config(&cfg)?;
160            let output = json!({
161                "action": "key_removed",
162                "fingerprint": fingerprint,
163            });
164            println!("{}", serde_json::to_string(&output)?);
165            Ok(())
166        }
167        ConfigAction::Doctor { json: _ } => {
168            let config_path = config::config_file_path()
169                .map(|p| p.display().to_string())
170                .unwrap_or_else(|_| "unavailable".to_string());
171            let config_exists = std::path::Path::new(&config_path).exists();
172            let providers = ["openrouter"];
173            let mut results = vec![];
174            for provider in &providers {
175                let resolved = config::resolve_api_key(provider, None);
176                results.push(json!({
177                    "provider": provider,
178                    "resolved": resolved.is_some(),
179                    "source": resolved.as_ref().map(|r| r.source),
180                    "masked_value": resolved.as_ref().map(|r| {
181                        use secrecy::ExposeSecret;
182                        mask_key(r.value.expose_secret())
183                    }),
184                }));
185            }
186            // Wave 4: operational knobs with source layer (flag|xdg|default).
187            // Product env is never a source.
188            let knob = |key: &str, default: &str, runtime_flag: Option<&str>| {
189                let source = if runtime_flag.map(|s| !s.is_empty()).unwrap_or(false) {
190                    "flag"
191                } else if config::get_setting(key)
192                    .ok()
193                    .flatten()
194                    .map(|v| !v.is_empty())
195                    .unwrap_or(false)
196                {
197                    "xdg"
198                } else {
199                    "default"
200                };
201                let value = crate::runtime_config::resolve_string(runtime_flag, key, default);
202                json!({ "key": key, "value": value, "source": source })
203            };
204            let rt = crate::runtime_config::get();
205            let knobs = vec![
206                knob(
207                    "enrich.entity_description.quality_sample",
208                    "50",
209                    None,
210                ),
211                knob(
212                    "enrich.entity_description.grounding_threshold",
213                    "0.12",
214                    None,
215                ),
216                knob("enrich.entity_connect.default_limit", "100", None),
217                knob("enrich.entity_connect.large_ns_limit", "25", None),
218                knob("enrich.yield_every_n_items", "10", None),
219                knob("namespace.default", "global", None),
220                knob("display.tz", "UTC", rt.display_tz.as_deref()),
221                knob("i18n.lang", "en", rt.lang.as_deref()),
222                knob("log.level", "warn", rt.log_level.as_deref()),
223                knob(
224                    "llm.claude_binary",
225                    "",
226                    rt.claude_binary.as_deref(),
227                ),
228                knob("llm.codex_binary", "", rt.codex_binary.as_deref()),
229                knob(
230                    "llm.opencode_binary",
231                    "",
232                    rt.opencode_binary.as_deref(),
233                ),
234                knob("paths.cache", "", None),
235                knob("embedding.dim", "384", None),
236            ];
237            let output = json!({
238                "config_path": config_path,
239                "config_exists": config_exists,
240                "providers": results,
241                "knobs": knobs,
242                "product_env_reads": false,
243                "note": "Precedence: CLI flag > XDG config set > named default. No SQLITE_GRAPHRAG_* product env.",
244            });
245            println!("{}", serde_json::to_string_pretty(&output)?);
246            Ok(())
247        }
248        ConfigAction::Path { json: _ } => {
249            let path = config::config_file_path()?;
250            let output = json!({
251                "config_path": path.display().to_string(),
252                "exists": path.exists(),
253            });
254            println!("{}", serde_json::to_string(&output)?);
255            Ok(())
256        }
257        ConfigAction::Set {
258            key,
259            value,
260            json: _,
261        } => {
262            config::set_setting(&key, &value)?;
263            let output = json!({
264                "action": "setting_set",
265                "key": key,
266                "value": value,
267            });
268            println!("{}", serde_json::to_string(&output)?);
269            Ok(())
270        }
271        ConfigAction::Get { key, json: _ } => {
272            let value = config::get_setting(&key)?;
273            let output = json!({
274                "key": key,
275                "value": value,
276                "found": value.is_some(),
277            });
278            println!("{}", serde_json::to_string(&output)?);
279            Ok(())
280        }
281        ConfigAction::List { effective, json: _ } => {
282            let mut settings = config::list_settings()?;
283            if effective {
284                let defaults: &[(&str, &str)] = &[
285                    (
286                        "network.openrouter.chat_url",
287                        crate::constants::DEFAULT_OPENROUTER_CHAT_URL,
288                    ),
289                    (
290                        "network.openrouter.embeddings_url",
291                        crate::constants::DEFAULT_OPENROUTER_EMBEDDINGS_URL,
292                    ),
293                    ("llm.probe_timeout_ms", "800"),
294                    ("llm.fallback", "codex,claude,none"),
295                    ("embedding.dim", "384"),
296                    ("log.level", "info"),
297                    ("display.tz", "UTC"),
298                ];
299                for (k, v) in defaults {
300                    settings.entry(k.to_string()).or_insert_with(|| v.to_string());
301                }
302            }
303            let output = json!({
304                "settings": settings,
305                "effective": effective,
306            });
307            println!("{}", serde_json::to_string_pretty(&output)?);
308            Ok(())
309        }
310        ConfigAction::Unset { key, json: _ } => {
311            let removed = config::unset_setting(&key)?;
312            let output = json!({
313                "action": "setting_unset",
314                "key": key,
315                "removed": removed,
316            });
317            println!("{}", serde_json::to_string(&output)?);
318            Ok(())
319        }
320    }
321}