use std::io::Write;
use std::path::{Path, PathBuf};
use anyhow::{Result, anyhow};
use clap::{Args, builder::TypedValueParser};
pub mod config_file;
pub mod gitignore;
pub mod hooks;
pub mod presets;
pub mod wizard;
use crate::Exit;
use crate::auth;
use crate::diff;
pub use hooks::HookKind;
#[cfg(test)]
mod tests;
#[derive(Debug, Args)]
pub struct InitArgs {
#[arg(long, value_name = "DIR", default_value = ".")]
pub path: PathBuf,
#[arg(long, value_parser = provider_parser())]
pub provider: Option<String>,
#[arg(long)]
pub model: Option<String>,
#[arg(long)]
pub endpoint: Option<String>,
#[arg(long, value_enum, default_value_t = HookKind::PrePush)]
pub hooks: HookKind,
#[arg(long)]
pub force: bool,
#[arg(long)]
pub no_gitignore: bool,
#[arg(long, conflicts_with = "interactive")]
pub non_interactive: bool,
#[arg(long)]
pub interactive: bool,
}
fn provider_parser() -> impl TypedValueParser<Value = String> {
use clap::builder::PossibleValuesParser;
PossibleValuesParser::new(presets::preset_keys())
}
pub async fn run(args: &InitArgs) -> Result<Exit> {
let mut out = std::io::stdout().lock();
run_with(&mut out, args, &auth::default_path()?).await
}
pub async fn run_with<W: Write>(out: &mut W, args: &InitArgs, auth_path: &Path) -> Result<Exit> {
let toplevel = match diff::run_git(&args.path, &["rev-parse", "--show-toplevel"]).await {
Ok(s) => s,
Err(err) => {
return Err(anyhow!(
"{} is not inside a git repository: {err}",
args.path.display(),
));
}
};
let root = PathBuf::from(toplevel);
let store = auth::AuthStore::load(auth_path)?;
let interactive = is_interactive(args);
let force = {
let mut console = wizard::Terminal::new(out);
match existing_config(&root, args, interactive, &mut console)? {
Some(force) => force,
None => return Ok(Exit::Clean),
}
};
let plan = if interactive {
let quirks = crate::llm::quirks::Cached::new(crate::llm::quirks::default_path());
let models = crate::llm::models::Http::new();
let mut console = wizard::Terminal::new(out);
wizard::run(
&mut console,
wizard::Deps {
store: &store,
source: &models,
quirks_source: &quirks,
env_is_set: &wizard::real_env,
codex_status: &crate::llm::codex::current_status,
},
)
.await?
} else {
plan_from_flags(args, &store)?
};
apply(out, &root, plan, store, auth_path, force, args.force).await?;
Ok(Exit::Clean)
}
pub(crate) fn existing_config(
root: &Path,
args: &InitArgs,
interactive: bool,
console: &mut dyn wizard::Console,
) -> Result<Option<bool>> {
let path = root.join(crate::config::default_config_path());
if args.force || !path.exists() {
return Ok(Some(args.force));
}
if !interactive {
return Err(config_file::already_exists(&path));
}
console.say(&format!("{} already configures:", path.display()))?;
for line in describe(&path) {
console.say(&format!(" {line}"))?;
}
console.say("")?;
if wizard::confirm(console, "Replace it?", false)? {
return Ok(Some(true));
}
console.say("Left unchanged. `drep auth login` rotates a key without touching this file.")?;
Ok(None)
}
pub(crate) fn describe(path: &Path) -> Vec<String> {
let Ok(raw) = std::fs::read_to_string(path) else {
return vec!["(could not be read)".to_string()];
};
let Ok(value) = toml::from_str::<toml::Value>(&raw) else {
return vec!["(could not be parsed)".to_string()];
};
let Some(entries) = value.get("llm").and_then(toml::Value::as_array) else {
return vec!["(no [[llm]] provider)".to_string()];
};
if entries.is_empty() {
return vec!["(no [[llm]] provider)".to_string()];
}
entries
.iter()
.map(|entry| {
let field = |name: &str| {
entry
.get(name)
.and_then(toml::Value::as_str)
.unwrap_or("(unset)")
.to_string()
};
let disabled = match entry.get("enabled").and_then(toml::Value::as_bool) {
Some(false) => " (disabled)",
_ => "",
};
if entry.get("backend").and_then(toml::Value::as_str) == Some("codex") {
format!(
"{} via ChatGPT/Codex subscription{disabled}",
field("model")
)
} else {
format!("{} at {}{disabled}", field("model"), field("endpoint"))
}
})
.collect()
}
fn is_interactive(args: &InitArgs) -> bool {
use std::io::IsTerminal;
wants_wizard(args, std::io::stdin().is_terminal())
}
pub(crate) fn wants_wizard(args: &InitArgs, stdin_is_terminal: bool) -> bool {
if args.interactive {
return true;
}
if args.non_interactive {
return false;
}
args.provider.is_none() && stdin_is_terminal
}
pub(crate) fn plan_from_flags(args: &InitArgs, store: &auth::AuthStore) -> Result<wizard::Plan> {
let provider = args.provider.as_deref().unwrap_or("local");
let preset =
presets::preset(provider).ok_or_else(|| anyhow!("unknown provider `{provider}`"))?;
let endpoint = match &preset.backend {
presets::PresetBackend::Codex(_) => None,
presets::PresetBackend::Http(http) => Some(
args.endpoint
.clone()
.or_else(|| http.endpoint.map(str::to_owned))
.ok_or_else(|| {
anyhow!(
"--provider {} needs an --endpoint (it presumes no host)",
preset.key
)
})?,
),
};
let model = args
.model
.clone()
.or_else(|| preset.default_model.map(str::to_owned))
.ok_or_else(|| anyhow!("--provider {} needs a --model", preset.key))?;
Ok(wizard::Plan {
choices: vec![match endpoint {
Some(endpoint) => {
let key_in_store = store.get(&endpoint).is_some();
config_file::Choice::http(preset, model, endpoint, key_in_store, preset.quirks())
}
None => config_file::Choice::codex(preset, model),
}],
new_keys: Vec::new(),
hooks: args.hooks,
gitignore: !args.no_gitignore,
})
}
async fn apply<W: Write>(
out: &mut W,
root: &Path,
plan: wizard::Plan,
mut store: auth::AuthStore,
auth_path: &Path,
config_force: bool,
hooks_force: bool,
) -> Result<()> {
if !plan.new_keys.is_empty() {
for (endpoint, key) in &plan.new_keys {
store.set(endpoint, key)?;
}
store.save(auth_path)?;
writeln!(out)?;
writeln!(
out,
"✓ Stored {} key(s) for this machine (`drep auth list` to review)",
plan.new_keys.len()
)?;
}
let path = config_file::write(
root,
&config_file::render_chain(&plan.choices),
config_force,
)?;
let summary = plan
.choices
.iter()
.map(|c| format!("{} ({})", c.preset.display_name, c.model))
.collect::<Vec<_>>()
.join(", then ");
writeln!(out, "✓ Wrote {} - {summary}", path.display())?;
if plan.gitignore {
gitignore::ensure_to(out, root).await?;
}
hooks::install(out, root, plan.hooks, hooks_force).await?;
let mut needed: Vec<&str> = Vec::new();
for var in plan
.choices
.iter()
.filter(|choice| choice.is_http() && !choice.key_in_store())
.filter_map(|choice| choice.preset.api_key_env())
{
if !needed.contains(&var) {
needed.push(var);
}
}
if !needed.is_empty() {
writeln!(out)?;
writeln!(out, "This config reads its key from the environment:")?;
for var in needed {
match std::env::var_os(var) {
Some(_) => writeln!(out, " {var} - already set")?,
None => writeln!(out, " {var} - NOT set; export it before running drep")?,
}
}
}
Ok(())
}