use anyhow::{Result, anyhow};
use super::config_file::Choice;
use super::hooks::HookKind;
use super::presets::{self, LlmPreset};
use crate::auth::AuthStore;
use crate::llm::models::{Model, ModelSource};
use crate::llm::quirks::{self, QuirksSource, Registry};
pub fn real_env(name: &str) -> bool {
std::env::var_os(name).is_some()
}
#[derive(Clone)]
pub struct Plan {
pub choices: Vec<Choice>,
pub new_keys: Vec<(String, String)>,
pub hooks: HookKind,
pub gitignore: bool,
}
pub async fn run<S: ModelSource, Q: QuirksSource>(
console: &mut dyn Console,
deps: Deps<'_, S, Q>,
) -> Result<Plan> {
console.say("Setting up drep. Enter accepts the value in brackets.")?;
let mut registry = LazyRegistry::new();
let mut codex_status: Option<Result<crate::llm::codex::CodexStatus, String>> = None;
console.say("")?;
let mut choices = Vec::new();
let mut new_keys: Vec<(String, String)> = Vec::new();
loop {
let position = choices.len() + 1;
let (choice, key) = one_provider(
console,
&deps,
&mut registry,
&mut codex_status,
&new_keys,
position,
)
.await?;
if let Some(pair) = key {
new_keys.push(pair);
}
choices.push(choice);
console.say("")?;
if !confirm(console, "Add a fallback provider?", false)? {
break;
}
console.say("")?;
}
console.say("")?;
let hooks = ask_hooks(console)?;
console.say("")?;
let gitignore = confirm(
console,
"Add drep.toml to .gitignore? (it holds no secrets, so committing it \
shares your provider choice with the repo)",
true,
)?;
Ok(Plan {
choices,
new_keys,
hooks,
gitignore,
})
}
impl std::fmt::Debug for Plan {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Plan")
.field("choices", &self.choices)
.field(
"new_keys",
&self
.new_keys
.iter()
.map(|(endpoint, _)| endpoint.as_str())
.collect::<Vec<_>>(),
)
.field("hooks", &self.hooks)
.field("gitignore", &self.gitignore)
.finish()
}
}
pub struct Deps<'a, S, Q> {
pub store: &'a AuthStore,
pub source: &'a S,
pub quirks_source: &'a Q,
pub env_is_set: &'a dyn Fn(&str) -> bool,
pub(crate) codex_status: &'a dyn Fn() -> Result<crate::llm::codex::CodexStatus, String>,
}
struct LazyRegistry {
fetched: bool,
registry: Option<Registry>,
}
impl LazyRegistry {
fn new() -> Self {
Self {
fetched: false,
registry: None,
}
}
async fn get<Q: QuirksSource>(
&mut self,
console: &mut dyn Console,
source: &Q,
) -> Option<&Registry> {
if !self.fetched {
self.fetched = true;
match source.registry().await {
Ok(registry) => self.registry = Some(registry),
Err(err) => {
let _ = console.say(&format!(" Could not check model quirks: {err}"));
let _ = console.say(" Falling back to this provider's own defaults.");
}
}
}
self.registry.as_ref()
}
}
async fn one_provider<S: ModelSource, Q: QuirksSource>(
console: &mut dyn Console,
deps: &Deps<'_, S, Q>,
registry: &mut LazyRegistry,
codex_status: &mut Option<Result<crate::llm::codex::CodexStatus, String>>,
pending: &[(String, String)],
position: usize,
) -> Result<(Choice, Option<(String, String)>)> {
let preset = ask_provider(console, position)?;
if matches!(preset.backend, presets::PresetBackend::Codex(_)) {
let status = codex_status
.get_or_insert_with(|| (deps.codex_status)())
.as_ref()
.map_err(|err| anyhow!(err.clone()))?;
console.say(&format!(
" Codex CLI {} is authenticated through ChatGPT.",
status.cli_version()
))?;
let model = ask_required(console, "Model", preset.default_model)?;
return Ok((Choice::codex(preset, model), None));
}
let endpoint = ask_required(console, "Endpoint", preset.endpoint())?;
let key = ask_key(
console,
deps.store,
deps.env_is_set,
pending,
preset,
&endpoint,
)?;
let model = ask_model(
console,
deps.source,
preset,
&endpoint,
key.usable.as_deref(),
)
.await?;
let quirks = quirks::resolve(
registry.get(console, deps.quirks_source).await,
preset.quirks(),
&endpoint,
&model,
);
let endpoint_for_store = endpoint.clone();
Ok((
Choice::http(preset, model, endpoint, key.in_store, quirks),
key.to_store.map(|stored| (endpoint_for_store, stored)),
))
}
async fn ask_model<S: ModelSource>(
console: &mut dyn Console,
source: &S,
preset: &LlmPreset,
endpoint: &str,
key: Option<&str>,
) -> Result<String> {
match source
.list(endpoint, key.unwrap_or(""), preset.protocol())
.await
{
Ok(models) => choose_model(console, &models, preset.default_model),
Err(err) => {
console.say(&format!(" Could not list models: {err}"))?;
ask_required(console, "Model", preset.default_model)
}
}
}
fn choose_model(
console: &mut dyn Console,
models: &[Model],
preferred: Option<&str>,
) -> Result<String> {
console.say(" This endpoint serves:")?;
for (index, model) in models.iter().enumerate() {
console.say(&format!(" {}. {}", index + 1, model.label()))?;
}
let default = preferred.and_then(|id| models.iter().position(|model| model.id == id));
if default.is_none()
&& let Some(id) = preferred
{
console.say(&format!(
" (drep's usual default `{id}` is not in this list.)"
))?;
}
let default = default.map(|index| (index + 1).to_string());
loop {
let answer = console.ask(" Number or model name", default.as_deref())?;
let answer = answer.trim();
if answer.is_empty() {
console.say(" Pick a number, or type a model name.")?;
continue;
}
match answer.parse::<usize>() {
Ok(number) if (1..=models.len()).contains(&number) => {
return Ok(models[number - 1].id.clone());
}
Ok(_) => console.say(&format!(" Enter a number from 1 to {}.", models.len()))?,
Err(_) => return Ok(answer.to_string()),
}
}
}
fn ask_provider(console: &mut dyn Console, position: usize) -> Result<&'static LlmPreset> {
let label = match position {
1 => "Which provider?".to_string(),
n => format!("Which provider for fallback #{}?", n - 1),
};
console.say(&label)?;
let presets = presets::PRESETS;
for (index, preset) in presets.iter().enumerate() {
console.say(&format!(
" {}. {} - {}",
index + 1,
preset.display_name,
preset.description
))?;
}
loop {
let answer = console.ask("Number", Some("1"))?;
match answer.trim().parse::<usize>() {
Ok(n) if (1..=presets.len()).contains(&n) => return Ok(presets[n - 1]),
_ => console.say(&format!("Enter a number from 1 to {}.", presets.len()))?,
}
}
}
fn ask_required(console: &mut dyn Console, label: &str, default: Option<&str>) -> Result<String> {
loop {
let answer = console.ask(label, default)?;
let answer = answer.trim();
if !answer.is_empty() {
return Ok(answer.to_string());
}
console.say(&format!("{label} cannot be empty."))?;
}
}
fn ask_key(
console: &mut dyn Console,
store: &AuthStore,
env_is_set: &dyn Fn(&str) -> bool,
pending: &[(String, String)],
preset: &LlmPreset,
endpoint: &str,
) -> Result<KeyChoice> {
let Some(env) = preset.api_key_env() else {
return Ok(KeyChoice::none());
};
let held = store.get(endpoint).map(str::to_string).or_else(|| {
pending
.iter()
.find(|(stored, _)| crate::auth::normalise(stored) == crate::auth::normalise(endpoint))
.map(|(_, key)| key.clone())
});
if let Some(existing) = held {
console.say(" A key is already stored for this endpoint; reusing it.")?;
console.say(" (`drep auth login` replaces it, without touching drep.toml.)")?;
return Ok(KeyChoice {
in_store: true,
to_store: None,
usable: Some(existing),
});
}
if let Some(url) = preset.key_url() {
console.say(&format!(" Get a key: {url}"))?;
}
if env_is_set(env) {
console.say(&format!(" {env} is already set in this shell."))?;
}
let key = console.ask_secret(&format!(
" Paste your API key (or Enter to use ${{{env}}} instead)"
))?;
if key.trim().is_empty() {
console.say(&format!(
" No key stored. drep will read {env} from the environment."
))?;
return Ok(KeyChoice {
in_store: false,
to_store: None,
usable: std::env::var(env).ok(),
});
}
console.say(" Key stored for this machine, not in drep.toml.")?;
let key = key.trim().to_string();
Ok(KeyChoice {
in_store: true,
to_store: Some(key.clone()),
usable: Some(key),
})
}
struct KeyChoice {
in_store: bool,
to_store: Option<String>,
usable: Option<String>,
}
impl KeyChoice {
fn none() -> Self {
Self {
in_store: false,
to_store: None,
usable: None,
}
}
}
fn ask_hooks(console: &mut dyn Console) -> Result<HookKind> {
console.say("Which git hooks?")?;
console.say(" 1. pre-push - review what you are about to push (recommended)")?;
console.say(" 2. pre-commit - review every commit; slower, and costs per commit")?;
console.say(" 3. both")?;
console.say(" 4. none - write the config only")?;
loop {
match console.ask("Number", Some("1"))?.trim() {
"1" => return Ok(HookKind::PrePush),
"2" => return Ok(HookKind::PreCommit),
"3" => return Ok(HookKind::Both),
"4" => return Ok(HookKind::None),
_ => console.say("Enter a number from 1 to 4.")?,
}
}
}
pub fn confirm(console: &mut dyn Console, question: &str, default_yes: bool) -> Result<bool> {
let hint = if default_yes { "Y/n" } else { "y/N" };
loop {
let answer = console.ask(&format!("{question} [{hint}]"), None)?;
match answer.trim().to_ascii_lowercase().as_str() {
"" => return Ok(default_yes),
"y" | "yes" => return Ok(true),
"n" | "no" => return Ok(false),
_ => console.say("Enter y or n.")?,
}
}
}
mod console;
pub use console::{Console, Terminal};
#[cfg(test)]
pub(crate) mod tests;