Skip to main content

thndrs_lib/cli/app/
onboarding.rs

1//! First-run provider setup, authentication, and credential recovery.
2//!
3//! This module handles the setup flow for a selected model:
4//!
5//! 1. choosing a provider
6//! 2. selecting credential and model-config scope
7//! 3. collecting an API key
8//! 4. writing or removing the resulting credential.
9//!
10//! ChatGPT Codex uses browser-first PKCE OAuth instead of API-key entry. Device
11//! code remains an explicit headless/remote alternative.
12//!
13//! API-key input stays in [`FirstRunRecovery::secret_input`] until it is
14//! written to the provider credential store.
15//!
16//! It is not copied into transcript, prompt, or session metadata.
17
18use std::io;
19
20use super::*;
21
22/// Focused first-run and credential recovery surface.
23#[derive(Clone, Eq, PartialEq)]
24pub struct FirstRunRecovery {
25    /// Provider being configured or diagnosed.
26    pub provider: Option<SetupProviderArg>,
27    /// Current recovery step.
28    pub stage: RecoveryStage,
29    /// Whether a prompt submit is waiting on this recovery.
30    pub pending_provider_prompt: bool,
31    /// Selected action row.
32    pub selected: usize,
33    /// Hidden API-key buffer. This is never rendered or written to transcripts.
34    pub secret_input: String,
35    /// ChatGPT OAuth state. Token material is never rendered.
36    pub chatgpt_oauth: Option<ChatGptOAuthRecovery>,
37}
38
39impl std::fmt::Debug for FirstRunRecovery {
40    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
41        formatter
42            .debug_struct("FirstRunRecovery")
43            .field("provider", &self.provider)
44            .field("stage", &self.stage)
45            .field("pending_provider_prompt", &self.pending_provider_prompt)
46            .field("selected", &self.selected)
47            .field(
48                "secret_input",
49                &if self.secret_input.is_empty() { "<empty>" } else { "[redacted]" },
50            )
51            .field("chatgpt_oauth", &self.chatgpt_oauth)
52            .finish()
53    }
54}
55
56impl FirstRunRecovery {
57    pub fn missing_label(&self) -> &'static str {
58        match self.stage {
59            RecoveryStage::ChooseProvider | RecoveryStage::ModelSelection | RecoveryStage::ModelConfigScope => "none",
60            _ => match self.provider {
61                Some(crate::cli::commands::setup::SetupProviderArg::ChatgptCodex) => "ChatGPT OAuth credential",
62                Some(provider) => provider.api_key_env_var().unwrap_or("credential"),
63                None => "ACP agent config",
64            },
65        }
66    }
67
68    pub fn setup(default_provider: SetupProviderArg) -> Self {
69        let selected = match default_provider {
70            SetupProviderArg::ChatgptCodex => 0,
71            SetupProviderArg::Umans => 1,
72            SetupProviderArg::OpencodeGo | SetupProviderArg::OpencodeZen => 2,
73        };
74        Self {
75            provider: Some(default_provider),
76            stage: RecoveryStage::ChooseProvider,
77            pending_provider_prompt: false,
78            selected,
79            secret_input: String::new(),
80            chatgpt_oauth: None,
81        }
82    }
83
84    pub fn missing_provider(provider: SetupProviderArg, pending_provider_prompt: bool) -> Self {
85        Self {
86            provider: Some(provider),
87            stage: RecoveryStage::MissingCredential,
88            pending_provider_prompt,
89            selected: 0,
90            secret_input: String::new(),
91            chatgpt_oauth: None,
92        }
93    }
94
95    pub fn acp_missing(pending_provider_prompt: bool) -> Self {
96        Self {
97            provider: None,
98            stage: RecoveryStage::AcpMissing,
99            pending_provider_prompt,
100            selected: 0,
101            secret_input: String::new(),
102            chatgpt_oauth: None,
103        }
104    }
105
106    pub fn login(provider: SetupProviderArg) -> Self {
107        Self {
108            provider: Some(provider),
109            stage: if provider == SetupProviderArg::ChatgptCodex {
110                RecoveryStage::MissingCredential
111            } else {
112                RecoveryStage::EnterKey
113            },
114            pending_provider_prompt: false,
115            selected: 0,
116            secret_input: String::new(),
117            chatgpt_oauth: None,
118        }
119    }
120
121    pub fn logout(provider: SetupProviderArg) -> Self {
122        Self {
123            provider: Some(provider),
124            stage: RecoveryStage::LogoutConfirm,
125            pending_provider_prompt: false,
126            selected: 0,
127            secret_input: String::new(),
128            chatgpt_oauth: None,
129        }
130    }
131
132    pub fn action_count(&self) -> usize {
133        match self.stage {
134            RecoveryStage::ChooseProvider => 3,
135            RecoveryStage::ModelSelection => self.app_model_selection_count(),
136            RecoveryStage::ModelConfigScope => 4,
137            RecoveryStage::MissingCredential => {
138                if self.provider == Some(SetupProviderArg::ChatgptCodex) {
139                    6
140                } else {
141                    5
142                }
143            }
144            RecoveryStage::EnterKey => 1,
145            RecoveryStage::ConfirmStore | RecoveryStage::LogoutConfirm => 3,
146            RecoveryStage::Instructions => 2,
147            RecoveryStage::ChatGptOAuthRequesting | RecoveryStage::ChatGptOAuthPasteRedirect => 1,
148            RecoveryStage::ChatGptOAuthPolling => self
149                .chatgpt_oauth
150                .as_ref()
151                .filter(|oauth| oauth.method == ChatGptOAuthMethod::Browser)
152                .map_or(1, |_| 2),
153            RecoveryStage::ChatGptOAuthFailed => 3,
154            RecoveryStage::AcpMissing => 4,
155        }
156    }
157
158    fn app_model_selection_count(&self) -> usize {
159        self.provider
160            .map(setup_model_options)
161            .map(|options| options.len())
162            .unwrap_or(0)
163    }
164}
165
166/// ChatGPT OAuth method selected by the user.
167#[derive(Clone, Copy, Debug, Eq, PartialEq)]
168pub enum ChatGptOAuthMethod {
169    /// Browser PKCE with a loopback callback.
170    Browser,
171    /// Device code for headless or remote environments.
172    DeviceCode,
173}
174
175/// ChatGPT OAuth state shown in the focused recovery surface.
176#[derive(Clone, Eq, PartialEq)]
177pub struct ChatGptOAuthRecovery {
178    /// OAuth method selected by the user.
179    pub method: ChatGptOAuthMethod,
180    /// Browser authorization URL, when browser PKCE is active.
181    pub authorization_url: Option<String>,
182    /// Device-code response used for polling, when device code is active. Its
183    /// debug output redacts the device token.
184    pub code: Option<auth::ChatGptCodexDeviceCode>,
185    /// UI tick when the next single device-code poll is allowed.
186    pub next_poll_tick: u64,
187    /// OAuth expiry tick.
188    pub expires_at_tick: u64,
189    /// Redacted status text for the recovery surface.
190    pub status: String,
191}
192
193impl std::fmt::Debug for ChatGptOAuthRecovery {
194    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
195        formatter
196            .debug_struct("ChatGptOAuthRecovery")
197            .field("method", &self.method)
198            .field(
199                "authorization_url",
200                &self.authorization_url.as_ref().map(|_| "[redacted]"),
201            )
202            .field("code", &self.code)
203            .field("next_poll_tick", &self.next_poll_tick)
204            .field("expires_at_tick", &self.expires_at_tick)
205            .field("status", &self.status)
206            .finish()
207    }
208}
209
210/// Step within the first-run recovery surface.
211#[derive(Clone, Copy, Debug, Eq, PartialEq)]
212pub enum RecoveryStage {
213    /// Choose which built-in provider to configure.
214    ChooseProvider,
215    /// Choose whether and where to persist the selected provider's default model.
216    ModelConfigScope,
217    /// Choose a model after provider authentication succeeds.
218    ModelSelection,
219    /// Selected provider is missing an API-key credential.
220    MissingCredential,
221    /// Hidden API-key entry is active.
222    EnterKey,
223    /// Select global/project storage before writing the key.
224    ConfirmStore,
225    /// Show setup instructions in a focused surface.
226    Instructions,
227    /// Starting a ChatGPT OAuth method.
228    ChatGptOAuthRequesting,
229    /// Waiting for browser callback or device-code authorization.
230    ChatGptOAuthPolling,
231    /// Pasting the full browser redirect URL into a hidden input.
232    ChatGptOAuthPasteRedirect,
233    /// ChatGPT OAuth failed with a redacted, user-readable error.
234    ChatGptOAuthFailed,
235    /// Confirm logout and storage scope.
236    LogoutConfirm,
237    /// ACP model recovery, separate from provider API-key setup.
238    AcpMissing,
239}
240
241impl RecoveryStage {
242    pub fn label(self) -> &'static str {
243        match self {
244            RecoveryStage::ChooseProvider => "choose provider",
245            RecoveryStage::ModelSelection => "choose model",
246            RecoveryStage::ModelConfigScope => "model scope",
247            RecoveryStage::MissingCredential => "authentication required",
248            RecoveryStage::EnterKey => "credential entry",
249            RecoveryStage::ConfirmStore => "credential scope",
250            RecoveryStage::Instructions => "setup instructions",
251            RecoveryStage::ChatGptOAuthRequesting => "starting OAuth",
252            RecoveryStage::ChatGptOAuthPolling => "OAuth in progress",
253            RecoveryStage::ChatGptOAuthPasteRedirect => "paste redirect",
254            RecoveryStage::ChatGptOAuthFailed => "OAuth failed",
255            RecoveryStage::LogoutConfirm => "remove credential",
256            RecoveryStage::AcpMissing => "ACP setup required",
257        }
258    }
259}
260
261/// Small seam for testing TUI OAuth without real network calls.
262///
263/// FIXME: what on earth is this
264#[derive(Clone, Copy, Debug)]
265pub struct ChatGptOAuthDriver {
266    pub start_browser_login: fn() -> Result<auth::ChatGptCodexBrowserLogin, auth::AuthError>,
267    pub open_browser: fn(&str) -> Result<(), auth::AuthError>,
268    pub poll_browser_login:
269        fn(&mut auth::ChatGptCodexBrowserLogin) -> Result<auth::ChatGptCodexBrowserPoll, auth::AuthError>,
270    pub complete_browser_redirect:
271        fn(&auth::ChatGptCodexBrowserLogin, &str) -> Result<auth::ChatGptCodexCredentials, auth::AuthError>,
272    pub request_device_code: fn() -> Result<auth::ChatGptCodexDeviceCode, auth::AuthError>,
273    pub poll_device_code_once:
274        fn(&auth::ChatGptCodexDeviceCode) -> Result<auth::ChatGptCodexDevicePoll, auth::AuthError>,
275    pub write_credentials: fn(&auth::ChatGptCodexCredentials) -> Result<(), auth::AuthError>,
276}
277
278impl Default for ChatGptOAuthDriver {
279    fn default() -> Self {
280        Self {
281            start_browser_login: auth::start_chatgpt_codex_browser_login,
282            open_browser: auth::open_chatgpt_codex_authorization_url,
283            poll_browser_login: auth::poll_chatgpt_codex_browser_login_once,
284            complete_browser_redirect: auth::ChatGptCodexBrowserLogin::complete_redirect,
285            request_device_code: auth::request_chatgpt_codex_device_code,
286            poll_device_code_once: auth::poll_chatgpt_codex_device_code_once,
287            write_credentials: auth::write_chatgpt_codex_credentials,
288        }
289    }
290}
291
292/// Setup state held while the reasoning picker is open.
293#[derive(Clone, Copy, Debug, Eq, PartialEq)]
294pub struct PendingSetupReasoningEffort {
295    pub provider: SetupProviderArg,
296    pub scope: CredentialScope,
297}
298
299pub fn provider_for_model(model: &str) -> SetupProviderArg {
300    if opencode::is_zen_model_id(model) {
301        SetupProviderArg::OpencodeZen
302    } else if opencode::is_go_model_id(model) {
303        SetupProviderArg::OpencodeGo
304    } else if codex::is_model_id(model) {
305        SetupProviderArg::ChatgptCodex
306    } else {
307        SetupProviderArg::Umans
308    }
309}
310
311pub fn provider_authenticated(provider: SetupProviderArg, cwd: &std::path::Path) -> bool {
312    match provider {
313        SetupProviderArg::ChatgptCodex => chatgpt_codex_auth_available_locally(),
314        _ => match provider.api_key_env_var() {
315            Some(env_var) => auth::credential_source(env_var, cwd).is_some(),
316            _ => false,
317        },
318    }
319}
320
321pub fn chatgpt_codex_auth_available_locally() -> bool {
322    if let Ok(token) = std::env::var(auth::CHATGPT_CODEX_ACCESS_TOKEN_ENV)
323        && !token.trim().is_empty()
324    {
325        return auth::chatgpt_account_id_from_jwt(&token).is_ok();
326    }
327
328    matches!(
329        auth::read_chatgpt_codex_credentials(),
330        Ok(Some(credentials))
331            if !credentials.access_token.trim().is_empty()
332                && !credentials.refresh_token.trim().is_empty()
333                && !credentials.account_id.trim().is_empty()
334    )
335}
336
337pub fn selected_provider_missing(app: &App) -> Option<FirstRunRecovery> {
338    if app.model.trim().is_empty() {
339        return Some(FirstRunRecovery::setup(SetupProviderArg::ChatgptCodex));
340    }
341
342    if let Some(acp_name) = crate::acp::config::parse_model_id(&app.model) {
343        if app.cli.acp_agents.contains_key(acp_name) {
344            return None;
345        }
346        return Some(FirstRunRecovery::acp_missing(true));
347    }
348
349    let provider = provider_for_model(&app.model);
350    if !provider_authenticated(provider, &app.cwd) {
351        Some(FirstRunRecovery::missing_provider(provider, true))
352    } else {
353        None
354    }
355}
356
357pub fn handle_first_run_key(app: &mut App, key: KeyEvent) -> Option<Msg> {
358    let recovery = app.first_run_recovery.as_mut()?;
359
360    if recovery.stage == RecoveryStage::EnterKey || recovery.stage == RecoveryStage::ChatGptOAuthPasteRedirect {
361        match key.code {
362            KeyCode::Esc => {
363                recovery.secret_input.clear();
364                recovery.stage = if recovery.stage == RecoveryStage::ChatGptOAuthPasteRedirect {
365                    RecoveryStage::ChatGptOAuthPolling
366                } else {
367                    RecoveryStage::MissingCredential
368                };
369                recovery.selected = 0;
370            }
371            KeyCode::Backspace => {
372                recovery.secret_input.pop();
373            }
374            KeyCode::Enter => {
375                if recovery.secret_input.trim().is_empty() {
376                    app.transcript.push(Entry::Error {
377                        text: if recovery.stage == RecoveryStage::ChatGptOAuthPasteRedirect {
378                            String::from("paste the full ChatGPT redirect URL or press Esc to cancel")
379                        } else {
380                            String::from("API key cannot be empty")
381                        },
382                    });
383                } else if recovery.stage == RecoveryStage::ChatGptOAuthPasteRedirect {
384                    let redirect = recovery.secret_input.clone();
385                    recovery.secret_input.clear();
386                    complete_chatgpt_oauth_redirect(app, &redirect);
387                } else {
388                    recovery.stage = RecoveryStage::ConfirmStore;
389                    recovery.selected = 0;
390                }
391            }
392            KeyCode::Char(ch) => recovery.secret_input.push(ch),
393            _ => {}
394        }
395        return None;
396    }
397
398    if matches!(
399        recovery.stage,
400        RecoveryStage::ChatGptOAuthRequesting | RecoveryStage::ChatGptOAuthPolling
401    ) && key.code == KeyCode::Esc
402    {
403        recovery.stage = RecoveryStage::MissingCredential;
404        recovery.selected = 0;
405        recovery.chatgpt_oauth = None;
406        app.chatgpt_browser_login = None;
407        return None;
408    }
409
410    match key.code {
411        KeyCode::Esc => {
412            app.first_run_recovery = None;
413            None
414        }
415        KeyCode::Up => {
416            recovery.selected = recovery.selected.saturating_sub(1);
417            None
418        }
419        KeyCode::Down => {
420            let max = recovery.action_count().saturating_sub(1);
421            recovery.selected = (recovery.selected + 1).min(max);
422            None
423        }
424        KeyCode::Enter => accept_recovery_action(app),
425        _ => None,
426    }
427}
428
429pub fn accept_recovery_action(app: &mut App) -> Option<Msg> {
430    let recovery = app.first_run_recovery.clone()?;
431
432    match recovery.stage {
433        RecoveryStage::ChooseProvider => {
434            let Some(provider) = first_run_provider(recovery.selected) else {
435                app.first_run_recovery = Some(FirstRunRecovery {
436                    provider: None,
437                    stage: RecoveryStage::Instructions,
438                    pending_provider_prompt: recovery.pending_provider_prompt,
439                    selected: 0,
440                    secret_input: String::new(),
441                    chatgpt_oauth: None,
442                });
443                return None;
444            };
445            let stage = if provider_authenticated(provider, &app.cwd) {
446                RecoveryStage::ModelSelection
447            } else {
448                RecoveryStage::MissingCredential
449            };
450            app.first_run_recovery = Some(FirstRunRecovery {
451                provider: Some(provider),
452                stage,
453                pending_provider_prompt: recovery.pending_provider_prompt,
454                selected: 0,
455                secret_input: String::new(),
456                chatgpt_oauth: None,
457            });
458        }
459        RecoveryStage::ModelSelection => select_setup_model(app, &recovery),
460        RecoveryStage::ModelConfigScope => configure_setup_model_scope(app, &recovery),
461        RecoveryStage::MissingCredential if recovery.provider == Some(SetupProviderArg::ChatgptCodex) => {
462            match recovery.selected {
463                0 => start_chatgpt_browser_oauth_recovery(app),
464                1 => start_chatgpt_device_oauth_recovery(app),
465                2 => {
466                    app.first_run_recovery = None;
467                    open_model_picker(app);
468                }
469                3 => {
470                    if let Some(active) = app.first_run_recovery.as_mut() {
471                        active.stage = RecoveryStage::Instructions;
472                        active.selected = 0;
473                        active.chatgpt_oauth = None;
474                    }
475                }
476                4 => {
477                    if recovery.pending_provider_prompt {
478                        app.transcript.push(Entry::Status {
479                            text: String::from(
480                                "setup required before submitting this ChatGPT Codex prompt; start OAuth login or switch model",
481                            ),
482                        });
483                    } else {
484                        app.first_run_recovery = None;
485                        app.transcript
486                            .push(Entry::Status { text: String::from("setup skipped") });
487                    }
488                }
489                5 => {
490                    app.quit = true;
491                    return Some(Msg::Quit);
492                }
493                _ => {}
494            }
495        }
496        RecoveryStage::MissingCredential => match recovery.selected {
497            0 => {
498                if let Some(active) = app.first_run_recovery.as_mut() {
499                    active.stage = RecoveryStage::EnterKey;
500                    active.selected = 0;
501                    active.secret_input.clear();
502                }
503            }
504            1 => {
505                if recovery.provider == Some(SetupProviderArg::ChatgptCodex) {
506                    if let Some(active) = app.first_run_recovery.as_mut() {
507                        active.stage = RecoveryStage::Instructions;
508                        active.selected = 0;
509                    }
510                    return None;
511                }
512                app.first_run_recovery = None;
513                open_model_picker(app);
514            }
515            2 => {
516                if recovery.provider == Some(SetupProviderArg::ChatgptCodex) {
517                    if recovery.pending_provider_prompt {
518                        app.transcript.push(Entry::Status {
519                            text: String::from(
520                                "setup required before submitting this ChatGPT Codex prompt; start ChatGPT OAuth login or switch model",
521                            ),
522                        });
523                    } else {
524                        app.first_run_recovery = None;
525                        app.transcript
526                            .push(Entry::Status { text: String::from("setup skipped") });
527                    }
528                    return None;
529                }
530                if let Some(active) = app.first_run_recovery.as_mut() {
531                    active.stage = RecoveryStage::Instructions;
532                    active.selected = 0;
533                }
534            }
535            3 => {
536                if recovery.provider == Some(SetupProviderArg::ChatgptCodex) {
537                    app.quit = true;
538                    return Some(Msg::Quit);
539                }
540                if recovery.pending_provider_prompt {
541                    app.transcript.push(Entry::Status {
542                        text: String::from(
543                            "setup required before submitting this provider-backed prompt; enter a key or switch model",
544                        ),
545                    });
546                } else {
547                    app.first_run_recovery = None;
548                    app.transcript
549                        .push(Entry::Status { text: String::from("setup skipped") });
550                }
551            }
552            4 => {
553                app.quit = true;
554                return Some(Msg::Quit);
555            }
556            _ => {}
557        },
558        RecoveryStage::ConfirmStore => store_recovery_credential(app, &recovery),
559        RecoveryStage::Instructions => match recovery.selected {
560            0 => {
561                if let Some(active) = app.first_run_recovery.as_mut() {
562                    active.stage = if active.provider.is_none() {
563                        RecoveryStage::ChooseProvider
564                    } else {
565                        RecoveryStage::MissingCredential
566                    };
567                    active.selected = 0;
568                }
569            }
570            1 => app.first_run_recovery = None,
571            _ => {}
572        },
573        RecoveryStage::ChatGptOAuthRequesting => {}
574        RecoveryStage::ChatGptOAuthPolling => {
575            if recovery
576                .chatgpt_oauth
577                .as_ref()
578                .is_some_and(|oauth| oauth.method == ChatGptOAuthMethod::Browser && recovery.selected == 1)
579            {
580                if let Some(active) = app.first_run_recovery.as_mut() {
581                    active.stage = RecoveryStage::ChatGptOAuthPasteRedirect;
582                    active.selected = 0;
583                    active.secret_input.clear();
584                }
585            } else if let Some(active) = app.first_run_recovery.as_mut() {
586                active.stage = RecoveryStage::MissingCredential;
587                active.selected = 0;
588                active.chatgpt_oauth = None;
589                app.chatgpt_browser_login = None;
590            }
591        }
592        RecoveryStage::ChatGptOAuthPasteRedirect => {}
593        RecoveryStage::ChatGptOAuthFailed => match recovery.selected {
594            0 => match recovery.chatgpt_oauth.as_ref().map(|oauth| oauth.method) {
595                Some(ChatGptOAuthMethod::DeviceCode) => start_chatgpt_device_oauth_recovery(app),
596                _ => start_chatgpt_browser_oauth_recovery(app),
597            },
598            1 => {
599                start_chatgpt_device_oauth_recovery(app);
600            }
601            2 => {
602                if let Some(active) = app.first_run_recovery.as_mut() {
603                    active.stage = RecoveryStage::MissingCredential;
604                    active.selected = 0;
605                    active.chatgpt_oauth = None;
606                }
607                app.chatgpt_browser_login = None;
608            }
609            _ => {}
610        },
611        RecoveryStage::LogoutConfirm => remove_recovery_credential(app, &recovery),
612        RecoveryStage::AcpMissing => match recovery.selected {
613            0 => {
614                app.first_run_recovery = None;
615                open_model_picker(app);
616            }
617            1 => {
618                app.transcript.push(Entry::Status {
619                    text: String::from("ACP setup: run `thndrs acp list` or `thndrs acp registry` outside the TUI"),
620                });
621            }
622            2 => {
623                if recovery.pending_provider_prompt {
624                    app.transcript.push(Entry::Status {
625                        text: String::from(
626                            "ACP agent config is required before submitting this prompt; switch model or configure ACP",
627                        ),
628                    });
629                } else {
630                    app.first_run_recovery = None;
631                }
632            }
633            3 => {
634                app.quit = true;
635                return Some(Msg::Quit);
636            }
637            _ => {}
638        },
639        RecoveryStage::EnterKey => {}
640    }
641
642    None
643}
644
645pub fn configure_setup_model_scope(app: &mut App, recovery: &FirstRunRecovery) {
646    let Some(provider) = recovery.provider else {
647        app.first_run_recovery = None;
648        return;
649    };
650
651    match recovery.selected {
652        0 => {
653            if write_setup_model_config(app, provider, CredentialScope::Project).is_ok() {
654                after_setup_model_config(app, provider, CredentialScope::Project);
655            }
656        }
657        1 => {
658            if write_setup_model_config(app, provider, CredentialScope::Global).is_ok() {
659                after_setup_model_config(app, provider, CredentialScope::Global);
660            }
661        }
662        2 => {
663            app.transcript
664                .push(Entry::Status { text: String::from("model config skipped") });
665            advance_after_setup_model_config(app, provider);
666        }
667        _ => {
668            app.first_run_recovery = None;
669            app.transcript
670                .push(Entry::Status { text: String::from("setup skipped") });
671        }
672    }
673}
674
675pub fn after_setup_model_config(app: &mut App, provider: SetupProviderArg, scope: CredentialScope) {
676    if codex::supports_reasoning_effort(&app.model) {
677        app.first_run_recovery = None;
678        app.pending_setup_reasoning_effort = Some(PendingSetupReasoningEffort { provider, scope });
679        open_reasoning_effort_picker(app);
680    } else {
681        advance_after_setup_model_config(app, provider);
682    }
683}
684
685pub fn write_setup_model_config(app: &mut App, _provider: SetupProviderArg, scope: CredentialScope) -> io::Result<()> {
686    let model = app.model.trim();
687    if model.is_empty() {
688        let err = io::Error::new(io::ErrorKind::InvalidInput, "choose a model before saving setup");
689        app.transcript
690            .push(Entry::Error { text: format!("failed to save selected model to config: {err}") });
691        return Err(err);
692    }
693    let path = match scope {
694        CredentialScope::Global => match config::global_config_path() {
695            Some(path) => path,
696            None => {
697                let err = io::Error::new(io::ErrorKind::NotFound, "HOME is not available");
698                app.transcript
699                    .push(Entry::Error { text: format!("failed to save selected model to global config: {err}") });
700                return Err(err);
701            }
702        },
703        CredentialScope::Project => config::project_config_path(&app.cwd),
704    };
705
706    match config::write_model_config(&path, model) {
707        Ok(()) => {
708            let display = match scope {
709                CredentialScope::Global => config::global_config_path_display(&path),
710                CredentialScope::Project => config::project_config_path_display(&path, &app.cwd),
711            };
712            app.transcript
713                .push(Entry::Status { text: format!("model: {model} (saved to {display})") });
714            Ok(())
715        }
716        Err(err) => {
717            app.transcript.push(Entry::Error {
718                text: format!("failed to save selected model to {} config: {err}", scope.label()),
719            });
720            Err(err)
721        }
722    }
723}
724
725pub fn advance_after_setup_model_config(app: &mut App, provider: SetupProviderArg) {
726    if provider_authenticated(provider, &app.cwd) {
727        app.first_run_recovery = None;
728        app.transcript.push(Entry::Status {
729            text: format!(
730                "setup saved for {}; thndrs will verify the credential on the first provider request",
731                provider.label()
732            ),
733        });
734    } else if provider == SetupProviderArg::ChatgptCodex {
735        app.first_run_recovery = Some(FirstRunRecovery::missing_provider(provider, false));
736    } else {
737        app.first_run_recovery = Some(FirstRunRecovery::login(provider));
738    }
739}
740
741pub fn setup_model_options(provider: SetupProviderArg) -> Vec<PickerItem> {
742    let mut options: Vec<PickerItem> = offline_model_picker_items()
743        .into_iter()
744        .filter(|item| provider_for_model(&item.label) == provider)
745        .collect();
746    if !options.iter().any(|item| item.label == provider.default_model()) {
747        options.insert(0, PickerItem::new(provider.default_model(), "provider setup model"));
748    }
749    options
750}
751
752/// Start the browser-first ChatGPT Codex OAuth recovery.
753pub fn start_chatgpt_browser_oauth_recovery(app: &mut App) {
754    let pending_provider_prompt = app
755        .first_run_recovery
756        .as_ref()
757        .is_some_and(|recovery| recovery.pending_provider_prompt);
758    if let Some(active) = app.first_run_recovery.as_mut() {
759        active.stage = RecoveryStage::ChatGptOAuthRequesting;
760        active.selected = 0;
761        active.chatgpt_oauth = None;
762    }
763    app.chatgpt_browser_login = None;
764
765    match (app.chatgpt_oauth_driver.start_browser_login)() {
766        Ok(login) => {
767            let authorization_url = login.authorization_url().to_string();
768            let status = match (app.chatgpt_oauth_driver.open_browser)(&authorization_url) {
769                Ok(()) => String::from("Browser opened. Waiting for the ChatGPT callback."),
770                Err(_) => String::from("Browser did not open; copy the authorization URL below."),
771            };
772            let expires_at_tick = app.ui_tick.wrapping_add(seconds_to_ticks(app, 5 * 60));
773            app.chatgpt_browser_login = Some(login);
774            app.first_run_recovery = Some(FirstRunRecovery {
775                provider: Some(SetupProviderArg::ChatgptCodex),
776                stage: RecoveryStage::ChatGptOAuthPolling,
777                pending_provider_prompt,
778                selected: 0,
779                secret_input: String::new(),
780                chatgpt_oauth: Some(ChatGptOAuthRecovery {
781                    method: ChatGptOAuthMethod::Browser,
782                    authorization_url: Some(authorization_url),
783                    code: None,
784                    next_poll_tick: app.ui_tick,
785                    expires_at_tick,
786                    status,
787                }),
788            });
789        }
790        Err(err) => set_chatgpt_oauth_failure(
791            app,
792            ChatGptOAuthMethod::Browser,
793            pending_provider_prompt,
794            format!(
795                "ChatGPT browser OAuth could not start: {}",
796                redact_auth_error(&err.to_string())
797            ),
798        ),
799    }
800}
801
802/// Start the explicitly selected headless ChatGPT Codex device-code recovery.
803pub fn start_chatgpt_device_oauth_recovery(app: &mut App) {
804    let pending_provider_prompt = app
805        .first_run_recovery
806        .as_ref()
807        .is_some_and(|recovery| recovery.pending_provider_prompt);
808    if let Some(active) = app.first_run_recovery.as_mut() {
809        active.stage = RecoveryStage::ChatGptOAuthRequesting;
810        active.selected = 0;
811        active.chatgpt_oauth = None;
812    }
813    app.chatgpt_browser_login = None;
814
815    match (app.chatgpt_oauth_driver.request_device_code)() {
816        Ok(code) => {
817            let next_poll_tick = app
818                .ui_tick
819                .wrapping_add(seconds_to_ticks(app, code.interval.unwrap_or(5).max(1)));
820            let expires_at_tick = app
821                .ui_tick
822                .wrapping_add(seconds_to_ticks(app, code.expires_in.unwrap_or(900).max(1)));
823            app.first_run_recovery = Some(FirstRunRecovery {
824                provider: Some(SetupProviderArg::ChatgptCodex),
825                stage: RecoveryStage::ChatGptOAuthPolling,
826                pending_provider_prompt,
827                selected: 0,
828                secret_input: String::new(),
829                chatgpt_oauth: Some(ChatGptOAuthRecovery {
830                    method: ChatGptOAuthMethod::DeviceCode,
831                    authorization_url: None,
832                    code: Some(code),
833                    next_poll_tick,
834                    expires_at_tick,
835                    status: String::from("Waiting for ChatGPT authorization."),
836                }),
837            });
838        }
839        Err(err) => {
840            set_chatgpt_oauth_failure(
841                app,
842                ChatGptOAuthMethod::DeviceCode,
843                pending_provider_prompt,
844                format!(
845                    "ChatGPT device-code login could not start: {}",
846                    redact_auth_error(&err.to_string())
847                ),
848            );
849        }
850    }
851}
852
853fn set_chatgpt_oauth_failure(app: &mut App, method: ChatGptOAuthMethod, pending_provider_prompt: bool, status: String) {
854    app.chatgpt_browser_login = None;
855    app.first_run_recovery = Some(FirstRunRecovery {
856        provider: Some(SetupProviderArg::ChatgptCodex),
857        stage: RecoveryStage::ChatGptOAuthFailed,
858        pending_provider_prompt,
859        selected: 0,
860        secret_input: String::new(),
861        chatgpt_oauth: Some(ChatGptOAuthRecovery {
862            method,
863            authorization_url: None,
864            code: None,
865            next_poll_tick: app.ui_tick,
866            expires_at_tick: app.ui_tick,
867            status: status.clone(),
868        }),
869    });
870    app.transcript.push(Entry::Error { text: status });
871}
872
873fn finish_chatgpt_oauth(
874    app: &mut App, credentials: &auth::ChatGptCodexCredentials, pending_provider_prompt: bool,
875    method: ChatGptOAuthMethod,
876) {
877    match (app.chatgpt_oauth_driver.write_credentials)(credentials) {
878        Ok(()) => {
879            app.chatgpt_browser_login = None;
880            let needs_model = app.model.trim().is_empty();
881            app.transcript.push(Entry::Status {
882                text: String::from("chatgpt-codex OAuth credential stored in global auth store"),
883            });
884            if needs_model {
885                app.first_run_recovery = Some(FirstRunRecovery {
886                    provider: Some(SetupProviderArg::ChatgptCodex),
887                    stage: RecoveryStage::ModelSelection,
888                    pending_provider_prompt,
889                    selected: 0,
890                    secret_input: String::new(),
891                    chatgpt_oauth: None,
892                });
893            } else {
894                app.first_run_recovery = None;
895            }
896        }
897        Err(err) => set_chatgpt_oauth_failure(
898            app,
899            method,
900            pending_provider_prompt,
901            format!(
902                "ChatGPT OAuth credential write failed: {}",
903                redact_auth_error(&err.to_string())
904            ),
905        ),
906    }
907}
908
909fn complete_chatgpt_oauth_redirect(app: &mut App, redirect: &str) {
910    let pending_provider_prompt = app
911        .first_run_recovery
912        .as_ref()
913        .is_some_and(|recovery| recovery.pending_provider_prompt);
914    let result = app
915        .chatgpt_browser_login
916        .as_ref()
917        .ok_or_else(|| auth::AuthError::ChatGptCodex("browser OAuth session is no longer active".to_string()))
918        .and_then(|login| (app.chatgpt_oauth_driver.complete_browser_redirect)(login, redirect));
919    match result {
920        Ok(credentials) => {
921            finish_chatgpt_oauth(app, &credentials, pending_provider_prompt, ChatGptOAuthMethod::Browser)
922        }
923        Err(err) => {
924            let status = format!("ChatGPT redirect was rejected: {}", redact_auth_error(&err.to_string()));
925            if let Some(recovery) = app.first_run_recovery.as_mut() {
926                recovery.stage = RecoveryStage::ChatGptOAuthPolling;
927                recovery.selected = 0;
928                if let Some(oauth) = recovery.chatgpt_oauth.as_mut() {
929                    oauth.status = status.clone();
930                }
931            }
932            app.transcript.push(Entry::Error { text: status });
933        }
934    }
935}
936
937pub fn poll_chatgpt_oauth_on_tick(app: &mut App) {
938    let tick_ms = app.cli.tick_rate_ms.max(1);
939    let Some(recovery) = app.first_run_recovery.as_ref() else {
940        return;
941    };
942    if recovery.stage != RecoveryStage::ChatGptOAuthPolling {
943        return;
944    }
945    let Some(oauth) = recovery.chatgpt_oauth.as_ref() else {
946        return;
947    };
948    let method = oauth.method;
949    let pending_provider_prompt = recovery.pending_provider_prompt;
950    let expires_at_tick = oauth.expires_at_tick;
951    let next_poll_tick = oauth.next_poll_tick;
952    if super::agent_lifecycle::now_or_after_deadline(app.ui_tick, expires_at_tick) {
953        set_chatgpt_oauth_failure(
954            app,
955            method,
956            pending_provider_prompt,
957            match method {
958                ChatGptOAuthMethod::Browser => String::from("ChatGPT browser OAuth callback expired."),
959                ChatGptOAuthMethod::DeviceCode => String::from("ChatGPT device-code login expired."),
960            },
961        );
962        return;
963    }
964    if method == ChatGptOAuthMethod::DeviceCode
965        && !super::agent_lifecycle::now_or_after_deadline(app.ui_tick, next_poll_tick)
966    {
967        return;
968    }
969
970    match method {
971        ChatGptOAuthMethod::Browser => {
972            let result = app
973                .chatgpt_browser_login
974                .as_mut()
975                .ok_or_else(|| auth::AuthError::ChatGptCodex("browser OAuth session is no longer active".to_string()))
976                .and_then(|login| (app.chatgpt_oauth_driver.poll_browser_login)(login));
977            match result {
978                Ok(auth::ChatGptCodexBrowserPoll::Pending) => {}
979                Ok(auth::ChatGptCodexBrowserPoll::Authorized(credentials)) => {
980                    finish_chatgpt_oauth(app, &credentials, pending_provider_prompt, method);
981                }
982                Err(err) => set_chatgpt_oauth_failure(
983                    app,
984                    method,
985                    pending_provider_prompt,
986                    format!("ChatGPT browser OAuth failed: {}", redact_auth_error(&err.to_string())),
987                ),
988            }
989        }
990        ChatGptOAuthMethod::DeviceCode => {
991            let Some(code) = app
992                .first_run_recovery
993                .as_ref()
994                .and_then(|recovery| recovery.chatgpt_oauth.as_ref())
995                .and_then(|oauth| oauth.code.as_ref())
996                .cloned()
997            else {
998                set_chatgpt_oauth_failure(
999                    app,
1000                    method,
1001                    pending_provider_prompt,
1002                    String::from("ChatGPT device-code state is unavailable."),
1003                );
1004                return;
1005            };
1006            match (app.chatgpt_oauth_driver.poll_device_code_once)(&code) {
1007                Ok(auth::ChatGptCodexDevicePoll::Pending) => {
1008                    if let Some(oauth) = app
1009                        .first_run_recovery
1010                        .as_mut()
1011                        .and_then(|recovery| recovery.chatgpt_oauth.as_mut())
1012                    {
1013                        oauth.status = String::from("Waiting for ChatGPT authorization.");
1014                        oauth.next_poll_tick = app
1015                            .ui_tick
1016                            .wrapping_add(seconds_to_ticks_for_ms(tick_ms, code.interval.unwrap_or(5).max(1)));
1017                    }
1018                }
1019                Ok(auth::ChatGptCodexDevicePoll::SlowDown) => {
1020                    if let Some(oauth) = app
1021                        .first_run_recovery
1022                        .as_mut()
1023                        .and_then(|recovery| recovery.chatgpt_oauth.as_mut())
1024                    {
1025                        oauth.status = String::from("ChatGPT asked the client to slow down; waiting.");
1026                        oauth.next_poll_tick = app.ui_tick.wrapping_add(seconds_to_ticks_for_ms(
1027                            tick_ms,
1028                            code.interval.unwrap_or(5).max(1).saturating_add(5),
1029                        ));
1030                    }
1031                }
1032                Ok(auth::ChatGptCodexDevicePoll::Authorized(credentials)) => {
1033                    finish_chatgpt_oauth(app, &credentials, pending_provider_prompt, method);
1034                }
1035                Err(err) => set_chatgpt_oauth_failure(
1036                    app,
1037                    method,
1038                    pending_provider_prompt,
1039                    format!(
1040                        "ChatGPT device-code polling failed: {}",
1041                        redact_auth_error(&err.to_string())
1042                    ),
1043                ),
1044            }
1045        }
1046    }
1047}
1048
1049pub fn seconds_to_ticks(app: &App, seconds: u64) -> u64 {
1050    seconds_to_ticks_for_ms(app.cli.tick_rate_ms.max(1), seconds)
1051}
1052
1053pub fn seconds_to_ticks_for_ms(tick_ms: u64, seconds: u64) -> u64 {
1054    seconds.saturating_mul(1000).div_ceil(tick_ms).max(1)
1055}
1056
1057pub fn redact_auth_error(message: &str) -> String {
1058    let mut redacted = Vec::new();
1059    for part in message.split_whitespace() {
1060        if part.len() >= 24
1061            || part.contains("access_token")
1062            || part.contains("refresh_token")
1063            || part.contains("device_auth_id")
1064            || part.contains("device_code")
1065        {
1066            redacted.push("[redacted]");
1067        } else {
1068            redacted.push(part);
1069        }
1070    }
1071    redacted.join(" ")
1072}
1073
1074pub fn selected_scope(selected: usize) -> Option<CredentialScope> {
1075    match selected {
1076        0 => Some(CredentialScope::Global),
1077        1 => Some(CredentialScope::Project),
1078        _ => None,
1079    }
1080}
1081
1082pub fn store_recovery_credential(app: &mut App, recovery: &FirstRunRecovery) {
1083    let Some(provider) = recovery.provider else {
1084        app.first_run_recovery = None;
1085        return;
1086    };
1087    let Some(scope) = selected_scope(recovery.selected) else {
1088        app.first_run_recovery = Some(FirstRunRecovery::missing_provider(
1089            provider,
1090            recovery.pending_provider_prompt,
1091        ));
1092        return;
1093    };
1094
1095    let key = recovery.secret_input.trim();
1096    let path = match crate::cli::commands::auth::credential_path(scope, &app.cwd) {
1097        Ok(path) => path,
1098        Err(err) => {
1099            app.transcript
1100                .push(Entry::Error { text: format!("credential store unavailable: {err}") });
1101            return;
1102        }
1103    };
1104
1105    let Some(env_var) = provider.api_key_env_var() else {
1106        app.first_run_recovery = Some(FirstRunRecovery::missing_provider(
1107            provider,
1108            recovery.pending_provider_prompt,
1109        ));
1110        app.transcript
1111            .push(Entry::Error { text: String::from("ChatGPT Codex uses OAuth login, not API-key storage") });
1112        return;
1113    };
1114    match auth::set_credential(&path, env_var, key) {
1115        Ok(()) => {
1116            if scope == CredentialScope::Project
1117                && let Err(err) = auth::ensure_git_exclude(&app.cwd)
1118            {
1119                app.transcript
1120                    .push(Entry::Error { text: format!("git exclude update failed: {err}") });
1121            }
1122            app.transcript
1123                .push(Entry::Status { text: format!("{} credential stored in {}", provider.label(), scope.label()) });
1124            if app.model.trim().is_empty() {
1125                app.first_run_recovery = Some(FirstRunRecovery {
1126                    provider: Some(provider),
1127                    stage: RecoveryStage::ModelSelection,
1128                    pending_provider_prompt: recovery.pending_provider_prompt,
1129                    selected: 0,
1130                    secret_input: String::new(),
1131                    chatgpt_oauth: None,
1132                });
1133            } else {
1134                app.first_run_recovery = None;
1135            }
1136        }
1137        Err(err) => app
1138            .transcript
1139            .push(Entry::Error { text: format!("credential write failed: {err}") }),
1140    }
1141}
1142
1143pub fn remove_recovery_credential(app: &mut App, recovery: &FirstRunRecovery) {
1144    let Some(provider) = recovery.provider else {
1145        app.first_run_recovery = None;
1146        return;
1147    };
1148    let Some(scope) = selected_scope(recovery.selected) else {
1149        app.first_run_recovery = None;
1150        app.transcript
1151            .push(Entry::Status { text: String::from("logout cancelled") });
1152        return;
1153    };
1154    let path = match crate::cli::commands::auth::credential_path(scope, &app.cwd) {
1155        Ok(path) => path,
1156        Err(err) => {
1157            app.transcript
1158                .push(Entry::Error { text: format!("credential store unavailable: {err}") });
1159            return;
1160        }
1161    };
1162    let Some(env_var) = provider.api_key_env_var() else {
1163        app.first_run_recovery = None;
1164        app.transcript
1165            .push(Entry::Error { text: String::from("ChatGPT Codex credentials are stored in ~/.thndrs/auth.json") });
1166        return;
1167    };
1168    match auth::remove_credential(&path, env_var) {
1169        Ok(()) => {
1170            app.first_run_recovery = None;
1171            app.transcript.push(Entry::Status {
1172                text: format!("{} credential removed from {}", provider.label(), scope.label()),
1173            });
1174        }
1175        Err(err) => app
1176            .transcript
1177            .push(Entry::Error { text: format!("credential remove failed: {err}") }),
1178    }
1179}
1180
1181fn select_setup_model(app: &mut App, recovery: &FirstRunRecovery) {
1182    let Some(provider) = recovery.provider else {
1183        app.first_run_recovery = Some(FirstRunRecovery::setup(SetupProviderArg::ChatgptCodex));
1184        return;
1185    };
1186    let options = setup_model_options(provider);
1187    let Some(model) = options.get(recovery.selected).map(|item| item.label.clone()) else {
1188        return;
1189    };
1190    app.model = model.clone();
1191    app.cli.model = model.clone();
1192    app.transcript
1193        .push(Entry::Status { text: format!("model selected: {model}") });
1194    app.first_run_recovery = Some(FirstRunRecovery {
1195        provider: Some(provider),
1196        stage: RecoveryStage::ModelConfigScope,
1197        pending_provider_prompt: recovery.pending_provider_prompt,
1198        selected: 0,
1199        secret_input: String::new(),
1200        chatgpt_oauth: None,
1201    });
1202}
1203
1204fn first_run_provider(selected: usize) -> Option<SetupProviderArg> {
1205    match selected {
1206        0 => Some(SetupProviderArg::ChatgptCodex),
1207        1 => Some(SetupProviderArg::Umans),
1208        _ => None,
1209    }
1210}