drep/cli/init/wizard.rs
1//! The interactive half of `drep init`.
2//!
3//! `drep init --provider kimi` is the scripted path and stays exactly as it
4//! was. This is what runs when nobody passed `--provider` and there is a person
5//! at the other end: pick a provider, paste a key, add a fallback, choose the
6//! hooks, decide whether the config is committed.
7//!
8//! ## Everything goes through [`Console`]
9//!
10//! The wizard never touches stdin or stdout directly. That is what makes it
11//! testable without a terminal - the tests drive it with a scripted queue of
12//! answers and read back everything it said - and it is the same reason
13//! `init::run_to` writes to a `&mut dyn Write` rather than to stdout.
14//!
15//! It also keeps the one genuinely awkward operation, reading a key without
16//! echoing it, behind a single method. `rpassword` is used for that in
17//! production and nothing in this file knows about it.
18//!
19//! ## The wizard decides, it does not act
20//!
21//! [`run`] returns a [`Plan`]. It writes no file, stores no key and installs no
22//! hook. `init` applies the plan afterwards, in the same order and through the
23//! same functions the flag path uses, so an answer given interactively cannot
24//! reach a different code path than the equivalent flag.
25
26use anyhow::{Result, anyhow};
27
28use super::config_file::Choice;
29use super::hooks::HookKind;
30use super::presets::{self, LlmPreset};
31use crate::auth::AuthStore;
32use crate::llm::models::{Model, ModelSource};
33use crate::llm::quirks::{self, QuirksSource, Registry};
34
35/// Whether an environment variable is set, in the real process.
36///
37/// The production answer for `run`'s `env_is_set`. Injected rather than read
38/// inline so the wizard's tests need no `std::env::set_var`, which is `unsafe`
39/// in edition 2024 because a concurrent reader on another thread is a data race
40/// - and `cargo test` is multi-threaded.
41pub fn real_env(name: &str) -> bool {
42 std::env::var_os(name).is_some()
43}
44
45/// What the wizard decided, for `init` to carry out.
46#[derive(Clone)]
47pub struct Plan {
48 /// The failover chain, head first.
49 pub choices: Vec<Choice>,
50 /// `(endpoint, key)` pairs to write to the auth store.
51 ///
52 /// Separate from `choices` because storing a key is a side effect on the
53 /// machine rather than on the repository, and the two are applied by
54 /// different code with different failure modes.
55 pub new_keys: Vec<(String, String)>,
56 /// Which git hooks to install.
57 pub hooks: HookKind,
58 /// Whether to add `drep.toml` to `.gitignore`.
59 pub gitignore: bool,
60}
61
62/// Run the wizard against `console`, consulting `store` for keys already held,
63/// `source` for what each endpoint actually serves and `quirks_source` for what
64/// the chosen model accepts.
65pub async fn run<S: ModelSource, Q: QuirksSource>(
66 console: &mut dyn Console,
67 deps: Deps<'_, S, Q>,
68) -> Result<Plan> {
69 console.say("Setting up drep. Enter accepts the value in brackets.")?;
70
71 // Fetched on first use, not here. Every provider needs it and it is one
72 // document rather than one per endpoint, so it is fetched at most once - but
73 // doing it before the first prompt meant an offline `drep init` sat through
74 // the whole timeout and then opened with an error about a service the user
75 // may never reach. Deferring it puts any wait after the questions the user
76 // came to answer, and a warm cache makes it invisible either way.
77 let mut registry = LazyRegistry::new();
78 let mut codex_status: Option<Result<crate::llm::codex::CodexStatus, String>> = None;
79 console.say("")?;
80
81 let mut choices = Vec::new();
82 let mut new_keys: Vec<(String, String)> = Vec::new();
83
84 loop {
85 let position = choices.len() + 1;
86 let (choice, key) = one_provider(
87 console,
88 &deps,
89 &mut registry,
90 &mut codex_status,
91 &new_keys,
92 position,
93 )
94 .await?;
95 if let Some(pair) = key {
96 new_keys.push(pair);
97 }
98 choices.push(choice);
99
100 console.say("")?;
101 if !confirm(console, "Add a fallback provider?", false)? {
102 break;
103 }
104 console.say("")?;
105 }
106
107 console.say("")?;
108 let hooks = ask_hooks(console)?;
109 console.say("")?;
110 let gitignore = confirm(
111 console,
112 "Add drep.toml to .gitignore? (it holds no secrets, so committing it \
113 shares your provider choice with the repo)",
114 true,
115 )?;
116
117 Ok(Plan {
118 choices,
119 new_keys,
120 hooks,
121 gitignore,
122 })
123}
124
125/// Hand-written so a pasted key cannot reach a log.
126///
127/// `new_keys` holds credentials, so a derived `Debug` would print every one of
128/// them from any `{:?}`, `dbg!`, or `expect` message that touched a `Plan`. The
129/// same reason `LlmConfig`, `LlmClient` and `AuthStore` all write theirs.
130impl std::fmt::Debug for Plan {
131 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
132 f.debug_struct("Plan")
133 .field("choices", &self.choices)
134 .field(
135 "new_keys",
136 &self
137 .new_keys
138 .iter()
139 .map(|(endpoint, _)| endpoint.as_str())
140 .collect::<Vec<_>>(),
141 )
142 .field("hooks", &self.hooks)
143 .field("gitignore", &self.gitignore)
144 .finish()
145 }
146}
147
148/// What the wizard needs from the outside world.
149///
150/// Bundled rather than threaded one by one: every field here exists so a test
151/// can supply a stand-in - the model listing, the quirks registry, and whether
152/// an environment variable is set are the three things that would otherwise
153/// reach the network or the process environment from inside a unit test.
154///
155/// The bounds sit on the functions that use the fields rather than on the
156/// struct: a bound on a data declaration has to be repeated at every mention
157/// of the type without constraining anything the fields do.
158pub struct Deps<'a, S, Q> {
159 /// Keys already held for this machine.
160 pub store: &'a AuthStore,
161 /// What models an endpoint serves.
162 pub source: &'a S,
163 /// What quirks a chosen model has.
164 pub quirks_source: &'a Q,
165 /// Whether an environment variable is set.
166 pub env_is_set: &'a dyn Fn(&str) -> bool,
167 /// Whether the Codex CLI has usable ChatGPT-managed authentication.
168 pub(crate) codex_status: &'a dyn Fn() -> Result<crate::llm::codex::CodexStatus, String>,
169}
170
171/// The registry, fetched at most once and only when something needs it.
172///
173/// `None` after a failed attempt is remembered, so a provider chain does not
174/// retry a fetch that already failed once per entry - and does not report the
175/// same failure repeatedly.
176struct LazyRegistry {
177 fetched: bool,
178 registry: Option<Registry>,
179}
180
181impl LazyRegistry {
182 fn new() -> Self {
183 Self {
184 fetched: false,
185 registry: None,
186 }
187 }
188
189 /// The registry, fetching it the first time and reporting a failure once.
190 async fn get<Q: QuirksSource>(
191 &mut self,
192 console: &mut dyn Console,
193 source: &Q,
194 ) -> Option<&Registry> {
195 if !self.fetched {
196 self.fetched = true;
197 match source.registry().await {
198 Ok(registry) => self.registry = Some(registry),
199 Err(err) => {
200 // Said, never returned: nothing about model quirks may stop
201 // `drep init`.
202 let _ = console.say(&format!(" Could not check model quirks: {err}"));
203 let _ = console.say(" Falling back to this provider's own defaults.");
204 }
205 }
206 }
207 self.registry.as_ref()
208 }
209}
210
211/// Ask for one provider: which, where, which model, and its key.
212///
213/// Returns the choice and, when the user pasted one, the key to store.
214async fn one_provider<S: ModelSource, Q: QuirksSource>(
215 console: &mut dyn Console,
216 deps: &Deps<'_, S, Q>,
217 registry: &mut LazyRegistry,
218 codex_status: &mut Option<Result<crate::llm::codex::CodexStatus, String>>,
219 pending: &[(String, String)],
220 position: usize,
221) -> Result<(Choice, Option<(String, String)>)> {
222 let preset = ask_provider(console, position)?;
223
224 if matches!(preset.backend, presets::PresetBackend::Codex(_)) {
225 let status = codex_status
226 .get_or_insert_with(|| (deps.codex_status)())
227 .as_ref()
228 .map_err(|err| anyhow!(err.clone()))?;
229 console.say(&format!(
230 " Codex CLI {} is authenticated through ChatGPT.",
231 status.cli_version()
232 ))?;
233 let model = ask_required(console, "Model", preset.default_model)?;
234 return Ok((Choice::codex(preset, model), None));
235 }
236
237 let endpoint = ask_required(console, "Endpoint", preset.endpoint())?;
238
239 // The key is settled *before* the model, which is the whole reason the
240 // endpoint can be asked what it serves: a listing needs authenticating.
241 let key = ask_key(
242 console,
243 deps.store,
244 deps.env_is_set,
245 pending,
246 preset,
247 &endpoint,
248 )?;
249 let model = ask_model(
250 console,
251 deps.source,
252 preset,
253 &endpoint,
254 key.usable.as_deref(),
255 )
256 .await?;
257
258 // The chosen model, not the preset, decides `temperature` and `max_tokens`.
259 // Nothing here can fail: an absent registry, or one that does not name this
260 // model, yields the preset's values unchanged.
261 let quirks = quirks::resolve(
262 registry.get(console, deps.quirks_source).await,
263 preset.quirks(),
264 &endpoint,
265 &model,
266 );
267 let endpoint_for_store = endpoint.clone();
268
269 Ok((
270 Choice::http(preset, model, endpoint, key.in_store, quirks),
271 key.to_store.map(|stored| (endpoint_for_store, stored)),
272 ))
273}
274
275/// Offer the models the endpoint actually serves, falling back to typing a name.
276///
277/// The fallback is not an edge case: a local llama.cpp build, a gateway, or any
278/// endpoint older than its vendor's listing route will land there, and setup has
279/// to continue exactly as it did before. Every failure is reported and stepped
280/// past, never returned.
281async fn ask_model<S: ModelSource>(
282 console: &mut dyn Console,
283 source: &S,
284 preset: &LlmPreset,
285 endpoint: &str,
286 key: Option<&str>,
287) -> Result<String> {
288 match source
289 .list(endpoint, key.unwrap_or(""), preset.protocol())
290 .await
291 {
292 Ok(models) => choose_model(console, &models, preset.default_model),
293 Err(err) => {
294 console.say(&format!(" Could not list models: {err}"))?;
295 ask_required(console, "Model", preset.default_model)
296 }
297 }
298}
299
300/// Pick from a listing, or type a name that is not in it.
301///
302/// A name outside the list is accepted rather than rejected: a model released
303/// this morning is exactly the one somebody is trying to configure, and a menu
304/// that refused it would be worse than the free-text prompt it replaced.
305fn choose_model(
306 console: &mut dyn Console,
307 models: &[Model],
308 preferred: Option<&str>,
309) -> Result<String> {
310 console.say(" This endpoint serves:")?;
311 for (index, model) in models.iter().enumerate() {
312 console.say(&format!(" {}. {}", index + 1, model.label()))?;
313 }
314
315 // The preset's default, if the endpoint still serves it. When it does not,
316 // saying so is the signal that the shipped default has gone stale - which
317 // is the failure this whole listing exists to remove.
318 let default = preferred.and_then(|id| models.iter().position(|model| model.id == id));
319 if default.is_none()
320 && let Some(id) = preferred
321 {
322 console.say(&format!(
323 " (drep's usual default `{id}` is not in this list.)"
324 ))?;
325 }
326 let default = default.map(|index| (index + 1).to_string());
327
328 loop {
329 let answer = console.ask(" Number or model name", default.as_deref())?;
330 let answer = answer.trim();
331
332 if answer.is_empty() {
333 console.say(" Pick a number, or type a model name.")?;
334 continue;
335 }
336 // Only a bare integer is a selection. Anything else is a name - which
337 // is how a model too new to be listed still gets configured.
338 match answer.parse::<usize>() {
339 Ok(number) if (1..=models.len()).contains(&number) => {
340 return Ok(models[number - 1].id.clone());
341 }
342 Ok(_) => console.say(&format!(" Enter a number from 1 to {}.", models.len()))?,
343 Err(_) => return Ok(answer.to_string()),
344 }
345 }
346}
347
348/// Offer the preset table and read a selection.
349fn ask_provider(console: &mut dyn Console, position: usize) -> Result<&'static LlmPreset> {
350 let label = match position {
351 1 => "Which provider?".to_string(),
352 n => format!("Which provider for fallback #{}?", n - 1),
353 };
354 console.say(&label)?;
355
356 let presets = presets::PRESETS;
357 for (index, preset) in presets.iter().enumerate() {
358 console.say(&format!(
359 " {}. {} - {}",
360 index + 1,
361 preset.display_name,
362 preset.description
363 ))?;
364 }
365
366 loop {
367 let answer = console.ask("Number", Some("1"))?;
368 match answer.trim().parse::<usize>() {
369 Ok(n) if (1..=presets.len()).contains(&n) => return Ok(presets[n - 1]),
370 _ => console.say(&format!("Enter a number from 1 to {}.", presets.len()))?,
371 }
372 }
373}
374
375/// Ask until a non-empty answer arrives, offering `default` if there is one.
376///
377/// A preset with no default (`custom`) has no value to fall back on, so an
378/// empty answer has to be re-asked rather than accepted - writing an empty
379/// endpoint would produce a config `config::load` rejects, reported as a
380/// success by the command that wrote it.
381fn ask_required(console: &mut dyn Console, label: &str, default: Option<&str>) -> Result<String> {
382 loop {
383 let answer = console.ask(label, default)?;
384 let answer = answer.trim();
385 if !answer.is_empty() {
386 return Ok(answer.to_string());
387 }
388 console.say(&format!("{label} cannot be empty."))?;
389 }
390}
391
392/// Resolve this provider's key: reuse a stored one, paste a new one, or fall
393/// back to the environment variable.
394///
395/// Returns whether the rendered block should omit `api_key`, and the pair to
396/// store when one was pasted.
397fn ask_key(
398 console: &mut dyn Console,
399 store: &AuthStore,
400 env_is_set: &dyn Fn(&str) -> bool,
401 pending: &[(String, String)],
402 preset: &LlmPreset,
403 endpoint: &str,
404) -> Result<KeyChoice> {
405 // A preset that needs no key at all - a local server - has nothing to ask.
406 // `usable` stays `None`, and the listing is attempted unauthenticated,
407 // which is what such a server expects.
408 let Some(env) = preset.api_key_env() else {
409 return Ok(KeyChoice::none());
410 };
411
412 // Already held, either on disk or pasted earlier in this same run for a
413 // provider sharing the endpoint. Asking again would invite the user to
414 // overwrite a working key with a typo.
415 let held = store.get(endpoint).map(str::to_string).or_else(|| {
416 pending
417 .iter()
418 .find(|(stored, _)| crate::auth::normalise(stored) == crate::auth::normalise(endpoint))
419 .map(|(_, key)| key.clone())
420 });
421 if let Some(existing) = held {
422 console.say(" A key is already stored for this endpoint; reusing it.")?;
423 console.say(" (`drep auth login` replaces it, without touching drep.toml.)")?;
424 return Ok(KeyChoice {
425 in_store: true,
426 to_store: None,
427 usable: Some(existing),
428 });
429 }
430
431 if let Some(url) = preset.key_url() {
432 console.say(&format!(" Get a key: {url}"))?;
433 }
434
435 // Reported because it changes what the empty answer means: with the
436 // variable already exported, skipping is a complete setup rather than a
437 // deferred one.
438 if env_is_set(env) {
439 console.say(&format!(" {env} is already set in this shell."))?;
440 }
441
442 let key = console.ask_secret(&format!(
443 " Paste your API key (or Enter to use ${{{env}}} instead)"
444 ))?;
445
446 if key.trim().is_empty() {
447 console.say(&format!(
448 " No key stored. drep will read {env} from the environment."
449 ))?;
450 // The exported value, when there is one, still authenticates the model
451 // listing - the user skipped storing a key, not using one.
452 return Ok(KeyChoice {
453 in_store: false,
454 to_store: None,
455 // Read directly rather than through `env_is_set`, which answers
456 // only whether a value exists. A test injecting a stub reports no
457 // usable key here, which is what it should: there is no value to
458 // authenticate a listing with.
459 usable: std::env::var(env).ok(),
460 });
461 }
462
463 console.say(" Key stored for this machine, not in drep.toml.")?;
464 let key = key.trim().to_string();
465 Ok(KeyChoice {
466 in_store: true,
467 to_store: Some(key.clone()),
468 usable: Some(key),
469 })
470}
471
472/// What asking about a key settled.
473struct KeyChoice {
474 /// Whether the rendered block should omit `api_key`.
475 in_store: bool,
476 /// The key to write to the auth store, when one was pasted. The endpoint it
477 /// belongs to is the one the caller already passed in.
478 to_store: Option<String>,
479 /// A key that can authenticate the model listing, from wherever it came.
480 usable: Option<String>,
481}
482
483impl KeyChoice {
484 /// No key, and none needed.
485 fn none() -> Self {
486 Self {
487 in_store: false,
488 to_store: None,
489 usable: None,
490 }
491 }
492}
493
494/// Ask which hooks to install.
495fn ask_hooks(console: &mut dyn Console) -> Result<HookKind> {
496 console.say("Which git hooks?")?;
497 console.say(" 1. pre-push - review what you are about to push (recommended)")?;
498 console.say(" 2. pre-commit - review every commit; slower, and costs per commit")?;
499 console.say(" 3. both")?;
500 console.say(" 4. none - write the config only")?;
501
502 loop {
503 // No empty arm: both `Console` implementations substitute the default
504 // for an empty answer, which is what `ask_provider` relies on too.
505 match console.ask("Number", Some("1"))?.trim() {
506 "1" => return Ok(HookKind::PrePush),
507 "2" => return Ok(HookKind::PreCommit),
508 "3" => return Ok(HookKind::Both),
509 "4" => return Ok(HookKind::None),
510 _ => console.say("Enter a number from 1 to 4.")?,
511 }
512 }
513}
514
515/// Ask a yes/no question. `default_yes` decides what Enter means.
516///
517/// Public because `init` asks one of its own before the wizard starts: whether
518/// to replace an existing config. Sharing it keeps the two prompts answering to
519/// the same vocabulary.
520pub fn confirm(console: &mut dyn Console, question: &str, default_yes: bool) -> Result<bool> {
521 let hint = if default_yes { "Y/n" } else { "y/N" };
522 loop {
523 let answer = console.ask(&format!("{question} [{hint}]"), None)?;
524 match answer.trim().to_ascii_lowercase().as_str() {
525 "" => return Ok(default_yes),
526 "y" | "yes" => return Ok(true),
527 "n" | "no" => return Ok(false),
528 _ => console.say("Enter y or n.")?,
529 }
530 }
531}
532
533mod console;
534pub use console::{Console, Terminal};
535
536#[cfg(test)]
537pub(crate) mod tests;