Skip to main content

bamboo_agent/
setup_cli.rs

1//! `bamboo init` / `bamboo doctor` / `bamboo config set` — first-run onboarding.
2//!
3//! These are local, server-less commands: they read and write `config.json`
4//! under the data dir directly (via [`bamboo_config::Config`]), so a fresh
5//! install can configure a provider + API key and self-diagnose **without** the
6//! web UI or hand-editing JSON. Writes go through `Config::save_to_dir`, which
7//! encrypts provider keys at rest and writes atomically (with a rotating `.bak`).
8
9use std::io::{IsTerminal, Write};
10use std::path::PathBuf;
11use std::time::Duration;
12
13use anyhow::{bail, Context, Result};
14use bamboo_config::Config;
15
16/// Providers that `init` / `config set` can configure with a static API key.
17///
18/// Copilot uses an interactive OAuth device flow (not a static key), so it is
19/// intentionally excluded here — configure it via the web UI / `bamboo actor`.
20const KEYED_PROVIDERS: [&str; 3] = ["anthropic", "openai", "gemini"];
21
22/// All providers the config recognizes, for selecting the *default* provider.
23/// Broader than [`KEYED_PROVIDERS`]: `copilot` (OAuth) and `bodhi` (proxy) are
24/// valid default providers even though this CLI does not manage their keys.
25const KNOWN_PROVIDERS: [&str; 5] = ["anthropic", "openai", "gemini", "copilot", "bodhi"];
26
27/// A reasonable default chat model per provider, matching the ids referenced
28/// elsewhere in the repo (README / `docker/config.example.json`). Used only when
29/// the user does not pass an explicit model.
30fn 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
39/// Arguments for [`run_init`], mirrored from the `bamboo init` clap variant.
40pub 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
49/// `bamboo init` — scaffold/populate `config.json` with a provider + API key.
50///
51/// Interactive by default (prompts for anything not passed as a flag); with
52/// `--non-interactive` (or when stdin is not a TTY) it requires the values as
53/// flags instead of prompting, so it is CI-safe.
54pub 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    // Load existing (or default) config from the target dir. No env overrides
62    // (this one-shot writer immediately re-saves; applying `BAMBOO_*` here would
63    // bake transient env values into config.json) and no cache publish (#40).
64    let mut config = Config::from_data_dir_without_env(Some(data_dir.clone()));
65
66    // 1. Provider.
67    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    // 2. Overwrite guard — don't silently clobber an existing key.
77    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    // 3. API key.
92    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    // 4. Model (explicit, else a sensible provider default).
103    let model = args
104        .model
105        .or_else(|| default_model_for(&provider).map(String::from));
106
107    // 5. Apply + persist (encrypts the key, atomic write, rotates .bak).
108    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    // 6. Report + next steps.
118    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
133/// `bamboo doctor` — diagnose the local install. Returns `Ok(true)` when healthy,
134/// `Ok(false)` when a blocking problem was found (caller should exit non-zero).
135pub 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    // 1. config.json presence.
145    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    // 2. Active provider + credential.
158    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    // 3. Server reachability (informational — not running is normal). Probe the
186    // configured bind; a wildcard/empty bind is reached via loopback.
187    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
216/// `bamboo config set <key> <value>` — write a single config value.
217///
218/// Two classes of keys:
219///
220/// **Secret-aware keys** (encrypted at rest; the value is set on the typed
221/// config and `Config::save_to_dir` writes the `*_encrypted` form):
222///   - `provider` (default provider selection; not a secret, kept for
223///     backward compatibility)
224///   - `providers.<anthropic|openai|gemini>.api_key` (unchanged legacy path)
225///   - `providers.bodhi.api_key`
226///   - `provider_instances.<id>.api_key` (the instance must already exist)
227///   - `notifications.ntfy.token`, `notifications.bark.device_key`
228///   - `providers.<anthropic|openai|gemini>.model` (legacy path, not a secret)
229///
230/// **Everything else** goes through the generic validated dot-path setter
231/// ([`bamboo_config::dot_path::apply_dot_path_set`]): the value is parsed as
232/// JSON when it parses (numbers/bools/arrays/objects), else taken as a
233/// string; the result must deserialize back into the typed `Config` (type
234/// mismatches and unknown fields are rejected before anything is written).
235/// `proxy_auth.*`, `subagents.broker.*` and all `*_encrypted` keys are
236/// refused. With `dry_run` the resulting diff is printed and nothing is
237/// saved.
238pub 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    // `Some(outcome)` = generic dot-path set (validated new config inside);
249    // `None` = a dedicated arm mutated `config` in place.
250    let outcome = match parts.as_slice() {
251        // Selecting the default provider accepts any known provider (incl.
252        // copilot/bodhi), not just the keyed ones.
253        ["provider"] => {
254            config.provider = normalize_known_provider(value)?;
255            None
256        }
257        // Bodhi's static key is secret-aware too, but is not a keyed provider
258        // for `init` — handled before the legacy keyed-provider arm.
259        ["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        // Generic, validated dot-path set for every other key. Secret paths
320        // not routed above are refused inside (defense in depth), so a
321        // plaintext secret can never be silently dropped or mis-stored.
322        _ => {
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
360/// Print the prospective `config set` change as a path-level diff.
361/// Both sides are serialized with plaintext secrets sanitized (the same
362/// clearing `save_to_dir` performs), so the preview never leaks a secret.
363fn 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
387/// In-memory serialization with the same plaintext-secret clearing that
388/// `save_to_dir` applies before writing (env vars, cluster-fabric SSH
389/// secrets; `api_key`/token plaintexts are `skip_serializing` already).
390fn 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
397// ---- provider helpers ----
398
399/// Validate + canonicalize a provider name against the keyed-provider set
400/// (providers this CLI can set an API key for).
401fn 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
413/// Validate + canonicalize a provider name against the full known-provider set
414/// (for selecting the default provider, which may be copilot/bodhi).
415fn 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
427/// Construct an empty provider-config struct via serde (these structs don't
428/// derive `Default`; every field is either serde-`default` or `Option`, so an
429/// empty JSON object deserializes cleanly).
430fn 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
534/// A credential is present if either the in-memory plaintext key or the on-disk
535/// encrypted key is non-empty.
536fn 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
554// ---- health probe ----
555
556async 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
566// ---- prompts / formatting ----
567
568fn 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
614/// Show a secret as `head…tail (N chars)` so a confirmation line never prints
615/// the full key.
616fn 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    /// A default in-memory config that never touches a real data dir: loading
649    /// from a non-existent directory returns defaults without reading or writing
650    /// anything on disk.
651    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        // copilot has no static key → not a keyed provider.
662        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        // Selecting the default provider accepts copilot/bodhi too.
669        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        // Setting the model again must not wipe the key.
685        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        // Unknown / mistyped keys are rejected without writing.
729        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        // Legacy secret-aware arm (unchanged behavior).
756        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        // A follow-up generic set must keep the stored ciphertext intact.
775        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}