1use std::io::{IsTerminal, Write};
10use std::path::PathBuf;
11use std::time::Duration;
12
13use anyhow::{bail, Context, Result};
14use bamboo_config::Config;
15
16const KEYED_PROVIDERS: [&str; 3] = ["anthropic", "openai", "gemini"];
21
22const KNOWN_PROVIDERS: [&str; 5] = ["anthropic", "openai", "gemini", "copilot", "bodhi"];
26
27fn default_model_for(provider: &str) -> Option<&'static str> {
31 match provider {
32 "anthropic" => Some("claude-sonnet-4-6"),
33 "openai" => Some("gpt-4o-mini"),
34 "gemini" => Some("gemini-2.0-flash-exp"),
35 _ => None,
36 }
37}
38
39pub struct InitArgs {
41 pub data_dir: Option<PathBuf>,
42 pub provider: Option<String>,
43 pub api_key: Option<String>,
44 pub model: Option<String>,
45 pub force: bool,
46 pub non_interactive: bool,
47}
48
49pub fn run_init(args: InitArgs) -> Result<()> {
55 let data_dir = args
56 .data_dir
57 .clone()
58 .unwrap_or_else(bamboo_config::paths::resolve_bamboo_dir);
59 let interactive = !args.non_interactive && std::io::stdin().is_terminal();
60
61 let mut config = Config::from_data_dir_without_env(Some(data_dir.clone()));
65
66 let provider = match args.provider {
68 Some(p) => normalize_provider(&p)?,
69 None if interactive => prompt_provider()?,
70 None => bail!(
71 "--provider is required in non-interactive mode (one of: {})",
72 KEYED_PROVIDERS.join(", ")
73 ),
74 };
75
76 if provider_has_key(&config, &provider) && !args.force {
78 if interactive {
79 if !prompt_yes_no(
80 &format!("Provider '{provider}' already has an API key. Overwrite?"),
81 false,
82 )? {
83 println!("Aborted; existing configuration left unchanged.");
84 return Ok(());
85 }
86 } else {
87 bail!("provider '{provider}' already has an API key; pass --force to overwrite");
88 }
89 }
90
91 let api_key = match args.api_key {
93 Some(k) => k,
94 None if interactive => prompt_api_key(&provider)?,
95 None => bail!("--api-key is required in non-interactive mode"),
96 };
97 let api_key = api_key.trim().to_string();
98 if api_key.is_empty() {
99 bail!("API key must not be empty");
100 }
101
102 let model = args
104 .model
105 .or_else(|| default_model_for(&provider).map(String::from));
106
107 config.provider = provider.clone();
109 set_provider_api_key(&mut config, &provider, &api_key)?;
110 if let Some(m) = &model {
111 set_provider_model(&mut config, &provider, m)?;
112 }
113 config
114 .save_to_dir(data_dir.clone())
115 .context("failed to write config.json")?;
116
117 let path = data_dir.join("config.json");
119 println!("✓ Wrote {}", path.display());
120 println!(" provider = {provider}");
121 if let Some(m) = &model {
122 println!(" model = {m}");
123 }
124 println!(" api_key = {} (stored encrypted)", mask_secret(&api_key));
125 println!();
126 println!("Next steps:");
127 println!(" bamboo doctor # verify the setup");
128 println!(" bamboo -p \"hello\" # run a one-shot agent");
129 println!(" bamboo serve # start the HTTP server");
130 Ok(())
131}
132
133pub async fn run_doctor(data_dir: Option<PathBuf>) -> Result<bool> {
136 let data_dir = data_dir.unwrap_or_else(bamboo_config::paths::resolve_bamboo_dir);
137 let config_path = data_dir.join("config.json");
138 let mut errors = 0u32;
139 let mut warns = 0u32;
140
141 println!("Bamboo doctor — data dir: {}", data_dir.display());
142 println!();
143
144 if config_path.exists() {
146 ok(&format!("config.json found ({})", config_path.display()));
147 } else {
148 err(&format!(
149 "config.json not found ({}) — run `bamboo init`",
150 config_path.display()
151 ));
152 errors += 1;
153 }
154
155 let config = Config::from_data_dir_without_publish(Some(data_dir.clone()));
156
157 let provider = config.provider.clone();
159 if provider.trim().is_empty() {
160 err("no default provider set — run `bamboo init`");
161 errors += 1;
162 } else {
163 info(&format!("default provider = {provider}"));
164 match provider_credential_status(&config, &provider) {
165 CredentialStatus::Present => ok(&format!("provider '{provider}' has an API key")),
166 CredentialStatus::OauthProvider => ok(&format!(
167 "provider '{provider}' uses interactive login (no static key required)"
168 )),
169 CredentialStatus::Missing => {
170 err(&format!(
171 "provider '{provider}' has no API key — run `bamboo init` or \
172 `bamboo config set providers.{provider}.api_key <key>`"
173 ));
174 errors += 1;
175 }
176 CredentialStatus::Unknown => {
177 warn(&format!(
178 "provider '{provider}' is not a recognized keyed provider; cannot verify a credential"
179 ));
180 warns += 1;
181 }
182 }
183 }
184
185 let port = config.server.port;
188 let bind = config.server.bind.trim();
189 let host = if bind.is_empty() || bind == "0.0.0.0" || bind == "::" {
190 "127.0.0.1"
191 } else {
192 bind
193 };
194 let url = format!("http://{host}:{port}/api/v1/health");
195 if check_health(&url).await {
196 ok(&format!("server reachable at {host}:{port}"));
197 } else {
198 info(&format!(
199 "server not running on {host}:{port} (start it with `bamboo serve`)"
200 ));
201 }
202
203 println!();
204 if errors > 0 {
205 println!("✗ {errors} problem(s) found.");
206 Ok(false)
207 } else if warns > 0 {
208 println!("✓ Ready ({warns} warning(s)).");
209 Ok(true)
210 } else {
211 println!("✓ All checks passed.");
212 Ok(true)
213 }
214}
215
216pub fn run_config_set(
239 key: &str,
240 value: &str,
241 data_dir: Option<PathBuf>,
242 dry_run: bool,
243) -> Result<()> {
244 let data_dir = data_dir.unwrap_or_else(bamboo_config::paths::resolve_bamboo_dir);
245 let mut config = Config::from_data_dir_without_env(Some(data_dir.clone()));
246
247 let parts: Vec<&str> = key.split('.').collect();
248 let outcome = match parts.as_slice() {
251 ["provider"] => {
254 config.provider = normalize_known_provider(value)?;
255 None
256 }
257 ["providers", "bodhi", "api_key"] => {
260 let v = value.trim();
261 if v.is_empty() {
262 bail!("api_key must not be empty");
263 }
264 config
265 .providers
266 .bodhi
267 .get_or_insert_with(empty_provider)
268 .api_key = v.to_string();
269 None
270 }
271 ["providers", p, "api_key"] => {
272 let p = normalize_provider(p)?;
273 let v = value.trim();
274 if v.is_empty() {
275 bail!("api_key must not be empty");
276 }
277 set_provider_api_key(&mut config, &p, v)?;
278 None
279 }
280 ["providers", p, "model"] => {
281 let p = normalize_provider(p)?;
282 let v = value.trim();
283 if v.is_empty() {
284 bail!("model must not be empty");
285 }
286 set_provider_model(&mut config, &p, v)?;
287 None
288 }
289 ["provider_instances", id, "api_key"] => {
290 let v = value.trim();
291 if v.is_empty() {
292 bail!("api_key must not be empty");
293 }
294 let Some(instance) = config.provider_instances.get_mut(*id) else {
295 bail!(
296 "provider instance '{id}' not found; create the instance first \
297 (its api_key is then stored encrypted at rest)"
298 );
299 };
300 instance.api_key = v.to_string();
301 None
302 }
303 ["notifications", "ntfy", "token"] => {
304 let v = value.trim();
305 if v.is_empty() {
306 bail!("token must not be empty");
307 }
308 config.notifications.ntfy.token = Some(v.to_string());
309 None
310 }
311 ["notifications", "bark", "device_key"] => {
312 let v = value.trim();
313 if v.is_empty() {
314 bail!("device_key must not be empty");
315 }
316 config.notifications.bark.device_key = Some(v.to_string());
317 None
318 }
319 _ => {
323 let parsed = bamboo_config::dot_path::parse_cli_value(value);
324 Some(bamboo_config::dot_path::apply_dot_path_set(
325 &config, key, parsed,
326 )?)
327 }
328 };
329
330 let is_secret_key =
331 key.ends_with("api_key") || key.ends_with(".token") || key.ends_with(".device_key");
332 let shown = if is_secret_key {
333 mask_secret(value)
334 } else {
335 value.to_string()
336 };
337
338 if dry_run {
339 match &outcome {
340 Some(out) => print_dry_run_diff(&config, &out.config),
341 None => println!(
342 "--dry-run: would set {key} = {shown} via the dedicated setter \
343 (secrets are stored encrypted at rest). No changes written."
344 ),
345 }
346 return Ok(());
347 }
348
349 let to_save = match outcome {
350 Some(out) => out.config,
351 None => config,
352 };
353 to_save
354 .save_to_dir(data_dir.clone())
355 .context("failed to write config.json")?;
356 println!("✓ set {key} = {shown}");
357 Ok(())
358}
359
360fn print_dry_run_diff(current: &Config, updated: &Config) {
364 let before = sanitized_config_value(current);
365 let after = sanitized_config_value(updated);
366 let diff = bamboo_config::dot_path::diff_json(&before, &after);
367 if diff.is_empty() {
368 println!("--dry-run: no effective change. Nothing would be written.");
369 return;
370 }
371 println!(
372 "--dry-run: would apply {} change(s) (secrets omitted):",
373 diff.len()
374 );
375 for (path, old, new) in diff {
376 let old = old
377 .map(|v| v.to_string())
378 .unwrap_or_else(|| "(absent)".to_string());
379 let new = new
380 .map(|v| v.to_string())
381 .unwrap_or_else(|| "(absent)".to_string());
382 println!(" {path}: {old} -> {new}");
383 }
384 println!("No changes written.");
385}
386
387fn sanitized_config_value(config: &Config) -> serde_json::Value {
391 let mut c = config.clone();
392 c.sanitize_env_vars_for_disk();
393 c.sanitize_cluster_fabric_for_disk();
394 serde_json::to_value(&c).unwrap_or(serde_json::Value::Null)
395}
396
397fn normalize_provider(provider: &str) -> Result<String> {
402 let p = provider.trim().to_ascii_lowercase();
403 if KEYED_PROVIDERS.contains(&p.as_str()) {
404 Ok(p)
405 } else {
406 bail!(
407 "unsupported provider '{provider}' (supported: {})",
408 KEYED_PROVIDERS.join(", ")
409 )
410 }
411}
412
413fn normalize_known_provider(provider: &str) -> Result<String> {
416 let p = provider.trim().to_ascii_lowercase();
417 if KNOWN_PROVIDERS.contains(&p.as_str()) {
418 Ok(p)
419 } else {
420 bail!(
421 "unknown provider '{provider}' (known: {})",
422 KNOWN_PROVIDERS.join(", ")
423 )
424 }
425}
426
427fn empty_provider<T: serde::de::DeserializeOwned>() -> T {
431 serde_json::from_value(serde_json::json!({}))
432 .expect("an empty provider config always deserializes")
433}
434
435fn set_provider_api_key(config: &mut Config, provider: &str, key: &str) -> Result<()> {
436 match provider {
437 "anthropic" => {
438 config
439 .providers
440 .anthropic
441 .get_or_insert_with(empty_provider)
442 .api_key = key.to_string()
443 }
444 "openai" => {
445 config
446 .providers
447 .openai
448 .get_or_insert_with(empty_provider)
449 .api_key = key.to_string()
450 }
451 "gemini" => {
452 config
453 .providers
454 .gemini
455 .get_or_insert_with(empty_provider)
456 .api_key = key.to_string()
457 }
458 _ => bail!("unsupported provider '{provider}'"),
459 }
460 Ok(())
461}
462
463fn set_provider_model(config: &mut Config, provider: &str, model: &str) -> Result<()> {
464 let model = Some(model.to_string());
465 match provider {
466 "anthropic" => {
467 config
468 .providers
469 .anthropic
470 .get_or_insert_with(empty_provider)
471 .model = model
472 }
473 "openai" => {
474 config
475 .providers
476 .openai
477 .get_or_insert_with(empty_provider)
478 .model = model
479 }
480 "gemini" => {
481 config
482 .providers
483 .gemini
484 .get_or_insert_with(empty_provider)
485 .model = model
486 }
487 _ => bail!("unsupported provider '{provider}'"),
488 }
489 Ok(())
490}
491
492enum CredentialStatus {
493 Present,
494 Missing,
495 OauthProvider,
496 Unknown,
497}
498
499fn provider_credential_status(config: &Config, provider: &str) -> CredentialStatus {
500 match provider {
501 "anthropic" => key_status(
502 config
503 .providers
504 .anthropic
505 .as_ref()
506 .map(|c| (c.api_key.as_str(), c.api_key_encrypted.as_deref())),
507 ),
508 "openai" => key_status(
509 config
510 .providers
511 .openai
512 .as_ref()
513 .map(|c| (c.api_key.as_str(), c.api_key_encrypted.as_deref())),
514 ),
515 "gemini" => key_status(
516 config
517 .providers
518 .gemini
519 .as_ref()
520 .map(|c| (c.api_key.as_str(), c.api_key_encrypted.as_deref())),
521 ),
522 "bodhi" => key_status(
523 config
524 .providers
525 .bodhi
526 .as_ref()
527 .map(|c| (c.api_key.as_str(), c.api_key_encrypted.as_deref())),
528 ),
529 "copilot" => CredentialStatus::OauthProvider,
530 _ => CredentialStatus::Unknown,
531 }
532}
533
534fn key_status(pair: Option<(&str, Option<&str>)>) -> CredentialStatus {
537 match pair {
538 Some((plain, enc))
539 if !plain.trim().is_empty() || enc.is_some_and(|e| !e.trim().is_empty()) =>
540 {
541 CredentialStatus::Present
542 }
543 _ => CredentialStatus::Missing,
544 }
545}
546
547fn provider_has_key(config: &Config, provider: &str) -> bool {
548 matches!(
549 provider_credential_status(config, provider),
550 CredentialStatus::Present
551 )
552}
553
554async fn check_health(url: &str) -> bool {
557 let Ok(client) = reqwest::Client::builder()
558 .timeout(Duration::from_secs(2))
559 .build()
560 else {
561 return false;
562 };
563 matches!(client.get(url).send().await, Ok(r) if r.status().is_success())
564}
565
566fn prompt_provider() -> Result<String> {
569 println!("Select a provider:");
570 for (i, p) in KEYED_PROVIDERS.iter().enumerate() {
571 println!(" {}) {}", i + 1, p);
572 }
573 let line = prompt_line("Provider [number or name] (default: anthropic): ")?;
574 let line = line.trim();
575 if line.is_empty() {
576 return Ok("anthropic".to_string());
577 }
578 if let Ok(n) = line.parse::<usize>() {
579 if (1..=KEYED_PROVIDERS.len()).contains(&n) {
580 return Ok(KEYED_PROVIDERS[n - 1].to_string());
581 }
582 }
583 normalize_provider(line)
584}
585
586fn prompt_api_key(provider: &str) -> Result<String> {
587 eprintln!("Note: the key is visible as you type; it is stored encrypted at rest.");
588 prompt_line(&format!("Enter the {provider} API key: "))
589}
590
591fn prompt_line(prompt: &str) -> Result<String> {
592 print!("{prompt}");
593 std::io::stdout().flush().ok();
594 let mut s = String::new();
595 let n = std::io::stdin()
596 .read_line(&mut s)
597 .context("failed to read from stdin")?;
598 if n == 0 {
599 bail!("unexpected end of input");
600 }
601 Ok(s.trim_end_matches(['\n', '\r']).to_string())
602}
603
604fn prompt_yes_no(question: &str, default_yes: bool) -> Result<bool> {
605 let hint = if default_yes { "[Y/n]" } else { "[y/N]" };
606 let ans = prompt_line(&format!("{question} {hint} "))?;
607 Ok(match ans.trim().to_ascii_lowercase().as_str() {
608 "" => default_yes,
609 "y" | "yes" => true,
610 _ => false,
611 })
612}
613
614fn mask_secret(secret: &str) -> String {
617 let s = secret.trim();
618 let n = s.chars().count();
619 if n <= 6 {
620 return "*".repeat(n.max(1));
621 }
622 let head: String = s.chars().take(3).collect();
623 let tail: String = {
624 let mut t: Vec<char> = s.chars().rev().take(2).collect();
625 t.reverse();
626 t.into_iter().collect()
627 };
628 format!("{head}…{tail} ({n} chars)")
629}
630
631fn ok(msg: &str) {
632 println!(" ✓ {msg}");
633}
634fn err(msg: &str) {
635 println!(" ✗ {msg}");
636}
637fn warn(msg: &str) {
638 println!(" ! {msg}");
639}
640fn info(msg: &str) {
641 println!(" · {msg}");
642}
643
644#[cfg(test)]
645mod tests {
646 use super::*;
647
648 fn default_config() -> Config {
652 Config::from_data_dir_without_publish(Some(PathBuf::from(
653 "/nonexistent-bamboo-setup-cli-test-dir",
654 )))
655 }
656
657 #[test]
658 fn normalize_provider_accepts_keyed_and_rejects_unknown() {
659 assert_eq!(normalize_provider("Anthropic").unwrap(), "anthropic");
660 assert_eq!(normalize_provider(" openai ").unwrap(), "openai");
661 assert!(normalize_provider("copilot").is_err());
663 assert!(normalize_provider("nope").is_err());
664 }
665
666 #[test]
667 fn normalize_known_provider_accepts_oauth_and_proxy_providers() {
668 assert_eq!(normalize_known_provider("Copilot").unwrap(), "copilot");
670 assert_eq!(normalize_known_provider("bodhi").unwrap(), "bodhi");
671 assert_eq!(normalize_known_provider("anthropic").unwrap(), "anthropic");
672 assert!(normalize_known_provider("nope").is_err());
673 }
674
675 #[test]
676 fn set_key_and_model_preserve_the_other_field() {
677 let mut config = default_config();
678 set_provider_api_key(&mut config, "anthropic", "sk-ant-xyz").unwrap();
679 set_provider_model(&mut config, "anthropic", "claude-x").unwrap();
680 let a = config.providers.anthropic.as_ref().unwrap();
681 assert_eq!(a.api_key, "sk-ant-xyz");
682 assert_eq!(a.model.as_deref(), Some("claude-x"));
683
684 set_provider_model(&mut config, "anthropic", "claude-y").unwrap();
686 let a = config.providers.anthropic.as_ref().unwrap();
687 assert_eq!(a.api_key, "sk-ant-xyz");
688 assert_eq!(a.model.as_deref(), Some("claude-y"));
689 }
690
691 #[test]
692 fn credential_status_reflects_plaintext_and_encrypted() {
693 let mut config = default_config();
694 assert!(matches!(
695 provider_credential_status(&config, "openai"),
696 CredentialStatus::Missing
697 ));
698 set_provider_api_key(&mut config, "openai", "sk-openai").unwrap();
699 assert!(matches!(
700 provider_credential_status(&config, "openai"),
701 CredentialStatus::Present
702 ));
703 assert!(matches!(
704 provider_credential_status(&config, "copilot"),
705 CredentialStatus::OauthProvider
706 ));
707 }
708
709 #[test]
710 fn mask_secret_hides_the_middle() {
711 assert_eq!(mask_secret("abc"), "***");
712 let m = mask_secret("sk-ant-1234567890");
713 assert!(m.starts_with("sk-"));
714 assert!(m.ends_with("chars)"));
715 assert!(!m.contains("1234567890"));
716 }
717
718 #[test]
719 fn config_set_generic_key_round_trips_to_disk() {
720 let dir = tempfile::tempdir().expect("tempdir");
721 let data_dir = dir.path().to_path_buf();
722
723 run_config_set("server.port", "19999", Some(data_dir.clone()), false).unwrap();
724 let raw = std::fs::read_to_string(data_dir.join("config.json")).unwrap();
725 let root: serde_json::Value = serde_json::from_str(&raw).unwrap();
726 assert_eq!(root["server"]["port"], serde_json::json!(19999));
727
728 assert!(run_config_set("server.prot", "1", Some(data_dir.clone()), false).is_err());
730 assert!(
731 run_config_set("server.port", "not-a-port", Some(data_dir.clone()), false).is_err()
732 );
733 let raw_after = std::fs::read_to_string(data_dir.join("config.json")).unwrap();
734 let root: serde_json::Value = serde_json::from_str(&raw_after).unwrap();
735 assert_eq!(root["server"]["port"], serde_json::json!(19999));
736 }
737
738 #[test]
739 fn config_set_dry_run_writes_nothing() {
740 let dir = tempfile::tempdir().expect("tempdir");
741 let data_dir = dir.path().to_path_buf();
742
743 run_config_set("server.port", "12001", Some(data_dir.clone()), true).unwrap();
744 assert!(
745 !data_dir.join("config.json").exists(),
746 "--dry-run must not create/modify config.json"
747 );
748 }
749
750 #[test]
751 fn config_set_api_key_still_encrypted_at_rest() {
752 let dir = tempfile::tempdir().expect("tempdir");
753 let data_dir = dir.path().to_path_buf();
754
755 run_config_set(
757 "providers.anthropic.api_key",
758 "sk-ant-setupcli-secret",
759 Some(data_dir.clone()),
760 false,
761 )
762 .unwrap();
763
764 let raw = std::fs::read_to_string(data_dir.join("config.json")).unwrap();
765 assert!(
766 !raw.contains("sk-ant-setupcli-secret"),
767 "plaintext api_key must never reach disk"
768 );
769 let root: serde_json::Value = serde_json::from_str(&raw).unwrap();
770 assert!(root["providers"]["anthropic"]["api_key_encrypted"]
771 .as_str()
772 .is_some_and(|s| !s.is_empty()));
773
774 run_config_set(
776 "providers.anthropic.model",
777 "claude-x",
778 Some(data_dir.clone()),
779 false,
780 )
781 .unwrap();
782 let raw = std::fs::read_to_string(data_dir.join("config.json")).unwrap();
783 assert!(!raw.contains("sk-ant-setupcli-secret"));
784 let root: serde_json::Value = serde_json::from_str(&raw).unwrap();
785 assert_eq!(root["providers"]["anthropic"]["model"], "claude-x");
786 assert!(root["providers"]["anthropic"]["api_key_encrypted"]
787 .as_str()
788 .is_some_and(|s| !s.is_empty()));
789
790 let reloaded = Config::from_data_dir_without_env(Some(data_dir));
791 assert_eq!(
792 reloaded.providers.anthropic.as_ref().unwrap().api_key,
793 "sk-ant-setupcli-secret",
794 "the stored key must still decrypt after a generic set"
795 );
796 }
797
798 #[test]
799 fn config_set_rejects_secret_paths_it_cannot_protect() {
800 let dir = tempfile::tempdir().expect("tempdir");
801 let data_dir = dir.path().to_path_buf();
802 for (key, value) in [
803 ("proxy_auth.username", "alice"),
804 ("providers.anthropic.api_key_encrypted", "cipher"),
805 ("provider_instances.nope.api_key", "sk-x"),
806 ] {
807 assert!(
808 run_config_set(key, value, Some(data_dir.clone()), false).is_err(),
809 "{key} must be refused"
810 );
811 }
812 assert!(!data_dir.join("config.json").exists());
813 }
814}