Skip to main content

thndrs_lib/cli/commands/
setup.rs

1//! `thndrs setup` command definitions.
2
3use std::io::{self, IsTerminal, Write};
4use std::path::Path;
5
6use clap::{Args, ValueEnum};
7
8use super::auth::{
9    CredentialScope, ProviderCredentialHealth, check_chatgpt_codex_auth, check_provider_key, confirm, credential_path,
10    credential_rejected_error, credential_rejected_error_for_source, prompt_scope, read_hidden_api_key,
11    run_chatgpt_codex_login,
12};
13use crate::cli::Cli;
14use crate::context;
15use crate::thndrs_core::auth;
16use crate::{config, providers};
17
18/// Providers supported by first-run setup and login commands.
19#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
20#[value(rename_all = "kebab-case")]
21pub enum SetupProviderArg {
22    /// Umans Code provider.
23    Umans,
24    /// OpenCode Go provider.
25    OpencodeGo,
26    /// OpenCode Zen provider.
27    OpencodeZen,
28    /// ChatGPT subscription-backed Codex provider.
29    ChatgptCodex,
30}
31
32impl SetupProviderArg {
33    fn for_model(model: &str) -> Self {
34        if providers::opencode::is_zen_model_id(model) {
35            Self::OpencodeZen
36        } else if providers::opencode::is_go_model_id(model) {
37            Self::OpencodeGo
38        } else if providers::chatgpt_codex::is_model_id(model) {
39            Self::ChatgptCodex
40        } else {
41            Self::Umans
42        }
43    }
44}
45
46/// Authentication mechanism used by a setup provider.
47#[derive(Clone, Copy, Debug, Eq, PartialEq)]
48pub enum ProviderAuthKind {
49    /// Provider uses a single API key stored in a credential env file.
50    ApiKey { env_var: &'static str },
51    /// Provider uses ChatGPT OAuth credentials stored in `~/.thndrs/auth.json`.
52    ChatGptOAuth { env_override: &'static str },
53}
54
55#[derive(Clone, Copy, Debug, Eq, PartialEq)]
56enum ChatGptSetupAuthStatus {
57    EnvironmentOverride,
58    StoredCredential,
59    MissingCredential,
60    InvalidStore,
61}
62
63impl ChatGptSetupAuthStatus {
64    fn label(self) -> &'static str {
65        match self {
66            Self::EnvironmentOverride => "environment override",
67            Self::StoredCredential => "~/.thndrs/auth.json",
68            Self::MissingCredential => "missing OAuth credential",
69            Self::InvalidStore => "invalid auth store",
70        }
71    }
72}
73
74/// Static metadata used by setup, login, and auth status commands.
75#[derive(Clone, Copy, Debug, Eq, PartialEq)]
76pub struct ProviderMetadata {
77    /// Public provider argument value.
78    pub label: &'static str,
79    /// Default model written by setup when requested.
80    pub default_model: &'static str,
81    /// Provider auth storage behavior.
82    pub auth_kind: ProviderAuthKind,
83    /// Short setup summary copy.
84    pub setup_summary: &'static str,
85}
86
87impl SetupProviderArg {
88    /// All built-in setup providers in default prompt order.
89    pub const ALL: [Self; 4] = [Self::OpencodeZen, Self::ChatgptCodex, Self::Umans, Self::OpencodeGo];
90
91    /// Provider metadata.
92    pub const fn metadata(self) -> ProviderMetadata {
93        match self {
94            Self::Umans => ProviderMetadata {
95                label: "umans",
96                default_model: "umans-coder",
97                auth_kind: ProviderAuthKind::ApiKey { env_var: auth::UMANS_API_KEY_ENV },
98                setup_summary: "Umans Code uses a UMANS_API_KEY from app.umans.ai, stored only in a thndrs credential store.",
99            },
100            Self::OpencodeGo => ProviderMetadata {
101                label: "opencode-go",
102                default_model: "opencode-go/kimi-k2.7-code",
103                auth_kind: ProviderAuthKind::ApiKey { env_var: auth::OPENCODE_GO_KEY_ENV },
104                setup_summary: "OpenCode Go uses OPENCODE_GO_KEY stored in a thndrs credential store.",
105            },
106            Self::OpencodeZen => ProviderMetadata {
107                label: "opencode-zen",
108                default_model: "opencode/big-pickle",
109                auth_kind: ProviderAuthKind::ApiKey { env_var: auth::OPENCODE_ZEN_KEY_ENV },
110                setup_summary: "OpenCode Zen Big Pickle is the default model. It requires OPENCODE_ZEN_KEY; OpenCode describes Big Pickle as free for a limited time, and free-period prompts may be used to improve the model.",
111            },
112            Self::ChatgptCodex => ProviderMetadata {
113                label: "chatgpt-codex",
114                default_model: "chatgpt-codex/gpt-5.6-sol",
115                auth_kind: ProviderAuthKind::ChatGptOAuth { env_override: auth::CHATGPT_CODEX_ACCESS_TOKEN_ENV },
116                setup_summary: "ChatGPT Codex uses ChatGPT OAuth stored in ~/.thndrs/auth.json.",
117            },
118        }
119    }
120
121    /// Provider display label.
122    pub fn label(self) -> &'static str {
123        self.metadata().label
124    }
125
126    /// Required API-key environment variable for API-key providers.
127    pub fn api_key_env_var(self) -> Option<&'static str> {
128        match self.metadata().auth_kind {
129            ProviderAuthKind::ApiKey { env_var } => Some(env_var),
130            ProviderAuthKind::ChatGptOAuth { .. } => None,
131        }
132    }
133
134    /// Default model used when setup writes a new config model key.
135    pub fn default_model(self) -> &'static str {
136        self.metadata().default_model
137    }
138}
139
140/// First-run setup options.
141#[derive(Clone, Debug, Eq, PartialEq, Args)]
142pub struct SetupCommand {
143    /// Provider to configure.
144    #[arg(long, value_enum)]
145    pub provider: Option<SetupProviderArg>,
146    /// Write global setup files under the user's home directory.
147    #[arg(long, conflicts_with = "project")]
148    pub global: bool,
149    /// Write project setup files under the current workspace.
150    #[arg(long, conflicts_with = "global")]
151    pub project: bool,
152}
153
154impl SetupCommand {
155    fn scope(&self) -> Option<CredentialScope> {
156        if self.global {
157            Some(CredentialScope::Global)
158        } else if self.project {
159            Some(CredentialScope::Project)
160        } else {
161            None
162        }
163    }
164}
165
166/// Run the first-run setup workflow.
167pub fn run(cli: &Cli, command: &SetupCommand) -> io::Result<()> {
168    let stdout = io::stdout();
169    let mut writer = stdout.lock();
170    run_with_writer(
171        cli,
172        command,
173        io::stdin().is_terminal(),
174        &mut writer,
175        run_chatgpt_codex_login,
176        check_provider_key,
177        check_chatgpt_codex_auth,
178    )
179}
180
181fn run_with_writer<W, F, H, O>(
182    cli: &Cli, command: &SetupCommand, interactive: bool, writer: &mut W, chatgpt_login: F, check_key: H,
183    check_chatgpt_auth: O,
184) -> io::Result<()>
185where
186    W: Write,
187    F: FnOnce(&mut W) -> io::Result<()>,
188    H: Fn(SetupProviderArg, &str) -> ProviderCredentialHealth,
189    O: Fn(&Path) -> ProviderCredentialHealth,
190{
191    let workspace = context::discover_workspace_root(&cli.cwd);
192    let inferred_provider = (!cli.model.trim().is_empty()).then(|| SetupProviderArg::for_model(&cli.model));
193    let provider = match command.provider {
194        Some(provider) => provider,
195        None if interactive => prompt_provider(writer, inferred_provider)?,
196        None => {
197            return Err(io::Error::new(
198                io::ErrorKind::InvalidInput,
199                "non-interactive setup needs --provider <chatgpt-codex|umans|opencode-zen|opencode-go> or --model <model-id>",
200            ));
201        }
202    };
203    let chatgpt_auth_status = (provider == SetupProviderArg::ChatgptCodex).then(chatgpt_setup_auth_status);
204    let auth_status = chatgpt_auth_status
205        .map(|status| status.label().to_string())
206        .unwrap_or_else(|| auth_status(provider, &workspace));
207    let scope = command.scope();
208
209    writeln!(writer, "workspace: {}", workspace.display())?;
210    writeln!(writer, "model: {}", provider.default_model())?;
211    writeln!(writer, "provider: {}", provider.label())?;
212    writeln!(writer, "auth: {auth_status}")?;
213    writeln!(writer, "setup: {}", provider.metadata().setup_summary)?;
214
215    if let Some(chatgpt_auth_status) = chatgpt_auth_status {
216        run_chatgpt_setup(
217            provider,
218            chatgpt_auth_status,
219            &workspace,
220            interactive,
221            writer,
222            chatgpt_login,
223            check_chatgpt_auth,
224        )?;
225        maybe_write_oauth_model_config(&workspace, provider, scope, interactive, writer)?;
226        write_chatgpt_setup_next(provider, chatgpt_auth_status, writer)?;
227        return Ok(());
228    }
229
230    let env_var = provider
231        .api_key_env_var()
232        .expect("API-key setup branch only handles API-key providers");
233    let credential = auth::resolve_credential(env_var, &workspace);
234    let credential_source = credential.as_ref().map(|(_, source)| *source);
235    if scope.is_none() && credential_source.is_none() && !interactive {
236        return Err(io::Error::new(
237            io::ErrorKind::InvalidInput,
238            "setup needs an interactive terminal to choose a credential store and read a hidden API key; pass provider env vars for non-interactive use",
239        ));
240    }
241    let scope = match scope {
242        Some(scope) => scope,
243        None if interactive => prompt_scope(writer)?,
244        None => CredentialScope::Project,
245    };
246
247    if let Some((api_key, source)) = credential {
248        match check_key(provider, &api_key) {
249            ProviderCredentialHealth::Verified => {}
250            ProviderCredentialHealth::Rejected => return Err(credential_rejected_error_for_source(provider, source)),
251            ProviderCredentialHealth::Unavailable => {
252                return Err(verification_unavailable_error(provider));
253            }
254        }
255    } else {
256        if !interactive {
257            return Err(io::Error::new(
258                io::ErrorKind::InvalidInput,
259                format!(
260                    "{} is missing; run `thndrs login {}` interactively",
261                    env_var,
262                    provider.label()
263                ),
264            ));
265        }
266        if confirm(writer, &format!("Enter {} API key now?", provider.label()))? {
267            let api_key = read_hidden_api_key(writer, provider)?;
268            let api_key = api_key.trim();
269            if api_key.is_empty() {
270                return Err(io::Error::new(io::ErrorKind::InvalidInput, "API key cannot be empty"));
271            }
272            if confirm(
273                writer,
274                &format!("Store {} credential in {} store?", provider.label(), scope.label()),
275            )? {
276                let health = check_key(provider, api_key);
277                if health == ProviderCredentialHealth::Rejected {
278                    return Err(credential_rejected_error(provider));
279                }
280                let path = credential_path(scope, &workspace)?;
281                auth::set_credential(&path, env_var, api_key).map_err(io::Error::other)?;
282                if scope == CredentialScope::Project {
283                    auth::ensure_git_exclude(&workspace).map_err(io::Error::other)?;
284                }
285                writeln!(writer, "{} credential stored in {}", provider.label(), scope.label())?;
286                match health {
287                    ProviderCredentialHealth::Verified => writeln!(writer, "validation: ok")?,
288                    ProviderCredentialHealth::Unavailable => {
289                        writeln!(
290                            writer,
291                            "validation: unavailable; credential stored but not verified. Retry `thndrs setup --provider {}` before coding.",
292                            provider.label()
293                        )?;
294                        return Err(verification_unavailable_error(provider));
295                    }
296                    ProviderCredentialHealth::Rejected => return Err(credential_rejected_error(provider)),
297                }
298            } else {
299                return Err(io::Error::new(
300                    io::ErrorKind::InvalidInput,
301                    format!(
302                        "credential was not stored; run `thndrs login {}` before coding",
303                        provider.label()
304                    ),
305                ));
306            }
307        } else {
308            return Err(io::Error::new(
309                io::ErrorKind::InvalidInput,
310                format!(
311                    "credential setup skipped; run `thndrs login {}` before coding",
312                    provider.label()
313                ),
314            ));
315        }
316    }
317
318    maybe_write_model_config(&workspace, provider, scope, interactive, true, writer)?;
319    writeln!(writer, "next: thndrs")?;
320    Ok(())
321}
322
323fn verification_unavailable_error(provider: SetupProviderArg) -> io::Error {
324    io::Error::new(
325        io::ErrorKind::ConnectionAborted,
326        format!(
327            "could not verify {} credentials; retry `thndrs setup --provider {}` before coding",
328            provider.label(),
329            provider.label()
330        ),
331    )
332}
333
334fn prompt_provider<W: Write>(
335    writer: &mut W, default_provider: Option<SetupProviderArg>,
336) -> io::Result<SetupProviderArg> {
337    write_provider_choices(writer, default_provider)?;
338    let mut answer = String::new();
339    io::stdin().read_line(&mut answer)?;
340    let answer = answer.trim();
341    if answer.is_empty()
342        && let Some(default_provider) = default_provider
343    {
344        return Ok(default_provider);
345    }
346    match answer {
347        "1" | "opencode-zen" => Ok(SetupProviderArg::OpencodeZen),
348        "2" | "chatgpt-codex" => Ok(SetupProviderArg::ChatgptCodex),
349        "3" | "umans" => Ok(SetupProviderArg::Umans),
350        "4" | "opencode-go" => Ok(SetupProviderArg::OpencodeGo),
351        _ => Err(io::Error::new(
352            io::ErrorKind::InvalidInput,
353            "expected provider number or provider name",
354        )),
355    }
356}
357
358fn write_provider_choices<W: Write>(writer: &mut W, default_provider: Option<SetupProviderArg>) -> io::Result<()> {
359    writeln!(writer, "Choose provider:")?;
360    for (index, provider) in SetupProviderArg::ALL.iter().enumerate() {
361        let default = if Some(*provider) == default_provider { " (default)" } else { "" };
362        writeln!(
363            writer,
364            "  {}) {}{} [{}] - {}",
365            index + 1,
366            provider.label(),
367            default,
368            provider.default_model(),
369            provider.metadata().setup_summary
370        )?;
371    }
372    match default_provider {
373        Some(provider) => write!(writer, "Provider [{}]: ", provider.label())?,
374        None => write!(writer, "Provider: ")?,
375    }
376    writer.flush()
377}
378
379fn auth_status(provider: SetupProviderArg, workspace: &std::path::Path) -> String {
380    match provider.metadata().auth_kind {
381        ProviderAuthKind::ApiKey { env_var } => auth::credential_source(env_var, workspace)
382            .map(|source| source.label().to_string())
383            .unwrap_or_else(|| "missing".to_string()),
384        ProviderAuthKind::ChatGptOAuth { .. } => chatgpt_setup_auth_status().label().to_string(),
385    }
386}
387
388fn chatgpt_setup_auth_status() -> ChatGptSetupAuthStatus {
389    if std::env::var(auth::CHATGPT_CODEX_ACCESS_TOKEN_ENV).is_ok_and(|value| !value.trim().is_empty()) {
390        return ChatGptSetupAuthStatus::EnvironmentOverride;
391    }
392    match auth::read_chatgpt_codex_credentials() {
393        Ok(Some(_)) => ChatGptSetupAuthStatus::StoredCredential,
394        Ok(None) => ChatGptSetupAuthStatus::MissingCredential,
395        Err(_) => ChatGptSetupAuthStatus::InvalidStore,
396    }
397}
398
399fn verify_chatgpt_setup_auth(status: ChatGptSetupAuthStatus, health: ProviderCredentialHealth) -> io::Result<()> {
400    match health {
401        ProviderCredentialHealth::Verified => Ok(()),
402        ProviderCredentialHealth::Rejected => match status {
403            ChatGptSetupAuthStatus::EnvironmentOverride => Err(credential_rejected_error_for_source(
404                SetupProviderArg::ChatgptCodex,
405                auth::CredentialSource::Environment,
406            )),
407            ChatGptSetupAuthStatus::StoredCredential | ChatGptSetupAuthStatus::InvalidStore => {
408                Err(credential_rejected_error(SetupProviderArg::ChatgptCodex))
409            }
410            ChatGptSetupAuthStatus::MissingCredential => Ok(()),
411        },
412        ProviderCredentialHealth::Unavailable => Err(verification_unavailable_error(SetupProviderArg::ChatgptCodex)),
413    }
414}
415
416fn maybe_write_oauth_model_config<W: Write>(
417    workspace: &std::path::Path, provider: SetupProviderArg, scope: Option<CredentialScope>, interactive: bool,
418    writer: &mut W,
419) -> io::Result<()> {
420    if let Some(scope) = scope {
421        maybe_write_model_config(workspace, provider, scope, interactive, true, writer)?;
422    } else if interactive && confirm(writer, "Write default model to a config file?")? {
423        let scope = prompt_config_scope(writer)?;
424        maybe_write_model_config(workspace, provider, scope, interactive, false, writer)?;
425    }
426    Ok(())
427}
428
429fn prompt_config_scope<W: Write>(writer: &mut W) -> io::Result<CredentialScope> {
430    writeln!(writer, "Choose config scope:")?;
431    writeln!(writer, "  1) global (~/.thndrs/config.toml)")?;
432    writeln!(writer, "  2) project (.thndrs/config.toml)")?;
433    write!(writer, "Config [global/project]: ")?;
434    writer.flush()?;
435
436    let mut answer = String::new();
437    io::stdin().read_line(&mut answer)?;
438    match answer.trim().to_ascii_lowercase().as_str() {
439        "" | "g" | "global" | "1" => Ok(CredentialScope::Global),
440        "p" | "project" | "2" => Ok(CredentialScope::Project),
441        _ => Err(io::Error::new(
442            io::ErrorKind::InvalidInput,
443            "expected `global` or `project`",
444        )),
445    }
446}
447
448fn run_chatgpt_setup<W, F, O>(
449    provider: SetupProviderArg, auth_status: ChatGptSetupAuthStatus, workspace: &Path, interactive: bool,
450    writer: &mut W, chatgpt_login: F, check_chatgpt_auth: O,
451) -> io::Result<()>
452where
453    W: Write,
454    F: FnOnce(&mut W) -> io::Result<()>,
455    O: Fn(&Path) -> ProviderCredentialHealth,
456{
457    match auth_status {
458        ChatGptSetupAuthStatus::EnvironmentOverride => {
459            verify_chatgpt_setup_auth(auth_status, check_chatgpt_auth(workspace))?;
460            if interactive && confirm(writer, "Create or update stored ChatGPT OAuth credentials now?")? {
461                chatgpt_login(writer)?;
462            }
463        }
464        ChatGptSetupAuthStatus::StoredCredential | ChatGptSetupAuthStatus::InvalidStore => {
465            verify_chatgpt_setup_auth(auth_status, check_chatgpt_auth(workspace))?;
466        }
467        ChatGptSetupAuthStatus::MissingCredential => {
468            if !interactive {
469                return Err(io::Error::new(
470                    io::ErrorKind::InvalidInput,
471                    format!(
472                        "{} setup uses interactive ChatGPT OAuth, not an API key; run `thndrs setup --provider {}` or `thndrs login {}` in a terminal",
473                        provider.label(),
474                        provider.label(),
475                        provider.label()
476                    ),
477                ));
478            }
479            chatgpt_login(writer)?;
480            verify_chatgpt_setup_auth(ChatGptSetupAuthStatus::StoredCredential, check_chatgpt_auth(workspace))?;
481        }
482    }
483    Ok(())
484}
485
486fn write_chatgpt_setup_next<W: Write>(
487    provider: SetupProviderArg, auth_status: ChatGptSetupAuthStatus, writer: &mut W,
488) -> io::Result<()> {
489    match auth_status {
490        ChatGptSetupAuthStatus::EnvironmentOverride => writeln!(
491            writer,
492            "next: thndrs (using {}); run `thndrs login {}` later to create or update stored OAuth credentials",
493            auth::CHATGPT_CODEX_ACCESS_TOKEN_ENV,
494            provider.label(),
495        ),
496        _ => writeln!(writer, "next: thndrs"),
497    }
498}
499
500fn maybe_write_model_config<W: Write>(
501    workspace: &std::path::Path, provider: SetupProviderArg, scope: CredentialScope, interactive: bool, ask: bool,
502    writer: &mut W,
503) -> io::Result<()> {
504    let path = match scope {
505        CredentialScope::Global => config::global_config_path()
506            .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "HOME is not available"))?,
507        CredentialScope::Project => config::project_config_path(workspace),
508    };
509
510    if config::model_config_has_model(&path)? {
511        return Ok(());
512    }
513    if !interactive {
514        return Ok(());
515    }
516    if ask && !confirm(writer, &format!("Write default model to {} config?", scope.label()))? {
517        return Ok(());
518    }
519    config::write_model_config_if_missing(&path, provider.default_model())?;
520    Ok(())
521}
522
523#[cfg(test)]
524mod tests {
525    use super::*;
526    use std::fs;
527    use std::path::Path;
528
529    fn with_home<T>(home: &Path, f: impl FnOnce() -> T) -> T {
530        let _guard = crate::test_env::lock();
531        let old_home = std::env::var_os("HOME");
532        unsafe {
533            std::env::set_var("HOME", home);
534        }
535        let result = f();
536        unsafe {
537            if let Some(old_home) = old_home {
538                std::env::set_var("HOME", old_home);
539            } else {
540                std::env::remove_var("HOME");
541            }
542        }
543        result
544    }
545
546    fn with_chatgpt_access_token<T>(token: Option<&str>, f: impl FnOnce() -> T) -> T {
547        let old_token = std::env::var_os(auth::CHATGPT_CODEX_ACCESS_TOKEN_ENV);
548        unsafe {
549            match token {
550                Some(token) => std::env::set_var(auth::CHATGPT_CODEX_ACCESS_TOKEN_ENV, token),
551                None => std::env::remove_var(auth::CHATGPT_CODEX_ACCESS_TOKEN_ENV),
552            }
553        }
554        let result = f();
555        unsafe {
556            if let Some(token) = old_token {
557                std::env::set_var(auth::CHATGPT_CODEX_ACCESS_TOKEN_ENV, token);
558            } else {
559                std::env::remove_var(auth::CHATGPT_CODEX_ACCESS_TOKEN_ENV);
560            }
561        }
562        result
563    }
564
565    #[test]
566    fn provider_defaults_from_model() {
567        assert_eq!(SetupProviderArg::for_model("umans-coder"), SetupProviderArg::Umans);
568        assert_eq!(
569            SetupProviderArg::for_model("opencode/big-pickle"),
570            SetupProviderArg::OpencodeZen
571        );
572        assert_eq!(
573            SetupProviderArg::for_model("opencode-go/kimi-k2.7-code"),
574            SetupProviderArg::OpencodeGo
575        );
576        assert_eq!(
577            SetupProviderArg::for_model("chatgpt-codex/gpt-5.5"),
578            SetupProviderArg::ChatgptCodex
579        );
580    }
581
582    #[test]
583    fn opencode_zen_is_the_setup_default_model_for_big_pickle() {
584        assert_eq!(SetupProviderArg::OpencodeZen.default_model(), "opencode/big-pickle");
585        assert_eq!(
586            SetupProviderArg::OpencodeZen.api_key_env_var(),
587            Some(auth::OPENCODE_ZEN_KEY_ENV)
588        );
589    }
590
591    #[test]
592    fn provider_metadata_models_auth_kinds() {
593        assert_eq!(SetupProviderArg::ALL[0], SetupProviderArg::OpencodeZen);
594        assert_eq!(
595            SetupProviderArg::Umans.metadata().auth_kind,
596            ProviderAuthKind::ApiKey { env_var: auth::UMANS_API_KEY_ENV }
597        );
598        assert_eq!(
599            SetupProviderArg::ChatgptCodex.metadata().auth_kind,
600            ProviderAuthKind::ChatGptOAuth { env_override: auth::CHATGPT_CODEX_ACCESS_TOKEN_ENV }
601        );
602        assert_eq!(SetupProviderArg::ChatgptCodex.api_key_env_var(), None);
603    }
604
605    #[test]
606    fn provider_choice_copy_marks_opencode_zen_default_and_caveats() {
607        let mut output = Vec::new();
608        write_provider_choices(&mut output, Some(SetupProviderArg::OpencodeZen)).expect("choices");
609        let output = String::from_utf8(output).expect("utf8");
610
611        assert!(output.contains("opencode-zen (default)"));
612        assert!(output.contains("opencode/big-pickle"));
613        assert!(output.contains(auth::OPENCODE_ZEN_KEY_ENV));
614        assert!(output.contains("free for a limited time"));
615        assert!(output.contains("prompts may be used to improve the model"));
616    }
617
618    #[test]
619    fn opencode_zen_noninteractive_summary_uses_provider_copy() {
620        let tmp = tempfile::tempdir().expect("tempdir");
621        let workspace = tmp.path().join("workspace");
622        let home = tmp.path().join("home");
623        fs::create_dir_all(&workspace).expect("workspace");
624        fs::create_dir_all(&home).expect("home");
625        auth::set_credential(
626            &auth::project_credentials_path(&workspace),
627            auth::OPENCODE_ZEN_KEY_ENV,
628            "secret",
629        )
630        .expect("credential");
631        let cli = Cli { cwd: workspace, ..Cli::default() };
632        let command = SetupCommand { provider: Some(SetupProviderArg::OpencodeZen), global: false, project: false };
633        let mut output = Vec::new();
634
635        with_home(&home, || {
636            run_with_writer(
637                &cli,
638                &command,
639                false,
640                &mut output,
641                |_| Ok(()),
642                |_, _| ProviderCredentialHealth::Verified,
643                |_| ProviderCredentialHealth::Verified,
644            )
645            .expect("setup");
646        });
647        let output = String::from_utf8(output).expect("utf8");
648
649        assert!(output.contains("provider: opencode-zen"));
650        assert!(output.contains("model: opencode/big-pickle"));
651        assert!(output.contains("auth: project credentials"));
652        assert!(output.contains("OpenCode Zen Big Pickle is the default model"));
653    }
654
655    #[test]
656    fn rejected_existing_credential_keeps_setup_incomplete() {
657        let tmp = tempfile::tempdir().expect("tempdir");
658        let workspace = tmp.path().join("workspace");
659        let home = tmp.path().join("home");
660        fs::create_dir_all(&workspace).expect("workspace");
661        fs::create_dir_all(&home).expect("home");
662        auth::set_credential(
663            &auth::project_credentials_path(&workspace),
664            auth::UMANS_API_KEY_ENV,
665            "rejected-key",
666        )
667        .expect("credential");
668        let cli = Cli { cwd: workspace.clone(), ..Cli::default() };
669        let command = SetupCommand { provider: Some(SetupProviderArg::Umans), global: false, project: false };
670        let mut output = Vec::new();
671
672        let error = with_home(&home, || {
673            run_with_writer(
674                &cli,
675                &command,
676                false,
677                &mut output,
678                |_| Ok(()),
679                |_, _| ProviderCredentialHealth::Rejected,
680                |_| ProviderCredentialHealth::Verified,
681            )
682            .expect_err("rejected credential")
683        });
684        let output = String::from_utf8(output).expect("utf8");
685
686        assert_eq!(error.kind(), io::ErrorKind::PermissionDenied);
687        assert!(error.to_string().contains("thndrs login umans"));
688        assert!(!output.contains("next: thndrs"));
689        assert!(!workspace.join(".thndrs/config.toml").exists());
690    }
691
692    #[test]
693    fn unavailable_existing_credential_keeps_setup_incomplete() {
694        let tmp = tempfile::tempdir().expect("tempdir");
695        let workspace = tmp.path().join("workspace");
696        let home = tmp.path().join("home");
697        fs::create_dir_all(&workspace).expect("workspace");
698        fs::create_dir_all(&home).expect("home");
699        auth::set_credential(
700            &auth::project_credentials_path(&workspace),
701            auth::UMANS_API_KEY_ENV,
702            "unverified-key",
703        )
704        .expect("credential");
705        let cli = Cli { cwd: workspace.clone(), ..Cli::default() };
706        let command = SetupCommand { provider: Some(SetupProviderArg::Umans), global: false, project: false };
707        let mut output = Vec::new();
708
709        let error = with_home(&home, || {
710            run_with_writer(
711                &cli,
712                &command,
713                false,
714                &mut output,
715                |_| Ok(()),
716                |_, _| ProviderCredentialHealth::Unavailable,
717                |_| ProviderCredentialHealth::Verified,
718            )
719            .expect_err("unavailable verification")
720        });
721        let output = String::from_utf8(output).expect("utf8");
722
723        assert_eq!(error.kind(), io::ErrorKind::ConnectionAborted);
724        assert!(error.to_string().contains("could not verify umans credentials"));
725        assert!(!output.contains("next: thndrs"));
726        assert!(!workspace.join(".thndrs/config.toml").exists());
727    }
728
729    #[test]
730    fn rejected_environment_credential_explains_precedence() {
731        let _guard = crate::test_env::lock();
732        let tmp = tempfile::tempdir().expect("tempdir");
733        let workspace = tmp.path().join("workspace");
734        let home = tmp.path().join("home");
735        fs::create_dir_all(&workspace).expect("workspace");
736        fs::create_dir_all(&home).expect("home");
737        let old_home = std::env::var_os("HOME");
738        let old_key = std::env::var_os(auth::UMANS_API_KEY_ENV);
739        unsafe {
740            std::env::set_var("HOME", &home);
741            std::env::set_var(auth::UMANS_API_KEY_ENV, "rejected-environment-key");
742        }
743        let cli = Cli { cwd: workspace.clone(), ..Cli::default() };
744        let command = SetupCommand { provider: Some(SetupProviderArg::Umans), global: false, project: false };
745        let mut output = Vec::new();
746
747        let error = run_with_writer(
748            &cli,
749            &command,
750            false,
751            &mut output,
752            |_| Ok(()),
753            |_, _| ProviderCredentialHealth::Rejected,
754            |_| ProviderCredentialHealth::Verified,
755        )
756        .expect_err("rejected environment credential");
757
758        unsafe {
759            if let Some(home) = old_home {
760                std::env::set_var("HOME", home);
761            } else {
762                std::env::remove_var("HOME");
763            }
764            if let Some(key) = old_key {
765                std::env::set_var(auth::UMANS_API_KEY_ENV, key);
766            } else {
767                std::env::remove_var(auth::UMANS_API_KEY_ENV);
768            }
769        }
770
771        assert_eq!(error.kind(), io::ErrorKind::PermissionDenied);
772        assert!(error.to_string().contains("replace or unset UMANS_API_KEY"));
773        assert!(error.to_string().contains("thndrs login umans"));
774        assert!(!String::from_utf8(output).expect("utf8").contains("next: thndrs"));
775        assert!(!workspace.join(".thndrs/config.toml").exists());
776    }
777
778    #[test]
779    fn chatgpt_setup_noninteractive_fails_without_api_key_prompt() {
780        let tmp = tempfile::tempdir().expect("tempdir");
781        let workspace = tmp.path().join("workspace");
782        let home = tmp.path().join("home");
783        fs::create_dir_all(&workspace).expect("workspace");
784        fs::create_dir_all(&home).expect("home");
785        let cli = Cli { cwd: workspace, ..Cli::default() };
786        let command = SetupCommand { provider: Some(SetupProviderArg::ChatgptCodex), global: false, project: false };
787        let mut output = Vec::new();
788
789        let err = with_home(&home, || {
790            with_chatgpt_access_token(None, || {
791                run_with_writer(
792                    &cli,
793                    &command,
794                    false,
795                    &mut output,
796                    |_| Ok(()),
797                    |_, _| ProviderCredentialHealth::Verified,
798                    |_| ProviderCredentialHealth::Verified,
799                )
800                .expect_err("missing auth")
801            })
802        });
803        let output = String::from_utf8(output).expect("utf8");
804        let err = err.to_string();
805
806        assert!(output.contains("provider: chatgpt-codex"));
807        assert!(output.contains("auth: missing OAuth credential"));
808        assert!(err.contains("interactive ChatGPT OAuth"));
809        assert!(!output.contains("Enter chatgpt-codex API key"));
810        assert!(!err.contains("CHATGPT_CODEX_ACCESS_TOKEN is missing"));
811    }
812
813    #[test]
814    fn setup_without_provider_or_model_explains_noninteractive_route() {
815        let tmp = tempfile::tempdir().expect("tempdir");
816        let workspace = tmp.path().join("workspace");
817        let home = tmp.path().join("home");
818        fs::create_dir_all(&workspace).expect("workspace");
819        fs::create_dir_all(&home).expect("home");
820        let cli = Cli { cwd: workspace, ..Cli::default() };
821        let command = SetupCommand { provider: None, global: false, project: false };
822        let mut output = Vec::new();
823
824        let err = with_home(&home, || {
825            run_with_writer(
826                &cli,
827                &command,
828                false,
829                &mut output,
830                |_| Ok(()),
831                |_, _| ProviderCredentialHealth::Verified,
832                |_| ProviderCredentialHealth::Verified,
833            )
834            .expect_err("missing route")
835        });
836
837        assert!(output.is_empty());
838        assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
839        assert!(err.to_string().contains("--provider"));
840        assert!(err.to_string().contains("--model"));
841    }
842
843    #[test]
844    fn chatgpt_setup_interactive_uses_oauth_runner_not_api_key_input() {
845        let tmp = tempfile::tempdir().expect("tempdir");
846        let workspace = tmp.path().join("workspace");
847        let home = tmp.path().join("home");
848        fs::create_dir_all(workspace.join(".thndrs")).expect("workspace config dir");
849        fs::create_dir_all(&home).expect("home");
850        fs::write(
851            config::project_config_path(&workspace),
852            "model = \"chatgpt-codex/gpt-5.5\"\n",
853        )
854        .expect("project config");
855        let cli = Cli { cwd: workspace, ..Cli::default() };
856        let command = SetupCommand { provider: Some(SetupProviderArg::ChatgptCodex), global: false, project: true };
857        let mut output = Vec::new();
858        let mut called = false;
859
860        with_home(&home, || {
861            with_chatgpt_access_token(None, || {
862                run_with_writer(
863                    &cli,
864                    &command,
865                    true,
866                    &mut output,
867                    |writer| {
868                        called = true;
869                        writeln!(writer, "fake ChatGPT OAuth login")
870                    },
871                    |_, _| ProviderCredentialHealth::Verified,
872                    |_| ProviderCredentialHealth::Verified,
873                )
874                .expect("setup");
875            });
876        });
877        let output = String::from_utf8(output).expect("utf8");
878
879        assert!(called);
880        assert!(output.contains("fake ChatGPT OAuth login"));
881        assert!(!output.contains("Enter chatgpt-codex API key"));
882        assert!(!output.contains("Choose credential store"));
883    }
884
885    #[test]
886    fn rejected_chatgpt_environment_override_explains_precedence_before_setup_writes_config() {
887        let _guard = crate::test_env::lock();
888        let tmp = tempfile::tempdir().expect("tempdir");
889        let workspace = tmp.path().join("workspace");
890        let home = tmp.path().join("home");
891        fs::create_dir_all(&workspace).expect("workspace");
892        fs::create_dir_all(&home).expect("home");
893        let old_home = std::env::var_os("HOME");
894        unsafe {
895            std::env::set_var("HOME", &home);
896        }
897        let cli = Cli { cwd: workspace.clone(), ..Cli::default() };
898        let command = SetupCommand { provider: Some(SetupProviderArg::ChatgptCodex), global: false, project: true };
899        let mut output = Vec::new();
900
901        let error = with_chatgpt_access_token(Some("rejected-environment-token"), || {
902            run_with_writer(
903                &cli,
904                &command,
905                true,
906                &mut output,
907                |_| panic!("rejected environment override must not start OAuth login"),
908                |_, _| ProviderCredentialHealth::Verified,
909                |_| ProviderCredentialHealth::Rejected,
910            )
911            .expect_err("rejected environment override")
912        });
913
914        unsafe {
915            if let Some(home) = old_home {
916                std::env::set_var("HOME", home);
917            } else {
918                std::env::remove_var("HOME");
919            }
920        }
921
922        assert_eq!(error.kind(), io::ErrorKind::PermissionDenied);
923        assert!(
924            error
925                .to_string()
926                .contains("replace or unset CHATGPT_CODEX_ACCESS_TOKEN")
927        );
928        assert!(error.to_string().contains("thndrs login chatgpt-codex"));
929        assert!(!String::from_utf8(output).expect("utf8").contains("next: thndrs"));
930        assert!(!workspace.join(".thndrs/config.toml").exists());
931    }
932
933    #[test]
934    fn unavailable_stored_chatgpt_credential_keeps_setup_incomplete() {
935        let tmp = tempfile::tempdir().expect("tempdir");
936        let workspace = tmp.path().join("workspace");
937        let home = tmp.path().join("home");
938        fs::create_dir_all(&workspace).expect("workspace");
939        fs::create_dir_all(&home).expect("home");
940        let cli = Cli { cwd: workspace.clone(), ..Cli::default() };
941        let command = SetupCommand { provider: Some(SetupProviderArg::ChatgptCodex), global: false, project: true };
942        let mut output = Vec::new();
943
944        let error = with_home(&home, || {
945            with_chatgpt_access_token(None, || {
946                auth::write_chatgpt_codex_credentials(&auth::ChatGptCodexCredentials {
947                    access_token: "access-token".to_string(),
948                    refresh_token: "refresh-token".to_string(),
949                    expires_at_ms: u64::MAX,
950                    account_id: "acct_test".to_string(),
951                })
952                .expect("stored OAuth credential");
953                run_with_writer(
954                    &cli,
955                    &command,
956                    true,
957                    &mut output,
958                    |_| panic!("unavailable stored credential must not start OAuth login"),
959                    |_, _| ProviderCredentialHealth::Verified,
960                    |_| ProviderCredentialHealth::Unavailable,
961                )
962                .expect_err("unavailable OAuth verification")
963            })
964        });
965        let output = String::from_utf8(output).expect("utf8");
966
967        assert_eq!(error.kind(), io::ErrorKind::ConnectionAborted);
968        assert!(error.to_string().contains("could not verify chatgpt-codex credentials"));
969        assert!(!error.to_string().contains("thndrs login"));
970        assert!(!output.contains("next: thndrs"));
971        assert!(!workspace.join(".thndrs/config.toml").exists());
972    }
973
974    #[test]
975    fn fresh_chatgpt_oauth_is_verified_before_setup_writes_config() {
976        let tmp = tempfile::tempdir().expect("tempdir");
977        let workspace = tmp.path().join("workspace");
978        let home = tmp.path().join("home");
979        fs::create_dir_all(&workspace).expect("workspace");
980        fs::create_dir_all(&home).expect("home");
981        let cli = Cli { cwd: workspace.clone(), ..Cli::default() };
982        let command = SetupCommand { provider: Some(SetupProviderArg::ChatgptCodex), global: false, project: true };
983        let mut output = Vec::new();
984        let mut login_called = false;
985
986        let error = with_home(&home, || {
987            with_chatgpt_access_token(None, || {
988                run_with_writer(
989                    &cli,
990                    &command,
991                    true,
992                    &mut output,
993                    |_| {
994                        login_called = true;
995                        Ok(())
996                    },
997                    |_, _| ProviderCredentialHealth::Verified,
998                    |_| ProviderCredentialHealth::Rejected,
999                )
1000                .expect_err("post-login OAuth verification should reject unusable credentials")
1001            })
1002        });
1003        let output = String::from_utf8(output).expect("utf8");
1004
1005        assert!(login_called);
1006        assert_eq!(error.kind(), io::ErrorKind::PermissionDenied);
1007        assert!(error.to_string().contains("thndrs login chatgpt-codex"));
1008        assert!(!output.contains("next: thndrs"));
1009        assert!(!workspace.join(".thndrs/config.toml").exists());
1010    }
1011
1012    #[test]
1013    fn setup_scope_uses_flags() {
1014        assert_eq!(
1015            SetupCommand { provider: None, global: true, project: false }.scope(),
1016            Some(CredentialScope::Global)
1017        );
1018        assert_eq!(
1019            SetupCommand { provider: None, global: false, project: true }.scope(),
1020            Some(CredentialScope::Project)
1021        );
1022        assert_eq!(
1023            SetupCommand { provider: None, global: false, project: false }.scope(),
1024            None
1025        );
1026    }
1027}