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#[derive(Debug, Args)]
10pub struct ConfigArgs {
11 #[command(subcommand)]
13 pub action: ConfigAction,
14}
15
16#[derive(Debug, Subcommand)]
18pub enum ConfigAction {
19 AddKey {
21 #[arg(long)]
23 provider: String,
24 #[arg(long, default_value_t = true)]
26 from_stdin: bool,
27 #[arg(long, hide = true)]
29 json: bool,
30 #[arg(long, value_name = "PATH", help = DB_NOOP_HELP)]
32 db: Option<String>,
33 },
34 ListKeys {
36 #[arg(long, hide = true)]
38 json: bool,
39 #[arg(long, value_name = "PATH", help = DB_NOOP_HELP)]
41 db: Option<String>,
42 },
43 RemoveKey {
45 fingerprint: String,
47 #[arg(long, hide = true)]
49 json: bool,
50 #[arg(long, value_name = "PATH", help = DB_NOOP_HELP)]
52 db: Option<String>,
53 },
54 Doctor {
56 #[arg(long, hide = true)]
58 json: bool,
59 #[arg(long, value_name = "PATH", help = DB_NOOP_HELP)]
61 db: Option<String>,
62 },
63 Path {
65 #[arg(long, hide = true)]
67 json: bool,
68 #[arg(long, value_name = "PATH", help = DB_NOOP_HELP)]
70 db: Option<String>,
71 },
72 Set {
84 key: String,
86 value: String,
88 #[arg(long, hide = true)]
90 json: bool,
91 #[arg(long, value_name = "PATH", help = DB_NOOP_HELP)]
93 db: Option<String>,
94 },
95 Get {
97 key: String,
99 #[arg(long, hide = true)]
101 json: bool,
102 #[arg(long, value_name = "PATH", help = DB_NOOP_HELP)]
104 db: Option<String>,
105 },
106 List {
108 #[arg(long, default_value_t = false)]
110 effective: bool,
111 #[arg(long, hide = true)]
113 json: bool,
114 #[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 #[arg(long, value_name = "PATH", help = DB_NOOP_HELP)]
124 db: Option<String>,
125 },
126 Unset {
128 key: String,
130 #[arg(long, hide = true)]
132 json: bool,
133 #[arg(long, value_name = "PATH", help = DB_NOOP_HELP)]
135 db: Option<String>,
136 },
137}
138
139pub 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 let rt = crate::runtime_config::get();
248
249 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 (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 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}