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}