1use 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#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
20#[value(rename_all = "kebab-case")]
21pub enum SetupProviderArg {
22 Umans,
24 OpencodeGo,
26 OpencodeZen,
28 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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
48pub enum ProviderAuthKind {
49 ApiKey { env_var: &'static str },
51 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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
76pub struct ProviderMetadata {
77 pub label: &'static str,
79 pub default_model: &'static str,
81 pub auth_kind: ProviderAuthKind,
83 pub setup_summary: &'static str,
85}
86
87impl SetupProviderArg {
88 pub const ALL: [Self; 4] = [Self::OpencodeZen, Self::ChatgptCodex, Self::Umans, Self::OpencodeGo];
90
91 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 pub fn label(self) -> &'static str {
123 self.metadata().label
124 }
125
126 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 pub fn default_model(self) -> &'static str {
136 self.metadata().default_model
137 }
138}
139
140#[derive(Clone, Debug, Eq, PartialEq, Args)]
142pub struct SetupCommand {
143 #[arg(long, value_enum)]
145 pub provider: Option<SetupProviderArg>,
146 #[arg(long, conflicts_with = "project")]
148 pub global: bool,
149 #[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
166pub 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}