Skip to main content

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;