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 AddKey {
17 #[arg(long)]
18 provider: String,
19 #[arg(long, default_value_t = true)]
20 from_stdin: bool,
21 #[arg(long, hide = true)]
23 json: bool,
24 },
25 ListKeys {
27 #[arg(long, hide = true)]
29 json: bool,
30 },
31 RemoveKey {
33 fingerprint: String,
34 #[arg(long, hide = true)]
36 json: bool,
37 },
38 Doctor {
40 #[arg(long, hide = true)]
42 json: bool,
43 },
44 Path {
46 #[arg(long, hide = true)]
48 json: bool,
49 },
50 Set {
62 key: String,
64 value: String,
66 #[arg(long, hide = true)]
67 json: bool,
68 },
69 Get {
71 key: String,
72 #[arg(long, hide = true)]
73 json: bool,
74 },
75 List {
77 #[arg(long, default_value_t = false)]
79 effective: bool,
80 #[arg(long, hide = true)]
81 json: bool,
82 },
83 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 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}