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